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
+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)
}