mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 10:00:46 -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.
@@ -17,6 +17,11 @@ VIDEO_WIDTH="${VIDEO_WIDTH:-1920}"
|
|||||||
VIDEO_HEIGHT="${VIDEO_HEIGHT:-1080}"
|
VIDEO_HEIGHT="${VIDEO_HEIGHT:-1080}"
|
||||||
VIDEO_FPS="${VIDEO_FPS:-30}"
|
VIDEO_FPS="${VIDEO_FPS:-30}"
|
||||||
VIDEO_BITRATE="${VIDEO_BITRATE:-3000000}"
|
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 the camera 180deg (supported by rpicam-vid/libcamera-vid)
|
||||||
FLIP_ARGS=(--rotation 180)
|
FLIP_ARGS=(--rotation 180)
|
||||||
|
|
||||||
@@ -44,6 +49,43 @@ fi
|
|||||||
FLIP_ARGS=(--rotation 180)
|
FLIP_ARGS=(--rotation 180)
|
||||||
|
|
||||||
run_pipeline() {
|
run_pipeline() {
|
||||||
|
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}" \
|
"${LIBCAMERA_BIN_PATH}" \
|
||||||
--inline \
|
--inline \
|
||||||
--timeout 0 \
|
--timeout 0 \
|
||||||
@@ -71,6 +113,7 @@ run_pipeline() {
|
|||||||
-flush_packets 1 \
|
-flush_packets 1 \
|
||||||
-f mpegts \
|
-f mpegts \
|
||||||
"${PUBLISH_URL}"
|
"${PUBLISH_URL}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
|
|||||||
@@ -146,6 +146,41 @@ log "Installed roverd systemd unit"
|
|||||||
install_video_deps
|
install_video_deps
|
||||||
ensure_pwm_overlay
|
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 video publisher assets
|
||||||
install -D -o root -g root -m 0755 pi/bin/video-publisher.sh /usr/local/bin/video-publisher
|
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
|
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_HEIGHT=720
|
||||||
VIDEO_FPS=30
|
VIDEO_FPS=30
|
||||||
VIDEO_BITRATE=3000000
|
VIDEO_BITRATE=3000000
|
||||||
|
AUDIO_ENABLE=1
|
||||||
|
AUDIO_DEVICE=default
|
||||||
|
AUDIO_RATE=16000
|
||||||
|
AUDIO_CHANNELS=1
|
||||||
|
AUDIO_BITRATE=64000
|
||||||
ENV
|
ENV
|
||||||
chown roverd:roverd /var/lib/roverd/video.env
|
chown roverd:roverd /var/lib/roverd/video.env
|
||||||
chmod 0640 /var/lib/roverd/video.env
|
chmod 0640 /var/lib/roverd/video.env
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
install_audio_support
|
||||||
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable roverd.service
|
systemctl enable roverd.service
|
||||||
systemctl enable video-publisher.service
|
systemctl enable video-publisher.service
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("load config: %v", err)
|
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)
|
log.Fatalf("prepare media env: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ func main() {
|
|||||||
|
|
||||||
adapter := roverd.NewSerialAdapter(serialPort, logger)
|
adapter := roverd.NewSerialAdapter(serialPort, logger)
|
||||||
|
|
||||||
mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, logger)
|
mediaSupervisor := roverd.NewMediaSupervisor(cfg.Media, cfg.Audio, logger)
|
||||||
if mediaSupervisor != nil {
|
if mediaSupervisor != nil {
|
||||||
mediaSupervisor.Start(ctx)
|
mediaSupervisor.Start(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ type helloMessage struct {
|
|||||||
MaxWheelSpeed int `json:"maxWheelSpeed"`
|
MaxWheelSpeed int `json:"maxWheelSpeed"`
|
||||||
Media MediaConfig `json:"media"`
|
Media MediaConfig `json:"media"`
|
||||||
CameraServo CameraServoConfig `json:"cameraServo"`
|
CameraServo CameraServoConfig `json:"cameraServo"`
|
||||||
|
Audio AudioConfig `json:"audio"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type sensorMessage struct {
|
type sensorMessage struct {
|
||||||
@@ -24,6 +25,7 @@ type inboundMessage struct {
|
|||||||
SensorStream *sensorStreamPayload `json:"sensorStream,omitempty"`
|
SensorStream *sensorStreamPayload `json:"sensorStream,omitempty"`
|
||||||
Media *mediaCommand `json:"media,omitempty"`
|
Media *mediaCommand `json:"media,omitempty"`
|
||||||
Servo *servoPayload `json:"servo,omitempty"`
|
Servo *servoPayload `json:"servo,omitempty"`
|
||||||
|
TTS *ttsPayload `json:"tts,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type driveDirectPayload struct {
|
type driveDirectPayload struct {
|
||||||
@@ -51,6 +53,14 @@ type servoPayload struct {
|
|||||||
PulseUs *int `json:"pulseUs,omitempty"`
|
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 ackMessage struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ type BatteryConfig struct {
|
|||||||
Urgent int `yaml:"urgent"`
|
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 {
|
type MediaConfig struct {
|
||||||
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
|
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
|
||||||
PublishPort int `yaml:"publishPort" json:"-"`
|
PublishPort int `yaml:"publishPort" json:"-"`
|
||||||
@@ -89,6 +101,7 @@ type Config struct {
|
|||||||
MaxWheelMMs int `yaml:"maxWheelSpeed"`
|
MaxWheelMMs int `yaml:"maxWheelSpeed"`
|
||||||
Media MediaConfig `yaml:"media"`
|
Media MediaConfig `yaml:"media"`
|
||||||
CameraServo CameraServoConfig `yaml:"cameraServo"`
|
CameraServo CameraServoConfig `yaml:"cameraServo"`
|
||||||
|
Audio AudioConfig `yaml:"audio"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig(path string) (*Config, error) {
|
func LoadConfig(path string) (*Config, error) {
|
||||||
@@ -127,6 +140,17 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
HomeAngle: 0,
|
HomeAngle: 0,
|
||||||
NudgeDegrees: 2,
|
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 {
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -180,6 +204,7 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
if err := validateServoConfig(&cfg.CameraServo); err != nil {
|
if err := validateServoConfig(&cfg.CameraServo); err != nil {
|
||||||
return nil, fmt.Errorf("cameraServo: %w", err)
|
return nil, fmt.Errorf("cameraServo: %w", err)
|
||||||
}
|
}
|
||||||
|
validateAudioConfig(&cfg.Audio)
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +244,30 @@ func clampFloat(value, min, max float64) float64 {
|
|||||||
return value
|
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) {
|
func derivePublishURL(serverURL, roverName string, port int) (string, error) {
|
||||||
if roverName == "" {
|
if roverName == "" {
|
||||||
return "", errors.New("missing rover name for publishUrl")
|
return "", errors.New("missing rover name for publishUrl")
|
||||||
|
|||||||
+20
-8
@@ -9,24 +9,36 @@ import (
|
|||||||
|
|
||||||
const publisherEnvPath = "/var/lib/roverd/video.env"
|
const publisherEnvPath = "/var/lib/roverd/video.env"
|
||||||
|
|
||||||
func UpdatePublisherEnv(cfg MediaConfig) error {
|
func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
|
||||||
if cfg.PublishURL == "" {
|
if media.PublishURL == "" {
|
||||||
return fmt.Errorf("media publishUrl missing")
|
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")
|
return fmt.Errorf("invalid media dimensions/bitrate")
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
fmt.Fprintf(&buf, "PUBLISH_URL=%s\n", cfg.PublishURL)
|
fmt.Fprintf(&buf, "PUBLISH_URL=%s\n", media.PublishURL)
|
||||||
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", cfg.VideoWidth)
|
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
|
||||||
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", cfg.VideoHeight)
|
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight)
|
||||||
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", cfg.VideoFPS)
|
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS)
|
||||||
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", cfg.VideoBitrate)
|
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 {
|
if err := os.WriteFile(publisherEnvPath, buf.Bytes(), 0o640); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boolToInt(v bool) int {
|
||||||
|
if v {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,13 +13,14 @@ import (
|
|||||||
|
|
||||||
type MediaSupervisor struct {
|
type MediaSupervisor struct {
|
||||||
cfg MediaConfig
|
cfg MediaConfig
|
||||||
|
audio AudioConfig
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
client *http.Client
|
client *http.Client
|
||||||
checkInterval time.Duration
|
checkInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMediaSupervisor(cfg MediaConfig, logger *log.Logger) *MediaSupervisor {
|
func NewMediaSupervisor(cfg MediaConfig, audio AudioConfig, logger *log.Logger) *MediaSupervisor {
|
||||||
if err := UpdatePublisherEnv(cfg); err != nil {
|
if err := UpdatePublisherEnv(cfg, audio); err != nil {
|
||||||
logger.Printf("media supervisor: update env failed: %v", err)
|
logger.Printf("media supervisor: update env failed: %v", err)
|
||||||
}
|
}
|
||||||
if !cfg.Manage || cfg.Service == "" {
|
if !cfg.Manage || cfg.Service == "" {
|
||||||
@@ -35,6 +36,7 @@ func NewMediaSupervisor(cfg MediaConfig, logger *log.Logger) *MediaSupervisor {
|
|||||||
}
|
}
|
||||||
return &MediaSupervisor{
|
return &MediaSupervisor{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
audio: audio,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
client: client,
|
client: client,
|
||||||
checkInterval: interval,
|
checkInterval: interval,
|
||||||
@@ -45,7 +47,7 @@ func (m *MediaSupervisor) Start(ctx context.Context) {
|
|||||||
if m == nil {
|
if m == nil {
|
||||||
return
|
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)
|
m.logger.Printf("media supervisor: update env failed: %v", err)
|
||||||
}
|
}
|
||||||
if m.cfg.HealthURL == "" || m.client == nil {
|
if m.cfg.HealthURL == "" || m.client == nil {
|
||||||
@@ -76,7 +78,7 @@ func (m *MediaSupervisor) HandleAction(ctx context.Context, action string) error
|
|||||||
if m == nil {
|
if m == nil {
|
||||||
return errors.New("media supervisor disabled")
|
return errors.New("media supervisor disabled")
|
||||||
}
|
}
|
||||||
if err := UpdatePublisherEnv(m.cfg); err != nil {
|
if err := UpdatePublisherEnv(m.cfg, m.audio); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
switch action {
|
switch action {
|
||||||
|
|||||||
@@ -37,3 +37,13 @@ cameraServo:
|
|||||||
homeAngle: 0
|
homeAngle: 0
|
||||||
nudgeDegrees: 2
|
nudgeDegrees: 2
|
||||||
allowRawPulse: false
|
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,
|
MaxWheelSpeed: c.cfg.MaxWheelMMs,
|
||||||
Media: c.cfg.Media,
|
Media: c.cfg.Media,
|
||||||
CameraServo: c.cfg.CameraServo,
|
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)
|
c.log.Printf("sending hello (camera servo enabled=%v pin=%d)", msg.CameraServo.Enabled, msg.CameraServo.Pin)
|
||||||
return writeJSON(ctx, conn, msg)
|
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 fmt.Errorf("camera servo disabled")
|
||||||
}
|
}
|
||||||
return c.handleServoCommand(msg.Servo)
|
return c.handleServoCommand(msg.Servo)
|
||||||
|
case msg.TTS != nil:
|
||||||
|
return c.handleTTSPayload(msg.TTS)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported command type: %s", msg.Type)
|
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-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-A0W3Zfns.js"></script>
|
<script type="module" crossorigin src="/assets/index-Ch0OrDUK.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DOoEkJaz.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BxUpjkzh.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const { getRole } = require('./roleService');
|
|||||||
const { describeAssignment } = require('./assignmentService');
|
const { describeAssignment } = require('./assignmentService');
|
||||||
const roverManager = require('./roverManager');
|
const roverManager = require('./roverManager');
|
||||||
const { getNickname } = require('./nicknameService');
|
const { getNickname } = require('./nicknameService');
|
||||||
|
const { issueCommand } = require('./commandService');
|
||||||
|
|
||||||
const RATE_LIMIT_WINDOW_MS = 8000;
|
const RATE_LIMIT_WINDOW_MS = 8000;
|
||||||
const RATE_LIMIT_MAX = 5;
|
const RATE_LIMIT_MAX = 5;
|
||||||
@@ -66,6 +67,7 @@ function buildMessage(socket, text, meta = {}) {
|
|||||||
roverId,
|
roverId,
|
||||||
fromDiscord: Boolean(meta.fromDiscord),
|
fromDiscord: Boolean(meta.fromDiscord),
|
||||||
text,
|
text,
|
||||||
|
tts: meta.tts || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +75,20 @@ function broadcastMessage(message) {
|
|||||||
publishEvent({ source: 'chat', type: 'chat:message', payload: 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);
|
const role = getRole(socket);
|
||||||
// if (role === 'spectator') {
|
// if (role === 'spectator') {
|
||||||
// cb({ error: 'Spectators cannot chat' });
|
// cb({ error: 'Spectators cannot chat' });
|
||||||
@@ -104,12 +119,39 @@ function handleIncoming({ text } = {}, socket, cb = () => {}) {
|
|||||||
cb({ error: 'Message looks like spam' });
|
cb({ error: 'Message looks like spam' });
|
||||||
return;
|
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 });
|
logger.info('Chat message', { socket: socket.id, roverId: message.roverId });
|
||||||
broadcastMessage(message);
|
broadcastMessage(message);
|
||||||
|
maybeSpeak(socket, message, ttsOptions);
|
||||||
cb({ success: true });
|
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 }) {
|
function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null }) {
|
||||||
const clean = typeof text === 'string' ? text.trim() : '';
|
const clean = typeof text === 'string' ? text.trim() : '';
|
||||||
if (!clean || clean.length > 256) {
|
if (!clean || clean.length > 256) {
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ function getRoster() {
|
|||||||
maxWheelSpeed: record.meta?.maxWheelSpeed,
|
maxWheelSpeed: record.meta?.maxWheelSpeed,
|
||||||
media: record.meta?.media,
|
media: record.meta?.media,
|
||||||
cameraServo: record.meta?.cameraServo,
|
cameraServo: record.meta?.cameraServo,
|
||||||
|
audio: record.meta?.audio,
|
||||||
locked: record.locked,
|
locked: record.locked,
|
||||||
lockReason: record.lockReason,
|
lockReason: record.lockReason,
|
||||||
lastSeen: record.lastSeen,
|
lastSeen: record.lastSeen,
|
||||||
|
|||||||
@@ -25,13 +25,27 @@ function displayName(message) {
|
|||||||
return message.nickname || message.socketId?.slice(0, 6) || 'unknown';
|
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 }) {
|
export default function ChatPanel({ hideInput = false, hideSpectatorNotice = false, fillHeight = false }) {
|
||||||
const { session } = useSession();
|
const { session } = useSession();
|
||||||
const { messages, sendMessage, registerInputRef, onInputFocus, onInputBlur, blurChat } = useChat();
|
const { messages, sendMessage, registerInputRef, onInputFocus, onInputBlur, blurChat } = useChat();
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
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 canChat = session?.role !== 'spectator';
|
||||||
const listRef = useRef(null);
|
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]);
|
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;
|
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||||
}, [sorted]);
|
}, [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) {
|
async function handleSend(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!canChat || hideInput) return;
|
if (!canChat || hideInput) return;
|
||||||
@@ -47,7 +77,7 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
|||||||
if (!clean) return;
|
if (!clean) return;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
await sendMessage(clean);
|
await sendMessage(clean, ttsPayload);
|
||||||
setDraft('');
|
setDraft('');
|
||||||
blurChat();
|
blurChat();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -109,6 +139,52 @@ export default function ChatPanel({ hideInput = false, hideSpectatorNotice = fal
|
|||||||
placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'}
|
placeholder={canChat ? 'Type a message…' : hideSpectatorNotice ? '' : 'Spectators cannot chat'}
|
||||||
disabled={!canChat}
|
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">
|
<button type="submit" disabled={!canChat || sending} className="button-dark disabled:opacity-50">
|
||||||
{sending ? '...' : 'Send'}
|
{sending ? '...' : 'Send'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -51,9 +51,9 @@ export function ChatProvider({ children }) {
|
|||||||
}, [playSound, session?.socketId, socket]);
|
}, [playSound, session?.socketId, socket]);
|
||||||
|
|
||||||
const sendMessage = useCallback(
|
const sendMessage = useCallback(
|
||||||
(text) =>
|
(text, tts = null) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
socket.emit('chat:send', { text }, (resp = {}) => {
|
socket.emit('chat:send', { text, tts }, (resp = {}) => {
|
||||||
if (resp.error) {
|
if (resp.error) {
|
||||||
reject(new Error(resp.error));
|
reject(new Error(resp.error));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user