fix(web): Web-Audio-Kontext bei Nutzergeste freischalten (iOS-Chunk-Wiedergabe)

unlockTTS() schaltete bisher nur SpeechSynthesis frei. Der Web-Audio-Kontext
für die satzweise Chunk-Wiedergabe (Server-TTS-Modi) wurde erst lazy im
WS-Callback erzeugt — auf iOS startet ein so erzeugter AudioContext "suspended"
und resume() außerhalb einer Geste wird abgelehnt -> stumme/blockierte
Chunk-Wiedergabe.

Fix: unlockTTS() legt den AudioContext jetzt in der Nutzergeste an, ruft
resume() und spielt einen 1-Sample-Stille-Buffer (Standard-iOS-Unlock). Beide
Freischaltungen (Geräte-TTS + Web-Audio) laufen unabhängig, auch wenn
SpeechSynthesis fehlt.

Verifiziert (Desktop-Chrome): Kontext wird in der Geste erzeugt, ist "running"
und der Stille-Buffer läuft, bevor der erste Chunk eintrifft; Chunk-Wiedergabe
weiterhin gapless. Echtes iOS Safari hier nicht testbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-26 10:55:15 +02:00
commit e4f0ea7199

View file

@ -151,11 +151,29 @@ function deviceSpeakBtn(text, lang, btn) {
speechSynthesis.speak(u);
}
// iOS: speechSynthesis darf nur nach einer Nutzergeste starten -> beim ersten Tap freischalten.
// iOS: sowohl SpeechSynthesis (Geräte-TTS) als auch der Web-Audio-Kontext (Server-Chunks)
// dürfen nur nach einer Nutzergeste starten -> beim ersten Tap beide freischalten.
let _ttsUnlocked = false;
let _audioUnlocked = false;
function unlockTTS() {
if (_ttsUnlocked || !TTS_SUPPORTED) return;
try { speechSynthesis.speak(new SpeechSynthesisUtterance("")); _ttsUnlocked = true; } catch (e) {}
// Geräte-TTS per Leer-Utterance freischalten.
if (TTS_SUPPORTED && !_ttsUnlocked) {
try { speechSynthesis.speak(new SpeechSynthesisUtterance("")); _ttsUnlocked = true; } catch (e) {}
}
// Web-Audio-Kontext jetzt (in der Geste) anlegen + per Stille-Buffer aufwecken, damit
// die satzweise Chunk-Wiedergabe in den Server-Modi auch auf iOS läuft (sonst bleibt ein
// erst im WS-Callback erzeugter AudioContext dort "suspended").
if (!_audioUnlocked) {
try {
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
if (audioCtx.state === "suspended") audioCtx.resume();
const src = audioCtx.createBufferSource();
src.buffer = audioCtx.createBuffer(1, 1, 22050); // 1 Sample Stille
src.connect(audioCtx.destination);
src.start(0);
_audioUnlocked = true;
} catch (e) {}
}
}
const PLAYBACK_KEY = "va-playback"; // geräte-lokal: "device" wenn Geräte-TTS gewählt