mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
ptztts?
This commit is contained in:
@@ -91,12 +91,22 @@ function maybeSendAccessNotice(message, sendSystemMessage) {
|
||||
|
||||
function maybeSpeak(socket, message, ttsOptions) {
|
||||
if (!ttsOptions || !message?.roverId) return;
|
||||
/*
|
||||
TTS is a physical-rover capability backed by commandService and rover audio
|
||||
metadata. PTZ is only rover-like for chat identity, so a PTZ chat message
|
||||
should not try to speak through a non-existent rover record.
|
||||
*/
|
||||
if (String(message.roverId) === ptzCameraService.PTZ_CAMERA_ID) return;
|
||||
if (String(message.roverId) === ptzCameraService.PTZ_CAMERA_ID) {
|
||||
/*
|
||||
PTZ has no rover websocket, but it does have a real speaker behind the
|
||||
Reolink/neolink path. Keep PTZ routing here so chat remains the single
|
||||
place that decides whether a user's message should produce speech, while
|
||||
ptzCameraService owns camera-specific permissions and playback details.
|
||||
*/
|
||||
ptzCameraService.speakText(message.text, ttsOptions, socket)
|
||||
.then(() => {
|
||||
logger.info('PTZ TTS sent', { engine: ttsOptions.engine, socket: socket.id });
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn('PTZ TTS send failed', { error: err.message, socket: socket.id });
|
||||
});
|
||||
return;
|
||||
}
|
||||
const record = roverManager.rovers.get(message.roverId);
|
||||
const ttsEnabled = Boolean(record?.meta?.audio?.ttsEnabled);
|
||||
if (!ttsEnabled) return;
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
// PTZ Camera Audio Playback
|
||||
// Purpose: Generates server-side TTS files and sends them to the Reolink TrackMix speaker through neolink.
|
||||
// Scope: Owns file/cache/process details for PTZ speech only; PTZ ownership, chat identity, and camera motion stay in index.js.
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const { resolveDataDir } = require('../../helpers/dataPaths');
|
||||
|
||||
const DEFAULT_CAMERA_NAME = 'trackmix';
|
||||
const DEFAULT_MEDIA_PORT = 9000;
|
||||
const DEFAULT_NEOLINK_BIN = '/usr/local/bin/neolink';
|
||||
const DEFAULT_CHROMEGTTS_WAV_BIN = '/usr/local/bin/chromegtts-wav';
|
||||
const DEFAULT_ESPEAK_BIN = 'espeak';
|
||||
const DEFAULT_FLITE_BIN = 'flite';
|
||||
const DEFAULT_VOLUME = 1;
|
||||
const MAX_TEXT_CHARS = 512;
|
||||
const PLAYBACK_TIMEOUT_MS = 45000;
|
||||
|
||||
function clampNumber(value, fallback, min, max) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return fallback;
|
||||
return Math.max(min, Math.min(max, number));
|
||||
}
|
||||
|
||||
function normalizeText(text) {
|
||||
return String(text || '').replace(/\s+/g, ' ').trim().slice(0, MAX_TEXT_CHARS);
|
||||
}
|
||||
|
||||
function normalizeEngine(engine) {
|
||||
const value = String(engine || '').trim().toLowerCase();
|
||||
if (value === 'espeak' || value === 'e') return 'espeak';
|
||||
if (value === 'flite' || value === 'f') return 'flite';
|
||||
if (['chromegtts', 'googletts', 'gtts', 'google'].includes(value)) return 'chromegtts';
|
||||
return 'chromegtts';
|
||||
}
|
||||
|
||||
function tomlString(value) {
|
||||
/*
|
||||
The generated neolink config is intentionally tiny, so JSON string escaping
|
||||
is enough for TOML basic strings and avoids pulling in a TOML writer just to
|
||||
persist four operator-configured values.
|
||||
*/
|
||||
return JSON.stringify(String(value || ''));
|
||||
}
|
||||
|
||||
function cacheKeyFor(text, ttsOptions) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify({ text, ttsOptions }))
|
||||
.digest('hex')
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
function createPtzAudioPlayback(deps) {
|
||||
const {
|
||||
logger,
|
||||
cameraConfig,
|
||||
enabled,
|
||||
getSocketLabel,
|
||||
} = deps;
|
||||
|
||||
const audioConfig = cameraConfig.audio || {};
|
||||
const audioEnabled = audioConfig.enabled === undefined ? Boolean(enabled) : Boolean(audioConfig.enabled);
|
||||
const dataRoot = path.join(resolveDataDir(), 'ptz-camera-audio');
|
||||
const cacheDir = path.join(dataRoot, 'tts-cache');
|
||||
const configPath = path.join(dataRoot, 'neolink-trackmix.toml');
|
||||
const cameraName = String(audioConfig.neolinkCameraName || DEFAULT_CAMERA_NAME).trim() || DEFAULT_CAMERA_NAME;
|
||||
const mediaPort = Number(audioConfig.mediaPort) || DEFAULT_MEDIA_PORT;
|
||||
const neolinkBin = String(audioConfig.neolinkBin || process.env.NEOLINK_BIN || DEFAULT_NEOLINK_BIN).trim();
|
||||
const chromegttsWavBin = String(
|
||||
audioConfig.chromegttsWavBin || process.env.CHROMEGTTS_WAV_BIN || DEFAULT_CHROMEGTTS_WAV_BIN,
|
||||
).trim();
|
||||
const espeakBin = String(audioConfig.espeakBin || process.env.ESPEAK_BIN || DEFAULT_ESPEAK_BIN).trim();
|
||||
const fliteBin = String(audioConfig.fliteBin || process.env.FLITE_BIN || DEFAULT_FLITE_BIN).trim();
|
||||
const volume = clampNumber(audioConfig.volume, DEFAULT_VOLUME, 0, 4);
|
||||
const fliteDefaultVoice = String(audioConfig.fliteDefaultVoice || 'kal').trim();
|
||||
|
||||
let playbackProc = null;
|
||||
let playbackSeq = 0;
|
||||
|
||||
function getState() {
|
||||
return {
|
||||
enabled: audioEnabled,
|
||||
state: playbackProc ? 'playing' : 'idle',
|
||||
/*
|
||||
This state is sent to browser sessions through ptzCamera public state.
|
||||
Keep it operationally useful without leaking server filesystem layout or
|
||||
binary paths that are only meaningful to the Node process.
|
||||
*/
|
||||
cameraName,
|
||||
volume,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureNeolinkConfig() {
|
||||
await fsp.mkdir(dataRoot, { recursive: true });
|
||||
const host = String(cameraConfig.host || '').trim();
|
||||
const username = String(cameraConfig.username || '').trim();
|
||||
const password = String(cameraConfig.password || '');
|
||||
if (!host || !username || !password) {
|
||||
throw new Error('PTZ camera host/username/password required for audio playback');
|
||||
}
|
||||
|
||||
const body = [
|
||||
'bind = "127.0.0.1"',
|
||||
'',
|
||||
'[[cameras]]',
|
||||
`name = ${tomlString(cameraName)}`,
|
||||
`username = ${tomlString(username)}`,
|
||||
`password = ${tomlString(password)}`,
|
||||
`address = ${tomlString(`${host}:${mediaPort}`)}`,
|
||||
'stream = "subStream"',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
/*
|
||||
Write on every playback instead of trying to detect config drift. The file
|
||||
is small, and this guarantees a camera password/host change in config.yaml
|
||||
is reflected without an extra migration path or manual cleanup.
|
||||
*/
|
||||
await fsp.writeFile(configPath, body, { mode: 0o600 });
|
||||
return configPath;
|
||||
}
|
||||
|
||||
function spawnChecked(label, command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(command, args, {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
...options,
|
||||
});
|
||||
let stderr = '';
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}, options.timeoutMs || PLAYBACK_TIMEOUT_MS);
|
||||
proc.stderr?.on('data', (chunk) => {
|
||||
stderr = `${stderr}${String(chunk || '')}`.slice(-4000);
|
||||
});
|
||||
proc.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`${label} failed to start: ${err.message}`));
|
||||
});
|
||||
proc.on('exit', (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${label} exited code=${code} signal=${signal || 'none'} ${stderr.trim()}`.trim()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function renderEspeak(text, ttsOptions, filePath) {
|
||||
const args = ['-w', filePath];
|
||||
const pitch = clampNumber(ttsOptions.pitch, 50, 0, 99);
|
||||
if (pitch > 0) args.push('-p', String(Math.round(pitch)));
|
||||
args.push(text);
|
||||
await spawnChecked('espeak', espeakBin, args, { timeoutMs: 20000 });
|
||||
}
|
||||
|
||||
async function renderFlite(text, ttsOptions, filePath) {
|
||||
const args = ['-o', filePath];
|
||||
const voice = String(ttsOptions.voice || fliteDefaultVoice || '').trim();
|
||||
if (voice) args.push('-voice', voice);
|
||||
args.push('-t', text);
|
||||
await spawnChecked('flite', fliteBin, args, { timeoutMs: 20000 });
|
||||
}
|
||||
|
||||
async function renderChromeGoogleTts(text, ttsOptions, filePath) {
|
||||
const args = [
|
||||
'--text',
|
||||
text,
|
||||
'--voice',
|
||||
String(ttsOptions.voice || 'tpf'),
|
||||
'--pitch',
|
||||
String(clampNumber(ttsOptions.pitch, 1, 0.5, 2)),
|
||||
'--speed',
|
||||
String(clampNumber(ttsOptions.speed, 1, 0.5, 2)),
|
||||
'--output',
|
||||
filePath,
|
||||
];
|
||||
await spawnChecked('chromegtts-wav', chromegttsWavBin, args, { timeoutMs: 30000 });
|
||||
}
|
||||
|
||||
async function ensureTtsFile(text, rawOptions = {}) {
|
||||
const cleanText = normalizeText(text);
|
||||
if (!cleanText) throw new Error('PTZ TTS text required');
|
||||
const engine = normalizeEngine(rawOptions.engine);
|
||||
const ttsOptions = {
|
||||
engine,
|
||||
voice: typeof rawOptions.voice === 'string' ? rawOptions.voice.trim() : '',
|
||||
pitch: Number.isFinite(rawOptions.pitch) ? rawOptions.pitch : undefined,
|
||||
speed: Number.isFinite(rawOptions.speed) ? rawOptions.speed : undefined,
|
||||
};
|
||||
await fsp.mkdir(cacheDir, { recursive: true });
|
||||
const filePath = path.join(cacheDir, `${cacheKeyFor(cleanText, ttsOptions)}.wav`);
|
||||
try {
|
||||
const stat = await fsp.stat(filePath);
|
||||
if (stat.isFile() && stat.size > 44) return { filePath, engine, cached: true };
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') throw err;
|
||||
}
|
||||
|
||||
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp.wav`;
|
||||
/*
|
||||
Every renderer writes a normal WAV file. Neolink/GStreamer handles the
|
||||
final ADPCM talkback encoding that the Reolink camera expects, so the TTS
|
||||
renderer stays concerned only with faithfully matching the selected rover
|
||||
TTS engine's voice options.
|
||||
*/
|
||||
if (engine === 'espeak') await renderEspeak(cleanText, ttsOptions, tmpPath);
|
||||
else if (engine === 'flite') await renderFlite(cleanText, ttsOptions, tmpPath);
|
||||
else await renderChromeGoogleTts(cleanText, ttsOptions, tmpPath);
|
||||
|
||||
await fsp.rename(tmpPath, filePath);
|
||||
return { filePath, engine, cached: false };
|
||||
}
|
||||
|
||||
function stopActivePlayback(reason = 'replace') {
|
||||
if (!playbackProc) return;
|
||||
const proc = playbackProc;
|
||||
playbackProc = null;
|
||||
logger.info('Stopping PTZ TTS playback', { reason, pid: proc.pid || null });
|
||||
try {
|
||||
proc.kill('SIGTERM');
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (proc.exitCode == null && proc.signalCode == null) {
|
||||
try {
|
||||
proc.kill('SIGKILL');
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
async function playFile(filePath, context = {}) {
|
||||
if (!audioEnabled) throw new Error('PTZ audio disabled');
|
||||
const neolinkConfigPath = await ensureNeolinkConfig();
|
||||
const stat = await fsp.stat(filePath);
|
||||
if (!stat.isFile()) throw new Error(`PTZ TTS file is not a regular file: ${filePath}`);
|
||||
|
||||
stopActivePlayback('new-playback');
|
||||
const seq = ++playbackSeq;
|
||||
const args = [
|
||||
'talk',
|
||||
cameraName,
|
||||
'-c',
|
||||
neolinkConfigPath,
|
||||
'--volume',
|
||||
String(volume),
|
||||
'--file-path',
|
||||
filePath,
|
||||
];
|
||||
const proc = spawn(neolinkBin, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
playbackProc = proc;
|
||||
let stderr = '';
|
||||
const timer = setTimeout(() => {
|
||||
if (playbackProc === proc) stopActivePlayback('timeout');
|
||||
}, PLAYBACK_TIMEOUT_MS);
|
||||
|
||||
proc.stderr?.on('data', (chunk) => {
|
||||
stderr = `${stderr}${String(chunk || '')}`.slice(-4000);
|
||||
});
|
||||
proc.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
if (playbackProc === proc) playbackProc = null;
|
||||
logger.warn('PTZ TTS neolink spawn failed', { error: err.message, context });
|
||||
});
|
||||
proc.on('exit', (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
if (playbackProc === proc) playbackProc = null;
|
||||
if (code === 0 || signal === 'SIGTERM') {
|
||||
logger.info('PTZ TTS playback finished', { code, signal, context });
|
||||
return;
|
||||
}
|
||||
logger.warn('PTZ TTS playback failed', {
|
||||
code,
|
||||
signal,
|
||||
stderr: stderr.trim().slice(-1000),
|
||||
context,
|
||||
});
|
||||
});
|
||||
|
||||
logger.info('PTZ TTS playback started', {
|
||||
pid: proc.pid || null,
|
||||
filePath,
|
||||
engine: context.engine || null,
|
||||
actor: context.socketId ? getSocketLabel(context.socketId) : null,
|
||||
seq,
|
||||
});
|
||||
return { pid: proc.pid || null, seq };
|
||||
}
|
||||
|
||||
async function speakText(text, ttsOptions = {}, context = {}) {
|
||||
const rendered = await ensureTtsFile(text, ttsOptions);
|
||||
await playFile(rendered.filePath, {
|
||||
...context,
|
||||
engine: rendered.engine,
|
||||
cached: rendered.cached,
|
||||
});
|
||||
return rendered;
|
||||
}
|
||||
|
||||
return {
|
||||
getState,
|
||||
speakText,
|
||||
stopActivePlayback,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createPtzAudioPlayback,
|
||||
};
|
||||
@@ -18,6 +18,7 @@ const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
||||
const roverManager = require('../roverManager');
|
||||
const assignmentService = require('../assignmentService');
|
||||
const videoSessions = require('../videoSessions');
|
||||
const { createPtzAudioPlayback } = require('./audioPlayback');
|
||||
|
||||
const PTZ_CAMERA_ID = 'ptz-camera';
|
||||
const PTZ_STREAM_PATH = 'ptz-camera';
|
||||
@@ -91,6 +92,12 @@ let lastSnapshotState = null;
|
||||
const snapshotSubscribers = new Map();
|
||||
const socketSnapshotSubscriptions = new Map();
|
||||
const snapshotLastSentBySocket = new Map();
|
||||
const audioPlayback = createPtzAudioPlayback({
|
||||
logger,
|
||||
cameraConfig,
|
||||
enabled,
|
||||
getSocketLabel,
|
||||
});
|
||||
|
||||
function emitChange(reason = 'change') {
|
||||
events.emit('change', { reason, state: getPublicState() });
|
||||
@@ -302,6 +309,7 @@ function getPublicState(socket = null) {
|
||||
presetsError: state.presetsError,
|
||||
publisher: state.publisher,
|
||||
reolinkApi: state.reolinkApi,
|
||||
audio: audioPlayback.getState(),
|
||||
isOperator: Boolean(socketId && state.operatorSocketId === socketId),
|
||||
queuedPosition: socketId ? state.queue.indexOf(socketId) + 1 || null : null,
|
||||
canUse: socket ? canUsePtzFeature(socket) : false,
|
||||
@@ -327,6 +335,27 @@ function getChatTargetForSocket(socketId) {
|
||||
};
|
||||
}
|
||||
|
||||
function canSpeakThroughPtz(socket) {
|
||||
/*
|
||||
PTZ chat uses roverId for identity, but the camera has its own queue rather
|
||||
than a roverManager driver record. Match the rover TTS rule closely: the
|
||||
current operator may speak, and queued users may prepare/use TTS while they
|
||||
are in the camera queue. canUsePtzFeature keeps the normal VIP/admin/mode
|
||||
access gates in front of both cases.
|
||||
*/
|
||||
if (!canUsePtzFeature(socket)) return false;
|
||||
const socketId = socket?.id ? String(socket.id) : '';
|
||||
if (!socketId) return false;
|
||||
return state.operatorSocketId === socketId || state.queue.includes(socketId);
|
||||
}
|
||||
|
||||
async function speakText(text, ttsOptions = {}, socket = null) {
|
||||
if (!canSpeakThroughPtz(socket)) {
|
||||
throw new Error('Only the PTZ operator or queue can use PTZ TTS');
|
||||
}
|
||||
return audioPlayback.speakText(text, ttsOptions, { socketId: socket?.id || null });
|
||||
}
|
||||
|
||||
function callOnvif(method, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!onvifCam || typeof onvifCam[method] !== 'function') {
|
||||
@@ -1524,6 +1553,8 @@ module.exports = {
|
||||
ptzCameraEvents: events,
|
||||
getPublicState,
|
||||
getChatTargetForSocket,
|
||||
canSpeakThroughPtz,
|
||||
speakText,
|
||||
canRequestLiveVideo,
|
||||
disableEmittersForIdle,
|
||||
getReplaySource: () => enabled && isReplayEnabled()
|
||||
|
||||
Reference in New Issue
Block a user