Add browser-local Piper WebAssembly speech
Build and publish frontend / publish (push) Failing after 6s
Build and publish frontend / publish (push) Failing after 6s
This commit is contained in:
@@ -82,6 +82,9 @@
|
||||
let listeningTimer = null;
|
||||
let currentUtterance = null;
|
||||
let speechPrimed = false;
|
||||
let piperAudioContext = null;
|
||||
let piperSource = null;
|
||||
let piperAudioUnlocked = false;
|
||||
|
||||
function setState(next, message = '') {
|
||||
state = next;
|
||||
@@ -95,19 +98,34 @@
|
||||
speechTimer = null;
|
||||
speechStartTimer = null;
|
||||
const wasQueued = currentUtterance !== null;
|
||||
const wasPiper = currentUtterance?.kind === 'piper';
|
||||
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 (wasPiper) {
|
||||
try { piperSource?.stop(); } catch (_) { /* Already stopped. */ }
|
||||
piperSource = null;
|
||||
} else {
|
||||
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() {
|
||||
const AudioContext = win.AudioContext || win.webkitAudioContext;
|
||||
if (win.GamePiper && AudioContext) {
|
||||
try {
|
||||
piperAudioContext ||= new AudioContext();
|
||||
if (piperAudioContext.state === 'suspended') piperAudioContext.resume().catch(() => {});
|
||||
piperAudioUnlocked = true;
|
||||
return true;
|
||||
} catch (_) { /* Fall through to the browser speech adapter. */ }
|
||||
}
|
||||
if (!win.speechSynthesis || !win.SpeechSynthesisUtterance) return false;
|
||||
if (speechPrimed) return true;
|
||||
// iOS Safari requires speech output to be initiated by a user gesture.
|
||||
@@ -211,7 +229,81 @@
|
||||
try { return win.speechSynthesis?.getVoices?.() || []; } catch (_) { return []; }
|
||||
}
|
||||
|
||||
function speakWithPiper(text, voiceOptions) {
|
||||
const AudioContext = win.AudioContext || win.webkitAudioContext;
|
||||
if (!AudioContext || !piperAudioUnlocked) return false;
|
||||
stopListening();
|
||||
if (currentUtterance) cancelSpeech();
|
||||
const token = ++generation;
|
||||
const playback = {kind: 'piper', token};
|
||||
currentUtterance = playback;
|
||||
setState('QUEUED', 'Förbereder svensk uppläsning. Första gången kan en röstmodell på cirka 63 MB behöva hämtas till enheten.');
|
||||
|
||||
if (speechTimer !== null) win.clearTimeout(speechTimer);
|
||||
speechTimer = win.setTimeout(() => {
|
||||
if (token !== generation) return;
|
||||
generation += 1;
|
||||
currentUtterance = null;
|
||||
try { piperSource?.stop(); } catch (_) { /* Best effort. */ }
|
||||
piperSource = null;
|
||||
speechTimer = null;
|
||||
setState('IDLE', 'Piper tog för lång tid på sig. Dialogen finns kvar som text. Tryck på Läs senaste repliken för att försöka igen.');
|
||||
}, 180000);
|
||||
|
||||
let lastProgress = -1;
|
||||
const progress = event => {
|
||||
if (token !== generation || !event?.total) return;
|
||||
const percent = Math.min(100, Math.floor((event.loaded / event.total) * 100));
|
||||
if (percent === lastProgress) return;
|
||||
lastProgress = percent;
|
||||
setState('PROCESSING', `Hämtar svensk röstmodell till enheten: ${percent} procent.`);
|
||||
};
|
||||
|
||||
Promise.resolve()
|
||||
.then(() => win.GamePiper.synthesize(String(text), progress))
|
||||
.then(async blob => {
|
||||
if (token !== generation) return;
|
||||
piperAudioContext ||= new AudioContext();
|
||||
if (piperAudioContext.state === 'suspended') await piperAudioContext.resume();
|
||||
const buffer = await piperAudioContext.decodeAudioData(await blob.arrayBuffer());
|
||||
if (token !== generation) return;
|
||||
const source = piperAudioContext.createBufferSource();
|
||||
const profile = voiceOptions.characterId ? settings.speakerProfiles?.[voiceOptions.characterId] || {} : {};
|
||||
const identity = characterVoiceProfile(voiceOptions.voiceIdentity, voiceOptions.characterId);
|
||||
const narrator = voiceOptions.role === 'narrator';
|
||||
const requestedRate = Number(voiceOptions.rate ?? profile.rate ?? settings.rate) || 1;
|
||||
source.buffer = buffer;
|
||||
source.playbackRate.value = Math.min(2, Math.max(0.5, requestedRate * (narrator || voiceOptions.rate != null || profile.rate != null ? 1 : identity.rate)));
|
||||
const pitch = narrator ? 1 : Number(voiceOptions.pitch ?? profile.pitch ?? identity.pitch) || 1;
|
||||
if (source.detune) source.detune.value = Math.min(600, Math.max(-600, (pitch - 1) * 1200));
|
||||
source.connect(piperAudioContext.destination);
|
||||
source.onended = () => {
|
||||
if (token !== generation) return;
|
||||
if (speechTimer !== null) win.clearTimeout(speechTimer);
|
||||
speechTimer = null;
|
||||
currentUtterance = null;
|
||||
piperSource = null;
|
||||
setState('IDLE');
|
||||
};
|
||||
piperSource = source;
|
||||
source.start();
|
||||
if (typeof voiceOptions.onStart === 'function') voiceOptions.onStart();
|
||||
setState('SPEAKING', 'Läser upp dialogen med svensk röst.');
|
||||
})
|
||||
.catch(error => {
|
||||
if (token !== generation) return;
|
||||
if (speechTimer !== null) win.clearTimeout(speechTimer);
|
||||
speechTimer = null;
|
||||
currentUtterance = null;
|
||||
piperSource = null;
|
||||
generation += 1;
|
||||
setState('IDLE', `Piper kunde inte skapa eller spela upp ljudet${error?.message ? `: ${error.message}` : '.'} Dialogen finns kvar som text. Tryck på Läs senaste repliken för att försöka igen.`);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function speak(text, voiceOptions = {}) {
|
||||
if (text && win.GamePiper?.synthesize && speakWithPiper(text, voiceOptions)) return true;
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user