adding barcodesses. piper slop tts

This commit is contained in:
legop3
2026-06-13 17:37:22 -04:00
parent d28d37575c
commit bddff1e67f
14 changed files with 3004 additions and 3889 deletions
@@ -1,114 +0,0 @@
// Kokoro Browser TTS Adapter
// Purpose: Runs scanner speech through Kokoro.js locally in the browser when available.
// Scope: Keeps the heavy model dependency behind a dynamic import so non-scanner pages do not load it.
const KOKORO_MODEL_ID = 'onnx-community/Kokoro-82M-v1.0-ONNX';
const KOKORO_OPTIONS = {
// q8 is the practical first pass for a scanner station: much smaller than
// fp32/fp16, still good enough for short object labels, and it runs on plain
// WASM so Firefox does not need WebGPU support.
dtype: 'q8',
device: 'wasm',
};
const KOKORO_VOICE = 'af_bella';
const KOKORO_SPEED = 1;
let kokoroModelPromise = null;
let currentAudio = null;
let currentObjectUrl = null;
function stopCurrentAudio() {
if (currentAudio) {
// Scanner scans can arrive close together. Stop the previous generated clip
// before starting the next one so the spoken label always matches the most
// recent server scan state.
currentAudio.pause();
currentAudio.removeAttribute('src');
currentAudio.load();
currentAudio = null;
}
if (currentObjectUrl) {
URL.revokeObjectURL(currentObjectUrl);
currentObjectUrl = null;
}
}
async function loadKokoroModel() {
if (!kokoroModelPromise) {
kokoroModelPromise = import('kokoro-js')
.then(async ({ KokoroTTS }) => {
console.info('[scanner tts] loading Kokoro model');
const model = await KokoroTTS.from_pretrained(KOKORO_MODEL_ID, {
...KOKORO_OPTIONS,
progress_callback: (progress) => {
// The normal scanner page stays clean, but console progress is very
// useful when first-load model downloads are slow on the scanner PC.
if (progress?.status) {
console.info('[scanner tts] Kokoro load', progress);
}
},
});
console.info('[scanner tts] Kokoro model ready');
return model;
})
.catch((err) => {
// Reset the promise after a failure so a later scan can retry. This is
// important for first-load network hiccups while still letting the hook
// fall back to browser speech immediately.
kokoroModelPromise = null;
throw err;
});
}
return kokoroModelPromise;
}
function playBlob(blob) {
stopCurrentAudio();
currentObjectUrl = URL.createObjectURL(blob);
currentAudio = new Audio(currentObjectUrl);
currentAudio.volume = 1;
return new Promise((resolve, reject) => {
const audio = currentAudio;
audio.onended = () => {
if (currentAudio === audio) {
stopCurrentAudio();
}
resolve(true);
};
audio.onerror = () => {
if (currentAudio === audio) {
stopCurrentAudio();
}
reject(new Error('Kokoro audio playback failed'));
};
audio.play().catch((err) => {
if (currentAudio === audio) {
stopCurrentAudio();
}
reject(err);
});
});
}
export async function speakWithKokoro(text) {
const cleanText = String(text || '').replace(/\s+/g, ' ').trim();
if (!cleanText) return false;
try {
const model = await loadKokoroModel();
const audio = await model.generate(cleanText, {
voice: KOKORO_VOICE,
speed: KOKORO_SPEED,
});
await playBlob(audio.toBlob());
return true;
} catch (err) {
console.warn('[scanner tts] Kokoro unavailable; falling back to browser speech', err);
return false;
}
}
export function stopKokoroSpeech() {
stopCurrentAudio();
}
@@ -0,0 +1,117 @@
// Piper Browser TTS Adapter
// Purpose: Runs scanner speech through Piper locally in the browser when available.
// Scope: Keeps the neural TTS package behind a dynamic import so non-scanner pages do not load it.
const PIPER_VOICE_ID = 'en_US-hfc_female-medium';
const PIPER_LIVE_TIMEOUT_MS = 900;
let piperModulePromise = null;
let currentAudio = null;
let currentObjectUrl = null;
let activeRequestId = 0;
function stopCurrentAudio() {
if (currentAudio) {
// Scanner labels should never overlap. Stop the previous clip before the
// next scan speaks so audio always matches the current large display text.
currentAudio.pause();
currentAudio.removeAttribute('src');
currentAudio.load();
currentAudio = null;
}
if (currentObjectUrl) {
URL.revokeObjectURL(currentObjectUrl);
currentObjectUrl = null;
}
}
async function loadPiperModule() {
if (!piperModulePromise) {
piperModulePromise = import('@mintplex-labs/piper-tts-web')
.then((module) => {
console.info('[scanner tts] Piper module ready');
return module;
})
.catch((err) => {
// Reset after failures so later scans can try again after a transient
// model/CDN/cache issue. The hook falls back to browser speech for the
// current scan rather than blocking the scanner interaction.
piperModulePromise = null;
throw err;
});
}
return piperModulePromise;
}
function playBlob(blob) {
stopCurrentAudio();
currentObjectUrl = URL.createObjectURL(blob);
currentAudio = new Audio(currentObjectUrl);
currentAudio.volume = 1;
return new Promise((resolve, reject) => {
const audio = currentAudio;
audio.onended = () => {
if (currentAudio === audio) {
stopCurrentAudio();
}
resolve(true);
};
audio.onerror = () => {
if (currentAudio === audio) {
stopCurrentAudio();
}
reject(new Error('Piper audio playback failed'));
};
audio.play().catch((err) => {
if (currentAudio === audio) {
stopCurrentAudio();
}
reject(err);
});
});
}
export async function speakWithPiper(text) {
const cleanText = String(text || '').replace(/\s+/g, ' ').trim();
if (!cleanText) return false;
const requestId = activeRequestId + 1;
activeRequestId = requestId;
try {
const wavBlob = await Promise.race([
loadPiperModule().then((piper) =>
piper.predict(
{
text: cleanText,
voiceId: PIPER_VOICE_ID,
},
(progress) => {
// Piper stores downloaded models in the browser origin-private file
// system. Console progress is enough for setup/debugging while the
// rover-facing scanner screen stays uncluttered.
if (progress?.url) {
console.info('[scanner tts] Piper load', progress);
}
},
),
),
new Promise((resolve) => {
window.setTimeout(() => resolve(null), PIPER_LIVE_TIMEOUT_MS);
}),
]);
if (!wavBlob || requestId !== activeRequestId) {
return false;
}
await playBlob(wavBlob);
return true;
} catch (err) {
console.warn('[scanner tts] Piper unavailable; falling back to browser speech', err);
return false;
}
}
export function stopPiperSpeech() {
activeRequestId += 1;
stopCurrentAudio();
}
@@ -2,7 +2,7 @@
// Purpose: Speaks server-provided scan labels on the scanner computer.
// Scope: Keeps browser TTS as a local output device while leaving scan meaning and phrase selection on the server.
import { useEffect, useRef } from 'react';
import { speakWithKokoro, stopKokoroSpeech } from './scannerTts/kokoroBrowserTts.js';
import { speakWithPiper, stopPiperSpeech } from './scannerTts/piperBrowserTts.js';
const SPEECH_START_TIMEOUT_MS = 1500;
const SPEECH_RETRY_DELAY_MS = 700;
@@ -54,7 +54,7 @@ export default function useScannerSpeech(scan) {
return () => {
window.clearTimeout(retryTimerRef.current);
window.clearTimeout(startTimerRef.current);
stopKokoroSpeech();
stopPiperSpeech();
};
}, []);
@@ -123,11 +123,12 @@ export default function useScannerSpeech(scan) {
function speakOnce() {
if (cancelled) return;
clearSpeechTimers();
stopKokoroSpeech();
// Kokoro is attempted first because it gives the scanner page a consistent
// local model voice instead of relying on the OS/browser Web Speech voice
// list. The old browser TTS path remains the reliability fallback.
speakWithKokoro(speechText).then((spoken) => {
stopPiperSpeech();
// Piper is attempted first because it is lighter than Kokoro while still
// giving the scanner page a consistent local model voice. The old browser
// TTS path remains the reliability fallback if Piper is still loading or
// fails on the scanner computer.
speakWithPiper(speechText).then((spoken) => {
if (cancelled || spoken) return;
speakWithBrowserFallback();
});
@@ -138,7 +139,7 @@ export default function useScannerSpeech(scan) {
return () => {
cancelled = true;
clearSpeechTimers();
stopKokoroSpeech();
stopPiperSpeech();
};
}, [scan]);
}