diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 418b61f..9c561c0 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,8 @@ "Write(*)", "Edit(*)", "Glob(*)", - "Grep(*)" + "Grep(*)", + "WebFetch(domain:192.168.85.11)" ] } } diff --git a/deploy/install.sh b/deploy/install.sh index c71c86d..dd473c0 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -61,7 +61,32 @@ systemctl daemon-reload systemctl enable voice-agent systemctl restart voice-agent -echo "[7/7] Nginx-Reverse-Proxy" +echo "[7/8] Selbst-signiertes TLS-Zertifikat (für Mikrofon-Zugriff via HTTPS)" +SSL_DIR="/etc/ssl/voice-agent" +SSL_CRT="$SSL_DIR/voice-agent.crt" +SSL_KEY="$SSL_DIR/voice-agent.key" +mkdir -p "$SSL_DIR" + +if [ ! -f "$SSL_CRT" ] || [ ! -f "$SSL_KEY" ]; then + apt-get install -y --no-install-recommends openssl >/dev/null + HOST_NAME="$(hostname --fqdn 2>/dev/null || hostname)" + HOST_SHORT="$(hostname)" + HOST_IP="$(hostname -I | awk '{print $1}')" + SAN="DNS:${HOST_NAME},DNS:${HOST_SHORT},DNS:voice-agent.local,IP:${HOST_IP}" + echo " Erzeuge Cert für: $SAN" + openssl req -x509 -nodes -newkey rsa:4096 -days 825 \ + -keyout "$SSL_KEY" -out "$SSL_CRT" \ + -subj "/CN=${HOST_NAME}" -addext "subjectAltName=${SAN}" \ + -addext "extendedKeyUsage=serverAuth" \ + -addext "basicConstraints=CA:FALSE" 2>/dev/null + chmod 0644 "$SSL_CRT" + chmod 0600 "$SSL_KEY" + echo " Cert erzeugt: $SSL_CRT" +else + echo " Cert bereits vorhanden, behalte: $SSL_CRT" +fi + +echo "[8/8] Nginx-Reverse-Proxy (HTTPS + HTTP-Redirect)" cp "$APP_DIR/deploy/nginx/voice-agent.conf" /etc/nginx/sites-available/voice-agent ln -sf /etc/nginx/sites-available/voice-agent /etc/nginx/sites-enabled/voice-agent rm -f /etc/nginx/sites-enabled/default @@ -78,10 +103,21 @@ chmod 0755 /usr/local/bin/voice-agent-admin echo echo "Fertig. Health-Check:" -echo " curl -s http://localhost/health" +echo " curl -sk https://localhost/health" echo -echo "Beim ersten Aufruf im Browser: Initialer Admin wird angelegt — bestehende Jobs" -echo "werden diesem Account automatisch zugeordnet." +echo "Aufruf im Browser: https:/// (HTTP wird auf HTTPS umgeleitet)" +echo "Beim ersten Aufruf wird der initiale Admin angelegt." +echo +echo "── Mikrofon-Aufnahme von Handys ──────────────────────────────────────" +echo "Damit iOS/Android das Mikrofon freigibt, MUSS die Seite über HTTPS laufen." +echo "Das selbst-signierte Zertifikat einmalig auf jedem Gerät installieren:" +echo +echo " iPhone: Safari öffnen, http:///ssl/voice-agent.crt aufrufen" +echo " → Profil herunterladen → Einstellungen → Profil installieren" +echo " → Allgemein → Info → Zertifikatsvertrauen → 'voice-agent' aktivieren" +echo +echo " Android: Browser öffnen, http:///ssl/voice-agent.crt aufrufen" +echo " → Cert installieren als 'VPN/Apps' (Variiert je nach Hersteller)" echo echo "Falls noch nicht geschehen: MAC_API_URL in $APP_DIR/lxc-frontend/.env setzen" echo "und Service neu starten: sudo systemctl restart voice-agent" diff --git a/deploy/nginx/voice-agent.conf b/deploy/nginx/voice-agent.conf index 9fd547d..dd9d941 100644 --- a/deploy/nginx/voice-agent.conf +++ b/deploy/nginx/voice-agent.conf @@ -1,11 +1,39 @@ +# HTTP — leitet auf HTTPS um, außer für /ssl/voice-agent.crt (Cert-Download) server { listen 80 default_server; server_name _; - # Upload-Limit muss zu MAX_UPLOAD_MB in .env passen client_max_body_size 600M; + proxy_read_timeout 1800; + proxy_send_timeout 1800; - # Lange Verarbeitung — Timeouts hoch + # Cert für Handy-Import bequem ohne HTTPS abholbar + location /ssl/voice-agent.crt { + alias /etc/ssl/voice-agent/voice-agent.crt; + default_type application/x-x509-ca-cert; + add_header Content-Disposition 'attachment; filename="voice-agent.crt"'; + } + + # Alles andere → HTTPS + location / { + return 301 https://$host$request_uri; + } +} + +# HTTPS mit selbst-signiertem Cert +server { + listen 443 ssl default_server; + listen [::]:443 ssl default_server; + http2 on; + server_name _; + + ssl_certificate /etc/ssl/voice-agent/voice-agent.crt; + ssl_certificate_key /etc/ssl/voice-agent/voice-agent.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + + client_max_body_size 600M; proxy_read_timeout 1800; proxy_send_timeout 1800; diff --git a/lxc-frontend/.env.example b/lxc-frontend/.env.example index ef31a8c..0ebfb6f 100644 --- a/lxc-frontend/.env.example +++ b/lxc-frontend/.env.example @@ -14,7 +14,7 @@ MAC_API_TIMEOUT=1800 # Limits MAX_UPLOAD_MB=500 -ALLOWED_EXTS=mp3,wav,m4a,mp4,ogg,flac +ALLOWED_EXTS=mp3,wav,m4a,mp4,ogg,flac,webm # Session-Cookie-Signierung — wird vom install.sh automatisch generiert. # NIE committen. Bei Wechsel werden alle Sessions invalidiert. diff --git a/lxc-frontend/app/core/config.py b/lxc-frontend/app/core/config.py index 8b0efb7..b34efa8 100644 --- a/lxc-frontend/app/core/config.py +++ b/lxc-frontend/app/core/config.py @@ -17,7 +17,7 @@ class Settings(BaseSettings): mac_api_timeout: int = 1800 max_upload_mb: int = 500 - allowed_exts: str = "mp3,wav,m4a,mp4,ogg,flac" + allowed_exts: str = "mp3,wav,m4a,mp4,ogg,flac,webm" # Wird beim Deploy automatisch generiert (siehe install.sh). session_secret: str = "CHANGE-ME-SET-IN-ENV" diff --git a/lxc-frontend/app/static/app.js b/lxc-frontend/app/static/app.js index de71d24..719bd9d 100644 --- a/lxc-frontend/app/static/app.js +++ b/lxc-frontend/app/static/app.js @@ -37,6 +37,17 @@ const defaultProfileName = document.getElementById("default-profile-name"); const headerProfileWrap = document.getElementById("header-profile-wrap"); const headerProfile = document.getElementById("header-profile"); +const recordToggle = document.getElementById("record-toggle"); +const recordToggleLabel = document.getElementById("record-toggle-label"); +const recordPane = document.getElementById("record-pane"); +const recordTimer = document.getElementById("record-timer"); +const recordStatus = document.getElementById("record-status"); +const recPauseBtn = document.getElementById("rec-pause"); +const recStopBtn = document.getElementById("rec-stop"); +const recordResult = document.getElementById("record-result"); +const recordFilename = document.getElementById("record-filename"); +const recordDiscardBtn = document.getElementById("record-discard"); + let currentUser = null; // { id, username, is_admin, default_profile } let mode = "login"; // "login" | "setup" let availableProfiles = []; // [{ name, display_name, language }, ...] @@ -310,9 +321,15 @@ async function loadJobs() { form.addEventListener("submit", (ev) => { ev.preventDefault(); - if (!audioInput.files.length) return; + // Quelle: Aufnahme bevorzugt, sonst File-Input + const file = recordedFile || (audioInput.files.length ? audioInput.files[0] : null); + if (!file) { + uploadMsg.textContent = "Bitte Datei wählen oder Aufnahme erstellen."; + uploadMsg.className = "msg err"; + return; + } const fd = new FormData(); - fd.append("audio", audioInput.files[0]); + fd.append("audio", file, file.name); fd.append("title", titleInput.value); // Profile mitgeben — leer = Server nimmt user.default_profile fd.append("profile", profileSelect.value || ""); @@ -341,6 +358,7 @@ form.addEventListener("submit", (ev) => { uploadMsg.textContent = `Job #${j.id} erstellt. Verarbeitung läuft …`; uploadMsg.className = "msg ok"; form.reset(); + discardRecording(); loadJobs(); } else { let err = "Upload fehlgeschlagen"; @@ -434,6 +452,167 @@ usersBody.addEventListener("click", async (ev) => { } }); +// ─── Audio-Aufnahme (MediaRecorder) ──────────────────────────────────────── +let mediaRecorder = null; +let recordedChunks = []; +let recordedFile = null; // File-Objekt nach Stop +let recStream = null; // MediaStream zum sauberen Schließen +let recStartedAt = 0; +let recPausedAt = 0; // wenn paused: Zeitpunkt des Pausierens +let recElapsedBeforePause = 0; // aufaddierte Aufnahme-Sekunden vor letztem Resume +let timerInterval = null; +let wakeLock = null; + +function pickMimeType() { + const candidates = [ + "audio/webm;codecs=opus", + "audio/webm", + "audio/mp4", // Safari iOS + "audio/mpeg", + "", // Browser-Default + ]; + for (const m of candidates) { + if (m === "" || (window.MediaRecorder && MediaRecorder.isTypeSupported(m))) return m; + } + return ""; +} + +function extFromMime(mime) { + if (!mime) return "webm"; + if (mime.includes("webm")) return "webm"; + if (mime.includes("mp4")) return "m4a"; + if (mime.includes("mpeg")) return "mp3"; + if (mime.includes("ogg")) return "ogg"; + return "webm"; +} + +function fmtTimer(s) { + const mm = String(Math.floor(s / 60)).padStart(2, "0"); + const ss = String(Math.floor(s % 60)).padStart(2, "0"); + return `${mm}:${ss}`; +} + +function tickTimer() { + if (!mediaRecorder) return; + let elapsed = recElapsedBeforePause; + if (mediaRecorder.state === "recording") elapsed += (Date.now() - recStartedAt) / 1000; + recordTimer.textContent = fmtTimer(elapsed); +} + +async function acquireWakeLock() { + try { + if ("wakeLock" in navigator) { + wakeLock = await navigator.wakeLock.request("screen"); + } + } catch { /* nicht kritisch */ } +} +async function releaseWakeLock() { + try { if (wakeLock) await wakeLock.release(); } catch {} + wakeLock = null; +} + +async function startRecording() { + if (!navigator.mediaDevices?.getUserMedia) { + alert("Dein Browser unterstützt kein Mikrofon — oder die Seite läuft nicht über HTTPS."); + return; + } + try { + recStream = await navigator.mediaDevices.getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, + }); + } catch (e) { + alert("Mikrofon-Zugriff verweigert oder fehlgeschlagen: " + e.message); + return; + } + + const mime = pickMimeType(); + recordedChunks = []; + try { + mediaRecorder = mime ? new MediaRecorder(recStream, { mimeType: mime }) : new MediaRecorder(recStream); + } catch (e) { + alert("MediaRecorder konnte nicht starten: " + e.message); + cleanupStream(); + return; + } + + mediaRecorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) recordedChunks.push(e.data); }; + mediaRecorder.onstop = () => { + const type = mediaRecorder.mimeType || mime || "audio/webm"; + const ext = extFromMime(type); + const blob = new Blob(recordedChunks, { type }); + const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); + recordedFile = new File([blob], `aufnahme-${ts}.${ext}`, { type }); + onRecordingFinalized(); + }; + + mediaRecorder.start(1000); // alle 1s ein dataavailable-Event + recStartedAt = Date.now(); + recPausedAt = 0; + recElapsedBeforePause = 0; + recordTimer.textContent = "00:00"; + recordStatus.textContent = "Aufnahme läuft"; + recPauseBtn.textContent = "Pause"; + recordPane.classList.remove("hidden"); + recordResult.classList.add("hidden"); + recordToggle.classList.add("hidden"); + audioInput.value = ""; // konkurrierende Datei-Auswahl löschen + timerInterval = setInterval(tickTimer, 250); + acquireWakeLock(); +} + +function pauseRecording() { + if (!mediaRecorder) return; + if (mediaRecorder.state === "recording") { + mediaRecorder.pause(); + recElapsedBeforePause += (Date.now() - recStartedAt) / 1000; + recPausedAt = Date.now(); + recordStatus.textContent = "Pausiert"; + recPauseBtn.textContent = "Fortsetzen"; + } else if (mediaRecorder.state === "paused") { + mediaRecorder.resume(); + recStartedAt = Date.now(); + recordStatus.textContent = "Aufnahme läuft"; + recPauseBtn.textContent = "Pause"; + } +} + +function stopRecording() { + if (!mediaRecorder) return; + if (mediaRecorder.state !== "inactive") mediaRecorder.stop(); + if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } + cleanupStream(); + releaseWakeLock(); +} + +function cleanupStream() { + if (recStream) { + recStream.getTracks().forEach((t) => t.stop()); + recStream = null; + } +} + +function onRecordingFinalized() { + recordPane.classList.add("hidden"); + recordResult.classList.remove("hidden"); + recordToggle.classList.remove("hidden"); + recordToggleLabel.textContent = "Neue Aufnahme starten"; + const sizeKB = Math.round(recordedFile.size / 1024); + recordFilename.textContent = `${recordedFile.name} (${sizeKB} KB)`; +} + +function discardRecording() { + recordedFile = null; + recordedChunks = []; + recordResult.classList.add("hidden"); + recordToggleLabel.textContent = "Aufnahme starten"; + recordFilename.textContent = ""; +} + +recordToggle.addEventListener("click", startRecording); +recPauseBtn.addEventListener("click", pauseRecording); +recStopBtn.addEventListener("click", stopRecording); +recordDiscardBtn.addEventListener("click", discardRecording); + // ─── Init ────────────────────────────────────────────────────────────────── bootstrap(); checkMac(); diff --git a/lxc-frontend/app/static/icon-192.png b/lxc-frontend/app/static/icon-192.png new file mode 100644 index 0000000..9ecdb19 Binary files /dev/null and b/lxc-frontend/app/static/icon-192.png differ diff --git a/lxc-frontend/app/static/icon-512.png b/lxc-frontend/app/static/icon-512.png new file mode 100644 index 0000000..d545c01 Binary files /dev/null and b/lxc-frontend/app/static/icon-512.png differ diff --git a/lxc-frontend/app/static/icon.svg b/lxc-frontend/app/static/icon.svg new file mode 100644 index 0000000..a715d9a --- /dev/null +++ b/lxc-frontend/app/static/icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/lxc-frontend/app/static/index.html b/lxc-frontend/app/static/index.html index 5a7cc6b..58eeb39 100644 --- a/lxc-frontend/app/static/index.html +++ b/lxc-frontend/app/static/index.html @@ -2,9 +2,17 @@ - + + Voice-Agent — Transkription + + + + + + +
@@ -63,10 +71,33 @@ Standard: (in Einstellungen änderbar) - +
+ +
— oder —
+ +
+ + + + +