server generated scanner voices!

This commit is contained in:
legop3
2026-06-13 17:53:52 -04:00
parent bddff1e67f
commit 61ace6e7c4
18 changed files with 323 additions and 3282 deletions
@@ -7,14 +7,17 @@ const logger = require('../../globals/logger').child('barcodeScannerService');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { getMode, MODES, modeEvents } = require('../modeManager');
const { sendAlert } = require('../alertService');
const { ensureAudioForText, warmAudioForTexts } = require('./ttsCache');
const DATA_DIR = resolveDataDir();
const REGISTRY_PATH = resolveDataPath('barcode-registry.json');
const RECENT_SCAN_LIMIT = 8;
const VALID_CODE_PATTERN = /^[a-z][0-9]{3}$/;
const SCANNER_SOCKET_ROOM = 'barcode-scanner';
let lastKnownGoodRegistry = null;
let lastRegistryError = null;
let lastPrewarmKey = '';
let state = {
lastScan: null,
recentScans: [],
@@ -103,6 +106,16 @@ function validateRegistry(rawRegistry) {
return { codes: normalizedCodes };
}
function prewarmRegistryAudio(registry) {
const labels = Object.values(registry?.codes || {})
.map((entry) => entry?.label)
.filter(Boolean);
const nextPrewarmKey = labels.slice().sort().join('\n');
if (!nextPrewarmKey || nextPrewarmKey === lastPrewarmKey) return;
lastPrewarmKey = nextPrewarmKey;
warmAudioForTexts(labels);
}
function loadRegistryForScan() {
try {
ensureRegistryFile();
@@ -111,6 +124,7 @@ function loadRegistryForScan() {
const registry = validateRegistry(parsed);
lastKnownGoodRegistry = registry;
lastRegistryError = null;
prewarmRegistryAudio(registry);
return { registry, error: null };
} catch (err) {
lastRegistryError = err.message;
@@ -136,7 +150,10 @@ function buildStatePayload() {
}
function broadcastState() {
io.emit('barcode:state', buildStatePayload());
// Scanner state is scoped to scanner clients because scan-specific packets can
// include generated audio. Driver/spectator/display pages should not receive
// barcode audio or scanner-only state traffic.
io.to(SCANNER_SOCKET_ROOM).emit('barcode:state', buildStatePayload());
}
function sendBarcodeScanAlert(result) {
@@ -217,7 +234,19 @@ function resolveScan(rawCode) {
};
}
function applyScan(rawCode) {
async function buildScanAudio(result) {
const text = String(result?.speechText || '').trim();
if (!text) return null;
const audio = await ensureAudioForText(text);
if (!audio?.buffer) return null;
return {
cacheKey: audio.cacheKey,
mime: audio.mime,
buffer: audio.buffer,
};
}
async function applyScan(rawCode) {
const result = resolveScan(rawCode);
// The latest result is the canonical display state. Recent scans are retained
// only for debugging and future scanner-page variants; the rover-facing first
@@ -228,15 +257,33 @@ function applyScan(rawCode) {
registryError: result.registryError || null,
};
broadcastState();
buildScanAudio(result)
.then((audio) => {
io.to(SCANNER_SOCKET_ROOM).emit('barcode:scanAudio', {
scan: result,
audio,
});
})
.catch((err) => {
logger.warn('Barcode scan audio emission failed', {
code: result.code,
error: err.message,
});
});
sendBarcodeScanAlert(result);
return result;
return { result };
}
io.on('connection', (socket) => {
socket.emit('barcode:state', buildStatePayload());
socket.on('barcode:scan', ({ code } = {}, cb = () => {}) => {
socket.on('barcode:subscribe', (_payload = {}, cb = () => {}) => {
socket.join(SCANNER_SOCKET_ROOM);
socket.emit('barcode:state', buildStatePayload());
cb({ success: true, state: buildStatePayload() });
});
socket.on('barcode:scan', async ({ code } = {}, cb = () => {}) => {
try {
const result = applyScan(code);
const { result } = await applyScan(code);
cb({ success: true, result, state: buildStatePayload() });
} catch (err) {
// Socket handlers should never let a malformed scan or registry edge case
@@ -0,0 +1,148 @@
// Barcode Scanner TTS Cache
// Purpose: Generates and caches scanner speech audio on the server with Kokoro.
// Scope: Keeps neural TTS completely outside the browser UI so scanner/audio experiments cannot freeze other web pages.
const crypto = require('crypto');
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const logger = require('../../globals/logger').child('barcodeScannerTts');
const { resolveDataDir } = require('../../helpers/dataPaths');
const CACHE_DIR = path.join(resolveDataDir(), 'barcode-tts-cache');
const MODEL_ID = 'onnx-community/Kokoro-82M-v1.0-ONNX';
const KOKORO_OPTIONS = {
// q8 keeps generation and model footprint smaller while preserving the voice
// quality that worked well in the browser experiment. Server generation is
// allowed to be slower than the scanner page, because cached files are reused.
dtype: 'q8',
device: 'cpu',
};
const VOICE_ID = 'af_bella';
const SPEED = 1;
let modelPromise = null;
const inFlightByText = new Map();
function normalizeSpeechText(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function cacheKeyForText(text) {
return crypto.createHash('sha256').update(text).digest('hex').slice(0, 24);
}
function cachePathForText(text) {
return path.join(CACHE_DIR, `${cacheKeyForText(text)}.wav`);
}
async function loadModel() {
if (!modelPromise) {
modelPromise = import('kokoro-js')
.then(async ({ KokoroTTS }) => {
logger.info('Loading Kokoro scanner TTS model');
const model = await KokoroTTS.from_pretrained(MODEL_ID, KOKORO_OPTIONS);
logger.info('Kokoro scanner TTS model ready');
return model;
})
.catch((err) => {
// Reset after a failed load so a future scan can retry after a transient
// model download or environment problem. Scanner clients will simply use
// browser fallback when no server audio is available.
modelPromise = null;
throw err;
});
}
return modelPromise;
}
async function readCachedAudio(text) {
const filePath = cachePathForText(text);
try {
return await fsp.readFile(filePath);
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to read scanner TTS cache file', { filePath, error: err.message });
}
return null;
}
}
async function generateAudio(text) {
await fsp.mkdir(CACHE_DIR, { recursive: true });
const model = await loadModel();
const audio = await model.generate(text, {
voice: VOICE_ID,
speed: SPEED,
});
const wavBuffer = Buffer.from(audio.toWav());
const filePath = cachePathForText(text);
await fsp.writeFile(filePath, wavBuffer);
return wavBuffer;
}
async function ensureAudioForText(text) {
const cleanText = normalizeSpeechText(text);
if (!cleanText) return null;
const cached = await readCachedAudio(cleanText);
if (cached) {
return {
cacheKey: cacheKeyForText(cleanText),
mime: 'audio/wav',
buffer: cached,
};
}
if (!inFlightByText.has(cleanText)) {
inFlightByText.set(
cleanText,
generateAudio(cleanText)
.catch((err) => {
logger.warn('Failed to generate scanner TTS audio', { text: cleanText, error: err.message });
return null;
})
.finally(() => {
inFlightByText.delete(cleanText);
}),
);
}
const generated = await inFlightByText.get(cleanText);
if (!generated) return null;
return {
cacheKey: cacheKeyForText(cleanText),
mime: 'audio/wav',
buffer: generated,
};
}
function warmAudioForTexts(texts = []) {
const uniqueTexts = Array.from(new Set(texts.map(normalizeSpeechText).filter(Boolean)));
if (!uniqueTexts.length) return;
// Prewarming should never block server startup or barcode resolution. It just
// fills the cache opportunistically so the scanner page usually receives an
// already-generated wav blob at scan time.
uniqueTexts.reduce(
(chain, text) =>
chain.then(async () => {
try {
await ensureAudioForText(text);
} catch (err) {
logger.warn('Scanner TTS prewarm failed', { text, error: err.message });
}
}),
Promise.resolve(),
);
}
function getCacheDir() {
fs.mkdirSync(CACHE_DIR, { recursive: true });
return CACHE_DIR;
}
module.exports = {
ensureAudioForText,
warmAudioForTexts,
getCacheDir,
};