mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 10:00:46 -04:00
chrome tts
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Binary file not shown.
@@ -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())
|
||||||
+61
-5
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -67,7 +67,8 @@ 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"`
|
||||||
|
Speed float64 `json:"speed,omitempty"`
|
||||||
Speak bool `json:"speak,omitempty"`
|
Speak bool `json:"speak,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-6
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,15 +218,24 @@ 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
|
<select
|
||||||
value={voice}
|
value={voice}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -199,12 +245,47 @@ export default function ChatPanel({
|
|||||||
}}
|
}}
|
||||||
className="field-input text-xs"
|
className="field-input text-xs"
|
||||||
>
|
>
|
||||||
{FLITE_VOICES.map((v) => (
|
{(engine === 'chromegtts' ? CHROME_TTS_VOICES : FLITE_VOICES).map((v) => (
|
||||||
<option key={v} value={v}>
|
<option key={v} value={v}>
|
||||||
{v}
|
{v}
|
||||||
</option>
|
</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';
|
||||||
|
|||||||
Reference in New Issue
Block a user