From 1d683d1d8f38928949a282b51847259aa0a20948 Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 8 Apr 2026 10:29:53 +0200 Subject: [PATCH 1/2] control: fix body rejection guard for empty POST requests rejectBody() returns true when the request body is acceptable and false when a body must be rejected. The TX and fault-reset handlers treated the return value the wrong way around and returned early on valid empty POST requests. This prevented actions like /tx/stop from running in the normal no-body case. Update the handlers to only abort when rejectBody() reports an actual rejection, so empty POST control actions proceed as intended. --- internal/control/control.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/control/control.go b/internal/control/control.go index 0af6741..e4cf0f5 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -155,12 +155,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) { From ffd6f4bcfe05066b6742e51d207be0cdf59dd4f9 Mon Sep 17 00:00:00 2001 From: Jan Date: Wed, 8 Apr 2026 10:57:45 +0200 Subject: [PATCH 2/2] control: make tone and gain updates truly live 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. --- cmd/fmrtx/main.go | 4 ++++ internal/app/engine.go | 27 ++++++++++++++++++++++++ internal/control/control.go | 39 ++++++++++++++++++++++++++++------- internal/offline/generator.go | 32 ++++++++++++++++++++++++++-- 4 files changed, 92 insertions(+), 10 deletions(-) diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index fdde1e4..c3e371d 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -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, }) } diff --git a/internal/app/engine.go b/internal/app/engine.go index 39b9a9f..a1f2e71 100644 --- a/internal/app/engine.go +++ b/internal/app/engine.go @@ -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 diff --git a/internal/control/control.go b/internal/control/control.go index e4cf0f5..ef67f30 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -45,6 +45,10 @@ type LivePatch struct { LimiterCeiling *float64 PS *string RadioText *string + ToneLeftHz *float64 + ToneRightHz *float64 + ToneAmplitude *float64 + AudioGain *float64 } type Server struct { @@ -597,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: diff --git a/internal/offline/generator.go b/internal/offline/generator.go index 6598087..b55c63a 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -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