This commit is contained in:
@@ -78,7 +78,10 @@
|
||||
let generation = 0;
|
||||
let recognition = null;
|
||||
let speechTimer = null;
|
||||
let speechStartTimer = null;
|
||||
let listeningTimer = null;
|
||||
let currentUtterance = null;
|
||||
let speechPrimed = false;
|
||||
|
||||
function setState(next, message = '') {
|
||||
state = next;
|
||||
@@ -88,11 +91,34 @@
|
||||
function cancelSpeech() {
|
||||
generation += 1;
|
||||
if (speechTimer !== null) win.clearTimeout(speechTimer);
|
||||
if (speechStartTimer !== null) win.clearTimeout(speechStartTimer);
|
||||
speechTimer = null;
|
||||
try { win.speechSynthesis?.cancel(); } catch (_) { /* Best effort. */ }
|
||||
speechStartTimer = null;
|
||||
const wasQueued = currentUtterance !== null;
|
||||
currentUtterance = null;
|
||||
// WebKit versions before Safari 27 can discard a speak() queued in the
|
||||
// same turn as cancel(). Do not clear an already-empty native queue.
|
||||
if (wasQueued) {
|
||||
try { win.speechSynthesis?.cancel(); } catch (_) { /* Best effort. */ }
|
||||
}
|
||||
if (!recognition) setState('IDLE');
|
||||
}
|
||||
|
||||
function activateFromUserGesture() {
|
||||
if (!win.speechSynthesis || !win.SpeechSynthesisUtterance) return false;
|
||||
if (speechPrimed) return true;
|
||||
// iOS Safari requires speech output to be initiated by a user gesture.
|
||||
// Prime its speech channel silently on the first trusted tap/key press so
|
||||
// later game replies can be spoken after the API response arrives.
|
||||
try {
|
||||
const primer = new win.SpeechSynthesisUtterance(' ');
|
||||
primer.volume = 0;
|
||||
win.speechSynthesis.speak(primer);
|
||||
speechPrimed = true;
|
||||
return true;
|
||||
} catch (_) { return false; }
|
||||
}
|
||||
|
||||
function finishRecognition(token, message = '') {
|
||||
if (token !== generation) return;
|
||||
if (listeningTimer !== null) win.clearTimeout(listeningTimer);
|
||||
@@ -181,8 +207,9 @@
|
||||
function speak(text, voiceOptions = {}) {
|
||||
if (!text || !win.speechSynthesis || !win.SpeechSynthesisUtterance) return false;
|
||||
stopListening();
|
||||
cancelSpeech();
|
||||
const token = generation;
|
||||
const hadQueuedUtterance = currentUtterance !== null;
|
||||
if (hadQueuedUtterance) cancelSpeech();
|
||||
const token = ++generation;
|
||||
let utterance;
|
||||
try { utterance = new win.SpeechSynthesisUtterance(text); }
|
||||
catch (_) { setState('IDLE', 'Talsyntesen kunde inte startas. Dialogen finns kvar som text.'); return false; }
|
||||
@@ -198,28 +225,63 @@
|
||||
utterance.voice = voices.find(voice => voice.name === requestedName)
|
||||
|| voices.find(voice => /^sv(-|$)/i.test(voice.lang))
|
||||
|| null;
|
||||
utterance.onstart = () => {
|
||||
if (token !== generation) return;
|
||||
if (speechStartTimer !== null) win.clearTimeout(speechStartTimer);
|
||||
speechStartTimer = null;
|
||||
if (typeof voiceOptions.onStart === 'function') voiceOptions.onStart();
|
||||
};
|
||||
utterance.onend = () => {
|
||||
if (token !== generation) return;
|
||||
if (speechTimer !== null) win.clearTimeout(speechTimer);
|
||||
if (speechStartTimer !== null) win.clearTimeout(speechStartTimer);
|
||||
speechTimer = null;
|
||||
speechStartTimer = null;
|
||||
currentUtterance = null;
|
||||
setState('IDLE');
|
||||
};
|
||||
utterance.onerror = () => {
|
||||
if (token !== generation) return;
|
||||
if (speechTimer !== null) win.clearTimeout(speechTimer);
|
||||
if (speechStartTimer !== null) win.clearTimeout(speechStartTimer);
|
||||
speechTimer = null;
|
||||
speechStartTimer = null;
|
||||
currentUtterance = null;
|
||||
generation += 1;
|
||||
setState('IDLE', 'Uppläsningen avbröts. Dialogen finns kvar som text.');
|
||||
};
|
||||
setState('SPEAKING', 'Läser upp dialog.');
|
||||
currentUtterance = utterance;
|
||||
speechStartTimer = win.setTimeout(() => {
|
||||
if (token !== generation || currentUtterance !== utterance) return;
|
||||
speechStartTimer = null;
|
||||
setState('IDLE', 'Uppläsningen startade inte. Tryck på Läs senaste repliken för att starta ljudet.');
|
||||
}, 4000);
|
||||
speechTimer = win.setTimeout(() => {
|
||||
if (token !== generation) return;
|
||||
generation += 1;
|
||||
try { win.speechSynthesis.cancel(); } catch (_) { /* Best effort. */ }
|
||||
currentUtterance = null;
|
||||
if (speechStartTimer !== null) win.clearTimeout(speechStartTimer);
|
||||
speechStartTimer = null;
|
||||
speechTimer = null;
|
||||
setState('IDLE', 'Uppläsningen avslutades. Dialogen finns kvar som text.');
|
||||
}, Math.max(30000, Math.min(180000, String(text).length * 180)));
|
||||
try { win.speechSynthesis.speak(utterance); }
|
||||
const enqueue = () => {
|
||||
if (token !== generation) return;
|
||||
try { win.speechSynthesis.speak(utterance); }
|
||||
catch (_) {
|
||||
currentUtterance = null;
|
||||
cancelSpeech();
|
||||
setState('IDLE', 'Talsyntesen kunde inte startas. Dialogen finns kvar som text.');
|
||||
}
|
||||
};
|
||||
try {
|
||||
// Give WebKit one event-loop turn to finish cancel() before replacing
|
||||
// speech that was actively playing. Normal replies enqueue immediately.
|
||||
if (hadQueuedUtterance) win.setTimeout(enqueue, 100);
|
||||
else enqueue();
|
||||
}
|
||||
catch (_) { cancelSpeech(); setState('IDLE', 'Talsyntesen kunde inte startas. Dialogen finns kvar som text.'); return false; }
|
||||
return true;
|
||||
}
|
||||
@@ -230,7 +292,7 @@
|
||||
function getSettings() { return {...settings}; }
|
||||
function getVoices() { return availableVoices(); }
|
||||
function supportsRecognition() { return Boolean(win.SpeechRecognition || win.webkitSpeechRecognition); }
|
||||
return {startListening, stopListening, speak, cancelSpeech, stopAll, update, getState, getSettings, getVoices, supportsRecognition};
|
||||
return {startListening, stopListening, speak, cancelSpeech, stopAll, activateFromUserGesture, update, getState, getSettings, getVoices, supportsRecognition};
|
||||
}
|
||||
|
||||
root.GameVoice = {createVoiceController, DEFAULT_SETTINGS, parseVoiceCommand, loadSettings, saveSettings, characterVoiceProfile};
|
||||
|
||||
Reference in New Issue
Block a user