mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
first pass of tts and audio stuff
This commit is contained in:
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
+70
-27
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user