Files
ai-spel-frontend/voice.js
T
Ein Anderssono 69184d1920
Build and publish frontend / publish (push) Successful in 38s
Speak onboarding and all game dialogue automatically
2026-10-15 10:54:10 +02:00

238 lines
11 KiB
JavaScript

/* 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 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 || !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] || {} : {};
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.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(); }
function supportsRecognition() { return Boolean(win.SpeechRecognition || win.webkitSpeechRecognition); }
return {startListening, stopListening, speak, cancelSpeech, stopAll, update, getState, getSettings, getVoices, supportsRecognition};
}
root.GameVoice = {createVoiceController, DEFAULT_SETTINGS, parseVoiceCommand, loadSettings, saveSettings, characterVoiceProfile};
})(typeof window !== 'undefined' ? window : globalThis);