This commit is contained in:
2026-03-12 12:33:19 +01:00
parent 1aeb5c164b
commit 228391f280
2 changed files with 112 additions and 18 deletions

View File

@@ -138,7 +138,10 @@ speakBtn.addEventListener('click', () => {
autoSpeak();
});
// ── Speech recognition ────────────────────────────────────────────────────────
// ── Device detection ──────────────────────────────────────────────────────────
const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent);
// ── Speech recognition (desktop only) ────────────────────────────────────────
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
let recognition = null;
@@ -201,27 +204,45 @@ let audioChunks = [];
let audioBlob = null;
async function startRecording() {
if (!SpeechRecognition) {
alert('Spracherkennung wird in diesem Browser nicht unterstützt. Bitte nutze Chrome oder Edge.');
return;
}
stopAudio();
audioBlob = null;
audioChunks = [];
downloadBtn.disabled = true;
// Start SpeechRecognition first — triggers mic permission dialog on Android
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 (_) {}
clearTimeout(recordingTimer);
recordingTimer = setTimeout(stopRecording, MAX_RECORD_SECONDS * 1000);
// MediaRecorder for audio download — desktop only; on mobile it conflicts with SpeechRecognition
const isMobile = /Android|iPhone|iPad/i.test(navigator.userAgent);
if (!isMobile) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
@@ -238,6 +259,9 @@ async function startRecording() {
console.warn('MediaRecorder unavailable:', e);
}
}
clearTimeout(recordingTimer);
recordingTimer = setTimeout(stopRecording, MAX_RECORD_SECONDS * 1000);
}
function stopRecording() {
@@ -248,13 +272,46 @@ function stopRecording() {
recognition = null;
}
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
mediaRecorder.stop(); // onstop handles the rest (incl. transcribeAudio on mobile)
mediaRecorder = null;
}
recordBtn.classList.remove('recording');
if (!isMobile) {
recordHint.textContent = 'Tippen zum Aufnehmen';
transcriptBox.contentEditable = 'true';
}
// On mobile, recordHint and contentEditable are updated inside transcribeAudio
}
async function transcribeAudio(blob) {
recordBtn.disabled = true;
recordHint.textContent = 'Transkribiere…';
try {
const res = await authFetch('api/transcribe', {
method: 'POST',
headers: { 'Content-Type': blob.type || 'audio/webm' },
body: blob,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Fehler');
const text = (data.transcript || '').trim();
state.transcript = text;
state.finalTranscript = text;
updateTranscriptBox(text);
checkBtn.disabled = !text;
transcriptBox.contentEditable = 'true';
} catch (err) {
console.error('Transcription failed:', err);
updateTranscriptBox('');
recordHint.textContent = 'Fehler nochmal versuchen';
} finally {
recordBtn.disabled = false;
if (!state.transcript) recordHint.textContent = 'Tippen zum Aufnehmen';
else recordHint.textContent = 'Tippen zum Aufnehmen';
}
}
recordBtn.addEventListener('click', () => state.isRecording ? stopRecording() : startRecording());

View File

@@ -4,6 +4,7 @@ const path = require("path");
const app = express();
const PORT = process.env.PORT || 8083;
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));
@@ -14,6 +15,42 @@ const LEVEL_DESCRIPTIONS = {
B2: "выше среднего (B2): детально анализируй смысл, структуру предложений, стиль и естественность речи, предлагай альтернативные формулировки",
};
// Audio is sent as raw binary body (Content-Type: audio/*)
app.post("/api/transcribe", express.raw({ type: "audio/*", limit: "25mb" }), async (req, res) => {
if (!req.body || !req.body.length) {
return res.status(400).json({ error: "No audio data" });
}
if (!OPENAI_API_KEY) {
return res.status(500).json({ error: "OpenAI API key not configured" });
}
try {
const mimeType = req.headers["content-type"] || "audio/webm";
const form = new FormData();
form.append("file", new Blob([req.body], { type: mimeType }), "audio.webm");
form.append("model", "whisper-1");
form.append("language", "de");
const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
method: "POST",
headers: { Authorization: `Bearer ${OPENAI_API_KEY}` },
body: form,
});
if (!response.ok) {
const err = await response.text();
console.error("Whisper API error:", response.status, err);
return res.status(502).json({ error: "Transcription service error" });
}
const data = await response.json();
res.json({ transcript: data.text || "" });
} catch (err) {
console.error("Transcribe request failed:", err);
res.status(500).json({ error: "Internal server error" });
}
});
app.post("/api/check", async (req, res) => {
const { question, answer, level } = req.body;