Merge pull request #13 from legop3/fix-laptoprover-audio

Fix laptoprover tts and horn including gtts
This commit is contained in:
legop3
2026-07-07 00:34:03 -04:00
committed by GitHub
3 changed files with 402 additions and 75 deletions
+40 -30
View File
@@ -1,32 +1,36 @@
# ALSA routing for the Debian laptop rover profile. # Dedicated ALSA routing for the Debian laptop rover profile.
# #
# This file intentionally mirrors the logical device names used by the Pi rover # This intentionally mirrors pi/asound.conf as closely as a normal PC can:
# audio setup. roverd can keep sending horn audio to "horn", forwarded browser # one fixed hardware card, one dmix playback engine, separate softvol controls
# audio to "forward", and TTS to ALSA's default playback path without caring # for TTS/horn/forwarded audio, and a raw capture alias for the rover mic.
# which physical sound card is underneath the profile. #
# This is NOT meant to preserve normal desktop audio behavior. The laptop rover
# installer disables PipeWire/PulseAudio so roverd owns the audio hardware like
# the Raspberry Pi rover does. If the laptop's real speaker/mic card is not ALSA
# card 0, change the hw:0,0/card 0 references below to the card shown by:
# aplay -l
# arecord -l
pcm.roverd_playback { # Mix multiple playback clients in software with a fixed low-cost format.
type plug pcm.dmixer {
type dmix
# Use the system's first normal ALSA playback device as the physical sink. ipc_key 1024
# This avoids referencing "default" here, because this file replaces ipc_perm 0666
# pcm.!default below and using it as a slave would recurse. slave {
slave.pcm "sysdefault" pcm "hw:0,0"
} format S16_LE
rate 16000
pcm.roverd_capture { channels 1
type plug period_time 0
period_size 1024
# The media publisher records from "default"; with pcm.!default below that buffer_size 4096
# capture side resolves here. Keeping capture separate from playback lets the }
# asym default expose ordinary microphone input while playback goes through
# the TTS softvol path.
slave.pcm "sysdefault"
} }
# TTS volume control (used by default playback path).
pcm.tts_softvol { pcm.tts_softvol {
type softvol type softvol
slave.pcm "roverd_playback" slave.pcm "dmixer"
control { control {
name "TTSMaster" name "TTSMaster"
card 0 card 0
@@ -35,9 +39,10 @@ pcm.tts_softvol {
max_dB 12.0 max_dB 12.0
} }
# Horn volume control.
pcm.horn_softvol { pcm.horn_softvol {
type softvol type softvol
slave.pcm "roverd_playback" slave.pcm "dmixer"
control { control {
name "HornMaster" name "HornMaster"
card 0 card 0
@@ -46,9 +51,10 @@ pcm.horn_softvol {
max_dB 12.0 max_dB 12.0
} }
# Forwarded audio volume control.
pcm.forward_softvol { pcm.forward_softvol {
type softvol type softvol
slave.pcm "roverd_playback" slave.pcm "dmixer"
control { control {
name "ForwardMaster" name "ForwardMaster"
card 0 card 0
@@ -57,6 +63,7 @@ pcm.forward_softvol {
max_dB 12.0 max_dB 12.0
} }
# Per-source playback PCMs.
pcm.tts { pcm.tts {
type plug type plug
slave.pcm "tts_softvol" slave.pcm "tts_softvol"
@@ -72,14 +79,17 @@ pcm.forward {
slave.pcm "forward_softvol" slave.pcm "forward_softvol"
} }
# Capture alias used by laptop rover config defaults.
pcm.rovermic {
type plug
slave.pcm "hw:0,0"
}
# Defaults: TTS direct playback + raw capture on the dedicated laptop sound card.
pcm.!default { pcm.!default {
type asym type asym
# Existing TTS engines play to their default ALSA output, so default playback
# is intentionally the TTS path. This preserves the current TTS execution
# model while still making the TTS volume control meaningful on laptops.
playback.pcm "tts" playback.pcm "tts"
capture.pcm "roverd_capture" capture.pcm "rovermic"
} }
ctl.!default { ctl.!default {
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
import ctypes
import ctypes.util
import json
import os
import struct
import subprocess
import sys
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"
PLAYBACK_DEVICE = "tts"
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
_runtime_handles = []
def load_shared_library(path):
mode = ctypes.RTLD_GLOBAL | getattr(os, "RTLD_NOW", 0)
return ctypes.CDLL(path, mode=mode)
def preload_runtime_libraries():
# Laptop-only workaround: some ChromeOS libchrometts builds reference
# compiler helper symbols such as __udivmodti4 without declaring the runtime
# library as an ELF dependency. Loading common compiler runtimes globally
# first makes those symbols visible before ctypes loads libchrometts.so.
for name in ("gcc_s", "atomic", "stdc++", "c++", "c++abi"):
lib = ctypes.util.find_library(name)
if not lib:
continue
try:
_runtime_handles.append(load_shared_library(lib))
except OSError:
pass
preload_runtime_libraries()
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"))
class ChromeTTS:
def __init__(self):
self.lib = load_shared_library(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 speak_to_aplay(self, text, 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")
player = subprocess.Popen(
["aplay", "-q", "-D", PLAYBACK_DEVICE, "-r", SAMPLE_RATE, "-f", "FLOAT_LE", "-c", "1"],
stdin=subprocess.PIPE,
)
try:
frames_written = ctypes.c_size_t(0)
while self.lib.GoogleTtsReadBuffered(self.buffer, ctypes.byref(frames_written)) > 0:
frames = int(frames_written.value)
if frames > 0:
player.stdin.write(ctypes.string_at(self.buffer, frames * ctypes.sizeof(ctypes.c_float)))
player.stdin.close()
rc = player.wait()
if rc != 0:
raise RuntimeError(f"aplay exited with {rc}")
finally:
if player.poll() is None:
player.kill()
player.wait()
def shutdown(self):
self.lib.GoogleTtsShutdown()
def respond(payload):
sys.stdout.write(json.dumps(payload, separators=(",", ":")) + "\n")
sys.stdout.flush()
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 main():
try:
tts = ChromeTTS()
except Exception as exc:
respond({"ok": False, "error": str(exc)})
return 1
respond({"ok": True, "ready": True})
try:
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
request = json.loads(line)
tts.speak_to_aplay(
str(request.get("text") or ""),
str(request.get("voice") or DEFAULT_VOICE),
request.get("pitch", DEFAULT_PITCH),
request.get("speed", DEFAULT_SPEED),
)
respond({"ok": True})
except Exception as exc:
respond({"ok": False, "error": str(exc)})
finally:
tts.shutdown()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+129 -14
View File
@@ -1,23 +1,63 @@
#!/usr/bin/env bash #!/usr/bin/env bash
install_debian_laptop_deps() { install_debian_laptop_deps() {
# This profile is deliberately Debian-only. Using apt directly is simpler # The laptop rover is a dedicated appliance, not a normal desktop/laptop audio
# than adding a fake cross-distro layer, and it keeps the installed package # install. Keep this package set intentionally close to the Pi profile so the
# set easy to inspect on the actual rover laptop. # same roverd TTS/playback/capture code paths are available on both targets.
if command -v ffmpeg >/dev/null 2>&1 \ if command -v ffmpeg >/dev/null 2>&1 \
&& command -v arecord >/dev/null 2>&1 \ && command -v arecord >/dev/null 2>&1 \
&& command -v aplay >/dev/null 2>&1 \ && command -v aplay >/dev/null 2>&1 \
&& command -v amixer >/dev/null 2>&1 \ && command -v amixer >/dev/null 2>&1 \
&& command -v v4l2-ctl >/dev/null 2>&1 \ && command -v v4l2-ctl >/dev/null 2>&1 \
&& command -v flite >/dev/null 2>&1 \ && command -v flite >/dev/null 2>&1 \
&& command -v espeak >/dev/null 2>&1; then && command -v espeak >/dev/null 2>&1 \
log "Debian laptop media/audio dependencies already installed; skipping apt install" && command -v python3 >/dev/null 2>&1 \
&& command -v curl >/dev/null 2>&1 \
&& command -v xz >/dev/null 2>&1 \
&& command -v unzip >/dev/null 2>&1 \
&& ldconfig -p 2>/dev/null | grep -q 'libgcc_s\.so\.1' \
&& ldconfig -p 2>/dev/null | grep -q 'libstdc\+\+\.so\.6' \
&& ldconfig -p 2>/dev/null | grep -q 'libc++\.so\.1' \
&& ldconfig -p 2>/dev/null | grep -q 'libc++abi\.so\.1'; then
log "Debian laptop media/audio/TTS dependencies already installed; skipping apt install"
return return
fi fi
log "Installing Debian laptop dependencies (ffmpeg, ALSA tools, V4L2 tools, flite, espeak)..." log "Installing Debian laptop rover dependencies (ffmpeg, ALSA tools, V4L2 tools, flite/espeak, Chrome TTS runtime deps)..."
apt-get update apt-get update
apt-get install -y --no-install-recommends ffmpeg alsa-utils v4l-utils ca-certificates flite espeak apt-get install -y --no-install-recommends \
ffmpeg alsa-utils v4l-utils ca-certificates flite espeak python3 curl xz-utils unzip libasound2-plugins libgcc-s1 libstdc++6 libc++1 libc++abi1 \
|| apt-get install -y --no-install-recommends \
ffmpeg alsa-utils v4l-utils ca-certificates flite espeak python3 curl xz-utils unzip libasound2-plugins libgcc-s1 libstdc++6 libc++1-14 libc++abi1-14
}
disable_debian_laptop_desktop_audio_stack() {
# This profile is for a dedicated rover laptop. PipeWire/PulseAudio are good
# desktop defaults, but they can grab the hardware device and make the rover's
# root/systemd ALSA services fail or route through a moving per-user graph.
# Mask them globally and kill already-running instances so ALSA owns the box,
# which is the closest behavior to the Pi rover appliance setup.
log "Disabling desktop audio daemons for dedicated laptop rover audio"
local -a user_units=(
pipewire.service
pipewire.socket
pipewire-pulse.service
pipewire-pulse.socket
wireplumber.service
pulseaudio.service
pulseaudio.socket
)
if command -v systemctl >/dev/null 2>&1; then
systemctl --global disable "${user_units[@]}" >/dev/null 2>&1 || true
systemctl --global mask "${user_units[@]}" >/dev/null 2>&1 || true
fi
pkill -x pipewire >/dev/null 2>&1 || true
pkill -x pipewire-pulse >/dev/null 2>&1 || true
pkill -x wireplumber >/dev/null 2>&1 || true
pkill -x pulseaudio >/dev/null 2>&1 || true
} }
install_debian_laptop_audio_support() { install_debian_laptop_audio_support() {
@@ -26,17 +66,92 @@ install_debian_laptop_audio_support() {
return return
fi fi
# The laptop profile still uses roverd's existing audio contract: TTS plays
# to ALSA's default output, horn plays to the named "horn" device, and
# forwarded web audio plays to the named "forward" device. Installing one
# profile-specific asound.conf gives those paths independent softvol mixer
# controls without changing the TTS runtime code.
install -m 0644 pi/asound.debian-laptop.conf /etc/asound.conf install -m 0644 pi/asound.debian-laptop.conf /etc/asound.conf
log "Installed Debian laptop ALSA config to /etc/asound.conf" log "Installed dedicated Debian laptop ALSA config to /etc/asound.conf"
log "ALSA config updated; restarting audio clients or rebooting is recommended before testing laptop audio"
install -D -o root -g root -m 0755 pi/bin/chromegtts-daemon-laptop.py /usr/local/bin/chromegtts-daemon
log "Installed laptop chromegtts daemon"
install_google_tts_assets_laptop
log "ALSA config updated; reboot recommended before testing laptop rover audio"
}
install_google_tts_assets_laptop() {
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 tmp_dir
local lib_member=""
local member
if [[ -f "${asset_dir}/libchrometts.so" && -f "${voice_dir}/pipeline.pb" ]]; then
log "Google Chrome TTS assets already installed; skipping download"
return
fi
tmp_dir="$(mktemp -d)"
log "Downloading Google Chrome TTS assets for Debian laptop profile..."
if ! curl -L -o "${tmp_dir}/googletts-26.5.tar.xz" "$dist_url"; then
rm -rf "$tmp_dir"
log "WARNING: failed to download Google Chrome TTS assets; chromegtts will be unavailable"
return
fi
local -a candidate_libs=()
case "$(uname -m)" in
aarch64|arm64)
candidate_libs=(libchrometts_arm64.so)
;;
armv7l|armhf)
candidate_libs=(libchrometts_armv7.so)
;;
x86_64|amd64)
candidate_libs=(libchrometts_x86_64.so libchrometts_amd64.so libchrometts_x64.so libchrometts.so)
;;
i386|i686)
candidate_libs=(libchrometts_x86.so libchrometts_i386.so libchrometts.so)
;;
*)
log "WARNING: unsupported Chrome TTS architecture $(uname -m); skipping Google TTS assets"
rm -rf "$tmp_dir"
return
;;
esac
for member in "${candidate_libs[@]}"; do
if tar -tf "${tmp_dir}/googletts-26.5.tar.xz" "$member" >/dev/null 2>&1; then
lib_member="$member"
break
fi
done
if [[ -z "$lib_member" ]]; then
log "WARNING: no libchrometts library matching $(uname -m) found in Google TTS archive; chromegtts will be unavailable"
rm -rf "$tmp_dir"
return
fi
if ! tar -xf "${tmp_dir}/googletts-26.5.tar.xz" -C "$tmp_dir" en-us-x-multi.zvoice "$lib_member"; then
rm -rf "$tmp_dir"
log "WARNING: failed to unpack Google Chrome TTS assets; chromegtts will be unavailable"
return
fi
install -d -o root -g root -m 0755 "$asset_dir"
install -o root -g root -m 0644 "${tmp_dir}/${lib_member}" "${asset_dir}/libchrometts.so"
rm -rf "$voice_dir"
install -d -o root -g root -m 0755 "$voice_dir"
unzip -q "${tmp_dir}/en-us-x-multi.zvoice" -d "$voice_dir"
chown -R root:root "$asset_dir"
find "$asset_dir" -type d -exec chmod 0755 {} +
find "$asset_dir" -type f -exec chmod 0644 {} +
rm -rf "$tmp_dir"
log "Installed Google Chrome TTS assets to $asset_dir using $lib_member"
} }
install_debian_laptop_profile() { install_debian_laptop_profile() {
install_debian_laptop_deps install_debian_laptop_deps
disable_debian_laptop_desktop_audio_stack
install_debian_laptop_audio_support install_debian_laptop_audio_support
} }