From eceb33052d7c28f8787cd4b231b67d0522a36b83 Mon Sep 17 00:00:00 2001 From: Ein Anderssono Date: Fri, 16 Oct 2026 06:18:26 +0200 Subject: [PATCH] Restore iOS speech after cancelling queued audio --- index.html | 9 ++++++--- scripts/check-frontend.sh | 2 +- tests/voice.test.cjs | 18 ++++++++++++++++-- voice.js | 11 ++++++++++- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/index.html b/index.html index b3205b6..024f74e 100644 --- a/index.html +++ b/index.html @@ -93,7 +93,6 @@ const voiceSettingsKey = 'ai-spel.voice-settings.v2'; let voiceSettings = window.GameVoice.loadSettings(localStorage, voiceSettingsKey, 'ai-spel.voice-settings.v1'); let voiceController; - let speechActivatedByGesture = false; let audioContext; let voiceDisclosureAcknowledged = false; let adminOffset = 0; @@ -228,8 +227,8 @@ }, }); function primeSpeechFromGesture(event) { - if (speechActivatedByGesture || !event.isTrusted) return; - speechActivatedByGesture = voiceController.activateFromUserGesture(); + if (!event.isTrusted) return; + voiceController.activateFromUserGesture(); } document.addEventListener('pointerdown', primeSpeechFromGesture, true); document.addEventListener('keydown', primeSpeechFromGesture, true); @@ -739,6 +738,10 @@ refreshMicrophone(); voiceController.stopListening(); voiceController.cancelSpeech(); + // cancel() above may also have discarded Safari's silent speech-channel + // primer. Restore it synchronously inside the submit gesture, before + // waiting for the API response that will be read aloud. + voiceController.activateFromUserGesture(); messageField.value = ''; try { if (stage === 'biography') await handleBiography(message); diff --git a/scripts/check-frontend.sh b/scripts/check-frontend.sh index a121693..082fd78 100755 --- a/scripts/check-frontend.sh +++ b/scripts/check-frontend.sh @@ -28,7 +28,7 @@ grep -q 'Jag förstår – starta mikrofonen' index.html grep -q 'supportsRecognition' voice.js grep -q 'characterVoiceProfile' voice.js grep -q 'utterance.volume = 1' voice.js -grep -q 'Uppläsningen har startat' voice.js +grep -q 'Webbläsaren har startat talsyntesen' voice.js grep -q 'WebKit versions before Safari 27' voice.js grep -q "voiceIdentity: speakingNpc.voice_identity" index.html grep -q "role: 'narrator', voiceIdentity: 'neutral'" index.html diff --git a/tests/voice.test.cjs b/tests/voice.test.cjs index 537debf..1259fe7 100644 --- a/tests/voice.test.cjs +++ b/tests/voice.test.cjs @@ -130,6 +130,20 @@ test('TTS selects Swedish fallback, applies rate, and cancellation invalidates c assert.equal(h.controller.getState(), 'IDLE'); }); +test('a player gesture restores the iOS speech primer after cancelling a queued reply', () => { + const h = makeHarness(); + assert.equal(h.controller.activateFromUserGesture(), true); + assert.equal(h.synthesis.spoken[0].text, ' '); + h.controller.speak('Tidigare replik'); + h.controller.cancelSpeech(); + const countAfterCancel = h.synthesis.spoken.length; + assert.equal(h.controller.activateFromUserGesture(), true); + assert.equal(h.synthesis.spoken.length, countAfterCancel + 1); + assert.equal(h.synthesis.spoken.at(-1).text, ' '); + h.controller.speak('Nytt svar'); + assert.equal(h.synthesis.spoken.at(-1).text, 'Nytt svar'); +}); + test('speech is reported as in progress only after the native onstart event', () => { const h = makeHarness(); h.controller.speak('Hej.'); @@ -137,8 +151,8 @@ test('speech is reported as in progress only after the native onstart event', () assert.match(h.states.at(-1).message, /Startar uppläsningen/); h.utterances[0].onstart(); assert.equal(h.controller.getState(), 'SPEAKING'); - assert.match(h.states.at(-1).message, /Uppläsningen har startat/); - assert.match(h.states.at(-1).message, /iPadens volym/); + assert.match(h.states.at(-1).message, /Webbläsaren har startat talsyntesen/); + assert.match(h.states.at(-1).message, /Läs senaste repliken/); }); test('iOS user gesture primes speech silently and idle speech is not cancelled before speak', () => { diff --git a/voice.js b/voice.js index c1c2ad0..c9643a6 100644 --- a/voice.js +++ b/voice.js @@ -100,6 +100,9 @@ // same turn as cancel(). Do not clear an already-empty native queue. if (wasQueued) { try { win.speechSynthesis?.cancel(); } catch (_) { /* Best effort. */ } + // cancel() also removes the silent user-gesture primer from the native + // queue. A later reply must prime again from the next player gesture. + speechPrimed = false; } if (!recognition) setState('IDLE'); } @@ -149,6 +152,10 @@ return false; } cancelSpeech(); + // This runs synchronously from the microphone button gesture. If + // cancelSpeech() removed the earlier primer, restore it before starting + // recognition so a spoken reply can follow the transcript. + activateFromUserGesture(); const token = ++generation; let instance; try { instance = new Recognition(); } @@ -233,8 +240,10 @@ if (token !== generation) return; if (speechStartTimer !== null) win.clearTimeout(speechStartTimer); speechStartTimer = null; - setState('SPEAKING', 'Uppläsningen har startat. Om du inte hör ljud, kontrollera iPadens volym och ljudutgång.'); if (typeof voiceOptions.onStart === 'function') voiceOptions.onStart(); + // onstart confirms the browser speech engine accepted the utterance; + // it cannot confirm that sound reached the device's speakers. + setState('SPEAKING', 'Webbläsaren har startat talsyntesen. Om du inte hör ljud, tryck på Läs senaste repliken.'); }; utterance.onend = () => { if (token !== generation) return;