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:
+158
-7
@@ -18,6 +18,10 @@
|
||||
#dialogue li[data-role="user"] { font-weight: 650; }
|
||||
#dialogue li[data-role="assistant"] { padding-inline-start: .75rem; border-inline-start: .2rem solid currentColor; }
|
||||
#message { width: 100%; min-height: 3.5rem; box-sizing: border-box; }
|
||||
#message-tools { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
|
||||
#voice-status { min-height: 1.5em; }
|
||||
#dialogue li[data-role="voice-controls"] { border: 1px solid currentColor; border-radius: .35rem; padding: .75rem; }
|
||||
#dialogue li[data-role="voice-controls"] fieldset { display: grid; gap: .5rem; margin-block: .5rem; }
|
||||
#admin-content { display: grid; gap: 1rem; }
|
||||
#admin-content section, #admin-content article { border: 1px solid currentColor; border-radius: .35rem; padding: .75rem; }
|
||||
#admin-content pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 32rem; overflow: auto; }
|
||||
@@ -45,8 +49,10 @@
|
||||
<form id="message-form">
|
||||
<label for="message" id="message-label">Skriv ett meddelande</label>
|
||||
<textarea id="message" name="message" maxlength="4000" aria-describedby="message-help" autocomplete="off"></textarea>
|
||||
<div id="message-tools"><button type="button" id="microphone" aria-pressed="false" aria-describedby="voice-status">Starta mikrofon</button></div>
|
||||
<span id="message-help" class="sr-only">Tryck Enter för att skicka. Tryck Skift och Enter för ny rad.</span>
|
||||
</form>
|
||||
<p id="voice-status" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
<section id="admin-view" aria-labelledby="admin-heading" hidden>
|
||||
<h1 id="admin-heading">Administration</h1>
|
||||
@@ -62,6 +68,7 @@
|
||||
</section>
|
||||
</main>
|
||||
<footer id="version-footer">Frontend <span id="frontend-version">__FRONTEND_VERSION__</span> · API <span id="api-version">hämtar version…</span></footer>
|
||||
<script src="/voice.js"></script>
|
||||
<script>
|
||||
const auth = document.querySelector('#auth');
|
||||
const play = document.querySelector('#play');
|
||||
@@ -76,6 +83,11 @@
|
||||
let gameState = null;
|
||||
let submitBusy = false;
|
||||
let pendingDialogue = null;
|
||||
let latestAssistantText = '';
|
||||
const voiceSettingsKey = 'ai-spel.voice-settings.v1';
|
||||
let voiceSettings = window.GameVoice.loadSettings(localStorage, voiceSettingsKey);
|
||||
let voiceController;
|
||||
let audioContext;
|
||||
let adminOffset = 0;
|
||||
const adminPageSize = 50;
|
||||
|
||||
@@ -85,17 +97,143 @@
|
||||
if (!response.ok) throw new Error(body?.error || `HTTP ${response.status}`);
|
||||
return body;
|
||||
}
|
||||
function appendLine(role, content) {
|
||||
function appendLine(role, content, options = {}) {
|
||||
const line = document.createElement('li');
|
||||
line.dataset.role = role;
|
||||
line.textContent = `${role === 'user' ? 'Du' : 'Berättelsen'}: ${content}`;
|
||||
line.textContent = `${options.speaker || (role === 'user' ? 'Du' : 'Berättelsen')}: ${content}`;
|
||||
transcript.append(line);
|
||||
line.scrollIntoView({block: 'end'});
|
||||
if (role === 'assistant') {
|
||||
latestAssistantText = content;
|
||||
if (options.speak !== false && !content.trim().endsWith('…') && !content.trim().endsWith('...')
|
||||
&& voiceSettings.mode === 'game-voice' && voiceSettings.autoSpeak && !play.hidden) {
|
||||
voiceController?.speak(content, options.voice || {});
|
||||
}
|
||||
}
|
||||
return line;
|
||||
}
|
||||
function setVoiceStatus(message = '') { document.querySelector('#voice-status').textContent = message; }
|
||||
function refreshMicrophone() {
|
||||
const button = document.querySelector('#microphone');
|
||||
if (button) button.disabled = play.hidden || messageField.disabled || submitBusy;
|
||||
}
|
||||
function persistVoiceSettings() {
|
||||
if (!window.GameVoice.saveSettings(localStorage, voiceSettingsKey, voiceSettings)) {
|
||||
setVoiceStatus('Röstinställningen kunde inte sparas i den här webbläsaren.');
|
||||
}
|
||||
voiceController?.update(voiceSettings);
|
||||
if (voiceSettings.mode !== 'game-voice') voiceController?.stopAll();
|
||||
else if (!voiceSettings.autoSpeak) voiceController?.cancelSpeech();
|
||||
transcript.setAttribute('aria-live', voiceSettings.mode === 'game-voice' ? 'off' : 'polite');
|
||||
}
|
||||
function playVoiceSignal(state) {
|
||||
if (!voiceSettings.soundSignals || !['LISTENING', 'PROCESSING', 'ERROR', 'STOPPED'].includes(state)) return;
|
||||
try {
|
||||
const Audio = window.AudioContext || window.webkitAudioContext;
|
||||
if (!Audio) return;
|
||||
audioContext ||= new Audio();
|
||||
if (audioContext.state === 'suspended') audioContext.resume();
|
||||
const oscillator = audioContext.createOscillator(); const gain = audioContext.createGain();
|
||||
oscillator.frequency.value = state === 'ERROR' ? 260 : state === 'PROCESSING' ? 820 : state === 'STOPPED' ? 420 : 560;
|
||||
gain.gain.value = 0.025; oscillator.connect(gain); gain.connect(audioContext.destination);
|
||||
oscillator.start(); oscillator.stop(audioContext.currentTime + 0.055);
|
||||
} catch (_) { /* Audio cues are optional and never block the game. */ }
|
||||
}
|
||||
voiceController = window.GameVoice.createVoiceController({
|
||||
settings: voiceSettings,
|
||||
onState(state, message) {
|
||||
const microphone = document.querySelector('#microphone');
|
||||
const listening = state === 'LISTENING';
|
||||
microphone.setAttribute('aria-pressed', String(listening));
|
||||
microphone.textContent = listening ? 'Avsluta mikrofon' : 'Starta mikrofon';
|
||||
refreshMicrophone();
|
||||
if (message) setVoiceStatus(message);
|
||||
playVoiceSignal(state === 'IDLE' && /Mikrofonen är avstängd/.test(message) ? 'STOPPED' : state);
|
||||
},
|
||||
onTranscript(text) {
|
||||
return submitMessage(text, 'voice');
|
||||
},
|
||||
});
|
||||
persistVoiceSettings();
|
||||
|
||||
function addVoiceButton(parent, label, handler) {
|
||||
const button = document.createElement('button'); button.type = 'button'; button.textContent = label;
|
||||
button.addEventListener('click', handler); parent.append(button); return button;
|
||||
}
|
||||
function appendVoiceSettings() {
|
||||
const item = document.createElement('li'); item.dataset.role = 'voice-controls';
|
||||
item.setAttribute('aria-label', 'Röstinställningar och uppläsning');
|
||||
const heading = document.createElement('strong'); heading.textContent = 'Röstinställningar'; item.append(heading);
|
||||
const fieldset = document.createElement('fieldset');
|
||||
const legend = document.createElement('legend'); legend.textContent = 'Välj hur dialogen ska läsas upp'; fieldset.append(legend);
|
||||
const modeLabel = document.createElement('label'); modeLabel.textContent = 'Läge';
|
||||
const mode = document.createElement('select');
|
||||
[['screen-reader', 'Skärmläsare'], ['game-voice', 'Spelets röst'], ['text-only', 'Endast text']].forEach(([value, label]) => {
|
||||
const option = document.createElement('option'); option.value = value; option.textContent = label; mode.append(option);
|
||||
});
|
||||
mode.value = voiceSettings.mode; modeLabel.append(mode); fieldset.append(modeLabel);
|
||||
const autoLabel = document.createElement('label'); const auto = document.createElement('input');
|
||||
auto.type = 'checkbox'; auto.checked = voiceSettings.autoSpeak;
|
||||
autoLabel.append(auto, document.createTextNode(' Läs nya repliker automatiskt')); fieldset.append(autoLabel);
|
||||
const speedLabel = document.createElement('label'); speedLabel.textContent = `Talhastighet: ${Number(voiceSettings.rate).toFixed(1)}`;
|
||||
const speed = document.createElement('input'); speed.type = 'range'; speed.min = '0.5'; speed.max = '2'; speed.step = '0.1'; speed.value = String(voiceSettings.rate);
|
||||
speed.setAttribute('aria-label', 'Talhastighet'); speed.addEventListener('input', () => { speedLabel.firstChild.textContent = `Talhastighet: ${Number(speed.value).toFixed(1)}`; });
|
||||
speedLabel.append(speed); fieldset.append(speedLabel);
|
||||
const voiceLabel = document.createElement('label'); voiceLabel.textContent = 'Svensk röst';
|
||||
const voice = document.createElement('select'); voice.setAttribute('aria-label', 'Svensk röst');
|
||||
const defaultVoice = document.createElement('option'); defaultVoice.value = ''; defaultVoice.textContent = 'Systemets standardröst'; voice.append(defaultVoice);
|
||||
function refreshVoices() {
|
||||
const selected = voiceSettings.voiceName;
|
||||
voice.replaceChildren(defaultVoice.cloneNode(true));
|
||||
voiceController.getVoices().filter(entry => /^sv(-|$)/i.test(entry.lang)).forEach(entry => {
|
||||
const option = document.createElement('option'); option.value = entry.name; option.textContent = `${entry.name} (${entry.lang})`; voice.append(option);
|
||||
});
|
||||
voice.value = selected;
|
||||
}
|
||||
refreshVoices();
|
||||
window.speechSynthesis?.addEventListener?.('voiceschanged', refreshVoices);
|
||||
voice.value = voiceSettings.voiceName; voiceLabel.append(voice); fieldset.append(voiceLabel);
|
||||
const signalLabel = document.createElement('label'); const signals = document.createElement('input');
|
||||
signals.type = 'checkbox'; signals.checked = voiceSettings.soundSignals;
|
||||
signalLabel.append(signals, document.createTextNode(' Ljudsignal vid röstinmatning')); fieldset.append(signalLabel);
|
||||
item.append(fieldset);
|
||||
const speakLatest = () => {
|
||||
if (!voiceController.speak(latestAssistantText)) setVoiceStatus('Talsyntes stöds inte här eller är avstängd. Dialogen finns kvar som text.');
|
||||
};
|
||||
addVoiceButton(item, 'Läs senaste repliken', speakLatest);
|
||||
addVoiceButton(item, 'Upprepa', speakLatest);
|
||||
addVoiceButton(item, 'Avbryt uppläsning', () => voiceController.cancelSpeech());
|
||||
mode.addEventListener('change', () => { voiceSettings.mode = mode.value; persistVoiceSettings(); });
|
||||
auto.addEventListener('change', () => { voiceSettings.autoSpeak = auto.checked; persistVoiceSettings(); });
|
||||
speed.addEventListener('change', () => { voiceSettings.rate = Number(speed.value); persistVoiceSettings(); });
|
||||
voice.addEventListener('change', () => { voiceSettings.voiceName = voice.value; persistVoiceSettings(); });
|
||||
signals.addEventListener('change', () => { voiceSettings.soundSignals = signals.checked; persistVoiceSettings(); });
|
||||
addVoiceButton(item, 'Stäng röstinställningar', () => item.remove());
|
||||
transcript.append(item); item.scrollIntoView({block: 'end'}); mode.focus();
|
||||
}
|
||||
function handleVoiceCommand(message, source) {
|
||||
const parsed = window.GameVoice.parseVoiceCommand(message, {requirePrefix: source === 'voice'});
|
||||
if (!parsed) return false;
|
||||
if (parsed.command === 'settings') appendVoiceSettings();
|
||||
else if (parsed.command === 'repeat') {
|
||||
if (!voiceController.speak(latestAssistantText)) setVoiceStatus('Talsyntes stöds inte här eller är avstängd. Dialogen finns kvar som text.');
|
||||
} else if (parsed.command === 'stop') {
|
||||
voiceController.cancelSpeech(); voiceController.stopListening(); setVoiceStatus('Röstinteraktionen är avbruten.');
|
||||
} else if (parsed.command === 'help') {
|
||||
appendLine('assistant', 'Skriv röstinställningar för röstlägen. I spelets röstläge kan du tala till mikrofonen eller skriva. Använd “röstkommando upprepa” för att höra senaste repliken igen.');
|
||||
} else setVoiceStatus('Okänt röstkommando. Skriv röstinställningar för alternativen.');
|
||||
return true;
|
||||
}
|
||||
document.querySelector('#microphone').addEventListener('click', () => {
|
||||
if (voiceController.getState() === 'LISTENING') voiceController.stopListening();
|
||||
else voiceController.startListening();
|
||||
});
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) voiceController.stopAll(); });
|
||||
window.addEventListener('pagehide', () => voiceController.stopAll());
|
||||
function setStage(next) {
|
||||
stage = next;
|
||||
messageField.disabled = false;
|
||||
refreshMicrophone();
|
||||
messageField.value = '';
|
||||
messageLabel.textContent = next === 'biography'
|
||||
? 'Berätta fritt om personen du vill spela'
|
||||
@@ -119,6 +257,7 @@
|
||||
adminView.hidden = true;
|
||||
play.hidden = false;
|
||||
document.querySelector('#version-footer').hidden = true;
|
||||
refreshMicrophone();
|
||||
messageField.focus();
|
||||
}
|
||||
function showAdmin() {
|
||||
@@ -260,7 +399,8 @@
|
||||
async function loadSavedDialogue() {
|
||||
const lines = await request('/api/dialogue');
|
||||
transcript.replaceChildren();
|
||||
for (const line of lines) appendLine(line.role === 'user' ? 'user' : 'assistant', line.content);
|
||||
latestAssistantText = '';
|
||||
for (const line of lines) appendLine(line.role === 'user' ? 'user' : 'assistant', line.content, {speak: false});
|
||||
if (lines.length === 0 && gameState?.world) {
|
||||
const place = gameState.world.location;
|
||||
const senses = place.senses || {};
|
||||
@@ -376,7 +516,10 @@
|
||||
gameState.world.version = result.world_version;
|
||||
gameState.world.world_time = result.world_time;
|
||||
pendingDialogue = null;
|
||||
appendLine('assistant', result.reply);
|
||||
const speakingNpc = result.intent === 'speak' ? gameState.world.npc : null;
|
||||
appendLine('assistant', result.reply, speakingNpc
|
||||
? {speaker: speakingNpc.name, voice: {characterId: speakingNpc.id}}
|
||||
: {speaker: 'Berättelsen'});
|
||||
} catch (error) {
|
||||
messageField.value = message;
|
||||
appendLine('assistant', `Meddelandet kunde inte behandlas: ${error.message}. Texten ligger kvar i inmatningen; skicka igen för att försöka på nytt.`);
|
||||
@@ -398,12 +541,15 @@
|
||||
window.alert(`Kunde inte logga in: ${error.message}`);
|
||||
}
|
||||
});
|
||||
document.querySelector('#message-form').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
async function submitMessage(input, source = 'text') {
|
||||
if (submitBusy || messageField.disabled) return;
|
||||
const message = messageField.value.trim();
|
||||
const message = String(input || '').trim();
|
||||
if (!message) return;
|
||||
if (handleVoiceCommand(message, source)) { messageField.value = ''; return; }
|
||||
submitBusy = true;
|
||||
refreshMicrophone();
|
||||
voiceController.stopListening();
|
||||
voiceController.cancelSpeech();
|
||||
messageField.value = '';
|
||||
try {
|
||||
if (stage === 'biography') await handleBiography(message);
|
||||
@@ -412,8 +558,13 @@
|
||||
appendLine('assistant', `Det gick inte att genomföra begäran: ${error.message}`);
|
||||
} finally {
|
||||
submitBusy = false;
|
||||
refreshMicrophone();
|
||||
if (!play.hidden && !messageField.disabled) messageField.focus();
|
||||
}
|
||||
}
|
||||
document.querySelector('#message-form').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
await submitMessage(messageField.value);
|
||||
});
|
||||
document.querySelectorAll('[data-admin-view]').forEach(button => button.addEventListener('click', async () => {
|
||||
adminContent.setAttribute('aria-busy', 'true');
|
||||
|
||||
Reference in New Issue
Block a user