Add browser voice input and speech output
Build and publish frontend / publish (push) Failing after 4s
Build and publish frontend / publish (push) Failing after 4s
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
/* 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);
|
||||
Reference in New Issue
Block a user