| @@ -353,6 +353,10 @@ func (b *txBridge) UpdateConfig(lp ctrlpkg.LivePatch) error { | |||
| LimiterCeiling: lp.LimiterCeiling, | |||
| PS: lp.PS, | |||
| RadioText: lp.RadioText, | |||
| ToneLeftHz: lp.ToneLeftHz, | |||
| ToneRightHz: lp.ToneRightHz, | |||
| ToneAmplitude: lp.ToneAmplitude, | |||
| AudioGain: lp.AudioGain, | |||
| }) | |||
| } | |||
| @@ -277,6 +277,11 @@ type LiveConfigUpdate struct { | |||
| LimiterCeiling *float64 | |||
| PS *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. | |||
| @@ -310,6 +315,16 @@ func (e *Engine) UpdateConfig(u LiveConfigUpdate) error { | |||
| 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() --- | |||
| if u.FrequencyMHz != nil { | |||
| @@ -357,6 +372,18 @@ func (e *Engine) UpdateConfig(u LiveConfigUpdate) error { | |||
| if u.LimiterCeiling != nil { | |||
| 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) | |||
| return nil | |||
| @@ -45,6 +45,10 @@ type LivePatch struct { | |||
| LimiterCeiling *float64 | |||
| PS *string | |||
| RadioText *string | |||
| ToneLeftHz *float64 | |||
| ToneRightHz *float64 | |||
| ToneAmplitude *float64 | |||
| AudioGain *float64 | |||
| } | |||
| type Server struct { | |||
| @@ -155,12 +159,15 @@ func hasRequestBody(r *http.Request) bool { | |||
| } | |||
| func (s *Server) rejectBody(w http.ResponseWriter, r *http.Request) bool { | |||
| // Returns true when the request has an unexpected body and the error response | |||
| // has already been written — callers should return immediately in that case. | |||
| // Returns false when there is no body (happy path — request should proceed). | |||
| if !hasRequestBody(r) { | |||
| return true | |||
| return false | |||
| } | |||
| s.recordAudit(auditUnexpectedBody) | |||
| http.Error(w, noBodyErrMsg, http.StatusBadRequest) | |||
| return false | |||
| return true | |||
| } | |||
| func (s *Server) recordAudit(evt auditEvent) { | |||
| @@ -594,18 +601,37 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { | |||
| LimiterCeiling: patch.LimiterCeiling, | |||
| PS: patch.PS, | |||
| 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 | |||
| 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 { | |||
| 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") | |||
| _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "live": live}) | |||
| default: | |||
| @@ -32,6 +32,11 @@ type LiveParams struct { | |||
| LimiterEnabled bool | |||
| LimiterCeiling float64 | |||
| 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. | |||
| @@ -112,6 +117,10 @@ type Generator struct { | |||
| // Optional external audio source (e.g. StreamResampler for live audio). | |||
| // When set, takes priority over WAV/tones in sourceFor(). | |||
| 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 { | |||
| @@ -227,6 +236,10 @@ func (g *Generator) init() { | |||
| LimiterEnabled: g.cfg.FM.LimiterEnabled, | |||
| LimiterCeiling: ceiling, | |||
| 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 | |||
| @@ -240,9 +253,13 @@ func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) { | |||
| 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.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 { | |||
| @@ -272,6 +289,17 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame | |||
| 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: | |||
| // | |||
| // Audio L/R → PreEmphasis | |||