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