mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
ptztts?
This commit is contained in:
Executable
+171
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Chrome Google TTS WAV renderer.
|
||||
|
||||
Purpose: Converts the same local ChromeOS Google TTS assets used by rovers into
|
||||
server-side WAV files that can be handed to another playback transport.
|
||||
Scope: This script only renders one utterance to a file; device playback and
|
||||
camera delivery stay owned by Node services.
|
||||
"""
|
||||
import argparse
|
||||
import ctypes
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import wave
|
||||
|
||||
|
||||
ASSET_ROOT = "/opt/roverd/googletts"
|
||||
LIB_PATH = os.path.join(ASSET_ROOT, "libchrometts.so")
|
||||
VOICE_DIR = os.path.join(ASSET_ROOT, "en-us-x-multi-r30")
|
||||
PIPELINE = "pipeline.pb"
|
||||
SAMPLE_RATE = 24000
|
||||
MAX_TEXT_CHARS = 512
|
||||
|
||||
VOICES = {
|
||||
"sfg": "female",
|
||||
"iob": "female",
|
||||
"iog": "female",
|
||||
"iol": "male",
|
||||
"iom": "male",
|
||||
"tpc": "female",
|
||||
"tpd": "male",
|
||||
"tpf": "female",
|
||||
}
|
||||
DEFAULT_VOICE = "tpf"
|
||||
DEFAULT_PITCH = 1.0
|
||||
DEFAULT_SPEED = 1.0
|
||||
MIN_PITCH = 0.5
|
||||
MAX_PITCH = 2.0
|
||||
MIN_SPEED = 0.5
|
||||
MAX_SPEED = 2.0
|
||||
|
||||
|
||||
def varint(value):
|
||||
out = bytearray()
|
||||
while value >= 0x80:
|
||||
out.append((value & 0x7F) | 0x80)
|
||||
value >>= 7
|
||||
out.append(value)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def field_bytes(number, payload):
|
||||
return varint((number << 3) | 2) + varint(len(payload)) + payload
|
||||
|
||||
|
||||
def field_float(number, value):
|
||||
return varint((number << 3) | 5) + struct.pack("<f", float(value))
|
||||
|
||||
|
||||
def build_utterance(text, pitch=1.0, speed=1.0):
|
||||
params = field_float(2, pitch) + field_float(3, speed)
|
||||
msg_b = field_bytes(1, text.encode("utf-8")) + field_bytes(20, params)
|
||||
msg_a = field_bytes(1, msg_b)
|
||||
return field_bytes(1, msg_a)
|
||||
|
||||
|
||||
def build_speaker(name, gender):
|
||||
return field_bytes(1, name.encode("utf-8")) + field_bytes(2, gender.encode("utf-8"))
|
||||
|
||||
|
||||
def clamp_float(value, minimum, maximum, fallback):
|
||||
try:
|
||||
value = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
if value <= 0:
|
||||
return fallback
|
||||
if value < minimum:
|
||||
return minimum
|
||||
if value > maximum:
|
||||
return maximum
|
||||
return value
|
||||
|
||||
|
||||
def float_to_s16le(samples):
|
||||
pcm = bytearray()
|
||||
for sample in samples:
|
||||
clipped = max(-1.0, min(1.0, float(sample)))
|
||||
pcm.extend(struct.pack("<h", int(clipped * 32767)))
|
||||
return bytes(pcm)
|
||||
|
||||
|
||||
class ChromeTTS:
|
||||
def __init__(self):
|
||||
self.lib = ctypes.CDLL(LIB_PATH)
|
||||
self.lib.GoogleTtsInit.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
||||
self.lib.GoogleTtsInit.restype = ctypes.c_bool
|
||||
self.lib.GoogleTtsInitBuffered.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int, ctypes.c_int]
|
||||
self.lib.GoogleTtsInitBuffered.restype = ctypes.c_bool
|
||||
self.lib.GoogleTtsGetFramesInAudioBuffer.argtypes = []
|
||||
self.lib.GoogleTtsGetFramesInAudioBuffer.restype = ctypes.c_size_t
|
||||
self.lib.GoogleTtsReadBuffered.argtypes = [
|
||||
ctypes.POINTER(ctypes.c_float),
|
||||
ctypes.POINTER(ctypes.c_size_t),
|
||||
]
|
||||
self.lib.GoogleTtsReadBuffered.restype = ctypes.c_int
|
||||
self.lib.GoogleTtsShutdown.argtypes = []
|
||||
self.lib.GoogleTtsShutdown.restype = None
|
||||
|
||||
voice_dir = os.path.abspath(VOICE_DIR) + os.sep
|
||||
pipeline = os.path.join(voice_dir, PIPELINE)
|
||||
if not self.lib.GoogleTtsInit(pipeline.encode("utf-8"), voice_dir.encode("utf-8")):
|
||||
raise RuntimeError("GoogleTtsInit failed")
|
||||
self.frames = int(self.lib.GoogleTtsGetFramesInAudioBuffer())
|
||||
if self.frames <= 0:
|
||||
raise RuntimeError("invalid Google TTS audio buffer size")
|
||||
self.buffer = (ctypes.c_float * self.frames)()
|
||||
|
||||
def render_wav(self, text, output_path, voice, pitch=DEFAULT_PITCH, speed=DEFAULT_SPEED):
|
||||
voice = voice if voice in VOICES else DEFAULT_VOICE
|
||||
pitch = clamp_float(pitch, MIN_PITCH, MAX_PITCH, DEFAULT_PITCH)
|
||||
speed = clamp_float(speed, MIN_SPEED, MAX_SPEED, DEFAULT_SPEED)
|
||||
text = text.strip()
|
||||
if not text:
|
||||
raise ValueError("text required")
|
||||
text = text[:MAX_TEXT_CHARS]
|
||||
|
||||
utterance = build_utterance(text, pitch=pitch, speed=speed)
|
||||
speaker = build_speaker(voice, VOICES[voice])
|
||||
if not self.lib.GoogleTtsInitBuffered(utterance, speaker, len(utterance), len(speaker)):
|
||||
raise RuntimeError("GoogleTtsInitBuffered failed")
|
||||
|
||||
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
|
||||
with wave.open(output_path, "wb") as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(SAMPLE_RATE)
|
||||
frames_written = ctypes.c_size_t(0)
|
||||
while self.lib.GoogleTtsReadBuffered(self.buffer, ctypes.byref(frames_written)) > 0:
|
||||
count = int(frames_written.value)
|
||||
if count > 0:
|
||||
wav.writeframes(float_to_s16le(self.buffer[:count]))
|
||||
|
||||
def shutdown(self):
|
||||
self.lib.GoogleTtsShutdown()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Render Chrome Google TTS to a WAV file.")
|
||||
parser.add_argument("--text", required=True)
|
||||
parser.add_argument("--voice", default=DEFAULT_VOICE)
|
||||
parser.add_argument("--pitch", type=float, default=DEFAULT_PITCH)
|
||||
parser.add_argument("--speed", type=float, default=DEFAULT_SPEED)
|
||||
parser.add_argument("--output", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
tts = ChromeTTS()
|
||||
try:
|
||||
tts.render_wav(args.text, args.output, args.voice, args.pitch, args.speed)
|
||||
finally:
|
||||
tts.shutdown()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc:
|
||||
sys.stderr.write(f"chromegtts-wav failed: {exc}\n")
|
||||
raise SystemExit(1)
|
||||
@@ -2,8 +2,12 @@
|
||||
set -euo pipefail
|
||||
|
||||
MEDIAMTX_VERSION="1.15.3"
|
||||
NEOLINK_VERSION="0.6.2"
|
||||
MEDIAMTX_BASE_URL="https://github.com/bluenviron/mediamtx/releases/download/v${MEDIAMTX_VERSION}"
|
||||
NEOLINK_BASE_URL="https://github.com/QuantumEntangledAndy/neolink/releases/download/v${NEOLINK_VERSION}"
|
||||
MEDIAMTX_BIN="/usr/local/bin/mediamtx"
|
||||
NEOLINK_BIN="/usr/local/bin/neolink"
|
||||
CHROMEGTTS_WAV_BIN="/usr/local/bin/chromegtts-wav"
|
||||
MEDIAMTX_CONF_DIR="/etc/mediamtx"
|
||||
MEDIAMTX_CONFIG="$MEDIAMTX_CONF_DIR/mediamtx.yml"
|
||||
ROVER_SNAPSHOT_WRITER_BIN="/usr/local/bin/rover-snapshot-writer.sh"
|
||||
@@ -29,6 +33,49 @@ SERVER_DIR="$SCRIPT_DIR"
|
||||
CONFIG_PATH="$SERVER_DIR/config.yaml"
|
||||
MEDIAMTX_TEMPLATE="$SERVER_DIR/mediamtx/mediamtx.yml"
|
||||
ROVER_SNAPSHOT_WRITER_TEMPLATE="$SERVER_DIR/mediamtx/rover-snapshot-writer.sh"
|
||||
CHROMEGTTS_WAV_TEMPLATE="$SERVER_DIR/bin/chromegtts-wav.py"
|
||||
|
||||
install_google_tts_assets() {
|
||||
local asset_dir="/opt/roverd/googletts"
|
||||
local voice_dir="${asset_dir}/en-us-x-multi-r30"
|
||||
local dist_url="https://storage.googleapis.com/chromeos-localmirror/distfiles/googletts-26.5.tar.xz"
|
||||
local lib_member=""
|
||||
local arch_name
|
||||
arch_name=$(uname -m)
|
||||
|
||||
# The PTZ camera is not a rover, so Google speech must be synthesized on the
|
||||
# server before neolink sends a WAV to the camera. These assets are the same
|
||||
# offline ChromeOS local TTS assets that rover installers already use; keeping
|
||||
# the layout identical lets the server helper and rover daemon share loader
|
||||
# assumptions.
|
||||
if [[ -f "${asset_dir}/libchrometts.so" && -f "${voice_dir}/pipeline.pb" ]]; then
|
||||
echo " Google TTS assets already installed"
|
||||
return
|
||||
fi
|
||||
|
||||
case "$arch_name" in
|
||||
x86_64|amd64)
|
||||
lib_member="libchrometts_x86_64.so"
|
||||
;;
|
||||
aarch64)
|
||||
lib_member="libchrometts_arm64.so"
|
||||
;;
|
||||
armv7l|armv6l)
|
||||
lib_member="libchrometts_armv7.so"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported Google TTS architecture: $arch_name" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo " Installing Google TTS assets -> $asset_dir"
|
||||
curl -L -o "$tmpdir/googletts-26.5.tar.xz" "$dist_url"
|
||||
tar -xf "$tmpdir/googletts-26.5.tar.xz" -C "$tmpdir" en-us-x-multi.zvoice "$lib_member"
|
||||
mkdir -p "$voice_dir"
|
||||
tar -xf "$tmpdir/en-us-x-multi.zvoice" -C "$voice_dir"
|
||||
install -o root -g root -m 0644 "$tmpdir/$lib_member" "${asset_dir}/libchrometts.so"
|
||||
}
|
||||
|
||||
echo "[1/6] Installing dependencies..."
|
||||
# The Kinect tooling uses a native libfreenect worker/probe rather than a
|
||||
@@ -40,9 +87,19 @@ dnf install -y \
|
||||
npm \
|
||||
curl \
|
||||
tar \
|
||||
unzip \
|
||||
xz \
|
||||
gcc-c++ \
|
||||
make \
|
||||
pkgconf-pkg-config \
|
||||
flite \
|
||||
espeak \
|
||||
python3 \
|
||||
gstreamer1 \
|
||||
gstreamer1-plugins-base \
|
||||
gstreamer1-plugins-good \
|
||||
gstreamer1-plugins-bad-free \
|
||||
gstreamer1-rtsp-server \
|
||||
libfreenect \
|
||||
libfreenect-devel \
|
||||
libusb1-devel >/dev/null
|
||||
@@ -62,6 +119,13 @@ EOF
|
||||
chmod 644 "$KINECT_UDEV_RULE"
|
||||
udevadm control --reload-rules
|
||||
|
||||
if [[ ! -f "$CHROMEGTTS_WAV_TEMPLATE" ]]; then
|
||||
echo "Chrome Google TTS WAV helper missing at $CHROMEGTTS_WAV_TEMPLATE" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " Installing Chrome Google TTS WAV helper -> $CHROMEGTTS_WAV_BIN"
|
||||
install -m 0755 "$CHROMEGTTS_WAV_TEMPLATE" "$CHROMEGTTS_WAV_BIN"
|
||||
|
||||
echo "[2/6] Installing Node production deps..."
|
||||
runuser -u "$TARGET_USER" -- bash -c "cd '$SERVER_DIR' && npm install --production"
|
||||
|
||||
@@ -83,12 +147,15 @@ arch=$(uname -m)
|
||||
case "$arch" in
|
||||
x86_64|amd64)
|
||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_amd64.tar.gz"
|
||||
neolink_pkg="neolink_linux_x86_64_ubuntu.zip"
|
||||
;;
|
||||
aarch64)
|
||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_arm64.tar.gz"
|
||||
neolink_pkg="neolink_linux_arm64.zip"
|
||||
;;
|
||||
armv7l)
|
||||
mediamtx_pkg="mediamtx_v${MEDIAMTX_VERSION}_linux_armv7.tar.gz"
|
||||
neolink_pkg="neolink_linux_armhf.zip"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: $arch" >&2
|
||||
@@ -101,6 +168,20 @@ curl -L "$MEDIAMTX_BASE_URL/$mediamtx_pkg" -o "$tmpdir/mediamtx.tgz"
|
||||
tar -xzf "$tmpdir/mediamtx.tgz" -C "$tmpdir" mediamtx
|
||||
install -m 0755 "$tmpdir/mediamtx" "$MEDIAMTX_BIN"
|
||||
|
||||
echo " Installing neolink ${NEOLINK_VERSION} -> $NEOLINK_BIN"
|
||||
curl -L "$NEOLINK_BASE_URL/$neolink_pkg" -o "$tmpdir/neolink.zip"
|
||||
unzip -q "$tmpdir/neolink.zip" -d "$tmpdir/neolink"
|
||||
neolink_extracted=$(find "$tmpdir/neolink" -type f -name neolink -perm /111 | head -n 1)
|
||||
if [[ -z "$neolink_extracted" ]]; then
|
||||
neolink_extracted=$(find "$tmpdir/neolink" -type f -name neolink | head -n 1)
|
||||
fi
|
||||
if [[ -z "$neolink_extracted" ]]; then
|
||||
echo "neolink binary missing from $neolink_pkg" >&2
|
||||
exit 1
|
||||
fi
|
||||
install -m 0755 "$neolink_extracted" "$NEOLINK_BIN"
|
||||
install_google_tts_assets
|
||||
|
||||
mkdir -p "$MEDIAMTX_CONF_DIR"
|
||||
if [[ ! -f "$MEDIAMTX_TEMPLATE" ]]; then
|
||||
echo "mediaMTX template missing at $MEDIAMTX_TEMPLATE" >&2
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -78,7 +78,7 @@
|
||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-DN9WPSGg.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BCATTSly.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BN3kEVFL.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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