Просмотр исходного кода

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.
main
Jan 1 месяц назад
Родитель
Сommit
ffd6f4bcfe
4 измененных файлов: 92 добавлений и 10 удалений
  1. +4
    -0
      cmd/fmrtx/main.go
  2. +27
    -0
      internal/app/engine.go
  3. +31
    -8
      internal/control/control.go
  4. +30
    -2
      internal/offline/generator.go

+ 4
- 0
cmd/fmrtx/main.go Просмотреть файл

@@ -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,
}) })
} }




+ 27
- 0
internal/app/engine.go Просмотреть файл

@@ -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


+ 31
- 8
internal/control/control.go Просмотреть файл

@@ -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:


+ 30
- 2
internal/offline/generator.go Просмотреть файл

@@ -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


Загрузка…
Отмена
Сохранить