config reshape

This commit is contained in:
legop3
2026-06-30 20:17:43 -04:00
parent 2eb6c00f9c
commit 31eacea7bc
21 changed files with 520 additions and 200 deletions
+195 -66
View File
@@ -56,13 +56,13 @@ type BatteryConfig struct {
}
type AudioConfig struct {
CaptureEnabled bool `yaml:"captureEnabled" json:"captureEnabled"`
CaptureDevice string `yaml:"captureDevice" json:"captureDevice,omitempty"`
PlaybackDevice string `yaml:"playbackDevice" json:"playbackDevice,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"`
// AudioConfig is only for sounds generated by roverd itself. Live capture
// and playback are media-publisher concerns, so those settings live under
// MediaConfig where they can be written directly into media.env.
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 HornConfig struct {
@@ -77,21 +77,59 @@ type HornConfig struct {
}
type MediaConfig struct {
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
AudioPublishURL string `yaml:"audioPublishUrl" json:"audioPublishUrl,omitempty"`
AudioForwardURL string `yaml:"audioForwardUrl" json:"audioForwardUrl,omitempty"`
PublishPort int `yaml:"publishPort" json:"-"`
CameraInverted bool `yaml:"cameraInverted" json:"-"`
Manage bool `yaml:"manage"`
ManageAudio bool `yaml:"manageAudio"`
Service string `yaml:"service"`
AudioService string `yaml:"audioService"`
HealthURL string `yaml:"healthUrl"`
HealthInterval Duration `yaml:"healthInterval"`
VideoWidth int `yaml:"videoWidth" json:"-"`
VideoHeight int `yaml:"videoHeight" json:"-"`
VideoFPS int `yaml:"videoFps" json:"-"`
VideoBitrate int `yaml:"videoBitrate" json:"-"`
// PublishPort is shared by the derived video, microphone, and forwarded-audio
// SRT URLs. Keeping it at this level prevents each nested block from needing
// to repeat the same server port when the common MediaMTX listener is used.
PublishPort int `yaml:"publishPort" json:"-"`
Manage bool `yaml:"manage" json:"manage"`
HealthURL string `yaml:"healthUrl" json:"healthUrl,omitempty"`
HealthInterval Duration `yaml:"healthInterval" json:"-"`
Video VideoMediaConfig `yaml:"video" json:"video"`
AudioCapture AudioCaptureConfig `yaml:"audioCapture" json:"audioCapture"`
AudioPlayback AudioPlaybackConfig `yaml:"audioPlayback" json:"audioPlayback"`
}
type VideoMediaConfig struct {
// Publisher selects the installed publisher script/pipeline family. The
// first pass uses pi-libcamera for current rovers; laptop-v4l2 can be added
// without changing the server-facing media shape again.
Enabled bool `yaml:"enabled" json:"enabled"`
Service string `yaml:"service" json:"service,omitempty"`
Publisher string `yaml:"publisher" json:"publisher,omitempty"`
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
Device string `yaml:"device" json:"device,omitempty"`
Width int `yaml:"width" json:"-"`
Height int `yaml:"height" json:"-"`
FPS int `yaml:"fps" json:"-"`
Bitrate int `yaml:"bitrate" json:"-"`
Inverted bool `yaml:"inverted" json:"-"`
SensorMode string `yaml:"sensorMode" json:"-"`
}
type AudioCaptureConfig struct {
// AudioCapture describes the rover microphone stream that browsers can
// subscribe to as "<rover>-audio". A disabled capture block still has
// normalized defaults so enabling it only requires flipping enabled: true.
Enabled bool `yaml:"enabled" json:"enabled"`
Service string `yaml:"service" json:"service,omitempty"`
PublishURL string `yaml:"publishUrl" json:"publishUrl,omitempty"`
Device string `yaml:"device" json:"device,omitempty"`
SampleRate int `yaml:"sampleRate" json:"-"`
Channels int `yaml:"channels" json:"-"`
Bitrate int `yaml:"bitrate" json:"-"`
}
type AudioPlaybackConfig struct {
// AudioPlayback describes the reverse stream that VIP users publish into
// MediaMTX for playback on the rover speaker. The URL is a request/read URL
// for the rover listener, while the server converts it to publish mode when
// it needs to inject audio.
Enabled bool `yaml:"enabled" json:"enabled"`
Service string `yaml:"service" json:"service,omitempty"`
ForwardURL string `yaml:"forwardUrl" json:"forwardUrl,omitempty"`
Device string `yaml:"device" json:"device,omitempty"`
Normalize bool `yaml:"normalize" json:"-"`
NormalizeFilter string `yaml:"normalizeFilter" json:"-"`
}
type CameraServoConfig struct {
@@ -189,9 +227,33 @@ func LoadConfig(path string) (*Config, error) {
},
Media: MediaConfig{
PublishPort: 9000,
CameraInverted: true,
HealthInterval: Duration{Duration: 30 * time.Second},
VideoBitrate: 2000000,
Video: VideoMediaConfig{
Enabled: true,
Service: "video-publisher.service",
Publisher: "pi-libcamera",
Width: 640,
Height: 480,
FPS: 30,
Bitrate: 2000000,
Inverted: true,
SensorMode: "1296:972",
},
AudioCapture: AudioCaptureConfig{
Enabled: false,
Service: "audio-only-publisher.service",
Device: "hw:0,0",
SampleRate: 48000,
Channels: 2,
Bitrate: 510000,
},
AudioPlayback: AudioPlaybackConfig{
Enabled: true,
Service: "audio-forward-listener.service",
Device: "forward",
Normalize: true,
NormalizeFilter: "dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled",
},
},
CameraServo: CameraServoConfig{
Pin: 12,
@@ -205,13 +267,10 @@ func LoadConfig(path string) (*Config, error) {
NudgeDegrees: 2,
},
Audio: AudioConfig{
CaptureEnabled: false,
CaptureDevice: "rovermic",
PlaybackDevice: "forward",
TTSEnabled: false,
DefaultEngine: "flite",
DefaultVoice: "rms",
DefaultPitch: 50,
TTSEnabled: false,
DefaultEngine: "flite",
DefaultVoice: "rms",
DefaultPitch: 50,
},
Horn: HornConfig{
Enabled: false,
@@ -283,38 +342,11 @@ func LoadConfig(path string) (*Config, error) {
if cfg.BRC.GPIOChip == "" {
cfg.BRC.GPIOChip = "gpiochip0"
}
if cfg.Media.Manage && cfg.Media.Service == "" {
return nil, errors.New("media.manage requires media.service")
}
if cfg.Media.Manage && cfg.Media.HealthInterval.Duration <= 0 {
cfg.Media.HealthInterval = Duration{Duration: 30 * time.Second}
}
if cfg.Media.VideoBitrate <= 0 {
cfg.Media.VideoBitrate = 3000000
}
if cfg.Media.PublishPort <= 0 {
cfg.Media.PublishPort = 9000
}
if cfg.Media.PublishURL == "" {
derived, err := derivePublishURL(cfg.ServerURL, cfg.Name, cfg.Media.PublishPort)
if err != nil {
return nil, fmt.Errorf("derive publishUrl: %w", err)
}
cfg.Media.PublishURL = derived
}
if cfg.Media.AudioPublishURL == "" {
derived, err := derivePublishURL(cfg.ServerURL, cfg.Name+"-audio", cfg.Media.PublishPort)
if err != nil {
return nil, fmt.Errorf("derive audioPublishUrl: %w", err)
}
cfg.Media.AudioPublishURL = derived
}
if cfg.Media.AudioForwardURL == "" {
derived, err := deriveReadURL(cfg.ServerURL, cfg.Name+"-fwd", cfg.Media.PublishPort)
if err != nil {
return nil, fmt.Errorf("derive audioForwardUrl: %w", err)
}
cfg.Media.AudioForwardURL = derived
if err := validateMediaConfig(&cfg.Media, cfg.ServerURL, cfg.Name); err != nil {
return nil, fmt.Errorf("media: %w", err)
}
if err := validateServoConfig(&cfg.CameraServo); err != nil {
return nil, fmt.Errorf("cameraServo: %w", err)
@@ -375,12 +407,6 @@ func clampFloat(value, min, max float64) float64 {
}
func validateAudioConfig(cfg *AudioConfig) {
if cfg.CaptureEnabled && cfg.CaptureDevice == "" {
cfg.CaptureDevice = "hw:0,0"
}
if cfg.PlaybackDevice == "" || cfg.PlaybackDevice == "default" {
cfg.PlaybackDevice = "forward"
}
if cfg.DefaultEngine == "" {
cfg.DefaultEngine = "flite"
}
@@ -392,6 +418,109 @@ func validateAudioConfig(cfg *AudioConfig) {
}
}
func validateMediaConfig(cfg *MediaConfig, serverURL string, roverName string) error {
/*
The nested media config is the source of truth for the publisher scripts,
so defaults that used to live in shell are normalized here before any env
file is written. This keeps the Pi behavior stable while making laptop
and future publisher variants explicit configuration choices.
*/
if cfg.PublishPort <= 0 {
cfg.PublishPort = 9000
}
if cfg.HealthInterval.Duration <= 0 {
cfg.HealthInterval = Duration{Duration: 30 * time.Second}
}
if err := validateVideoMediaConfig(&cfg.Video, serverURL, roverName, cfg.PublishPort); err != nil {
return fmt.Errorf("video: %w", err)
}
if err := validateAudioCaptureConfig(&cfg.AudioCapture, serverURL, roverName, cfg.PublishPort); err != nil {
return fmt.Errorf("audioCapture: %w", err)
}
if err := validateAudioPlaybackConfig(&cfg.AudioPlayback, serverURL, roverName, cfg.PublishPort); err != nil {
return fmt.Errorf("audioPlayback: %w", err)
}
return nil
}
func validateVideoMediaConfig(cfg *VideoMediaConfig, serverURL string, roverName string, publishPort int) error {
if cfg.Service == "" {
cfg.Service = "video-publisher.service"
}
if cfg.Publisher == "" {
cfg.Publisher = "pi-libcamera"
}
if cfg.Width <= 0 {
cfg.Width = 640
}
if cfg.Height <= 0 {
cfg.Height = 480
}
if cfg.FPS <= 0 {
cfg.FPS = 30
}
if cfg.Bitrate <= 0 {
cfg.Bitrate = 2000000
}
if cfg.SensorMode == "" && cfg.Publisher == "pi-libcamera" {
cfg.SensorMode = "1296:972"
}
if cfg.PublishURL == "" {
derived, err := derivePublishURL(serverURL, roverName, publishPort)
if err != nil {
return fmt.Errorf("derive publishUrl: %w", err)
}
cfg.PublishURL = derived
}
return nil
}
func validateAudioCaptureConfig(cfg *AudioCaptureConfig, serverURL string, roverName string, publishPort int) error {
if cfg.Service == "" {
cfg.Service = "audio-only-publisher.service"
}
if cfg.Device == "" {
cfg.Device = "hw:0,0"
}
if cfg.SampleRate <= 0 {
cfg.SampleRate = 48000
}
if cfg.Channels <= 0 {
cfg.Channels = 2
}
if cfg.Bitrate <= 0 {
cfg.Bitrate = 510000
}
if cfg.PublishURL == "" {
derived, err := derivePublishURL(serverURL, roverName+"-audio", publishPort)
if err != nil {
return fmt.Errorf("derive publishUrl: %w", err)
}
cfg.PublishURL = derived
}
return nil
}
func validateAudioPlaybackConfig(cfg *AudioPlaybackConfig, serverURL string, roverName string, publishPort int) error {
if cfg.Service == "" {
cfg.Service = "audio-forward-listener.service"
}
if cfg.Device == "" {
cfg.Device = "forward"
}
if cfg.NormalizeFilter == "" {
cfg.NormalizeFilter = "dynaudnorm=f=75:g=15:m=10:p=0.9,alimiter=limit=0.85:level=disabled"
}
if cfg.ForwardURL == "" {
derived, err := deriveReadURL(serverURL, roverName+"-fwd", publishPort)
if err != nil {
return fmt.Errorf("derive forwardUrl: %w", err)
}
cfg.ForwardURL = derived
}
return nil
}
func validateHornConfig(cfg *HornConfig) {
if cfg.Volume <= 0 {
cfg.Volume = 0.25