From 93c197e8e4d475ea47af53f40eb6db8e2565e972 Mon Sep 17 00:00:00 2001 From: balex Date: Thu, 12 Mar 2026 13:59:33 +0100 Subject: [PATCH] fix --- src/public/js/app.js | 175 ++++++++++++------------------------------- 1 file changed, 48 insertions(+), 127 deletions(-) diff --git a/src/public/js/app.js b/src/public/js/app.js index a5a94b6..b6e2874 100644 --- a/src/public/js/app.js +++ b/src/public/js/app.js @@ -112,7 +112,7 @@ function resetPracticeUI() { skipBtn.disabled = false; downloadBtn.disabled = true; audioBlob = null; - stopRecording(); + stopRecording(true); } // ── Audio playback ──────────────────────────────────────────────────────────── @@ -138,160 +138,82 @@ speakBtn.addEventListener('click', () => { autoSpeak(); }); -// ── Device detection ────────────────────────────────────────────────────────── -const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent); - -// ── Speech recognition (desktop only) ──────────────────────────────────────── -const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; -let recognition = null; - -function createRecognition() { - if (!SpeechRecognition) return null; - const r = new SpeechRecognition(); - r.lang = 'de-DE'; - r.continuous = false; // one phrase per session — avoids all Android e.resultIndex bugs - r.interimResults = true; // stream words within the phrase - r.maxAlternatives = 1; - - let committed = false; // guard: each session commits at most one final result - - r.onstart = () => { - recordBtn.classList.add('recording'); - recordHint.textContent = 'Tippen zum Stoppen'; - state.isRecording = true; - }; - - r.onresult = (e) => { - if (committed) return; - const result = e.results[0]; - const text = result[0].transcript; - if (result.isFinal) { - committed = true; - state.finalTranscript += (state.finalTranscript ? ' ' : '') + text; - state.transcript = state.finalTranscript; - } else { - // interim — show but don't commit - state.transcript = (state.finalTranscript ? state.finalTranscript + ' ' : '') + text; - } - if (state.transcript.trim().split(/\s+/).length >= MAX_RECORD_WORDS) { - stopRecording(); - return; - } - updateTranscriptBox(state.transcript); - checkBtn.disabled = !state.transcript.trim(); - }; - - r.onend = () => { - if (!state.isRecording) return; - setTimeout(() => { - if (!state.isRecording) return; - recognition = createRecognition(); - try { recognition.start(); } catch (_) { stopRecording(); } - }, 100); - }; - - r.onerror = (e) => { - if (e.error !== 'no-speech' && e.error !== 'aborted') stopRecording(); - }; - - return r; -} - -// ── MediaRecorder ───────────────────────────────────────────────────────────── +// ── MediaRecorder → Whisper ─────────────────────────────────────────────────── let recordingTimer = null; let mediaRecorder = null; let audioChunks = []; let audioBlob = null; +let _discardNext = false; // set to true when stopping without intent to transcribe async function startRecording() { stopAudio(); - audioBlob = null; - audioChunks = []; + _discardNext = false; + audioBlob = null; + audioChunks = []; downloadBtn.disabled = true; transcriptBox.contentEditable = 'false'; - if (isMobile) { - // ── Mobile: MediaRecorder → Whisper ─────────────────────────────────────── - try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - const mimeType = MediaRecorder.isTypeSupported('audio/mp4') ? 'audio/mp4' - : MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' - : ''; - mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : {}); - mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); }; - mediaRecorder.onstop = async () => { - stream.getTracks().forEach(t => t.stop()); - const blob = new Blob(audioChunks, { type: mediaRecorder._mimeType || mimeType || 'audio/webm' }); - await transcribeAudio(blob); - }; - mediaRecorder._mimeType = mimeType; // stash for onstop - mediaRecorder.start(); - state.isRecording = true; - recordBtn.classList.add('recording'); - recordHint.textContent = 'Tippen zum Stoppen'; - } catch (e) { - alert('Mikrofon nicht verfügbar: ' + e.message); - return; - } - } else { - // ── Desktop: Web Speech API ──────────────────────────────────────────────── - if (!SpeechRecognition) { - alert('Spracherkennung wird in diesem Browser nicht unterstützt. Bitte nutze Chrome oder Edge.'); - return; - } - recognition = createRecognition(); - state.isRecording = true; - try { recognition.start(); } catch (_) {} - - try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') - ? 'audio/webm;codecs=opus' : 'audio/webm'; - mediaRecorder = new MediaRecorder(stream, { mimeType }); - mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); }; - mediaRecorder.onstop = () => { - audioBlob = new Blob(audioChunks, { type: mimeType }); - stream.getTracks().forEach(t => t.stop()); - if (audioBlob.size > 0) downloadBtn.disabled = false; - }; - mediaRecorder.start(); - } catch (e) { - console.warn('MediaRecorder unavailable:', e); - } + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' + : MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' + : MediaRecorder.isTypeSupported('audio/mp4') ? 'audio/mp4' + : ''; + mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : {}); + mediaRecorder.ondataavailable = e => { if (e.data.size > 0) audioChunks.push(e.data); }; + mediaRecorder.onstop = async () => { + stream.getTracks().forEach(t => t.stop()); + if (_discardNext) { _discardNext = false; return; } + const blob = new Blob(audioChunks, { type: mimeType || 'audio/webm' }); + audioBlob = blob; + if (blob.size > 0) downloadBtn.disabled = false; + await transcribeAudio(blob); + }; + mediaRecorder.start(); + } catch (err) { + alert('Mikrofon nicht verfügbar: ' + err.message); + return; } + state.isRecording = true; + recordBtn.classList.add('recording'); + recordHint.textContent = 'Tippen zum Stoppen'; + + // Show listening indicator — not editable, no interim text + transcriptBox.textContent = 'Слушаю…'; + transcriptBox.classList.remove('empty'); + clearTimeout(recordingTimer); recordingTimer = setTimeout(stopRecording, MAX_RECORD_SECONDS * 1000); } -function stopRecording() { +function stopRecording(discard = false) { clearTimeout(recordingTimer); state.isRecording = false; - if (recognition) { - try { recognition.stop(); } catch (_) {} - recognition = null; - } + if (discard) _discardNext = true; if (mediaRecorder && mediaRecorder.state !== 'inactive') { - mediaRecorder.stop(); // onstop handles the rest (incl. transcribeAudio on mobile) + mediaRecorder.stop(); // onstop fires → transcribeAudio (unless _discardNext) mediaRecorder = null; } recordBtn.classList.remove('recording'); - if (!isMobile) { + if (discard) { recordHint.textContent = 'Tippen zum Aufnehmen'; transcriptBox.contentEditable = 'true'; } - // On mobile, recordHint and contentEditable are updated inside transcribeAudio + // When not discarding: UI updated inside transcribeAudio after server responds } async function transcribeAudio(blob) { recordBtn.disabled = true; recordHint.textContent = 'Transkribiere…'; + transcriptBox.textContent = 'Transkribiere…'; + transcriptBox.classList.remove('empty'); try { const res = await authFetch('api/transcribe', { - method: 'POST', + method: 'POST', headers: { 'Content-Type': blob.type || 'audio/webm' }, - body: blob, + body: blob, }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Fehler'); @@ -308,8 +230,7 @@ async function transcribeAudio(blob) { recordHint.textContent = 'Fehler – nochmal versuchen'; } finally { recordBtn.disabled = false; - if (!state.transcript) recordHint.textContent = 'Tippen zum Aufnehmen'; - else recordHint.textContent = 'Tippen zum Aufnehmen'; + recordHint.textContent = 'Tippen zum Aufnehmen'; } } @@ -342,7 +263,7 @@ downloadBtn.addEventListener('click', () => { // ── Clear ───────────────────────────────────────────────────────────────────── clearBtn.addEventListener('click', () => { - stopRecording(); + stopRecording(true); state.transcript = ''; state.finalTranscript = ''; transcriptBox.contentEditable = 'false'; @@ -357,7 +278,7 @@ checkBtn.addEventListener('click', async () => { const answer = state.transcript.trim(); if (!answer || state.isChecking) return; - stopRecording(); + stopRecording(true); state.isChecking = true; checkBtn.disabled = true; feedbackBox.classList.add('visible'); @@ -406,7 +327,7 @@ function simpleMarkdown(text) { // ── Skip / Retry / Next / Done ──────────────────────────────────────────────── skipBtn.addEventListener('click', () => { - stopRecording(); stopAudio(); + stopRecording(true); stopAudio(); state.questionQueue.shift(); state.questionQueue.length === 0 ? showDone() : loadQuestion(); }); @@ -425,7 +346,7 @@ function showDone() { showScreen('done'); } -backBtn.addEventListener('click', () => { stopRecording(); stopAudio(); showScreen('topics'); }); +backBtn.addEventListener('click', () => { stopRecording(true); stopAudio(); showScreen('topics'); }); document.getElementById('restart-btn').addEventListener('click', () => showScreen('topics')); // ── History ───────────────────────────────────────────────────────────────────