326 lines
15 KiB
JavaScript
326 lines
15 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 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. */ }
|
|
// 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');
|
|
}
|
|
|
|
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();
|
|
// 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;
|
|
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;
|
|
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 = () => {
|
|
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);
|