mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
first pass of tts and audio stuff
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user