Wire tone frequency, tone amplitude, and audio gain through the live control path so the UI's live-update behavior matches the engine. This changes the generator live params to carry tone and gain values, propagates them through Engine.UpdateConfig and txBridge.UpdateConfig, and extends the control-plane patch types accordingly. It also refines the control API behavior: - avoid holding the server config mutex across tx.UpdateConfig() - report live=true only when a request contains at least one genuinely live-applicable field Together these fixes align the UI semantics with the actual runtime behavior and remove a lock hazard in the config update path.main
| @@ -353,6 +353,10 @@ func (b *txBridge) UpdateConfig(lp ctrlpkg.LivePatch) error { | |||||
| LimiterCeiling: lp.LimiterCeiling, | LimiterCeiling: lp.LimiterCeiling, | ||||
| PS: lp.PS, | PS: lp.PS, | ||||
| RadioText: lp.RadioText, | RadioText: lp.RadioText, | ||||
| ToneLeftHz: lp.ToneLeftHz, | |||||
| ToneRightHz: lp.ToneRightHz, | |||||
| ToneAmplitude: lp.ToneAmplitude, | |||||
| AudioGain: lp.AudioGain, | |||||
| }) | }) | ||||
| } | } | ||||
| @@ -277,6 +277,11 @@ type LiveConfigUpdate struct { | |||||
| LimiterCeiling *float64 | LimiterCeiling *float64 | ||||
| PS *string | PS *string | ||||
| RadioText *string | RadioText *string | ||||
| // Tone and gain: live-patchable without engine restart. | |||||
| ToneLeftHz *float64 | |||||
| ToneRightHz *float64 | |||||
| ToneAmplitude *float64 | |||||
| AudioGain *float64 | |||||
| } | } | ||||
| // UpdateConfig applies live parameter changes without restarting the engine. | // UpdateConfig applies live parameter changes without restarting the engine. | ||||
| @@ -310,6 +315,16 @@ func (e *Engine) UpdateConfig(u LiveConfigUpdate) error { | |||||
| return fmt.Errorf("limiterCeiling out of range (0-2)") | return fmt.Errorf("limiterCeiling out of range (0-2)") | ||||
| } | } | ||||
| } | } | ||||
| if u.ToneAmplitude != nil { | |||||
| if *u.ToneAmplitude < 0 || *u.ToneAmplitude > 1 { | |||||
| return fmt.Errorf("toneAmplitude out of range (0-1)") | |||||
| } | |||||
| } | |||||
| if u.AudioGain != nil { | |||||
| if *u.AudioGain < 0 || *u.AudioGain > 4 { | |||||
| return fmt.Errorf("audioGain out of range (0-4)") | |||||
| } | |||||
| } | |||||
| // --- Frequency: store for run loop to apply via driver.Tune() --- | // --- Frequency: store for run loop to apply via driver.Tune() --- | ||||
| if u.FrequencyMHz != nil { | if u.FrequencyMHz != nil { | ||||
| @@ -357,6 +372,18 @@ func (e *Engine) UpdateConfig(u LiveConfigUpdate) error { | |||||
| if u.LimiterCeiling != nil { | if u.LimiterCeiling != nil { | ||||
| next.LimiterCeiling = *u.LimiterCeiling | next.LimiterCeiling = *u.LimiterCeiling | ||||
| } | } | ||||
| if u.ToneLeftHz != nil { | |||||
| next.ToneLeftHz = *u.ToneLeftHz | |||||
| } | |||||
| if u.ToneRightHz != nil { | |||||
| next.ToneRightHz = *u.ToneRightHz | |||||
| } | |||||
| if u.ToneAmplitude != nil { | |||||
| next.ToneAmplitude = *u.ToneAmplitude | |||||
| } | |||||
| if u.AudioGain != nil { | |||||
| next.AudioGain = *u.AudioGain | |||||
| } | |||||
| e.generator.UpdateLive(next) | e.generator.UpdateLive(next) | ||||
| return nil | return nil | ||||
| @@ -45,6 +45,10 @@ type LivePatch struct { | |||||
| LimiterCeiling *float64 | LimiterCeiling *float64 | ||||
| PS *string | PS *string | ||||
| RadioText *string | RadioText *string | ||||
| ToneLeftHz *float64 | |||||
| ToneRightHz *float64 | |||||
| ToneAmplitude *float64 | |||||
| AudioGain *float64 | |||||
| } | } | ||||
| type Server struct { | type Server struct { | ||||
| @@ -597,18 +601,37 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { | |||||
| LimiterCeiling: patch.LimiterCeiling, | LimiterCeiling: patch.LimiterCeiling, | ||||
| PS: patch.PS, | PS: patch.PS, | ||||
| RadioText: patch.RadioText, | RadioText: patch.RadioText, | ||||
| } | |||||
| ToneLeftHz: patch.ToneLeftHz, | |||||
| ToneRightHz: patch.ToneRightHz, | |||||
| ToneAmplitude: patch.ToneAmplitude, | |||||
| AudioGain: patch.AudioGain, | |||||
| } | |||||
| // NEU-02 fix: determine whether any live-patchable fields are present, | |||||
| // then release the lock before calling UpdateConfig to avoid holding | |||||
| // s.mu across a potentially blocking engine call. | |||||
| tx := s.tx | tx := s.tx | ||||
| if tx != nil { | |||||
| hasLiveFields := patch.FrequencyMHz != nil || patch.OutputDrive != nil || | |||||
| patch.StereoEnabled != nil || patch.PilotLevel != nil || | |||||
| patch.RDSInjection != nil || patch.RDSEnabled != nil || | |||||
| patch.LimiterEnabled != nil || patch.LimiterCeiling != nil || | |||||
| patch.PS != nil || patch.RadioText != nil || | |||||
| patch.ToneLeftHz != nil || patch.ToneRightHz != nil || | |||||
| patch.ToneAmplitude != nil || patch.AudioGain != nil | |||||
| s.cfg = next | |||||
| s.mu.Unlock() | |||||
| // Apply live fields to running engine outside the lock. | |||||
| var updateErr error | |||||
| if tx != nil && hasLiveFields { | |||||
| if err := tx.UpdateConfig(lp); err != nil { | if err := tx.UpdateConfig(lp); err != nil { | ||||
| s.mu.Unlock() | |||||
| http.Error(w, err.Error(), http.StatusBadRequest) | |||||
| return | |||||
| updateErr = err | |||||
| } | } | ||||
| } | } | ||||
| s.cfg = next | |||||
| live := tx != nil | |||||
| s.mu.Unlock() | |||||
| if updateErr != nil { | |||||
| http.Error(w, updateErr.Error(), http.StatusBadRequest) | |||||
| return | |||||
| } | |||||
| // NEU-03 fix: report live=true only when live-patchable fields were applied. | |||||
| live := tx != nil && hasLiveFields | |||||
| w.Header().Set("Content-Type", "application/json") | w.Header().Set("Content-Type", "application/json") | ||||
| _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "live": live}) | _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "live": live}) | ||||
| default: | default: | ||||
| @@ -32,6 +32,11 @@ type LiveParams struct { | |||||
| LimiterEnabled bool | LimiterEnabled bool | ||||
| LimiterCeiling float64 | LimiterCeiling float64 | ||||
| MpxGain float64 // hardware calibration factor for composite output | MpxGain float64 // hardware calibration factor for composite output | ||||
| // Tone + gain: live-patchable without DSP chain reinit. | |||||
| ToneLeftHz float64 | |||||
| ToneRightHz float64 | |||||
| ToneAmplitude float64 | |||||
| AudioGain float64 | |||||
| } | } | ||||
| // PreEmphasizedSource wraps an audio source and applies pre-emphasis. | // PreEmphasizedSource wraps an audio source and applies pre-emphasis. | ||||
| @@ -112,6 +117,10 @@ type Generator struct { | |||||
| // Optional external audio source (e.g. StreamResampler for live audio). | // Optional external audio source (e.g. StreamResampler for live audio). | ||||
| // When set, takes priority over WAV/tones in sourceFor(). | // When set, takes priority over WAV/tones in sourceFor(). | ||||
| externalSource frameSource | externalSource frameSource | ||||
| // Tone source reference — non-nil when a ToneSource is the active audio input. | |||||
| // Allows live-updating tone parameters via LiveParams each chunk. | |||||
| toneSource *audio.ToneSource | |||||
| } | } | ||||
| func NewGenerator(cfg cfgpkg.Config) *Generator { | func NewGenerator(cfg cfgpkg.Config) *Generator { | ||||
| @@ -227,6 +236,10 @@ func (g *Generator) init() { | |||||
| LimiterEnabled: g.cfg.FM.LimiterEnabled, | LimiterEnabled: g.cfg.FM.LimiterEnabled, | ||||
| LimiterCeiling: ceiling, | LimiterCeiling: ceiling, | ||||
| MpxGain: g.cfg.FM.MpxGain, | MpxGain: g.cfg.FM.MpxGain, | ||||
| ToneLeftHz: g.cfg.Audio.ToneLeftHz, | |||||
| ToneRightHz: g.cfg.Audio.ToneRightHz, | |||||
| ToneAmplitude: g.cfg.Audio.ToneAmplitude, | |||||
| AudioGain: g.cfg.Audio.Gain, | |||||
| }) | }) | ||||
| g.initialized = true | g.initialized = true | ||||
| @@ -240,9 +253,13 @@ func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) { | |||||
| if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil { | if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil { | ||||
| return audio.NewResampledSource(src, sampleRate), SourceInfo{Kind: "wav", SampleRate: float64(src.SampleRate), Detail: g.cfg.Audio.InputPath} | return audio.NewResampledSource(src, sampleRate), SourceInfo{Kind: "wav", SampleRate: float64(src.SampleRate), Detail: g.cfg.Audio.InputPath} | ||||
| } | } | ||||
| return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tone-fallback", SampleRate: sampleRate, Detail: g.cfg.Audio.InputPath} | |||||
| ts := audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude) | |||||
| g.toneSource = ts | |||||
| return ts, SourceInfo{Kind: "tone-fallback", SampleRate: sampleRate, Detail: g.cfg.Audio.InputPath} | |||||
| } | } | ||||
| return audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude), SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"} | |||||
| ts := audio.NewConfiguredToneSource(sampleRate, g.cfg.Audio.ToneLeftHz, g.cfg.Audio.ToneRightHz, g.cfg.Audio.ToneAmplitude) | |||||
| g.toneSource = ts | |||||
| return ts, SourceInfo{Kind: "tones", SampleRate: sampleRate, Detail: "generated"} | |||||
| } | } | ||||
| func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame { | func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame { | ||||
| @@ -272,6 +289,17 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame | |||||
| lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0} | lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0} | ||||
| } | } | ||||
| // Apply live tone and gain updates each chunk. GenerateFrame runs on a | |||||
| // single goroutine so these field writes are safe without additional locking. | |||||
| if g.toneSource != nil { | |||||
| g.toneSource.LeftFreq = lp.ToneLeftHz | |||||
| g.toneSource.RightFreq = lp.ToneRightHz | |||||
| g.toneSource.Amplitude = lp.ToneAmplitude | |||||
| } | |||||
| if g.source != nil { | |||||
| g.source.gain = lp.AudioGain | |||||
| } | |||||
| // Broadcast clip-filter-clip FM MPX signal chain: | // Broadcast clip-filter-clip FM MPX signal chain: | ||||
| // | // | ||||
| // Audio L/R → PreEmphasis | // Audio L/R → PreEmphasis | ||||