first pass of tts and audio stuff

This commit is contained in:
legop3
2025-11-27 16:26:21 -05:00
parent 871e04a8ba
commit 9289fb3467
20 changed files with 425 additions and 60 deletions
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+70 -27
View File
@@ -17,6 +17,11 @@ VIDEO_WIDTH="${VIDEO_WIDTH:-1920}"
VIDEO_HEIGHT="${VIDEO_HEIGHT:-1080}"
VIDEO_FPS="${VIDEO_FPS:-30}"
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
AUDIO_ENABLE="${AUDIO_ENABLE:-0}"
AUDIO_DEVICE="${AUDIO_DEVICE:-default}"
AUDIO_RATE="${AUDIO_RATE:-16000}"
AUDIO_CHANNELS="${AUDIO_CHANNELS:-1}"
AUDIO_BITRATE="${AUDIO_BITRATE:-64000}"
# Flip the camera 180deg (supported by rpicam-vid/libcamera-vid)
FLIP_ARGS=(--rotation 180)
@@ -44,33 +49,71 @@ fi
FLIP_ARGS=(--rotation 180)
run_pipeline() {
"${LIBCAMERA_BIN_PATH}" \
--inline \
--timeout 0 \
--width "${VIDEO_WIDTH}" \
--height "${VIDEO_HEIGHT}" \
"${FLIP_ARGS[@]}" \
--framerate "${VIDEO_FPS}" \
--bitrate "${VIDEO_BITRATE}" \
--codec h264 \
--profile baseline \
--denoise cdn_off \
--nopreview \
--awb custom \
--awbgains 1.5,1.8 \
--output - \
| "${FFMPEG_BIN_PATH}" \
-hide_banner \
-loglevel warning \
-fflags nobuffer \
-use_wallclock_as_timestamps 1 \
-f h264 \
-i pipe:0 \
-c:v copy \
-an \
-flush_packets 1 \
-f mpegts \
"${PUBLISH_URL}"
if [[ "${AUDIO_ENABLE}" -eq 1 ]]; then
"${LIBCAMERA_BIN_PATH}" \
--inline \
--timeout 0 \
--width "${VIDEO_WIDTH}" \
--height "${VIDEO_HEIGHT}" \
"${FLIP_ARGS[@]}" \
--framerate "${VIDEO_FPS}" \
--bitrate "${VIDEO_BITRATE}" \
--codec h264 \
--profile baseline \
--denoise cdn_off \
--nopreview \
--awb custom \
--awbgains 1.5,1.8 \
--output - \
| "${FFMPEG_BIN_PATH}" \
-hide_banner \
-loglevel warning \
-fflags nobuffer \
-use_wallclock_as_timestamps 1 \
-f h264 \
-i pipe:0 \
-f alsa \
-thread_queue_size 512 \
-ac "${AUDIO_CHANNELS}" \
-ar "${AUDIO_RATE}" \
-i "${AUDIO_DEVICE}" \
-c:v copy \
-c:a aac \
-b:a "${AUDIO_BITRATE}" \
-ac "${AUDIO_CHANNELS}" \
-ar "${AUDIO_RATE}" \
-flush_packets 1 \
-f mpegts \
"${PUBLISH_URL}"
else
"${LIBCAMERA_BIN_PATH}" \
--inline \
--timeout 0 \
--width "${VIDEO_WIDTH}" \
--height "${VIDEO_HEIGHT}" \
"${FLIP_ARGS[@]}" \
--framerate "${VIDEO_FPS}" \
--bitrate "${VIDEO_BITRATE}" \
--codec h264 \
--profile baseline \
--denoise cdn_off \
--nopreview \
--awb custom \
--awbgains 1.5,1.8 \
--output - \
| "${FFMPEG_BIN_PATH}" \
-hide_banner \
-loglevel warning \
-fflags nobuffer \
-use_wallclock_as_timestamps 1 \
-f h264 \
-i pipe:0 \
-c:v copy \
-an \
-flush_packets 1 \
-f mpegts \
"${PUBLISH_URL}"
fi
}
while true; do
+42
View File
@@ -146,6 +146,41 @@ log "Installed roverd systemd unit"
install_video_deps
ensure_pwm_overlay
# Enable Google AIY v1 sound card, ALSA defaults, and TTS engines
install_audio_support() {
local boot_config
if ! boot_config="$(find_boot_config)"; then
log "WARNING: unable to locate /boot config.txt; please enable googlevoicehat-soundcard overlay manually"
else
if ! grep -Eq '^\s*dtoverlay=googlevoicehat-soundcard\b' "$boot_config"; then
local backup="${boot_config}.roverd.$(date +%Y%m%d%H%M%S).bak"
cp "$boot_config" "$backup"
{
echo ""
echo "# Added by roverd installer to enable Google AIY v1 sound card"
echo "dtoverlay=googlevoicehat-soundcard"
echo "dtparam=audio=off"
} >> "$boot_config"
log "Enabled googlevoicehat-soundcard overlay in $boot_config (backup at $backup). Reboot required."
else
log "googlevoicehat-soundcard overlay already present in $boot_config"
fi
fi
if [[ -f pi/asound.conf ]]; then
install -m 0644 pi/asound.conf /etc/asound.conf
log "Installed ALSA config to /etc/asound.conf"
alsa_reload_notice=1
else
log "WARNING: pi/asound.conf missing; skipping ALSA config install"
fi
log "Installing TTS/audio packages (flite, espeak)..."
apt-get update
apt-get install -y --no-install-recommends flite espeak
if [[ "${alsa_reload_notice:-0}" -eq 1 ]]; then
log "ALSA config updated; reboot recommended for overlay + audio changes"
fi
}
# Install video publisher assets
install -D -o root -g root -m 0755 pi/bin/video-publisher.sh /usr/local/bin/video-publisher
install -m 0644 pi/systemd/video-publisher.service /etc/systemd/system/video-publisher.service
@@ -158,11 +193,18 @@ VIDEO_WIDTH=1280
VIDEO_HEIGHT=720
VIDEO_FPS=30
VIDEO_BITRATE=3000000
AUDIO_ENABLE=1
AUDIO_DEVICE=default
AUDIO_RATE=16000
AUDIO_CHANNELS=1
AUDIO_BITRATE=64000
ENV
chown roverd:roverd /var/lib/roverd/video.env
chmod 0640 /var/lib/roverd/video.env
fi
install_audio_support
systemctl daemon-reload
systemctl enable roverd.service
systemctl enable video-publisher.service
+2 -2
View File
@@ -21,7 +21,7 @@ func main() {
if err != nil {
log.Fatalf("load config: %v", err)
}
if err := roverd.UpdatePublisherEnv(cfg.Media); err != nil {
if err := roverd.UpdatePublisherEnv(cfg.Media, cfg.Audio); err != nil {
log.Fatalf("prepare media env: %v", err)
}
@@ -55,7 +55,7 @@ func main() {
adapter := roverd.NewSerialAdapter(serialPort, logger)
mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, logger)
mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, cfg.Audio, logger)
if mediaSupervisor != nil {
mediaSupervisor.Start(ctx)
}
+10
View File
@@ -7,6 +7,7 @@ type helloMessage struct {
MaxWheelSpeed int `json:"maxWheelSpeed"`
Media MediaConfig `json:"media"`
CameraServo CameraServoConfig `json:"cameraServo"`
Audio AudioConfig `json:"audio"`
}
type sensorMessage struct {
@@ -24,6 +25,7 @@ type inboundMessage struct {
SensorStream *sensorStreamPayload `json:"sensorStream,omitempty"`
Media *mediaCommand `json:"media,omitempty"`
Servo *servoPayload `json:"servo,omitempty"`
TTS *ttsPayload `json:"tts,omitempty"`
}
type driveDirectPayload struct {
@@ -51,6 +53,14 @@ type servoPayload struct {
PulseUs *int `json:"pulseUs,omitempty"`
}
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"`
}
type ackMessage struct {
Type string `json:"type"`
ID string `json:"id"`
+49
View File
@@ -53,6 +53,18 @@ type BatteryConfig struct {
Urgent int `yaml:"urgent"`
}
type AudioConfig struct {
CaptureEnabled bool `yaml:"captureEnabled" json:"captureEnabled"`
CaptureDevice string `yaml:"captureDevice" json:"captureDevice,omitempty"`
SampleRate int `yaml:"sampleRate" json:"sampleRate,omitempty"`
Channels int `yaml:"channels" json:"channels,omitempty"`
Bitrate int `yaml:"bitrate" json:"bitrate,omitempty"`
TTSEnabled bool `yaml:"ttsEnabled" json:"ttsEnabled"`
DefaultEngine string `yaml:"defaultEngine" json:"defaultEngine,omitempty"`
DefaultVoice string `yaml:"defaultVoice" json:"defaultVoice,omitempty"`
DefaultPitch int `yaml:"defaultPitch" json:"defaultPitch,omitempty"`
}
type MediaConfig struct {
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
PublishPort int `yaml:"publishPort" json:"-"`
@@ -89,6 +101,7 @@ type Config struct {
MaxWheelMMs int `yaml:"maxWheelSpeed"`
Media MediaConfig `yaml:"media"`
CameraServo CameraServoConfig `yaml:"cameraServo"`
Audio AudioConfig `yaml:"audio"`
}
func LoadConfig(path string) (*Config, error) {
@@ -127,6 +140,17 @@ func LoadConfig(path string) (*Config, error) {
HomeAngle: 0,
NudgeDegrees: 2,
},
Audio: AudioConfig{
CaptureEnabled: true,
CaptureDevice: "default",
SampleRate: 16000,
Channels: 1,
Bitrate: 64000,
TTSEnabled: false,
DefaultEngine: "flite",
DefaultVoice: "rms",
DefaultPitch: 50,
},
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
@@ -180,6 +204,7 @@ func LoadConfig(path string) (*Config, error) {
if err := validateServoConfig(&cfg.CameraServo); err != nil {
return nil, fmt.Errorf("cameraServo: %w", err)
}
validateAudioConfig(&cfg.Audio)
return &cfg, nil
}
@@ -219,6 +244,30 @@ func clampFloat(value, min, max float64) float64 {
return value
}
func validateAudioConfig(cfg *AudioConfig) {
if cfg.CaptureEnabled && cfg.CaptureDevice == "" {
cfg.CaptureDevice = "default"
}
if cfg.SampleRate <= 0 {
cfg.SampleRate = 16000
}
if cfg.Channels <= 0 {
cfg.Channels = 1
}
if cfg.Bitrate <= 0 {
cfg.Bitrate = 64000
}
if cfg.DefaultEngine == "" {
cfg.DefaultEngine = "flite"
}
if cfg.DefaultVoice == "" {
cfg.DefaultVoice = "rms"
}
if cfg.DefaultPitch <= 0 {
cfg.DefaultPitch = 50
}
}
func derivePublishURL(serverURL, roverName string, port int) (string, error) {
if roverName == "" {
return "", errors.New("missing rover name for publishUrl")
+20 -8
View File
@@ -9,24 +9,36 @@ import (
const publisherEnvPath = "/var/lib/roverd/video.env"
func UpdatePublisherEnv(cfg MediaConfig) error {
if cfg.PublishURL == "" {
func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
if media.PublishURL == "" {
return fmt.Errorf("media publishUrl missing")
}
if cfg.VideoWidth <= 0 || cfg.VideoHeight <= 0 || cfg.VideoFPS <= 0 || cfg.VideoBitrate <= 0 {
if media.VideoWidth <= 0 || media.VideoHeight <= 0 || media.VideoFPS <= 0 || media.VideoBitrate <= 0 {
return fmt.Errorf("invalid media dimensions/bitrate")
}
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
return err
}
var buf bytes.Buffer
fmt.Fprintf(&buf, "PUBLISH_URL=%s\n", cfg.PublishURL)
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", cfg.VideoWidth)
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", cfg.VideoHeight)
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", cfg.VideoFPS)
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", cfg.VideoBitrate)
fmt.Fprintf(&buf, "PUBLISH_URL=%s\n", media.PublishURL)
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight)
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS)
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
fmt.Fprintf(&buf, "AUDIO_ENABLE=%d\n", boolToInt(audio.CaptureEnabled))
fmt.Fprintf(&buf, "AUDIO_DEVICE=%s\n", audio.CaptureDevice)
fmt.Fprintf(&buf, "AUDIO_RATE=%d\n", audio.SampleRate)
fmt.Fprintf(&buf, "AUDIO_CHANNELS=%d\n", audio.Channels)
fmt.Fprintf(&buf, "AUDIO_BITRATE=%d\n", audio.Bitrate)
if err := os.WriteFile(publisherEnvPath, buf.Bytes(), 0o640); err != nil {
return err
}
return nil
}
func boolToInt(v bool) int {
if v {
return 1
}
return 0
}
+6 -4
View File
@@ -13,13 +13,14 @@ import (
type MediaSupervisor struct {
cfg MediaConfig
audio AudioConfig
logger *log.Logger
client *http.Client
checkInterval time.Duration
}
func NewMediaSupervisor(cfg MediaConfig, logger *log.Logger) *MediaSupervisor {
if err := UpdatePublisherEnv(cfg); err != nil {
func NewMediaSupervisor(cfg MediaConfig, audio AudioConfig, logger *log.Logger) *MediaSupervisor {
if err := UpdatePublisherEnv(cfg, audio); err != nil {
logger.Printf("media supervisor: update env failed: %v", err)
}
if !cfg.Manage || cfg.Service == "" {
@@ -35,6 +36,7 @@ func NewMediaSupervisor(cfg MediaConfig, logger *log.Logger) *MediaSupervisor {
}
return &MediaSupervisor{
cfg: cfg,
audio: audio,
logger: logger,
client: client,
checkInterval: interval,
@@ -45,7 +47,7 @@ func (m *MediaSupervisor) Start(ctx context.Context) {
if m == nil {
return
}
if err := UpdatePublisherEnv(m.cfg); err != nil {
if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil {
m.logger.Printf("media supervisor: update env failed: %v", err)
}
if m.cfg.HealthURL == "" || m.client == nil {
@@ -76,7 +78,7 @@ func (m *MediaSupervisor) HandleAction(ctx context.Context, action string) error
if m == nil {
return errors.New("media supervisor disabled")
}
if err := UpdatePublisherEnv(m.cfg); err != nil {
if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil {
return err
}
switch action {
+10
View File
@@ -37,3 +37,13 @@ cameraServo:
homeAngle: 0
nudgeDegrees: 2
allowRawPulse: false
audio:
captureEnabled: true
captureDevice: default
sampleRate: 16000
channels: 1
bitrate: 64000
ttsEnabled: false
defaultEngine: flite
defaultVoice: rms
defaultPitch: 50
+75
View File
@@ -0,0 +1,75 @@
package roverd
import (
"context"
"fmt"
"os/exec"
"strings"
"time"
)
func (c *WSClient) handleTTSPayload(payload *ttsPayload) error {
if payload == nil {
return fmt.Errorf("tts payload required")
}
if !c.cfg.Audio.TTSEnabled {
return fmt.Errorf("tts disabled on rover")
}
if payload.Speak == false {
return nil
}
text := strings.TrimSpace(payload.Text)
if text == "" {
return fmt.Errorf("tts text required")
}
if len([]rune(text)) > 512 {
text = string([]rune(text)[:512])
}
engine := strings.ToLower(strings.TrimSpace(payload.Engine))
if engine == "" {
engine = strings.ToLower(strings.TrimSpace(c.cfg.Audio.DefaultEngine))
}
if engine == "" {
engine = "flite"
}
voice := strings.TrimSpace(payload.Voice)
if voice == "" {
voice = strings.TrimSpace(c.cfg.Audio.DefaultVoice)
}
pitch := payload.Pitch
if pitch <= 0 {
pitch = c.cfg.Audio.DefaultPitch
}
pitch = clampInt(pitch, 0, 99)
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
var cmd *exec.Cmd
switch engine {
case "espeak", "e":
args := []string{}
if pitch > 0 {
args = append(args, "-p", fmt.Sprintf("%d", pitch))
}
args = append(args, text)
cmd = exec.CommandContext(ctx, "espeak", args...)
case "flite", "f":
args := []string{}
if voice != "" {
args = append(args, "-voice", voice)
}
args = append(args, "-t", text)
cmd = exec.CommandContext(ctx, "flite", args...)
default:
return fmt.Errorf("unsupported tts engine: %s", engine)
}
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("tts exec failed: %w (%s)", err, string(out))
}
return nil
}
+3
View File
@@ -71,6 +71,7 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
MaxWheelSpeed: c.cfg.MaxWheelMMs,
Media: c.cfg.Media,
CameraServo: c.cfg.CameraServo,
Audio: c.cfg.Audio,
}
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
return writeJSON(ctx, conn, msg)
@@ -150,6 +151,8 @@ func (c *WSClient) dispatch(ctx context.Context, msg *inboundMessage) error {
return fmt.Errorf("camera servo disabled")
}
return c.handleServoCommand(msg.Servo)
case msg.TTS != nil:
return c.handleTTSPayload(msg.TTS)
default:
return fmt.Errorf("unsupported command type: %s", msg.Type)
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-A0W3Zfns.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DOoEkJaz.css">
<script type="module" crossorigin src="/assets/index-Ch0OrDUK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BxUpjkzh.css">
</head>
<body>
<div id="root"></div>
+44 -2
View File
@@ -6,6 +6,7 @@ const { getRole } = require('./roleService');
const { describeAssignment } = require('./assignmentService');
const roverManager = require('./roverManager');
const { getNickname } = require('./nicknameService');
const { issueCommand } = require('./commandService');
const RATE_LIMIT_WINDOW_MS = 8000;
const RATE_LIMIT_MAX = 5;
@@ -66,6 +67,7 @@ function buildMessage(socket, text, meta = {}) {
roverId,
fromDiscord: Boolean(meta.fromDiscord),
text,
tts: meta.tts || null,
};
}
@@ -73,7 +75,20 @@ function broadcastMessage(message) {
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
}
function handleIncoming({ text } = {}, socket, cb = () => {}) {
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 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 };
}
function handleIncoming({ text, tts } = {}, socket, cb = () => {}) {
const role = getRole(socket);
// if (role === 'spectator') {
// cb({ error: 'Spectators cannot chat' });
@@ -104,12 +119,39 @@ function handleIncoming({ text } = {}, socket, cb = () => {}) {
cb({ error: 'Message looks like spam' });
return;
}
const message = buildMessage(socket, clean, { fromDiscord: false });
const roverId = resolveRoverId(socket?.id);
const ttsOptions = normalizeTtsOptions(tts);
const message = buildMessage(socket, clean, { fromDiscord: false, roverId, tts: ttsOptions });
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
broadcastMessage(message);
maybeSpeak(socket, message, ttsOptions);
cb({ success: true });
}
function maybeSpeak(socket, message, ttsOptions) {
if (!ttsOptions || !message?.roverId) return;
const record = roverManager.rovers.get(message.roverId);
const audio = record?.meta?.audio || {};
const ttsEnabled = Boolean(audio.ttsEnabled);
if (!ttsEnabled) return;
if (!roverManager.canDrive(message.roverId, socket)) return;
try {
issueCommand(message.roverId, {
type: 'tts',
tts: {
text: message.text,
engine: ttsOptions.engine,
voice: ttsOptions.voice,
pitch: ttsOptions.pitch,
speak: true,
},
});
logger.info('TTS sent', { roverId: message.roverId, engine: ttsOptions.engine, socket: socket.id });
} catch (err) {
logger.warn('TTS send failed', { roverId: message.roverId, error: err.message });
}
}
function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null }) {
const clean = typeof text === 'string' ? text.trim() : '';
if (!clean || clean.length > 256) {
+1
View File
@@ -118,6 +118,7 @@ function getRoster() {
maxWheelSpeed: record.meta?.maxWheelSpeed,
media: record.meta?.media,
cameraServo: record.meta?.cameraServo,
audio: record.meta?.audio,
locked: record.locked,
lockReason: record.lockReason,
lastSeen: record.lastSeen,
+77 -1
View File
@@ -25,13 +25,27 @@ function displayName(message) {
return message.nickname || message.socketId?.slice(0, 6) || 'unknown';
}
const FLITE_VOICES = ['kal', 'rms', 'slt', 'ksp', 'bdl'];
const ESPEAK_PITCHES = Array.from({ length: 10 }, (_, idx) => idx * 10);
export default function ChatPanel({ hideInput = false, hideSpectatorNotice = false, fillHeight = false }) {
const { session } = useSession();
const { messages, sendMessage, registerInputRef, onInputFocus, onInputBlur, blurChat } = useChat();
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const [speak, setSpeak] = useState(false);
const [engine, setEngine] = useState('flite');
const [voice, setVoice] = useState('rms');
const [pitch, setPitch] = useState(50);
const canChat = session?.role !== 'spectator';
const listRef = useRef(null);
const currentRoverId = session?.assignment?.roverId || null;
const rover = useMemo(
() => session?.roster?.find((entry) => String(entry.id) === String(currentRoverId)) || null,
[currentRoverId, session?.roster],
);
const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
const sorted = useMemo(() => messages.slice(-200), [messages]);
@@ -40,6 +54,22 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
listRef.current.scrollTop = listRef.current.scrollHeight;
}, [sorted]);
useEffect(() => {
if (ttsSupported) {
setSpeak(true);
} else {
setSpeak(false);
}
}, [ttsSupported]);
const ttsPayload = useMemo(() => {
if (!ttsSupported || !speak) return null;
if (engine === 'espeak') {
return { speak: true, engine, pitch };
}
return { speak: true, engine, voice };
}, [engine, pitch, speak, ttsSupported, voice]);
async function handleSend(event) {
event.preventDefault();
if (!canChat || hideInput) return;
@@ -47,7 +77,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
if (!clean) return;
setSending(true);
try {
await sendMessage(clean);
await sendMessage(clean, ttsPayload);
setDraft('');
blurChat();
} catch (err) {
@@ -109,6 +139,52 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'}
disabled={!canChat}
/>
{ttsSupported && (
<div className="flex items-center gap-0.5">
<label className="flex items-center gap-0.25 text-xs text-slate-300">
<input
type="checkbox"
checked={speak}
onChange={(e) => setSpeak(e.target.checked)}
className="accent-cyan-500"
/>
<span>Speak</span>
</label>
<select
value={engine}
onChange={(e) => setEngine(e.target.value)}
className="field-input text-xs"
>
<option value="flite">flite</option>
<option value="espeak">espeak</option>
</select>
{engine === 'flite' ? (
<select
value={voice}
onChange={(e) => setVoice(e.target.value)}
className="field-input text-xs"
>
{FLITE_VOICES.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
) : (
<select
value={pitch}
onChange={(e) => setPitch(Number(e.target.value))}
className="field-input text-xs"
>
{ESPEAK_PITCHES.map((p) => (
<option key={p} value={p}>
pitch {p}
</option>
))}
</select>
)}
</div>
)}
<button type="submit" disabled={!canChat || sending} className="button-dark disabled:opacity-50">
{sending ? '...' : 'Send'}
</button>
+2 -2
View File
@@ -51,9 +51,9 @@ export function ChatProvider({ children }) {
}, [playSound, session?.socketId, socket]);
const sendMessage = useCallback(
(text) =>
(text, tts = null) =>
new Promise((resolve, reject) => {
socket.emit('chat:send', { text }, (resp = {}) => {
socket.emit('chat:send', { text, tts }, (resp = {}) => {
if (resp.error) {
reject(new Error(resp.error));
} else {