Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
474fd80a95 | ||
|
|
bf5ee88610 | ||
|
|
db76289660 | ||
|
|
702780c61b | ||
|
|
eceb33052d |
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user