/* Browser voice adapters. Kept independent of the game submission and API layer. */ (function exposeVoice(root) { const DEFAULT_SETTINGS = Object.freeze({ mode: 'screen-reader', autoSpeak: false, 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) { try { const stored = JSON.parse(storage.getItem(key) || '{}'); const settings = {...DEFAULT_SETTINGS, ...stored}; if (!['screen-reader', 'game-voice', 'text-only'].includes(settings.mode)) settings.mode = DEFAULT_SETTINGS.mode; 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.autoSpeak = settings.autoSpeak === true; 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 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 listeningTimer = null; function setState(next, message = '') { state = next; onState(next, message); } function cancelSpeech() { generation += 1; if (speechTimer !== null) win.clearTimeout(speechTimer); speechTimer = null; try { win.speechSynthesis?.cancel(); } catch (_) { /* Best effort. */ } if (!recognition) setState('IDLE'); } 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 || settings.mode !== 'game-voice' || !win.speechSynthesis || !win.SpeechSynthesisUtterance) return false; stopListening(); 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'; const profile = voiceOptions.characterId ? settings.speakerProfiles?.[voiceOptions.characterId] || {} : {}; utterance.rate = Math.min(2, Math.max(0.5, Number(voiceOptions.rate ?? profile.rate ?? settings.rate) || 1)); utterance.pitch = Math.min(2, Math.max(0, Number(voiceOptions.pitch ?? profile.pitch ?? 1) || 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.onend = () => { if (token !== generation) return; if (speechTimer !== null) win.clearTimeout(speechTimer); speechTimer = null; setState('IDLE'); }; utterance.onerror = () => { if (token !== generation) return; if (speechTimer !== null) win.clearTimeout(speechTimer); speechTimer = null; generation += 1; setState('IDLE', 'Uppläsningen avbröts. Dialogen finns kvar som text.'); }; setState('SPEAKING', 'Läser upp dialog.'); speechTimer = win.setTimeout(() => { if (token !== generation) return; generation += 1; try { win.speechSynthesis.cancel(); } catch (_) { /* Best effort. */ } speechTimer = null; setState('IDLE', 'Uppläsningen avslutades. Dialogen finns kvar som text.'); }, Math.max(30000, Math.min(180000, String(text).length * 180))); try { win.speechSynthesis.speak(utterance); } 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(); } return {startListening, stopListening, speak, cancelSpeech, stopAll, update, getState, getSettings, getVoices}; } root.GameVoice = {createVoiceController, DEFAULT_SETTINGS, parseVoiceCommand, loadSettings, saveSettings}; })(typeof window !== 'undefined' ? window : globalThis);