mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
ptztts?
This commit is contained in:
@@ -5,6 +5,8 @@ create_2_Open_Interface_Spec.txt
|
||||
|
||||
logs
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pio
|
||||
.vscode/
|
||||
config.h
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,16 @@
|
||||
# make all bandwidth saving options toggleable in one centralized server config
|
||||
- multitab protection mode
|
||||
- allowed
|
||||
- verified only
|
||||
- not allowed
|
||||
- snapshots
|
||||
- non-turn snapshots
|
||||
- on (you see snapshots when its not your turn)
|
||||
- off (everyone gets full video all the time)
|
||||
- non-local spectator snapshots
|
||||
- on (external spectators are only allowed snapshots)
|
||||
- off (all spectators get full video)
|
||||
- external spectator access (new)
|
||||
- off (no one can access the spectate page externally)
|
||||
- on (everyone can access the spectate page externally)
|
||||
- anything else related to bandwidth savings should also get config
|
||||
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()
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
4. add admin ui for VIP and private requests instead of only through discord
|
||||
5. pull page / tab title from session, use the name from profile of interinstance if enabled, if not just default to old one
|
||||
6. make overcurrent limiter speed sensitive, slower fill at lower speeds
|
||||
7. add more background gap themes
|
||||
8. fix this:
|
||||
7. add feature chat commands, like /neato start, /lift up, etc
|
||||
8. add more background gap themes
|
||||
9. fix this:
|
||||
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
|
||||
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
|
||||
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
||||
|
||||
@@ -49,17 +49,37 @@ function resolveTtsSettings(settings) {
|
||||
|
||||
function useChatComposerSessionState(allowSpectatorInput) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
|
||||
const chatTargetId = useMemo(() => {
|
||||
/*
|
||||
PTZ is intentionally not a roster entry, but session users expose the
|
||||
current chat target for each socket. Prefer the self user entry so the TTS
|
||||
control follows PTZ queue/operation state instead of only physical rover
|
||||
assignment state.
|
||||
*/
|
||||
const self = users.find((entry) => entry?.socketId === socketId);
|
||||
return self?.roverId || currentRoverId || null;
|
||||
}, [currentRoverId, socketId, users]);
|
||||
|
||||
const rover = useMemo(
|
||||
() => roster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
|
||||
[currentRoverId, roster],
|
||||
() => roster.find((entry) => String(entry.id) === String(chatTargetId)) || null,
|
||||
[chatTargetId, roster],
|
||||
);
|
||||
const ptzTtsSupported = Boolean(
|
||||
ptz?.id &&
|
||||
String(chatTargetId) === String(ptz.id) &&
|
||||
ptz?.audio?.enabled &&
|
||||
(ptz?.isOperator || ptz?.queuedPosition),
|
||||
);
|
||||
|
||||
return {
|
||||
canChat: role !== 'spectator' || allowSpectatorInput,
|
||||
ttsSupported: Boolean(rover?.audio?.ttsEnabled),
|
||||
ttsSupported: Boolean(rover?.audio?.ttsEnabled || ptzTtsSupported),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -24,8 +24,11 @@ function detectSafari() {
|
||||
|
||||
function HudChatInput({ compact = false }) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
||||
const users = useSessionSelector((state) => state.session?.users || []);
|
||||
const roverRoster = useSessionSelector((state) => state.session?.roster || []);
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChatActions();
|
||||
const { value: ttsSettings } = useSettingsNamespace('tts', {
|
||||
// HUD chat shares the normal browser TTS defaults, but it has no visible
|
||||
@@ -41,11 +44,27 @@ function HudChatInput({ compact = false }) {
|
||||
const [sending, setSending] = useState(false);
|
||||
const canChat = role !== 'spectator';
|
||||
const hideHudChat = role === 'spectator';
|
||||
const chatTargetId = useMemo(() => {
|
||||
/*
|
||||
HUD chat does not render the full composer controls, but it still sends
|
||||
TTS payloads when the active target supports speech. PTZ appears only in
|
||||
the session user target, not the physical rover roster, so use the same
|
||||
self-target resolution as the full chat panel.
|
||||
*/
|
||||
const self = users.find((entry) => entry?.socketId === socketId);
|
||||
return self?.roverId || currentRoverId || null;
|
||||
}, [currentRoverId, socketId, users]);
|
||||
const rover = useMemo(
|
||||
() => roverRoster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
|
||||
[currentRoverId, roverRoster],
|
||||
() => roverRoster.find((entry) => String(entry.id) === String(chatTargetId)) || null,
|
||||
[chatTargetId, roverRoster],
|
||||
);
|
||||
const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
|
||||
const ptzTtsSupported = Boolean(
|
||||
ptz?.id &&
|
||||
String(chatTargetId) === String(ptz.id) &&
|
||||
ptz?.audio?.enabled &&
|
||||
(ptz?.isOperator || ptz?.queuedPosition),
|
||||
);
|
||||
const ttsSupported = Boolean(rover?.audio?.ttsEnabled || ptzTtsSupported);
|
||||
const ttsPayload = useMemo(() => {
|
||||
if (!ttsSupported) return null;
|
||||
const engine =
|
||||
|
||||
Reference in New Issue
Block a user