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 * create system users/groups if needed
* install /usr/local/bin/roverd and /etc/roverd.yaml * install /usr/local/bin/roverd and /etc/roverd.yaml
* install /usr/local/bin/video/audio helpers and systemd units * 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 * enable roverd.service and media publisher/listener services
USAGE USAGE
} }
@@ -190,15 +191,70 @@ install_audio_support() {
log "ALSA config updated; reboot recommended for overlay + audio changes" log "ALSA config updated; reboot recommended for overlay + audio changes"
fi 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 # 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 if command -v flite >/dev/null 2>&1 \
log "TTS packages flite and espeak already installed; skipping apt install" && 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 return
fi fi
apt-get update tmp_dir="$(mktemp -d)"
apt-get install -y --no-install-recommends flite espeak 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 # 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 { type ttsPayload struct {
Text string `json:"text"` Text string `json:"text"`
Engine string `json:"engine,omitempty"` Engine string `json:"engine,omitempty"`
Voice string `json:"voice,omitempty"` Voice string `json:"voice,omitempty"`
Pitch int `json:"pitch,omitempty"` Pitch float64 `json:"pitch,omitempty"`
Speak bool `json:"speak,omitempty"` Speed float64 `json:"speed,omitempty"`
Speak bool `json:"speak,omitempty"`
} }
type hornPayload struct { type hornPayload struct {
+11 -6
View File
@@ -38,11 +38,11 @@ func (c *WSClient) handleTTSPayload(ctx context.Context, payload *ttsPayload) er
if voice == "" { if voice == "" {
voice = strings.TrimSpace(c.cfg.Audio.DefaultVoice) voice = strings.TrimSpace(c.cfg.Audio.DefaultVoice)
} }
pitch := payload.Pitch espeakPitch := int(payload.Pitch)
if pitch <= 0 { if espeakPitch <= 0 {
pitch = c.cfg.Audio.DefaultPitch espeakPitch = c.cfg.Audio.DefaultPitch
} }
pitch = clampInt(pitch, 0, 99) espeakPitch = clampInt(espeakPitch, 0, 99)
runCtx, cancel := context.WithTimeout(ctx, 12*time.Second) runCtx, cancel := context.WithTimeout(ctx, 12*time.Second)
defer cancel() defer cancel()
@@ -51,8 +51,8 @@ func (c *WSClient) handleTTSPayload(ctx context.Context, payload *ttsPayload) er
switch engine { switch engine {
case "espeak", "e": case "espeak", "e":
args := []string{} args := []string{}
if pitch > 0 { if espeakPitch > 0 {
args = append(args, "-p", fmt.Sprintf("%d", pitch)) args = append(args, "-p", fmt.Sprintf("%d", espeakPitch))
} }
args = append(args, text) args = append(args, text)
cmd = exec.CommandContext(runCtx, "espeak", args...) 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) args = append(args, "-t", text)
cmd = exec.CommandContext(runCtx, "flite", args...) 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: default:
return fmt.Errorf("unsupported tts engine: %s", engine) return fmt.Errorf("unsupported tts engine: %s", engine)
} }
+21
View File
@@ -26,6 +26,7 @@ type WSClient struct {
recoverMu sync.Mutex recoverMu sync.Mutex
recovering bool recovering bool
ttsQueue chan *ttsPayload ttsQueue chan *ttsPayload
chromeTTS *chromeTTSDaemon
lastAux motorPWMPayload lastAux motorPWMPayload
autoSideOn bool autoSideOn bool
connMu sync.Mutex connMu sync.Mutex
@@ -43,6 +44,10 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
if cfg.Audio.TTSEnabled { if cfg.Audio.TTSEnabled {
ttsQueue = make(chan *ttsPayload, 2) ttsQueue = make(chan *ttsPayload, 2)
} }
var chromeTTS *chromeTTSDaemon
if cfg.Audio.TTSEnabled {
chromeTTS = NewChromeTTSDaemon(logger)
}
var horn *HornSynth var horn *HornSynth
if cfg.Horn.Enabled { if cfg.Horn.Enabled {
horn = NewHornSynth(cfg.Horn, logger) horn = NewHornSynth(cfg.Horn, logger)
@@ -58,6 +63,7 @@ func NewWSClient(cfg *Config, adapter *SerialAdapter, frames <-chan []byte, even
nightVision: nightVision, nightVision: nightVision,
log: logger, log: logger,
ttsQueue: ttsQueue, ttsQueue: ttsQueue,
chromeTTS: chromeTTS,
audioLevels: AudioLevels{ audioLevels: AudioLevels{
HornGain: 1.0, HornGain: 1.0,
TTSGain: 1.0, TTSGain: 1.0,
@@ -79,6 +85,9 @@ func (c *WSClient) Run(ctx context.Context) error {
c.markConnected() c.markConnected()
defer conn.Close(websocket.StatusInternalError, "closed") defer conn.Close(websocket.StatusInternalError, "closed")
defer c.markDisconnected() defer c.markDisconnected()
if c.chromeTTS != nil {
defer c.chromeTTS.Shutdown()
}
if err := c.sendHello(ctx, conn); err != nil { if err := c.sendHello(ctx, conn); err != nil {
return err return err
@@ -89,6 +98,7 @@ func (c *WSClient) Run(ctx context.Context) error {
errCh := make(chan error, 2) errCh := make(chan error, 2)
c.startTTSWorker(ctx) c.startTTSWorker(ctx)
c.warmChromeTTS(ctx)
go func() { go func() {
errCh <- c.readLoop(ctx, conn) 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 { func (c *WSClient) handleServoCommand(payload *servoPayload) error {
switch { switch {
case payload.Angle != nil: 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-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<title>Roomba Rover</title> <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"> <link rel="stylesheet" crossorigin href="/assets/index-CIj_suyF.css">
</head> </head>
<body> <body>
@@ -35,11 +35,22 @@ function normalizeTtsOptions(raw = {}) {
if (!raw || typeof raw !== 'object') return null; if (!raw || typeof raw !== 'object') return null;
const speak = raw.speak !== false; const speak = raw.speak !== false;
if (!speak) return null; 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; const voice = typeof raw.voice === 'string' ? raw.voice.trim() : undefined;
let pitch = Number.isFinite(raw.pitch) ? Math.round(raw.pitch) : undefined; let pitch = Number.isFinite(raw.pitch) ? raw.pitch : undefined;
if (typeof pitch === 'number') pitch = Math.max(0, Math.min(99, pitch)); let speed = Number.isFinite(raw.speed) ? raw.speed : undefined;
return { speak, engine, voice, pitch }; 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) { function buildAccessNoticeText(mode, reasonText) {
@@ -81,6 +92,7 @@ function maybeSpeak(socket, message, ttsOptions) {
engine: ttsOptions.engine, engine: ttsOptions.engine,
voice: ttsOptions.voice, voice: ttsOptions.voice,
pitch: ttsOptions.pitch, pitch: ttsOptions.pitch,
speed: ttsOptions.speed,
speak: true, speak: true,
}, },
}); });
+101 -20
View File
@@ -11,7 +11,10 @@ import CardFrame from '../CardFrame/index.jsx';
import NicknameForm from '../NicknameForm/index.jsx'; import NicknameForm from '../NicknameForm/index.jsx';
const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl']; 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 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({ export default function ChatPanel({
hideInput = false, hideInput = false,
@@ -37,13 +40,25 @@ export default function ChatPanel({
const { const {
value: ttsSettings, value: ttsSettings,
save: saveTtsSettings, 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 [draft, setDraft] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [speak, setSpeak] = useState(false); const [speak, setSpeak] = useState(false);
const [engine, setEngine] = useState(() => ttsSettings?.engine || 'flite'); const [engine, setEngine] = useState(() => ttsSettings?.engine || 'flite');
const [voice, setVoice] = useState(() => ttsSettings?.voice || 'rms'); const [voice, setVoice] = useState(() => ttsSettings?.voice || 'rms');
const [pitch, setPitch] = useState(() => (Number.isFinite(ttsSettings?.pitch) ? ttsSettings.pitch : 50)); 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 canChat = role !== 'spectator' || allowSpectatorInput;
const listRef = useRef(null); const listRef = useRef(null);
@@ -65,10 +80,29 @@ export default function ChatPanel({
const nextEngine = ttsSettings?.engine || 'flite'; const nextEngine = ttsSettings?.engine || 'flite';
const nextVoice = ttsSettings?.voice || 'rms'; const nextVoice = ttsSettings?.voice || 'rms';
const nextPitch = Number.isFinite(ttsSettings?.pitch) ? ttsSettings.pitch : 50; 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 (engine !== nextEngine) setEngine(nextEngine);
if (voice !== nextVoice) setVoice(nextVoice); if (voice !== nextVoice) setVoice(nextVoice);
if (pitch !== nextPitch) setPitch(nextPitch); 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(() => { useEffect(() => {
if (ttsSupported) { if (ttsSupported) {
@@ -83,8 +117,11 @@ export default function ChatPanel({
if (engine === 'espeak') { if (engine === 'espeak') {
return { speak: true, engine, pitch }; return { speak: true, engine, pitch };
} }
if (engine === 'chromegtts') {
return { speak: true, engine, voice, pitch: googlePitch, speed: googleSpeed };
}
return { speak: true, engine, voice }; return { speak: true, engine, voice };
}, [engine, pitch, speak, ttsSupported, voice]); }, [engine, googlePitch, googleSpeed, pitch, speak, ttsSupported, voice]);
async function handleSend(event) { async function handleSend(event) {
event.preventDefault(); event.preventDefault();
@@ -181,30 +218,74 @@ export default function ChatPanel({
value={engine} value={engine}
onChange={(e) => { onChange={(e) => {
const next = e.target.value; 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); setEngine(next);
saveTtsSettings((current) => ({ ...(current || {}), engine: next })); if (nextVoice !== voice) setVoice(nextVoice);
saveTtsSettings((current) => ({ ...(current || {}), engine: next, voice: nextVoice }));
}} }}
className="field-input text-xs" className="field-input text-xs"
> >
<option value="flite">flite</option> <option value="flite">flite</option>
<option value="espeak">espeak</option> <option value="espeak">espeak</option>
<option value="chromegtts">Google TTS</option>
</select> </select>
{engine === 'flite' ? ( {engine === 'flite' || engine === 'chromegtts' ? (
<select <>
value={voice} <select
onChange={(e) => { value={voice}
const next = e.target.value; onChange={(e) => {
setVoice(next); const next = e.target.value;
saveTtsSettings((current) => ({ ...(current || {}), voice: next })); setVoice(next);
}} saveTtsSettings((current) => ({ ...(current || {}), voice: next }));
className="field-input text-xs" }}
> className="field-input text-xs"
{FLITE_VOICES.map((v) => ( >
<option key={v} value={v}> {(engine === 'chromegtts' ? CHROME_TTS_VOICES : FLITE_VOICES).map((v) => (
{v} <option key={v} value={v}>
</option> {v}
))} </option>
</select> ))}
</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 <select
value={pitch} value={pitch}
@@ -11,7 +11,13 @@ function HudChatInput({ compact = false }) {
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null); const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
const roverRoster = useSessionSelector((state) => state.session?.roster || []); const roverRoster = useSessionSelector((state) => state.session?.roster || []);
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChat(); 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 [draft, setDraft] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const canChat = role !== 'spectator'; const canChat = role !== 'spectator';
@@ -23,7 +29,8 @@ function HudChatInput({ compact = false }) {
const ttsSupported = Boolean(rover?.audio?.ttsEnabled); const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
const ttsPayload = useMemo(() => { const ttsPayload = useMemo(() => {
if (!ttsSupported) return null; 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') { if (engine === 'espeak') {
let pitch = Number.isFinite(ttsSettings?.pitch) ? Math.round(ttsSettings.pitch) : undefined; let pitch = Number.isFinite(ttsSettings?.pitch) ? Math.round(ttsSettings.pitch) : undefined;
if (typeof pitch === 'number') { if (typeof pitch === 'number') {
@@ -32,8 +39,20 @@ function HudChatInput({ compact = false }) {
return { speak: true, engine, pitch }; return { speak: true, engine, pitch };
} }
const voice = typeof ttsSettings?.voice === 'string' ? ttsSettings.voice : undefined; 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 }; 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 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-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'; : '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';