/* Browser voice adapters. Kept independent of the game submission and API layer. */ (function exposeVoice(root) { const DEFAULT_SETTINGS = Object.freeze({ mode: 'game-voice', autoSpeak: true, language: 'sv-SE', rate: 1, voiceName: '', speakerProfiles: {}, autoStop: true, soundSignals: true, }); function parseVoiceCommand(text, options = {}) { const value = String(text || '').trim(); if (/^röstinställningar[.!]?$/i.test(value)) return options.requirePrefix ? null : {command: 'settings'}; const match = value.match(/^röstkommando\s+(.+?)[.!]?$/i); if (!match) return null; const command = match[1].trim().replace(/[.!?]+$/, '').toLocaleLowerCase('sv-SE'); const aliases = { upprepa: 'repeat', 'läs igen': 'repeat', avbryt: 'stop', stopp: 'stop', paus: 'stop', hjälp: 'help', alternativ: 'settings', inställningar: 'settings', röstinställningar: 'settings', }; return {command: aliases[command] || 'unknown'}; } function loadSettings(storage, key, legacyKey) { try { const current = storage.getItem(key); if (!current && legacyKey) { const legacy = JSON.parse(storage.getItem(legacyKey) || '{}'); const migrated = { ...DEFAULT_SETTINGS, rate: legacy.rate, voiceName: legacy.voiceName, speakerProfiles: legacy.speakerProfiles, soundSignals: legacy.soundSignals, }; saveSettings(storage, key, migrated); return migrated; } const stored = JSON.parse(current || '{}'); const settings = {...DEFAULT_SETTINGS, ...stored}; // 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.voiceName = typeof settings.voiceName === 'string' ? settings.voiceName : ''; settings.speakerProfiles = settings.speakerProfiles && typeof settings.speakerProfiles === 'object' ? settings.speakerProfiles : {}; settings.soundSignals = settings.soundSignals !== false; return settings; } catch (_) { return {...DEFAULT_SETTINGS}; } } function saveSettings(storage, key, settings) { try { storage.setItem(key, JSON.stringify(settings)); return true; } catch (_) { return false; } } function characterVoiceProfile(voiceIdentity, characterId = '') { const identity = ['masculine', 'feminine', 'neutral'].includes(voiceIdentity) ? voiceIdentity : 'neutral'; let hash = 2166136261; for (const char of String(characterId)) hash = Math.imul(hash ^ char.charCodeAt(0), 16777619) >>> 0; const variation = (hash % 1000) / 1000; if (identity === 'masculine') return {pitch: 0.88 + variation * 0.08, rate: 0.96 + variation * 0.04}; if (identity === 'feminine') return {pitch: 1.06 + variation * 0.08, rate: 0.98 + variation * 0.04}; return {pitch: 0.98 + variation * 0.04, rate: 1}; } function createVoiceController(options = {}) { const win = options.window || root; const onState = options.onState || (() => {}); const onTranscript = options.onTranscript || (() => {}); const settings = {...DEFAULT_SETTINGS, ...(options.settings || {})}; let state = 'IDLE'; 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; onState(next, message); } function cancelSpeech() { generation += 1; if (speechTimer !== null) win.clearTimeout(speechTimer); if (speechStartTimer !== null) win.clearTimeout(speechStartTimer); speechTimer = null; 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); listeningTimer = null; recognition = null; generation += 1; if (message) setState('ERROR', message); setState('IDLE', message); } function stopListening() { if (!recognition) return; const old = recognition; recognition = null; generation += 1; if (listeningTimer !== null) win.clearTimeout(listeningTimer); listeningTimer = null; try { old.abort(); } catch (_) { try { old.stop(); } catch (_) { /* Already stopped. */ } } setState('IDLE', 'Mikrofonen är avstängd.'); } function startListening() { if (recognition) { stopListening(); return true; } const Recognition = win.SpeechRecognition || win.webkitSpeechRecognition; if (!Recognition) { setState('ERROR', 'Taligenkänning stöds inte här. Du kan fortfarande skriva.'); setState('IDLE', 'Taligenkänning stöds inte här. Du kan fortfarande skriva.'); return false; } cancelSpeech(); const token = ++generation; let instance; try { instance = new Recognition(); } catch (_) { setState('IDLE', 'Mikrofonen kunde inte startas. Kontrollera behörigheten och försök igen.'); return false; } recognition = instance; instance.lang = settings.language || 'sv-SE'; instance.continuous = false; instance.interimResults = false; let delivered = false; instance.onresult = event => { if (token !== generation || delivered) return; const result = event.results?.[event.resultIndex ?? 0]; if (!result?.isFinal) return; const text = String(result[0]?.transcript || '').trim(); if (!text) { finishRecognition(token, 'Inget tal kunde identifieras. Försök igen eller skriv.'); return; } delivered = true; if (listeningTimer !== null) win.clearTimeout(listeningTimer); listeningTimer = null; setState('PROCESSING', 'Tal identifierat.'); Promise.resolve().then(() => onTranscript(text)).then( () => finishRecognition(token), () => finishRecognition(token, 'Meddelandet kunde inte skickas. Du kan försöka igen eller skriva.') ); }; instance.onerror = event => { if (token !== generation) return; const messages = { 'not-allowed': 'Mikrofonbehörighet nekades. Tillåt mikrofonen i webbläsaren eller skriv i stället.', 'service-not-allowed': 'Taligenkänning är inte tillgänglig. Du kan skriva i stället.', 'audio-capture': 'Ingen mikrofon hittades. Anslut en mikrofon eller skriv i stället.', 'no-speech': 'Inget tal hördes. Tryck på mikrofonen och försök igen.', network: 'Taligenkänningens nätverkstjänst svarar inte. Försök igen eller skriv.', aborted: 'Taligenkänningen avbröts. Du kan starta den igen.', }; finishRecognition(token, messages[event.error] || 'Taligenkänningen avslutades oväntat. Försök igen eller skriv.'); }; instance.onend = () => { if (token !== generation || delivered || state === 'PROCESSING') return; finishRecognition(token, 'Mikrofonen avslutades. Tryck för att försöka igen eller skriv.'); }; setState('LISTENING', 'Lyssnar. Tryck på mikrofonen igen för att avsluta.'); listeningTimer = win.setTimeout(() => { if (token !== generation) return; try { instance.abort(); } catch (_) { /* Recognition may already have stopped. */ } finishRecognition(token, 'Mikrofonen stängdes efter en minut. Tryck för att försöka igen eller skriv.'); }, 60000); try { instance.start(); } catch (_) { finishRecognition(token, 'Mikrofonen kunde inte startas. Kontrollera behörigheten och försök igen.'); return false; } return true; } function availableVoices() { try { return win.speechSynthesis?.getVoices?.() || []; } catch (_) { return []; } } function speak(text, voiceOptions = {}) { if (!text || !win.speechSynthesis || !win.SpeechSynthesisUtterance) { setState('IDLE', 'Talsyntes stöds inte i den här webbläsaren. Dialogen finns kvar som text.'); return false; } stopListening(); 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; } utterance.lang = settings.language || 'sv-SE'; utterance.volume = 1; const profile = voiceOptions.characterId ? settings.speakerProfiles?.[voiceOptions.characterId] || {} : {}; const identityProfile = characterVoiceProfile(voiceOptions.voiceIdentity, voiceOptions.characterId); const isNarrator = voiceOptions.role === 'narrator'; const configuredRate = Number(voiceOptions.rate ?? profile.rate ?? settings.rate) || 1; utterance.rate = Math.min(2, Math.max(0.5, configuredRate * (isNarrator || voiceOptions.rate != null || profile.rate != null ? 1 : identityProfile.rate))); 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 voices = availableVoices(); 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; 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(); }; 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 = event => { if (token !== generation) return; if (speechTimer !== null) win.clearTimeout(speechTimer); if (speechStartTimer !== null) win.clearTimeout(speechStartTimer); speechTimer = null; speechStartTimer = null; currentUtterance = null; generation += 1; const blocked = event?.error === 'not-allowed' || event?.error === 'service-not-allowed'; const message = blocked ? 'Safari blockerade uppläsningen. Tryck på Läs senaste repliken för att starta ljudet.' : 'Uppläsningen avbröts. Tryck på Läs senaste repliken för att försöka igen. Dialogen finns kvar som text.'; setState('IDLE', message); }; setState('QUEUED', 'Startar uppläsningen.'); currentUtterance = utterance; speechStartTimer = win.setTimeout(() => { if (token !== generation || currentUtterance !== utterance) return; speechStartTimer = null; generation += 1; currentUtterance = null; if (speechTimer !== null) win.clearTimeout(speechTimer); speechTimer = null; // Drop a blocked native utterance now. A player retry can then call // speak() directly from its button gesture instead of following a // same-turn cancel() that older WebKit versions may discard. try { win.speechSynthesis.cancel(); } catch (_) { /* Best effort. */ } 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))); 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; } function update(patch) { Object.assign(settings, patch); return {...settings}; } function stopAll() { stopListening(); cancelSpeech(); } function getState() { return state; } function getSettings() { return {...settings}; } function getVoices() { return availableVoices(); } function supportsRecognition() { return Boolean(win.SpeechRecognition || win.webkitSpeechRecognition); } return {startListening, stopListening, speak, cancelSpeech, stopAll, activateFromUserGesture, update, getState, getSettings, getVoices, supportsRecognition}; } root.GameVoice = {createVoiceController, DEFAULT_SETTINGS, parseVoiceCommand, loadSettings, saveSettings, characterVoiceProfile}; })(typeof window !== 'undefined' ? window : globalThis);