chrome tts

This commit is contained in:
legop3
2026-06-01 00:56:01 -04:00
parent e108eaa4eb
commit ac93d3227b
15 changed files with 588 additions and 56 deletions
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
import ctypes
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
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 = 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 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())
+62 -6
View File
@@ -21,6 +21,7 @@ The script must run from the repository root and as root (sudo). It will:
* create system users/groups if needed
* install /usr/local/bin/roverd and /etc/roverd.yaml
* install /usr/local/bin/video/audio helpers and systemd units
* install fixed-location Google Chrome TTS assets for roverd
* enable roverd.service and media publisher/listener services
USAGE
}
@@ -190,15 +191,70 @@ install_audio_support() {
log "ALSA config updated; reboot recommended for overlay + audio changes"
fi
log "Installing TTS/audio packages (flite, espeak)..."
log "Installing TTS/audio packages (flite, espeak, Chrome TTS runtime deps)..."
# check for flite and espeak before installing, and then install them if either is missing
if command -v flite >/dev/null 2>&1 && command -v espeak >/dev/null 2>&1; then
log "TTS packages flite and espeak already installed; skipping apt install"
if command -v flite >/dev/null 2>&1 \
&& command -v espeak >/dev/null 2>&1 \
&& 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 \
&& command -v aplay >/dev/null 2>&1 \
&& ldconfig -p 2>/dev/null | grep -q 'libc++\.so\.1' \
&& ldconfig -p 2>/dev/null | grep -q 'libc++abi\.so\.1'; then
log "Core TTS packages already installed; skipping apt install"
else
apt-get update
apt-get install -y --no-install-recommends flite espeak python3 curl xz-utils unzip alsa-utils libc++1 libc++abi1 \
|| apt-get install -y --no-install-recommends flite espeak python3 curl xz-utils unzip alsa-utils libc++1-14 libc++abi1-14
fi
install -D -o root -g root -m 0755 pi/bin/chromegtts-daemon.py /usr/local/bin/chromegtts-daemon
log "Installed chromegtts daemon"
install_google_tts_assets
}
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 tmp_dir
local lib_member
case "$(uname -m)" in
aarch64|arm64)
lib_member="libchrometts_arm64.so"
;;
armv7l|armhf)
lib_member="libchrometts_armv7.so"
;;
*)
log "WARNING: unsupported Chrome TTS architecture $(uname -m); skipping Google TTS assets"
return
;;
esac
if [[ -f "${asset_dir}/libchrometts.so" && -f "${voice_dir}/pipeline.pb" ]]; then
log "Google Chrome TTS assets already installed; skipping download"
return
fi
apt-get update
apt-get install -y --no-install-recommends flite espeak
tmp_dir="$(mktemp -d)"
log "Downloading Google Chrome TTS assets..."
curl -L -o "${tmp_dir}/googletts-26.5.tar.xz" "$dist_url"
tar -xf "${tmp_dir}/googletts-26.5.tar.xz" -C "$tmp_dir" en-us-x-multi.zvoice "$lib_member"
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"
}
# Install video publisher assets
+161
View File
@@ -0,0 +1,161 @@
package roverd
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log"
"os"
"os/exec"
"sync"
)
const chromeTTSDaemonPath = "/usr/local/bin/chromegtts-daemon"
type chromeTTSDaemon struct {
log *log.Logger
mu sync.Mutex
cmd *exec.Cmd
stdin io.WriteCloser
scanner *bufio.Scanner
}
func NewChromeTTSDaemon(logger *log.Logger) *chromeTTSDaemon {
return &chromeTTSDaemon{log: logger}
}
func (d *chromeTTSDaemon) Start(ctx context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
return d.startLocked(ctx)
}
func (d *chromeTTSDaemon) Speak(ctx context.Context, text, voice string, pitch, speed float64) error {
d.mu.Lock()
defer d.mu.Unlock()
if err := d.startLocked(ctx); err != nil {
return err
}
payload := map[string]any{
"text": text,
"voice": voice,
"pitch": pitch,
"speed": speed,
}
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
if _, err := d.stdin.Write(append(encoded, '\n')); err != nil {
d.stopLocked()
return fmt.Errorf("write chromegtts request: %w", err)
}
type result struct {
response chromeTTSResponse
err error
}
done := make(chan result, 1)
go func(scanner *bufio.Scanner) {
var response chromeTTSResponse
if !scanner.Scan() {
done <- result{err: fmt.Errorf("chromegtts daemon stopped")}
return
}
if err := json.Unmarshal(scanner.Bytes(), &response); err != nil {
done <- result{err: fmt.Errorf("decode chromegtts response: %w", err)}
return
}
done <- result{response: response}
}(d.scanner)
select {
case <-ctx.Done():
d.stopLocked()
return ctx.Err()
case res := <-done:
if res.err != nil {
d.stopLocked()
return res.err
}
if !res.response.OK {
return fmt.Errorf("chromegtts failed: %s", res.response.Error)
}
return nil
}
}
func (d *chromeTTSDaemon) Shutdown() {
d.mu.Lock()
defer d.mu.Unlock()
d.stopLocked()
}
type chromeTTSResponse struct {
OK bool `json:"ok"`
Ready bool `json:"ready,omitempty"`
Error string `json:"error,omitempty"`
}
func (d *chromeTTSDaemon) startLocked(ctx context.Context) error {
if d.cmd != nil && d.cmd.ProcessState == nil {
return nil
}
cmd := exec.Command(chromeTTSDaemonPath)
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("start chromegtts daemon: %w", err)
}
scanner := bufio.NewScanner(stdout)
var ready chromeTTSResponse
if !scanner.Scan() {
_ = cmd.Process.Kill()
_ = cmd.Wait()
return fmt.Errorf("chromegtts daemon exited before ready")
}
if err := json.Unmarshal(scanner.Bytes(), &ready); err != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
return fmt.Errorf("decode chromegtts ready: %w", err)
}
if !ready.OK {
_ = cmd.Process.Kill()
_ = cmd.Wait()
return fmt.Errorf("chromegtts daemon not ready: %s", ready.Error)
}
d.cmd = cmd
d.stdin = stdin
d.scanner = scanner
if d.log != nil {
d.log.Printf("chromegtts daemon started")
}
return nil
}
func (d *chromeTTSDaemon) stopLocked() {
if d.stdin != nil {
_ = d.stdin.Close()
d.stdin = nil
}
if d.cmd != nil && d.cmd.ProcessState == nil && d.cmd.Process != nil {
_ = d.cmd.Process.Kill()
_ = d.cmd.Wait()
}
d.cmd = nil
d.scanner = nil
}
+6 -5
View File
@@ -64,11 +64,12 @@ type servoPayload struct {
}
type ttsPayload struct {
Text string `json:"text"`
Engine string `json:"engine,omitempty"`
Voice string `json:"voice,omitempty"`
Pitch int `json:"pitch,omitempty"`
Speak bool `json:"speak,omitempty"`
Text string `json:"text"`
Engine string `json:"engine,omitempty"`
Voice string `json:"voice,omitempty"`
Pitch float64 `json:"pitch,omitempty"`
Speed float64 `json:"speed,omitempty"`
Speak bool `json:"speak,omitempty"`
}
type hornPayload struct {
+11 -6
View File
@@ -38,11 +38,11 @@ func (c *WSClient) handleTTSPayload(ctx context.Context, payload *ttsPayload) er
if voice == "" {
voice = strings.TrimSpace(c.cfg.Audio.DefaultVoice)
}
pitch := payload.Pitch
if pitch <= 0 {
pitch = c.cfg.Audio.DefaultPitch
espeakPitch := int(payload.Pitch)
if espeakPitch <= 0 {
espeakPitch = c.cfg.Audio.DefaultPitch
}
pitch = clampInt(pitch, 0, 99)
espeakPitch = clampInt(espeakPitch, 0, 99)
runCtx, cancel := context.WithTimeout(ctx, 12*time.Second)
defer cancel()
@@ -51,8 +51,8 @@ func (c *WSClient) handleTTSPayload(ctx context.Context, payload *ttsPayload) er
switch engine {
case "espeak", "e":
args := []string{}
if pitch > 0 {
args = append(args, "-p", fmt.Sprintf("%d", pitch))
if espeakPitch > 0 {
args = append(args, "-p", fmt.Sprintf("%d", espeakPitch))
}
args = append(args, text)
cmd = exec.CommandContext(runCtx, "espeak", args...)
@@ -63,6 +63,11 @@ func (c *WSClient) handleTTSPayload(ctx context.Context, payload *ttsPayload) er
}
args = append(args, "-t", text)
cmd = exec.CommandContext(runCtx, "flite", args...)
case "chromegtts", "googletts", "gtts", "google":
if c.chromeTTS == nil {
return fmt.Errorf("chromegtts unavailable")
}
return c.chromeTTS.Speak(runCtx, text, voice, payload.Pitch, payload.Speed)
default:
return fmt.Errorf("unsupported tts engine: %s", engine)
}
+21
View File
@@ -26,6 +26,7 @@ type WSClient struct {
recoverMu sync.Mutex
recovering bool
ttsQueue chan *ttsPayload
chromeTTS *chromeTTSDaemon
lastAux motorPWMPayload
autoSideOn bool
connMu sync.Mutex
@@ -43,6 +44,10 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
if cfg.Audio.TTSEnabled {
ttsQueue = make(chan *ttsPayload, 2)
}
var chromeTTS *chromeTTSDaemon
if cfg.Audio.TTSEnabled {
chromeTTS = NewChromeTTSDaemon(logger)
}
var horn *HornSynth
if cfg.Horn.Enabled {
horn = NewHornSynth(cfg.Horn, logger)
@@ -58,6 +63,7 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
nightVision: nightVision,
log: logger,
ttsQueue: ttsQueue,
chromeTTS: chromeTTS,
audioLevels: AudioLevels{
HornGain: 1.0,
TTSGain: 1.0,
@@ -79,6 +85,9 @@ func (c *WSClient) Run(ctx context.Context) error {
c.markConnected()
defer conn.Close(websocket.StatusInternalError, "closed")
defer c.markDisconnected()
if c.chromeTTS != nil {
defer c.chromeTTS.Shutdown()
}
if err := c.sendHello(ctx, conn); err != nil {
return err
@@ -89,6 +98,7 @@ func (c *WSClient) Run(ctx context.Context) error {
errCh := make(chan error, 2)
c.startTTSWorker(ctx)
c.warmChromeTTS(ctx)
go func() {
errCh <- c.readLoop(ctx, conn)
}()
@@ -356,6 +366,17 @@ func (c *WSClient) startTTSWorker(ctx context.Context) {
}()
}
func (c *WSClient) warmChromeTTS(ctx context.Context) {
if c.chromeTTS == nil {
return
}
go func() {
if err := c.chromeTTS.Start(ctx); err != nil {
c.log.Printf("chromegtts warmup failed: %v", err)
}
}()
}
func (c *WSClient) handleServoCommand(payload *servoPayload) error {
switch {
case payload.Angle != nil:
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-jVimdn6k.js"></script>
<script type="module" crossorigin src="/assets/index-DbRTVNLN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CIj_suyF.css">
</head>
<body>
@@ -35,11 +35,22 @@ function normalizeTtsOptions(raw = {}) {
if (!raw || typeof raw !== 'object') return null;
const speak = raw.speak !== false;
if (!speak) return null;
const engine = typeof raw.engine === 'string' && raw.engine.toLowerCase() === 'espeak' ? 'espeak' : 'flite';
const rawEngine = typeof raw.engine === 'string' ? raw.engine.toLowerCase() : '';
const engine = rawEngine === 'espeak' ? 'espeak' : rawEngine === 'chromegtts' ? 'chromegtts' : 'flite';
const voice = typeof raw.voice === 'string' ? raw.voice.trim() : undefined;
let pitch = Number.isFinite(raw.pitch) ? Math.round(raw.pitch) : undefined;
if (typeof pitch === 'number') pitch = Math.max(0, Math.min(99, pitch));
return { speak, engine, voice, pitch };
let pitch = Number.isFinite(raw.pitch) ? raw.pitch : undefined;
let speed = Number.isFinite(raw.speed) ? raw.speed : undefined;
if (engine === 'espeak') {
if (typeof pitch === 'number') pitch = Math.max(0, Math.min(99, Math.round(pitch)));
speed = undefined;
} else if (engine === 'chromegtts') {
if (typeof pitch === 'number') pitch = Math.max(0.5, Math.min(2, pitch));
if (typeof speed === 'number') speed = Math.max(0.5, Math.min(2, speed));
} else {
pitch = undefined;
speed = undefined;
}
return { speak, engine, voice, pitch, speed };
}
function buildAccessNoticeText(mode, reasonText) {
@@ -81,6 +92,7 @@ function maybeSpeak(socket, message, ttsOptions) {
engine: ttsOptions.engine,
voice: ttsOptions.voice,
pitch: ttsOptions.pitch,
speed: ttsOptions.speed,
speak: true,
},
});
+101 -20
View File
@@ -11,7 +11,10 @@ import CardFrame from '../CardFrame/index.jsx';
import NicknameForm from '../NicknameForm/index.jsx';
const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl'];
const CHROME_TTS_VOICES = ['sfg', 'iob', 'iog', 'iol', 'iom', 'tpc', 'tpd', 'tpf'];
const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
const GOOGLE_TTS_VALUES = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
const DEFAULT_GOOGLE_TTS_VALUE = 1.0;
export default function ChatPanel({
hideInput = false,
@@ -37,13 +40,25 @@ export default function ChatPanel({
const {
value: ttsSettings,
save: saveTtsSettings,
} = useSettingsNamespace('tts', { engine: 'flite', voice: 'rms', pitch: 50 });
} = useSettingsNamespace('tts', {
engine: 'flite',
voice: 'rms',
pitch: 50,
googlePitch: DEFAULT_GOOGLE_TTS_VALUE,
googleSpeed: DEFAULT_GOOGLE_TTS_VALUE,
});
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [speak, setSpeak] = useState(false);
const [engine, setEngine] = useState(() => ttsSettings?.engine || 'flite');
const [voice, setVoice] = useState(() => ttsSettings?.voice || 'rms');
const [pitch, setPitch] = useState(() => (Number.isFinite(ttsSettings?.pitch) ? ttsSettings.pitch : 50));
const [googlePitch, setGooglePitch] = useState(() =>
Number.isFinite(ttsSettings?.googlePitch) ? ttsSettings.googlePitch : DEFAULT_GOOGLE_TTS_VALUE,
);
const [googleSpeed, setGoogleSpeed] = useState(() =>
Number.isFinite(ttsSettings?.googleSpeed) ? ttsSettings.googleSpeed : DEFAULT_GOOGLE_TTS_VALUE,
);
const canChat = role !== 'spectator' || allowSpectatorInput;
const listRef = useRef(null);
@@ -65,10 +80,29 @@ export default function ChatPanel({
const nextEngine = ttsSettings?.engine || 'flite';
const nextVoice = ttsSettings?.voice || 'rms';
const nextPitch = Number.isFinite(ttsSettings?.pitch) ? ttsSettings.pitch : 50;
const nextGooglePitch = Number.isFinite(ttsSettings?.googlePitch)
? ttsSettings.googlePitch
: DEFAULT_GOOGLE_TTS_VALUE;
const nextGoogleSpeed = Number.isFinite(ttsSettings?.googleSpeed)
? ttsSettings.googleSpeed
: DEFAULT_GOOGLE_TTS_VALUE;
if (engine !== nextEngine) setEngine(nextEngine);
if (voice !== nextVoice) setVoice(nextVoice);
if (pitch !== nextPitch) setPitch(nextPitch);
}, [engine, pitch, ttsSettings?.engine, ttsSettings?.pitch, ttsSettings?.voice, voice]);
if (googlePitch !== nextGooglePitch) setGooglePitch(nextGooglePitch);
if (googleSpeed !== nextGoogleSpeed) setGoogleSpeed(nextGoogleSpeed);
}, [
engine,
googlePitch,
googleSpeed,
pitch,
ttsSettings?.engine,
ttsSettings?.googlePitch,
ttsSettings?.googleSpeed,
ttsSettings?.pitch,
ttsSettings?.voice,
voice,
]);
useEffect(() => {
if (ttsSupported) {
@@ -83,8 +117,11 @@ export default function ChatPanel({
if (engine === 'espeak') {
return { speak: true, engine, pitch };
}
if (engine === 'chromegtts') {
return { speak: true, engine, voice, pitch: googlePitch, speed: googleSpeed };
}
return { speak: true, engine, voice };
}, [engine, pitch, speak, ttsSupported, voice]);
}, [engine, googlePitch, googleSpeed, pitch, speak, ttsSupported, voice]);
async function handleSend(event) {
event.preventDefault();
@@ -181,30 +218,74 @@ export default function ChatPanel({
value={engine}
onChange={(e) => {
const next = e.target.value;
const nextVoice =
next === 'chromegtts' && !CHROME_TTS_VOICES.includes(voice)
? 'tpf'
: next === 'flite' && !FLITE_VOICES.includes(voice)
? 'rms'
: voice;
setEngine(next);
saveTtsSettings((current) => ({ ...(current || {}), engine: next }));
if (nextVoice !== voice) setVoice(nextVoice);
saveTtsSettings((current) => ({ ...(current || {}), engine: next, voice: nextVoice }));
}}
className="field-input text-xs"
>
<option value="flite">flite</option>
<option value="espeak">espeak</option>
<option value="chromegtts">Google TTS</option>
</select>
{engine === 'flite' ? (
<select
value={voice}
onChange={(e) => {
const next = e.target.value;
setVoice(next);
saveTtsSettings((current) => ({ ...(current || {}), voice: next }));
}}
className="field-input text-xs"
>
{FLITE_VOICES.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
{engine === 'flite' || engine === 'chromegtts' ? (
<>
<select
value={voice}
onChange={(e) => {
const next = e.target.value;
setVoice(next);
saveTtsSettings((current) => ({ ...(current || {}), voice: next }));
}}
className="field-input text-xs"
>
{(engine === 'chromegtts' ? CHROME_TTS_VOICES : FLITE_VOICES).map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
{engine === 'chromegtts' && (
<>
<select
value={googlePitch}
onChange={(e) => {
const next = Number(e.target.value);
setGooglePitch(next);
saveTtsSettings((current) => ({ ...(current || {}), googlePitch: next }));
}}
className="field-input text-xs"
>
{GOOGLE_TTS_VALUES.map((value) => (
<option key={`pitch-${value}`} value={value}>
pitch {value.toFixed(2)}
</option>
))}
</select>
<select
value={googleSpeed}
onChange={(e) => {
const next = Number(e.target.value);
setGoogleSpeed(next);
saveTtsSettings((current) => ({ ...(current || {}), googleSpeed: next }));
}}
className="field-input text-xs"
>
{GOOGLE_TTS_VALUES.map((value) => (
<option key={`speed-${value}`} value={value}>
speed {value.toFixed(2)}
</option>
))}
</select>
</>
)}
</>
) : (
<select
value={pitch}
@@ -11,7 +11,13 @@ function HudChatInput({ compact = false }) {
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
const roverRoster = useSessionSelector((state) => state.session?.roster || []);
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChat();
const { value: ttsSettings } = useSettingsNamespace('tts', { engine: 'flite', voice: 'rms', pitch: 50 });
const { value: ttsSettings } = useSettingsNamespace('tts', {
engine: 'flite',
voice: 'rms',
pitch: 50,
googlePitch: 1,
googleSpeed: 1,
});
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const canChat = role !== 'spectator';
@@ -23,7 +29,8 @@ function HudChatInput({ compact = false }) {
const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
const ttsPayload = useMemo(() => {
if (!ttsSupported) return null;
const engine = ttsSettings?.engine === 'espeak' ? 'espeak' : 'flite';
const engine =
ttsSettings?.engine === 'espeak' ? 'espeak' : ttsSettings?.engine === 'chromegtts' ? 'chromegtts' : 'flite';
if (engine === 'espeak') {
let pitch = Number.isFinite(ttsSettings?.pitch) ? Math.round(ttsSettings.pitch) : undefined;
if (typeof pitch === 'number') {
@@ -32,8 +39,20 @@ function HudChatInput({ compact = false }) {
return { speak: true, engine, pitch };
}
const voice = typeof ttsSettings?.voice === 'string' ? ttsSettings.voice : undefined;
if (engine === 'chromegtts') {
const pitch = Number.isFinite(ttsSettings?.googlePitch) ? ttsSettings.googlePitch : 1;
const speed = Number.isFinite(ttsSettings?.googleSpeed) ? ttsSettings.googleSpeed : 1;
return { speak: true, engine, voice, pitch, speed };
}
return { speak: true, engine, voice };
}, [ttsSettings?.engine, ttsSettings?.pitch, ttsSettings?.voice, ttsSupported]);
}, [
ttsSettings?.engine,
ttsSettings?.googlePitch,
ttsSettings?.googleSpeed,
ttsSettings?.pitch,
ttsSettings?.voice,
ttsSupported,
]);
const containerClass = compact
? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-[9rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.4 py-0.2'
: 'pointer-events-auto absolute bottom-1 right-1 flex w-[12rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25';