Compare commits

...
5 Commits
Author SHA1 Message Date
Gitea Actions 474fd80a95 Release 0.1.43 [auto-version-bump] 2026-10-16 06:31:31 +02:00
Ein Anderssono bf5ee88610 Honor the system default speech voice
Build and publish frontend / publish (push) Successful in 43s
2026-10-16 06:31:07 +02:00
Gitea Actions db76289660 Release 0.1.42 [auto-version-bump] 2026-10-16 06:19:32 +02:00
Ein Anderssono 702780c61b Fix speech controller test after microphone priming
Build and publish frontend / publish (push) Successful in 42s
2026-10-16 06:19:09 +02:00
Ein Anderssono eceb33052d Restore iOS speech after cancelling queued audio
Build and publish frontend / publish (push) Failing after 2s
2026-10-16 06:18:26 +02:00
6 changed files with 49 additions and 16 deletions
+1 -1
View File
@@ -1 +1 @@
0.1.41 0.1.43
+2 -2
View File
@@ -2,5 +2,5 @@ apiVersion: v2
name: ai-spel-frontend name: ai-spel-frontend
description: Static web frontend for the Swedish life simulator description: Static web frontend for the Swedish life simulator
type: application type: application
version: 0.1.41 version: 0.1.43
appVersion: "0.1.41" appVersion: "0.1.43"
+6 -3
View File
@@ -93,7 +93,6 @@
const voiceSettingsKey = 'ai-spel.voice-settings.v2'; const voiceSettingsKey = 'ai-spel.voice-settings.v2';
let voiceSettings = window.GameVoice.loadSettings(localStorage, voiceSettingsKey, 'ai-spel.voice-settings.v1'); let voiceSettings = window.GameVoice.loadSettings(localStorage, voiceSettingsKey, 'ai-spel.voice-settings.v1');
let voiceController; let voiceController;
let speechActivatedByGesture = false;
let audioContext; let audioContext;
let voiceDisclosureAcknowledged = false; let voiceDisclosureAcknowledged = false;
let adminOffset = 0; let adminOffset = 0;
@@ -228,8 +227,8 @@
}, },
}); });
function primeSpeechFromGesture(event) { function primeSpeechFromGesture(event) {
if (speechActivatedByGesture || !event.isTrusted) return; if (!event.isTrusted) return;
speechActivatedByGesture = voiceController.activateFromUserGesture(); voiceController.activateFromUserGesture();
} }
document.addEventListener('pointerdown', primeSpeechFromGesture, true); document.addEventListener('pointerdown', primeSpeechFromGesture, true);
document.addEventListener('keydown', primeSpeechFromGesture, true); document.addEventListener('keydown', primeSpeechFromGesture, true);
@@ -739,6 +738,10 @@
refreshMicrophone(); refreshMicrophone();
voiceController.stopListening(); voiceController.stopListening();
voiceController.cancelSpeech(); 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 = ''; messageField.value = '';
try { try {
if (stage === 'biography') await handleBiography(message); if (stage === 'biography') await handleBiography(message);
+1 -1
View File
@@ -28,7 +28,7 @@ grep -q 'Jag förstår – starta mikrofonen' index.html
grep -q 'supportsRecognition' voice.js grep -q 'supportsRecognition' voice.js
grep -q 'characterVoiceProfile' voice.js grep -q 'characterVoiceProfile' voice.js
grep -q 'utterance.volume = 1' 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 'WebKit versions before Safari 27' voice.js
grep -q "voiceIdentity: speakingNpc.voice_identity" index.html grep -q "voiceIdentity: speakingNpc.voice_identity" index.html
grep -q "role: 'narrator', voiceIdentity: 'neutral'" index.html grep -q "role: 'narrator', voiceIdentity: 'neutral'" index.html
+21 -5
View File
@@ -117,17 +117,33 @@ test('recognition watchdog closes a session that never ends and permits retry',
assert.equal(h.recognitions.length, 2); assert.equal(h.recognitions.length, 2);
}); });
test('TTS selects Swedish fallback, applies rate, and cancellation invalidates callbacks', () => { test('TTS uses the system default voice unless a named Swedish voice is selected', () => {
const h = makeHarness(); const h = makeHarness();
h.controller.update({mode: 'game-voice', rate: 1.4}); h.controller.update({mode: 'game-voice', rate: 1.4});
assert.equal(h.controller.speak('Hej.'), true); assert.equal(h.controller.speak('Hej.'), true);
const first = h.utterances[0]; const first = h.utterances[0];
assert.equal(first.lang, 'sv-SE'); assert.equal(first.lang, 'sv-SE');
assert.equal(first.rate, 1.4); assert.equal(first.rate, 1.4);
assert.equal(first.voice.name, 'Svensk'); assert.equal(first.voice, null);
h.controller.cancelSpeech(); h.controller.cancelSpeech();
first.onend(); first.onend();
assert.equal(h.controller.getState(), 'IDLE'); assert.equal(h.controller.getState(), 'IDLE');
assert.equal(h.controller.speak('Namngiven röst.', {voiceName: 'Svensk'}), true);
assert.equal(h.utterances.at(-1).voice.name, 'Svensk');
});
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', () => { test('speech is reported as in progress only after the native onstart event', () => {
@@ -137,8 +153,8 @@ test('speech is reported as in progress only after the native onstart event', ()
assert.match(h.states.at(-1).message, /Startar uppläsningen/); assert.match(h.states.at(-1).message, /Startar uppläsningen/);
h.utterances[0].onstart(); h.utterances[0].onstart();
assert.equal(h.controller.getState(), 'SPEAKING'); assert.equal(h.controller.getState(), 'SPEAKING');
assert.match(h.states.at(-1).message, /Uppläsningen har startat/); assert.match(h.states.at(-1).message, /Webbläsaren har startat talsyntesen/);
assert.match(h.states.at(-1).message, /iPadens volym/); 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', () => { test('iOS user gesture primes speech silently and idle speech is not cancelled before speak', () => {
@@ -214,7 +230,7 @@ test('TTS and microphone are mutually exclusive', () => {
h.controller.speak('Svar'); h.controller.speak('Svar');
assert.equal(h.recognitions[0].aborted, true); assert.equal(h.recognitions[0].aborted, true);
assert.equal(h.controller.getState(), 'QUEUED'); assert.equal(h.controller.getState(), 'QUEUED');
h.utterances[0].onstart(); h.utterances.at(-1).onstart();
assert.equal(h.controller.getState(), 'SPEAKING'); assert.equal(h.controller.getState(), 'SPEAKING');
}); });
+18 -4
View File
@@ -100,6 +100,9 @@
// same turn as cancel(). Do not clear an already-empty native queue. // same turn as cancel(). Do not clear an already-empty native queue.
if (wasQueued) { if (wasQueued) {
try { win.speechSynthesis?.cancel(); } catch (_) { /* Best effort. */ } 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'); if (!recognition) setState('IDLE');
} }
@@ -149,6 +152,10 @@
return false; return false;
} }
cancelSpeech(); 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; const token = ++generation;
let instance; let instance;
try { instance = new Recognition(); } try { instance = new Recognition(); }
@@ -226,15 +233,22 @@
utterance.pitch = isNarrator ? 1 : Math.min(2, Math.max(0, Number(voiceOptions.pitch ?? profile.pitch ?? identityProfile.pitch) || 1)); utterance.pitch = isNarrator ? 1 : Math.min(2, Math.max(0, Number(voiceOptions.pitch ?? profile.pitch ?? identityProfile.pitch) || 1));
const requestedName = voiceOptions.voiceName || profile.voiceName || settings.voiceName; const requestedName = voiceOptions.voiceName || profile.voiceName || settings.voiceName;
const voices = availableVoices(); const voices = availableVoices();
utterance.voice = voices.find(voice => voice.name === requestedName) // An empty selection means the operating system should choose its
|| voices.find(voice => /^sv(-|$)/i.test(voice.lang)) // default voice for the requested language. Forcing the first voice
|| null; // returned by WebKit can select a voice that is listed but cannot speak.
utterance.voice = requestedName
? voices.find(voice => voice.name === requestedName)
|| voices.find(voice => /^sv(-|$)/i.test(voice.lang))
|| null
: null;
utterance.onstart = () => { utterance.onstart = () => {
if (token !== generation) return; if (token !== generation) return;
if (speechStartTimer !== null) win.clearTimeout(speechStartTimer); if (speechStartTimer !== null) win.clearTimeout(speechStartTimer);
speechStartTimer = null; 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(); 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 = () => { utterance.onend = () => {
if (token !== generation) return; if (token !== generation) return;