Compare commits

...
2 Commits
Author SHA1 Message Date
Gitea Actions 0452d7d065 Release 0.1.32 [auto-version-bump] 2026-10-15 10:54:32 +02:00
Ein Anderssono 69184d1920 Speak onboarding and all game dialogue automatically
Build and publish frontend / publish (push) Successful in 38s
2026-10-15 10:54:10 +02:00
7 changed files with 73 additions and 39 deletions
+3 -1
View File
@@ -12,7 +12,9 @@ The separate `/admin` route provides an authenticated, read-only operations view
The game is text- and dialogue-based and uses no images. Place descriptions include sound, smell, air/temperature, touch/surface, and spatial-orientation cues. Visible details may also be described in words, but are never required to understand the scene or play. The transcript is screen-reader announced, and Enter submits the input while Shift+Enter inserts a newline; no separate send button is required. The game is text- and dialogue-based and uses no images. Place descriptions include sound, smell, air/temperature, touch/surface, and spatial-orientation cues. Visible details may also be described in words, but are never required to understand the scene or play. The transcript is screen-reader announced, and Enter submits the input while Shift+Enter inserts a newline; no separate send button is required.
Voice input and output are browser features. Every new game reply is read automatically with the browser's Swedish speech synthesis by default, including character creation and gameplay; the transcript remains available as text. The microphone button beside the text field uses Web Speech API when available and sends only its final Swedish transcript through the same submission path as typed text. Before its first use, the client explains that the browser may process audio through a provider-selected service; the game itself does not receive or store recordings, while the final transcript is saved as ordinary dialogue. Writing `röstinställningar` or saying `röstkommando alternativ` opens the temporary controls in the dialogue. The player can choose `Spelets röst`, `Skärmläsare`, or `Endast text`, select available Swedish speech voices and rate, and enable or disable auto-read and short audio cues. The default mode is `Spelets röst` with auto-read on. Screen-reader and text-only modes remain explicit alternatives. These preferences are stored in browser `localStorage`. If speech APIs are unavailable, text play remains available. Voice output uses the browser's Swedish speech synthesis for all player-facing game dialogue, onboarding, account responses, character and world creation, gameplay replies, in-game errors, and microphone disclosures. The `/admin` route is excluded. The first unauthenticated visit automatically reads an introduction that explains the game, how to create an account, and how to play; the introduction stays visible as text and can be replayed with `Läs introduktionen igen`. New game replies are read automatically and the transcript remains available to screen readers and as text. Returning players hear the latest saved game reply when they reopen the game. Voice settings can change the Swedish voice and speaking rate, but cannot disable automatic speech. These preferences are stored in browser `localStorage`. If the browser lacks speech synthesis or blocks playback, the same content remains available as text and the introduction can be started again with its replay button.
The microphone button beside the text field uses Web Speech API when available and sends only its final Swedish transcript through the same submission path as typed text. Before its first use, the client explains that the browser may process audio through a provider-selected service; the game itself does not receive or store recordings, while the final transcript is saved as ordinary dialogue. Writing `röstinställningar` or saying `röstkommando alternativ` opens temporary voice controls in the dialogue. The player can select an available Swedish voice and speaking rate and enable or disable short audio cues. Voice preferences are stored in browser `localStorage`.
When a character or NPC is created, its saved voice identity is masculine, feminine, or neutral. The biography prompt only assigns masculine/feminine when the character is explicitly described that way; otherwise it uses neutral. Speech synthesis applies a stable, subtly varied pitch/rate profile per character, so a character keeps the same sound across visits. Narration always uses neutral pitch. The browser's selected Swedish voice still determines the underlying voice, so the exact sound depends on installed system voices. When a character or NPC is created, its saved voice identity is masculine, feminine, or neutral. The biography prompt only assigns masculine/feminine when the character is explicitly described that way; otherwise it uses neutral. Speech synthesis applies a stable, subtly varied pitch/rate profile per character, so a character keeps the same sound across visits. Narration always uses neutral pitch. The browser's selected Swedish voice still determines the underlying voice, so the exact sound depends on installed system voices.
+1 -1
View File
@@ -1 +1 @@
0.1.31 0.1.32
+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.31 version: 0.1.32
appVersion: "0.1.31" appVersion: "0.1.32"
+50 -25
View File
@@ -36,7 +36,9 @@
<main> <main>
<section id="auth" aria-labelledby="auth-heading"> <section id="auth" aria-labelledby="auth-heading">
<h1 id="auth-heading">Svensk livssimulator</h1> <h1 id="auth-heading">Svensk livssimulator</h1>
<p>Skapa en vuxen person och utforska en bestående värld genom dialog. Inga bilder används.</p> <p id="auth-intro">Välkommen till en svensk, dialogbaserad livssimulator utan bilder. Världen beskrivs med ljud, dofter, temperatur, beröring och riktning. Skapa konto med ett användarnamn och ett lösenord på minst tolv tecken. Berätta sedan fritt om en vuxen person. Du får redigera och godkänna personens biografi och startplats. Spela genom att skriva eller tala vad du gör och frågar. Personer du möter kan minnas tidigare samtal. Spelets repliker läses automatiskt upp på svenska och finns kvar som text. Har du redan konto väljer du Logga in.</p>
<button type="button" id="auth-repeat-intro">Läs introduktionen igen</button>
<p id="auth-status" role="status" aria-live="polite" hidden></p>
<form id="auth-form"> <form id="auth-form">
<label>Användarnamn <input name="username" required minlength="3" maxlength="40" autocomplete="username"></label> <label>Användarnamn <input name="username" required minlength="3" maxlength="40" autocomplete="username"></label>
<label>Lösenord <input name="password" type="password" required minlength="12" maxlength="256" autocomplete="current-password"></label> <label>Lösenord <input name="password" type="password" required minlength="12" maxlength="256" autocomplete="current-password"></label>
@@ -108,8 +110,7 @@
if (role === 'assistant') { if (role === 'assistant') {
latestAssistantText = content; latestAssistantText = content;
latestAssistantVoice = options.voice || {role: 'narrator', voiceIdentity: 'neutral'}; latestAssistantVoice = options.voice || {role: 'narrator', voiceIdentity: 'neutral'};
if (options.speak !== false && !content.trim().endsWith('…') && !content.trim().endsWith('...') if (options.speak !== false && location.pathname !== '/admin') {
&& voiceSettings.mode === 'game-voice' && voiceSettings.autoSpeak && !play.hidden) {
voiceController?.speak(content, latestAssistantVoice); voiceController?.speak(content, latestAssistantVoice);
} }
} }
@@ -132,13 +133,13 @@
if (button) button.disabled = play.hidden || messageField.disabled || submitBusy; if (button) button.disabled = play.hidden || messageField.disabled || submitBusy;
} }
function persistVoiceSettings() { function persistVoiceSettings() {
voiceSettings.mode = 'game-voice';
voiceSettings.autoSpeak = true;
if (!window.GameVoice.saveSettings(localStorage, voiceSettingsKey, voiceSettings)) { if (!window.GameVoice.saveSettings(localStorage, voiceSettingsKey, voiceSettings)) {
setVoiceStatus('Röstinställningen kunde inte sparas i den här webbläsaren.'); setVoiceStatus('Röstinställningen kunde inte sparas i den här webbläsaren.');
} }
voiceController?.update(voiceSettings); voiceController?.update(voiceSettings);
if (voiceSettings.mode !== 'game-voice') voiceController?.stopAll(); transcript.setAttribute('aria-live', 'polite');
else if (!voiceSettings.autoSpeak) voiceController?.cancelSpeech();
transcript.setAttribute('aria-live', voiceSettings.mode === 'game-voice' ? 'off' : 'polite');
} }
function playVoiceSignal(state) { function playVoiceSignal(state) {
if (!voiceSettings.soundSignals || !['LISTENING', 'PROCESSING', 'ERROR', 'STOPPED'].includes(state)) return; if (!voiceSettings.soundSignals || !['LISTENING', 'PROCESSING', 'ERROR', 'STOPPED'].includes(state)) return;
@@ -179,16 +180,7 @@
item.setAttribute('aria-label', 'Röstinställningar och uppläsning'); item.setAttribute('aria-label', 'Röstinställningar och uppläsning');
const heading = document.createElement('strong'); heading.textContent = 'Röstinställningar'; item.append(heading); const heading = document.createElement('strong'); heading.textContent = 'Röstinställningar'; item.append(heading);
const fieldset = document.createElement('fieldset'); const fieldset = document.createElement('fieldset');
const legend = document.createElement('legend'); legend.textContent = 'Välj hur dialogen ska läsas upp'; fieldset.append(legend); const legend = document.createElement('legend'); legend.textContent = 'Spelets repliker läses alltid upp. Anpassa rösten här.'; fieldset.append(legend);
const modeLabel = document.createElement('label'); modeLabel.textContent = 'Läge';
const mode = document.createElement('select');
[['screen-reader', 'Skärmläsare'], ['game-voice', 'Spelets röst'], ['text-only', 'Endast text']].forEach(([value, label]) => {
const option = document.createElement('option'); option.value = value; option.textContent = label; mode.append(option);
});
mode.value = voiceSettings.mode; modeLabel.append(mode); fieldset.append(modeLabel);
const autoLabel = document.createElement('label'); const auto = document.createElement('input');
auto.type = 'checkbox'; auto.checked = voiceSettings.autoSpeak;
autoLabel.append(auto, document.createTextNode(' Läs nya repliker automatiskt')); fieldset.append(autoLabel);
const speedLabel = document.createElement('label'); speedLabel.textContent = `Talhastighet: ${Number(voiceSettings.rate).toFixed(1)}`; const speedLabel = document.createElement('label'); speedLabel.textContent = `Talhastighet: ${Number(voiceSettings.rate).toFixed(1)}`;
const speed = document.createElement('input'); speed.type = 'range'; speed.min = '0.5'; speed.max = '2'; speed.step = '0.1'; speed.value = String(voiceSettings.rate); const speed = document.createElement('input'); speed.type = 'range'; speed.min = '0.5'; speed.max = '2'; speed.step = '0.1'; speed.value = String(voiceSettings.rate);
speed.setAttribute('aria-label', 'Talhastighet'); speed.addEventListener('input', () => { speedLabel.firstChild.textContent = `Talhastighet: ${Number(speed.value).toFixed(1)}`; }); speed.setAttribute('aria-label', 'Talhastighet'); speed.addEventListener('input', () => { speedLabel.firstChild.textContent = `Talhastighet: ${Number(speed.value).toFixed(1)}`; });
@@ -212,18 +204,16 @@
signalLabel.append(signals, document.createTextNode(' Ljudsignal vid röstinmatning')); fieldset.append(signalLabel); signalLabel.append(signals, document.createTextNode(' Ljudsignal vid röstinmatning')); fieldset.append(signalLabel);
item.append(fieldset); item.append(fieldset);
const speakLatest = () => { const speakLatest = () => {
if (!voiceController.speak(latestAssistantText, latestAssistantVoice)) setVoiceStatus('Talsyntes stöds inte här eller är avstängd. Dialogen finns kvar som text.'); if (!voiceController.speak(latestAssistantText, latestAssistantVoice)) setVoiceStatus('Talsyntes stöds inte i den här webbläsaren. Dialogen finns kvar som text.');
}; };
addVoiceButton(item, 'Läs senaste repliken', speakLatest); addVoiceButton(item, 'Läs senaste repliken', speakLatest);
addVoiceButton(item, 'Upprepa', speakLatest); addVoiceButton(item, 'Upprepa', speakLatest);
addVoiceButton(item, 'Avbryt uppläsning', () => voiceController.cancelSpeech()); addVoiceButton(item, 'Avbryt uppläsning', () => voiceController.cancelSpeech());
mode.addEventListener('change', () => { voiceSettings.mode = mode.value; persistVoiceSettings(); });
auto.addEventListener('change', () => { voiceSettings.autoSpeak = auto.checked; persistVoiceSettings(); });
speed.addEventListener('change', () => { voiceSettings.rate = Number(speed.value); persistVoiceSettings(); }); speed.addEventListener('change', () => { voiceSettings.rate = Number(speed.value); persistVoiceSettings(); });
voice.addEventListener('change', () => { voiceSettings.voiceName = voice.value; persistVoiceSettings(); }); voice.addEventListener('change', () => { voiceSettings.voiceName = voice.value; persistVoiceSettings(); });
signals.addEventListener('change', () => { voiceSettings.soundSignals = signals.checked; persistVoiceSettings(); }); signals.addEventListener('change', () => { voiceSettings.soundSignals = signals.checked; persistVoiceSettings(); });
addVoiceButton(item, 'Stäng röstinställningar', () => item.remove()); addVoiceButton(item, 'Stäng röstinställningar', () => item.remove());
transcript.append(item); item.scrollIntoView({block: 'end'}); mode.focus(); transcript.append(item); item.scrollIntoView({block: 'end'}); speed.focus();
} }
function requestVoiceDisclosure() { function requestVoiceDisclosure() {
const item = document.createElement('li'); item.dataset.role = 'voice-controls'; const item = document.createElement('li'); item.dataset.role = 'voice-controls';
@@ -231,6 +221,7 @@
const message = document.createElement('p'); const message = document.createElement('p');
message.textContent = 'Innan du startar: webbläsarens taligenkänning kan behandla ljud genom en tjänst som webbläsaren väljer. Spelet tar inte emot eller sparar ljudinspelningen. Den färdiga transkriberingen skickas och sparas som din vanliga dialog. Fortsätt bara om du godkänner webbläsarens hantering av tal.'; message.textContent = 'Innan du startar: webbläsarens taligenkänning kan behandla ljud genom en tjänst som webbläsaren väljer. Spelet tar inte emot eller sparar ljudinspelningen. Den färdiga transkriberingen skickas och sparas som din vanliga dialog. Fortsätt bara om du godkänner webbläsarens hantering av tal.';
item.append(message); item.append(message);
voiceController.speak(message.textContent, {role: 'narrator', voiceIdentity: 'neutral'});
addVoiceButton(item, 'Jag förstår – starta mikrofonen', () => { addVoiceButton(item, 'Jag förstår – starta mikrofonen', () => {
voiceDisclosureAcknowledged = true; voiceDisclosureAcknowledged = true;
item.remove(); item.remove();
@@ -242,13 +233,16 @@
function handleVoiceCommand(message, source) { function handleVoiceCommand(message, source) {
const parsed = window.GameVoice.parseVoiceCommand(message, {requirePrefix: source === 'voice'}); const parsed = window.GameVoice.parseVoiceCommand(message, {requirePrefix: source === 'voice'});
if (!parsed) return false; if (!parsed) return false;
if (parsed.command === 'settings') appendVoiceSettings(); if (parsed.command === 'settings') {
appendLine('assistant', 'Här kan du välja en svensk röst och justera talhastigheten. Alla repliker i spelet läses upp automatiskt.');
appendVoiceSettings();
}
else if (parsed.command === 'repeat') { else if (parsed.command === 'repeat') {
if (!voiceController.speak(latestAssistantText, latestAssistantVoice)) setVoiceStatus('Talsyntes stöds inte här eller är avstängd. Dialogen finns kvar som text.'); if (!voiceController.speak(latestAssistantText, latestAssistantVoice)) setVoiceStatus('Talsyntes stöds inte i den här webbläsaren. Dialogen finns kvar som text.');
} else if (parsed.command === 'stop') { } else if (parsed.command === 'stop') {
voiceController.cancelSpeech(); voiceController.stopListening(); setVoiceStatus('Röstinteraktionen är avbruten.'); voiceController.cancelSpeech(); voiceController.stopListening(); setVoiceStatus('Röstinteraktionen är avbruten.');
} else if (parsed.command === 'help') { } else if (parsed.command === 'help') {
appendLine('assistant', 'Skriv röstinställningar för röstlägen. I spelets röstläge kan du tala till mikrofonen eller skriva. Använd “röstkommando upprepa” för att höra senaste repliken igen.'); appendLine('assistant', 'Skriv röstinställningar för att välja svensk röst och talhastighet. Spelets repliker läses alltid upp. Du kan tala i mikrofonen när den finns, eller skriva. Använd “röstkommando upprepa” för att höra senaste repliken igen.');
} else setVoiceStatus('Okänt röstkommando. Skriv röstinställningar för alternativen.'); } else setVoiceStatus('Okänt röstkommando. Skriv röstinställningar för alternativen.');
return true; return true;
} }
@@ -280,6 +274,11 @@
auth.hidden = false; auth.hidden = false;
document.querySelector('#auth-heading').textContent = location.pathname === '/admin' ? 'Administratörsinloggning' : 'Svensk livssimulator'; document.querySelector('#auth-heading').textContent = location.pathname === '/admin' ? 'Administratörsinloggning' : 'Svensk livssimulator';
document.querySelector('#version-footer').hidden = false; document.querySelector('#version-footer').hidden = false;
const adminRoute = location.pathname === '/admin';
document.querySelector('#auth-intro').hidden = adminRoute;
document.querySelector('#auth-repeat-intro').hidden = adminRoute;
document.querySelector('[name="intent"][value="register"]').hidden = adminRoute;
if (!adminRoute) playFirstVisitIntroduction();
} }
function showPlay() { function showPlay() {
auth.hidden = true; auth.hidden = true;
@@ -290,11 +289,31 @@
messageField.focus(); messageField.focus();
} }
function showAdmin() { function showAdmin() {
voiceController.stopAll();
auth.hidden = true; auth.hidden = true;
play.hidden = true; play.hidden = true;
adminView.hidden = false; adminView.hidden = false;
document.querySelector('#version-footer').hidden = true; document.querySelector('#version-footer').hidden = true;
} }
const introductionKey = 'ai-spel.introduction-heard.v1';
function playFirstVisitIntroduction() {
let alreadyHeard = false;
try { alreadyHeard = localStorage.getItem(introductionKey) === 'true'; } catch (_) { /* Keep the intro available for this visit. */ }
if (alreadyHeard) return;
const introduction = document.querySelector('#auth-intro').textContent;
if (voiceController.speak(introduction, {role: 'narrator', voiceIdentity: 'neutral'})) {
try { localStorage.setItem(introductionKey, 'true'); } catch (_) { /* The page text remains available. */ }
}
}
document.querySelector('#auth-repeat-intro').addEventListener('click', () => {
if (location.pathname !== '/admin') voiceController.speak(document.querySelector('#auth-intro').textContent, {role: 'narrator', voiceIdentity: 'neutral'});
});
function explainAuthError(error, intent) {
if (intent === 'register' && error?.message === 'username_unavailable') return 'Det användarnamnet används redan. Välj ett annat och försök igen.';
if (intent === 'register' && ['invalid_username', 'password_length_must_be_12_to_256'].includes(error?.message)) return 'Kontot kunde inte skapas. Användarnamnet ska ha 3 till 40 bokstäver, siffror eller tecknen punkt, bindestreck och understreck. Lösenordet ska ha minst tolv tecken.';
if (intent === 'login' && ['unauthorized', 'invalid_credentials'].includes(error?.message)) return 'Användarnamnet eller lösenordet stämmer inte. Försök igen.';
return 'Begäran kunde inte genomföras just nu. Kontrollera uppgifterna och försök igen.';
}
function adminElement(tag, text, parent = adminContent) { function adminElement(tag, text, parent = adminContent) {
const element = document.createElement(tag); const element = document.createElement(tag);
if (text !== undefined) element.textContent = text; if (text !== undefined) element.textContent = text;
@@ -430,7 +449,9 @@
transcript.replaceChildren(); transcript.replaceChildren();
latestAssistantText = ''; latestAssistantText = '';
for (const line of lines) appendLine(line.role === 'user' ? 'user' : 'assistant', line.content, {speak: false}); for (const line of lines) appendLine(line.role === 'user' ? 'user' : 'assistant', line.content, {speak: false});
if (lines.length === 0 && gameState?.world) { if (lines.length > 0 && latestAssistantText && location.pathname !== '/admin') {
voiceController?.speak(latestAssistantText, {role: 'narrator', voiceIdentity: 'neutral'});
} else if (lines.length === 0 && gameState?.world) {
const place = gameState.world.location; const place = gameState.world.location;
const senses = place.senses || {}; const senses = place.senses || {};
appendLine('assistant', `Du börjar på ${place.name}. ${place.description} Ljud: ${senses.sound || 'Inget särskilt ljud beskrivet.'} Doft: ${senses.smell || 'Ingen särskild doft beskriven.'} Luft och temperatur: ${senses.air_and_temperature || 'Inte beskrivet.'} Beröring och ytor: ${senses.touch_and_surfaces || 'Inte beskrivet.'} Orientering: ${senses.spatial_orientation || 'Inte beskrivet.'}`); appendLine('assistant', `Du börjar på ${place.name}. ${place.description} Ljud: ${senses.sound || 'Inget särskilt ljud beskrivet.'} Doft: ${senses.smell || 'Ingen särskild doft beskriven.'} Luft och temperatur: ${senses.air_and_temperature || 'Inte beskrivet.'} Beröring och ytor: ${senses.touch_and_surfaces || 'Inte beskrivet.'} Orientering: ${senses.spatial_orientation || 'Inte beskrivet.'}`);
@@ -456,6 +477,7 @@
} }
async function handleLogout() { async function handleLogout() {
await request('/api/logout', {method: 'POST'}); await request('/api/logout', {method: 'POST'});
if (location.pathname !== '/admin') appendLine('assistant', 'Du har loggat ut. Logga in igen för att fortsätta spela.');
characterDraft = null; characterDraft = null;
gameState = null; gameState = null;
transcript.replaceChildren(); transcript.replaceChildren();
@@ -573,7 +595,10 @@
form.reset(); form.reset();
if (location.pathname === '/admin') await openAdmin(); else await openGame(); if (location.pathname === '/admin') await openAdmin(); else await openGame();
} catch (error) { } catch (error) {
window.alert(`Kunde inte logga in: ${error.message}`); const status = document.querySelector('#auth-status');
status.textContent = explainAuthError(error, intent);
status.hidden = false;
if (location.pathname !== '/admin') voiceController.speak(status.textContent, {role: 'narrator', voiceIdentity: 'neutral'});
} }
}); });
async function submitMessage(input, source = 'text') { async function submitMessage(input, source = 'text') {
+7 -3
View File
@@ -8,6 +8,12 @@ grep -q "request('/version')" index.html
grep -Fq "credentials: 'same-origin'" index.html grep -Fq "credentials: 'same-origin'" index.html
grep -q 'id="dialogue" aria-label="Dialoghistorik" role="log" aria-live="polite"' index.html grep -q 'id="dialogue" aria-label="Dialoghistorik" role="log" aria-live="polite"' index.html
grep -q 'id="message" name="message"' index.html grep -q 'id="message" name="message"' index.html
grep -q 'id="auth-intro"' index.html
grep -q 'Skapa konto med ett användarnamn och ett lösenord på minst tolv tecken' index.html
grep -q 'id="auth-repeat-intro"' index.html
grep -q 'function playFirstVisitIntroduction' index.html
grep -q "location.pathname !== '/admin'" index.html
grep -q "if (options.speak !== false && location.pathname !== '/admin')" index.html
grep -q 'id="microphone" aria-pressed="false"' index.html grep -q 'id="microphone" aria-pressed="false"' index.html
grep -q 'id="voice-status" role="status" aria-live="polite"' index.html grep -q 'id="voice-status" role="status" aria-live="polite"' index.html
grep -q "window.GameVoice.createVoiceController" index.html grep -q "window.GameVoice.createVoiceController" index.html
@@ -19,9 +25,7 @@ grep -q "role: 'narrator', voiceIdentity: 'neutral'" index.html
grep -q 'COPY voice.js /usr/share/nginx/html/voice.js' Dockerfile grep -q 'COPY voice.js /usr/share/nginx/html/voice.js' Dockerfile
grep -q "SpeechRecognition || win.webkitSpeechRecognition" voice.js grep -q "SpeechRecognition || win.webkitSpeechRecognition" voice.js
grep -q "submitMessage(text, 'voice')" index.html grep -q "submitMessage(text, 'voice')" index.html
grep -q "'screen-reader', 'Skärmläsare'" index.html grep -q 'Spelets repliker läses alltid upp' index.html
grep -q "'game-voice', 'Spelets röst'" index.html
grep -q "'text-only', 'Endast text'" index.html
grep -q "event.key === 'Enter' && !event.shiftKey" index.html grep -q "event.key === 'Enter' && !event.shiftKey" index.html
grep -q "request('/api/character/draft'" index.html grep -q "request('/api/character/draft'" index.html
grep -q "request('/api/world/draft'" index.html grep -q "request('/api/world/draft'" index.html
+5 -4
View File
@@ -151,10 +151,11 @@ test('TTS and microphone are mutually exclusive', () => {
assert.equal(h.controller.getState(), 'SPEAKING'); assert.equal(h.controller.getState(), 'SPEAKING');
}); });
test('speech is disabled in screen-reader mode and gracefully degrades without synthesis', () => { test('game speech stays automatic even with legacy text-only settings and gracefully degrades without synthesis', () => {
const screenReader = makeHarness(); const screenReader = makeHarness();
screenReader.controller.update({mode: 'screen-reader'}); screenReader.controller.update({mode: 'text-only', autoSpeak: false});
assert.equal(screenReader.controller.speak('dialog'), false); assert.equal(screenReader.controller.speak('dialog'), true);
assert.equal(screenReader.synthesis.spoken[0].text, 'dialog');
const noTts = makeHarness({tts: false}); const noTts = makeHarness({tts: false});
noTts.controller.update({mode: 'game-voice'}); noTts.controller.update({mode: 'game-voice'});
assert.equal(noTts.controller.speak('dialog'), false); assert.equal(noTts.controller.speak('dialog'), false);
@@ -180,7 +181,7 @@ test('settings survive reload and invalid storage falls back to accessible defau
const defaults = GameVoice.loadSettings(storage, 'new-player'); const defaults = GameVoice.loadSettings(storage, 'new-player');
assert.equal(defaults.mode, 'game-voice'); assert.equal(defaults.mode, 'game-voice');
assert.equal(defaults.autoSpeak, true); assert.equal(defaults.autoSpeak, true);
values.set('voice-v1', JSON.stringify({mode: 'screen-reader', autoSpeak: false, rate: 1.4, voiceName: 'Svensk', soundSignals: false})); values.set('voice-v1', JSON.stringify({mode: 'text-only', autoSpeak: false, rate: 1.4, voiceName: 'Svensk', soundSignals: false}));
const migrated = GameVoice.loadSettings(storage, 'voice-v2', 'voice-v1'); const migrated = GameVoice.loadSettings(storage, 'voice-v2', 'voice-v1');
assert.equal(migrated.mode, 'game-voice'); assert.equal(migrated.mode, 'game-voice');
assert.equal(migrated.autoSpeak, true); assert.equal(migrated.autoSpeak, true);
+5 -3
View File
@@ -42,11 +42,13 @@
} }
const stored = JSON.parse(current || '{}'); const stored = JSON.parse(current || '{}');
const settings = {...DEFAULT_SETTINGS, ...stored}; const settings = {...DEFAULT_SETTINGS, ...stored};
if (!['screen-reader', 'game-voice', 'text-only'].includes(settings.mode)) settings.mode = DEFAULT_SETTINGS.mode; // Game dialogue is always spoken. These assignments also migrate older
// local settings that allowed text-only or screen-reader-only playback.
settings.mode = 'game-voice';
settings.autoSpeak = true;
settings.rate = Math.min(2, Math.max(0.5, Number(settings.rate) || 1)); settings.rate = Math.min(2, Math.max(0.5, Number(settings.rate) || 1));
settings.voiceName = typeof settings.voiceName === 'string' ? settings.voiceName : ''; settings.voiceName = typeof settings.voiceName === 'string' ? settings.voiceName : '';
settings.speakerProfiles = settings.speakerProfiles && typeof settings.speakerProfiles === 'object' ? settings.speakerProfiles : {}; settings.speakerProfiles = settings.speakerProfiles && typeof settings.speakerProfiles === 'object' ? settings.speakerProfiles : {};
settings.autoSpeak = settings.autoSpeak === true;
settings.soundSignals = settings.soundSignals !== false; settings.soundSignals = settings.soundSignals !== false;
return settings; return settings;
} catch (_) { return {...DEFAULT_SETTINGS}; } } catch (_) { return {...DEFAULT_SETTINGS}; }
@@ -177,7 +179,7 @@
} }
function speak(text, voiceOptions = {}) { function speak(text, voiceOptions = {}) {
if (!text || settings.mode !== 'game-voice' || !win.speechSynthesis || !win.SpeechSynthesisUtterance) return false; if (!text || !win.speechSynthesis || !win.SpeechSynthesisUtterance) return false;
stopListening(); stopListening();
cancelSpeech(); cancelSpeech();
const token = generation; const token = generation;