From 6724eff968ca9fd82e4c7accaf5a7e5aebb8faba Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 12 Apr 2026 12:52:16 +0200 Subject: [PATCH] fix(tx): decouple watermark from evaluation jingle Add an explicit fm.watermarkEnabled switch, validate fm.stereoMode values, and only persist /config snapshots after a live update succeeds. Also separate evaluation-jingle licensing behavior from watermark handling. --- cmd/fmrtx/main.go | 39 +++---- internal/app/engine.go | 26 +++-- internal/config/config.go | 38 ++++--- internal/config/config_test.go | 18 ++++ internal/control/control.go | 159 ++++++++++++++--------------- internal/control/control_test.go | 26 +++++ internal/offline/generator.go | 104 ++++++++++++------- internal/offline/generator_test.go | 155 +++++++++++++++++++++------- internal/stereo/encoder.go | 117 +++++++++++++++------ 9 files changed, 454 insertions(+), 228 deletions(-) diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index 0900921..71c8bd3 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -12,7 +12,6 @@ import ( "time" apppkg "github.com/jan/fm-rds-tx/internal/app" - "github.com/jan/fm-rds-tx/internal/license" "github.com/jan/fm-rds-tx/internal/audio" cfgpkg "github.com/jan/fm-rds-tx/internal/config" ctrlpkg "github.com/jan/fm-rds-tx/internal/control" @@ -20,6 +19,7 @@ import ( "github.com/jan/fm-rds-tx/internal/ingest" "github.com/jan/fm-rds-tx/internal/ingest/adapters/icecast" ingestfactory "github.com/jan/fm-rds-tx/internal/ingest/factory" + "github.com/jan/fm-rds-tx/internal/license" "github.com/jan/fm-rds-tx/internal/platform" "github.com/jan/fm-rds-tx/internal/platform/plutosdr" "github.com/jan/fm-rds-tx/internal/platform/soapysdr" @@ -35,7 +35,7 @@ func main() { simulateDuration := flag.Duration("simulate-duration", 500*time.Millisecond, "simulated transmit duration") txMode := flag.Bool("tx", false, "start real TX mode (requires hardware + build tags)") txAutoStart := flag.Bool("tx-auto-start", false, "auto-start TX on launch") - licenseKey := flag.String("license", "", "fm-rds-tx license key (omit for evaluation mode with jingle)") + licenseKey := flag.String("license", "", "fm-rds-tx license key (omit for evaluation mode with jingle)") listDevices := flag.Bool("list-devices", false, "enumerate SoapySDR devices and exit") audioStdin := flag.Bool("audio-stdin", false, "read S16LE stereo PCM audio from stdin") audioRate := flag.Int("audio-rate", 44100, "sample rate of stdin audio input (Hz)") @@ -185,6 +185,7 @@ func runTXMode(cfg cfgpkg.Config, configPath string, driver platform.SoapyDriver log.Printf("license: no valid key — evaluation jingle every %d minutes", license.JingleIntervalMinutes) } engine.SetLicenseState(licState, licenseKey) + engine.ConfigureWatermark(cfg.FM.WatermarkEnabled, licenseKey) cfg = applyLegacyAudioFlags(cfg, audioStdin, audioRate, audioHTTP) var streamSrc *audio.StreamSource @@ -361,23 +362,23 @@ func (b *txBridge) TXStats() map[string]any { } func (b *txBridge) UpdateConfig(lp ctrlpkg.LivePatch) error { return b.engine.UpdateConfig(apppkg.LiveConfigUpdate{ - FrequencyMHz: lp.FrequencyMHz, - OutputDrive: lp.OutputDrive, - StereoEnabled: lp.StereoEnabled, - StereoMode: lp.StereoMode, - PilotLevel: lp.PilotLevel, - RDSInjection: lp.RDSInjection, - RDSEnabled: lp.RDSEnabled, - LimiterEnabled: lp.LimiterEnabled, - LimiterCeiling: lp.LimiterCeiling, - PS: lp.PS, - RadioText: lp.RadioText, - TA: lp.TA, - TP: lp.TP, - ToneLeftHz: lp.ToneLeftHz, - ToneRightHz: lp.ToneRightHz, - ToneAmplitude: lp.ToneAmplitude, - AudioGain: lp.AudioGain, + FrequencyMHz: lp.FrequencyMHz, + OutputDrive: lp.OutputDrive, + StereoEnabled: lp.StereoEnabled, + StereoMode: lp.StereoMode, + PilotLevel: lp.PilotLevel, + RDSInjection: lp.RDSInjection, + RDSEnabled: lp.RDSEnabled, + LimiterEnabled: lp.LimiterEnabled, + LimiterCeiling: lp.LimiterCeiling, + PS: lp.PS, + RadioText: lp.RadioText, + TA: lp.TA, + TP: lp.TP, + ToneLeftHz: lp.ToneLeftHz, + ToneRightHz: lp.ToneRightHz, + ToneAmplitude: lp.ToneAmplitude, + AudioGain: lp.AudioGain, CompositeClipperEnabled: lp.CompositeClipperEnabled, }) } diff --git a/internal/app/engine.go b/internal/app/engine.go index aabec8c..73c8335 100644 --- a/internal/app/engine.go +++ b/internal/app/engine.go @@ -12,8 +12,8 @@ import ( "github.com/jan/fm-rds-tx/internal/audio" cfgpkg "github.com/jan/fm-rds-tx/internal/config" - "github.com/jan/fm-rds-tx/internal/license" "github.com/jan/fm-rds-tx/internal/dsp" + "github.com/jan/fm-rds-tx/internal/license" offpkg "github.com/jan/fm-rds-tx/internal/offline" "github.com/jan/fm-rds-tx/internal/output" "github.com/jan/fm-rds-tx/internal/platform" @@ -123,7 +123,7 @@ const ( queueMutedRecoveryThreshold = queueCriticalStreakThreshold queueFaultedStreakThreshold = queueCriticalStreakThreshold faultRepeatWindow = 1 * time.Second - lateBufferStreakThreshold = 3 // consecutive late writes required before alerting + lateBufferStreakThreshold = 3 // consecutive late writes required before alerting faultHistoryCapacity = 8 runtimeTransitionHistoryCapacity = 8 ) @@ -206,10 +206,16 @@ func (e *Engine) SetStreamSource(src *audio.StreamSource) { src.SampleRate, compositeRate, src.Stats().Capacity) } -// SetLicenseState passes the license/jingle state and raw key to the generator. -// Must be called before Start(). key is used to derive the watermark payload. -func (e *Engine) SetLicenseState(s *license.State, key string) { - e.generator.SetLicense(s, key) +// SetLicenseState passes license/jingle state to the generator. +// Must be called before Start(). It does not implicitly enable watermarking. +func (e *Engine) SetLicenseState(s *license.State, _ string) { + e.generator.SetLicense(s) +} + +// ConfigureWatermark explicitly enables or disables the optional program-audio watermark. +// Must be called before Start(). +func (e *Engine) ConfigureWatermark(enabled bool, key string) { + e.generator.ConfigureWatermark(enabled, key) } // StreamSource returns the live audio stream source, or nil. @@ -293,10 +299,10 @@ type LiveConfigUpdate struct { TA *bool TP *bool // Tone and gain: live-patchable without engine restart. - ToneLeftHz *float64 - ToneRightHz *float64 - ToneAmplitude *float64 - AudioGain *float64 + ToneLeftHz *float64 + ToneRightHz *float64 + ToneAmplitude *float64 + AudioGain *float64 CompositeClipperEnabled *bool } diff --git a/internal/config/config.go b/internal/config/config.go index 6c7f9bf..9ef07e7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -74,8 +74,8 @@ type RDSConfig struct { // EONEntryConfig describes another station for EON transmission. type EONEntryConfig struct { - PI string `json:"pi"` // hex PI code - PS string `json:"ps"` // 8-char station name + PI string `json:"pi"` // hex PI code + PS string `json:"ps"` // 8-char station name PTY int `json:"pty"` TP bool `json:"tp"` TA bool `json:"ta"` @@ -83,20 +83,21 @@ type EONEntryConfig struct { } type FMConfig struct { - FrequencyMHz float64 `json:"frequencyMHz"` - StereoEnabled bool `json:"stereoEnabled"` - StereoMode string `json:"stereoMode"` // "DSB" (standard), "SSB" (experimental LSB), "VSB" (vestigial) - PilotLevel float64 `json:"pilotLevel"` // fraction of ±75kHz deviation (0.09 = 9%, ITU standard) - RDSInjection float64 `json:"rdsInjection"` // fraction of ±75kHz deviation (0.04 = 4%, typical) - PreEmphasisTauUS float64 `json:"preEmphasisTauUS"` // time constant in µs: 50 (EU) or 75 (US), 0=off - OutputDrive float64 `json:"outputDrive"` - CompositeRateHz int `json:"compositeRateHz"` // internal DSP/MPX sample rate - MaxDeviationHz float64 `json:"maxDeviationHz"` - LimiterEnabled bool `json:"limiterEnabled"` - LimiterCeiling float64 `json:"limiterCeiling"` - FMModulationEnabled bool `json:"fmModulationEnabled"` - MpxGain float64 `json:"mpxGain"` // hardware calibration: scales entire composite output (default 1.0) - BS412Enabled bool `json:"bs412Enabled"` // ITU-R BS.412 MPX power limiter (EU requirement) + FrequencyMHz float64 `json:"frequencyMHz"` + StereoEnabled bool `json:"stereoEnabled"` + StereoMode string `json:"stereoMode"` // "DSB" (standard), "SSB" (experimental LSB), "VSB" (vestigial) + PilotLevel float64 `json:"pilotLevel"` // fraction of ±75kHz deviation (0.09 = 9%, ITU standard) + RDSInjection float64 `json:"rdsInjection"` // fraction of ±75kHz deviation (0.04 = 4%, typical) + PreEmphasisTauUS float64 `json:"preEmphasisTauUS"` // time constant in µs: 50 (EU) or 75 (US), 0=off + OutputDrive float64 `json:"outputDrive"` + CompositeRateHz int `json:"compositeRateHz"` // internal DSP/MPX sample rate + MaxDeviationHz float64 `json:"maxDeviationHz"` + LimiterEnabled bool `json:"limiterEnabled"` + LimiterCeiling float64 `json:"limiterCeiling"` + FMModulationEnabled bool `json:"fmModulationEnabled"` + WatermarkEnabled bool `json:"watermarkEnabled"` // explicit opt-in for STFT program-audio watermarking + MpxGain float64 `json:"mpxGain"` // hardware calibration: scales entire composite output (default 1.0) + BS412Enabled bool `json:"bs412Enabled"` // ITU-R BS.412 MPX power limiter (EU requirement) BS412ThresholdDBr float64 `json:"bs412ThresholdDBr"` // power limit in dBr (0 = standard, +3 = relaxed) CompositeClipper CompositeClipperConfig `json:"compositeClipper"` // ITU-R SM.1268 iterative composite clipper } @@ -388,6 +389,11 @@ func (c Config) Validate() error { if c.FM.PreEmphasisTauUS < 0 || c.FM.PreEmphasisTauUS > 100 { return fmt.Errorf("fm.preEmphasisTauUS out of range (0=off, 50=EU, 75=US)") } + switch mode := strings.ToUpper(strings.TrimSpace(c.FM.StereoMode)); mode { + case "DSB", "SSB", "VSB": + default: + return fmt.Errorf("fm.stereoMode invalid: %q (expected DSB, SSB, or VSB)", c.FM.StereoMode) + } if c.FM.MaxDeviationHz < 0 || c.FM.MaxDeviationHz > 150000 { return fmt.Errorf("fm.maxDeviationHz out of range") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0c0cd5a..c5f03eb 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -298,3 +298,21 @@ func TestValidateRejectsZeroMpxGain(t *testing.T) { t.Fatal("expected mpxGain error") } } + +func TestValidateRejectsInvalidStereoMode(t *testing.T) { + cfg := Default() + cfg.FM.StereoMode = "weird" + if err := cfg.Validate(); err == nil { + t.Fatal("expected stereoMode validation error") + } +} + +func TestValidateAcceptsStereoModes(t *testing.T) { + for _, mode := range []string{"DSB", "SSB", "VSB"} { + cfg := Default() + cfg.FM.StereoMode = mode + if err := cfg.Validate(); err != nil { + t.Fatalf("mode %s should validate: %v", mode, err) + } + } +} diff --git a/internal/control/control.go b/internal/control/control.go index 8923a27..451e548 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -35,23 +35,23 @@ type TXController interface { // LivePatch mirrors the patchable fields from ConfigPatch for the engine. // nil = no change. type LivePatch struct { - FrequencyMHz *float64 - OutputDrive *float64 - StereoEnabled *bool - StereoMode *string - PilotLevel *float64 - RDSInjection *float64 - RDSEnabled *bool - LimiterEnabled *bool - LimiterCeiling *float64 - PS *string - RadioText *string - TA *bool - TP *bool - ToneLeftHz *float64 - ToneRightHz *float64 - ToneAmplitude *float64 - AudioGain *float64 + FrequencyMHz *float64 + OutputDrive *float64 + StereoEnabled *bool + StereoMode *string + PilotLevel *float64 + RDSInjection *float64 + RDSEnabled *bool + LimiterEnabled *bool + LimiterCeiling *float64 + PS *string + RadioText *string + TA *bool + TP *bool + ToneLeftHz *float64 + ToneRightHz *float64 + ToneAmplitude *float64 + AudioGain *float64 CompositeClipperEnabled *bool } @@ -68,7 +68,7 @@ type Server struct { // BUG-F fix: reloadPending prevents multiple concurrent goroutines from // calling hardReload when handleIngestSave is hit multiple times quickly. reloadPending atomic.Bool - audit auditCounters + audit auditCounters } type AudioIngress interface { @@ -123,44 +123,44 @@ func isJSONContentType(r *http.Request) bool { } type ConfigPatch struct { - FrequencyMHz *float64 `json:"frequencyMHz,omitempty"` - OutputDrive *float64 `json:"outputDrive,omitempty"` - StereoEnabled *bool `json:"stereoEnabled,omitempty"` - StereoMode *string `json:"stereoMode,omitempty"` - PilotLevel *float64 `json:"pilotLevel,omitempty"` - RDSInjection *float64 `json:"rdsInjection,omitempty"` - RDSEnabled *bool `json:"rdsEnabled,omitempty"` - ToneLeftHz *float64 `json:"toneLeftHz,omitempty"` - ToneRightHz *float64 `json:"toneRightHz,omitempty"` - ToneAmplitude *float64 `json:"toneAmplitude,omitempty"` - PS *string `json:"ps,omitempty"` - RadioText *string `json:"radioText,omitempty"` - PreEmphasisTauUS *float64 `json:"preEmphasisTauUS,omitempty"` - LimiterEnabled *bool `json:"limiterEnabled,omitempty"` - LimiterCeiling *float64 `json:"limiterCeiling,omitempty"` - AudioGain *float64 `json:"audioGain,omitempty"` - PI *string `json:"pi,omitempty"` - PTY *int `json:"pty,omitempty"` - TP *bool `json:"tp,omitempty"` - TA *bool `json:"ta,omitempty"` - MS *bool `json:"ms,omitempty"` - CTEnabled *bool `json:"ctEnabled,omitempty"` - RTPlusEnabled *bool `json:"rtPlusEnabled,omitempty"` - RTPlusSeparator *string `json:"rtPlusSeparator,omitempty"` - PTYN *string `json:"ptyn,omitempty"` - LPS *string `json:"lps,omitempty"` - ERTEnabled *bool `json:"ertEnabled,omitempty"` - ERT *string `json:"ert,omitempty"` - RDS2Enabled *bool `json:"rds2Enabled,omitempty"` - StationLogoPath *string `json:"stationLogoPath,omitempty"` - AF *[]float64 `json:"af,omitempty"` - BS412Enabled *bool `json:"bs412Enabled,omitempty"` - BS412ThresholdDBr *float64 `json:"bs412ThresholdDBr,omitempty"` - MpxGain *float64 `json:"mpxGain,omitempty"` - CompositeClipperEnabled *bool `json:"compositeClipperEnabled,omitempty"` - CompositeClipperIterations *int `json:"compositeClipperIterations,omitempty"` - CompositeClipperSoftKnee *float64 `json:"compositeClipperSoftKnee,omitempty"` - CompositeClipperLookaheadMs *float64 `json:"compositeClipperLookaheadMs,omitempty"` + FrequencyMHz *float64 `json:"frequencyMHz,omitempty"` + OutputDrive *float64 `json:"outputDrive,omitempty"` + StereoEnabled *bool `json:"stereoEnabled,omitempty"` + StereoMode *string `json:"stereoMode,omitempty"` + PilotLevel *float64 `json:"pilotLevel,omitempty"` + RDSInjection *float64 `json:"rdsInjection,omitempty"` + RDSEnabled *bool `json:"rdsEnabled,omitempty"` + ToneLeftHz *float64 `json:"toneLeftHz,omitempty"` + ToneRightHz *float64 `json:"toneRightHz,omitempty"` + ToneAmplitude *float64 `json:"toneAmplitude,omitempty"` + PS *string `json:"ps,omitempty"` + RadioText *string `json:"radioText,omitempty"` + PreEmphasisTauUS *float64 `json:"preEmphasisTauUS,omitempty"` + LimiterEnabled *bool `json:"limiterEnabled,omitempty"` + LimiterCeiling *float64 `json:"limiterCeiling,omitempty"` + AudioGain *float64 `json:"audioGain,omitempty"` + PI *string `json:"pi,omitempty"` + PTY *int `json:"pty,omitempty"` + TP *bool `json:"tp,omitempty"` + TA *bool `json:"ta,omitempty"` + MS *bool `json:"ms,omitempty"` + CTEnabled *bool `json:"ctEnabled,omitempty"` + RTPlusEnabled *bool `json:"rtPlusEnabled,omitempty"` + RTPlusSeparator *string `json:"rtPlusSeparator,omitempty"` + PTYN *string `json:"ptyn,omitempty"` + LPS *string `json:"lps,omitempty"` + ERTEnabled *bool `json:"ertEnabled,omitempty"` + ERT *string `json:"ert,omitempty"` + RDS2Enabled *bool `json:"rds2Enabled,omitempty"` + StationLogoPath *string `json:"stationLogoPath,omitempty"` + AF *[]float64 `json:"af,omitempty"` + BS412Enabled *bool `json:"bs412Enabled,omitempty"` + BS412ThresholdDBr *float64 `json:"bs412ThresholdDBr,omitempty"` + MpxGain *float64 `json:"mpxGain,omitempty"` + CompositeClipperEnabled *bool `json:"compositeClipperEnabled,omitempty"` + CompositeClipperIterations *int `json:"compositeClipperIterations,omitempty"` + CompositeClipperSoftKnee *float64 `json:"compositeClipperSoftKnee,omitempty"` + CompositeClipperLookaheadMs *float64 `json:"compositeClipperLookaheadMs,omitempty"` } type IngestSaveRequest struct { @@ -675,23 +675,23 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { return } lp := LivePatch{ - FrequencyMHz: patch.FrequencyMHz, - OutputDrive: patch.OutputDrive, - StereoEnabled: patch.StereoEnabled, - StereoMode: patch.StereoMode, - PilotLevel: patch.PilotLevel, - RDSInjection: patch.RDSInjection, - RDSEnabled: patch.RDSEnabled, - LimiterEnabled: patch.LimiterEnabled, - LimiterCeiling: patch.LimiterCeiling, - PS: patch.PS, - RadioText: patch.RadioText, - TA: patch.TA, - TP: patch.TP, - ToneLeftHz: patch.ToneLeftHz, - ToneRightHz: patch.ToneRightHz, - ToneAmplitude: patch.ToneAmplitude, - AudioGain: patch.AudioGain, + FrequencyMHz: patch.FrequencyMHz, + OutputDrive: patch.OutputDrive, + StereoEnabled: patch.StereoEnabled, + StereoMode: patch.StereoMode, + PilotLevel: patch.PilotLevel, + RDSInjection: patch.RDSInjection, + RDSEnabled: patch.RDSEnabled, + LimiterEnabled: patch.LimiterEnabled, + LimiterCeiling: patch.LimiterCeiling, + PS: patch.PS, + RadioText: patch.RadioText, + TA: patch.TA, + TP: patch.TP, + ToneLeftHz: patch.ToneLeftHz, + ToneRightHz: patch.ToneRightHz, + ToneAmplitude: patch.ToneAmplitude, + AudioGain: patch.AudioGain, CompositeClipperEnabled: patch.CompositeClipperEnabled, } // NEU-02 fix: determine whether any live-patchable fields are present, @@ -706,19 +706,18 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { patch.ToneLeftHz != nil || patch.ToneRightHz != nil || patch.ToneAmplitude != nil || patch.AudioGain != nil || patch.CompositeClipperEnabled != 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 { - updateErr = err + http.Error(w, err.Error(), http.StatusBadRequest) + return } } - if updateErr != nil { - http.Error(w, updateErr.Error(), http.StatusBadRequest) - return - } + // Commit the server snapshot only after validation and any required live update succeeded. + s.mu.Lock() + s.cfg = next + s.mu.Unlock() // NEU-03 fix: report live=true only when live-patchable fields were applied. live := tx != nil && hasLiveFields w.Header().Set("Content-Type", "application/json") diff --git a/internal/control/control_test.go b/internal/control/control_test.go index 6134ca2..f5ac246 100644 --- a/internal/control/control_test.go +++ b/internal/control/control_test.go @@ -137,6 +137,32 @@ func TestConfigPatch(t *testing.T) { } } +func TestConfigPatchFailedLiveUpdateDoesNotMutateSnapshot(t *testing.T) { + cfg := cfgpkg.Default() + srv := NewServer(cfg) + srv.SetTXController(&fakeTXController{updateErr: errors.New("boom")}) + + body := []byte(`{"stereoMode":"SSB"}`) + rec := httptest.NewRecorder() + srv.Handler().ServeHTTP(rec, newConfigPostRequest(body)) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String()) + } + + cfgRec := httptest.NewRecorder() + srv.Handler().ServeHTTP(cfgRec, httptest.NewRequest(http.MethodGet, "/config", nil)) + if cfgRec.Code != http.StatusOK { + t.Fatalf("config status: %d", cfgRec.Code) + } + var got cfgpkg.Config + if err := json.Unmarshal(cfgRec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal config: %v", err) + } + if got.FM.StereoMode != cfg.FM.StereoMode { + t.Fatalf("snapshot mutated on failed live update: got %q want %q", got.FM.StereoMode, cfg.FM.StereoMode) + } +} + func TestConfigPatchRejectsOversizeBody(t *testing.T) { srv := NewServer(cfgpkg.Default()) rec := httptest.NewRecorder() diff --git a/internal/offline/generator.go b/internal/offline/generator.go index 2697131..4ee2e82 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -11,13 +11,13 @@ import ( "github.com/jan/fm-rds-tx/internal/audio" cfgpkg "github.com/jan/fm-rds-tx/internal/config" - "github.com/jan/fm-rds-tx/internal/license" - "github.com/jan/fm-rds-tx/internal/watermark" "github.com/jan/fm-rds-tx/internal/dsp" + "github.com/jan/fm-rds-tx/internal/license" "github.com/jan/fm-rds-tx/internal/mpx" "github.com/jan/fm-rds-tx/internal/output" "github.com/jan/fm-rds-tx/internal/rds" "github.com/jan/fm-rds-tx/internal/stereo" + "github.com/jan/fm-rds-tx/internal/watermark" ) type frameSource interface { @@ -103,17 +103,17 @@ type Generator struct { // → Stereo Encode → Composite Clip → Notch₁₉ → Notch₅₇ // → + Pilot → + RDS → FM // - audioLPF_L *dsp.FilterChain // 14kHz 8th-order (pre-clip) - audioLPF_R *dsp.FilterChain - pilotNotchL *dsp.FilterChain // 19kHz double-notch (guard band) - pilotNotchR *dsp.FilterChain - limiter *dsp.StereoLimiter // slow compressor (raises average, clips catch peaks) - cleanupLPF_L *dsp.FilterChain // 14kHz 8th-order (post-clip cleanup) - cleanupLPF_R *dsp.FilterChain - mpxNotch19 *dsp.FilterChain // composite clipper protection - mpxNotch57 *dsp.FilterChain - bs412 *dsp.BS412Limiter // ITU-R BS.412 MPX power limiter (optional) - compositeClip *dsp.CompositeClipper // ITU-R SM.1268 iterative composite clipper (optional) + audioLPF_L *dsp.FilterChain // 14kHz 8th-order (pre-clip) + audioLPF_R *dsp.FilterChain + pilotNotchL *dsp.FilterChain // 19kHz double-notch (guard band) + pilotNotchR *dsp.FilterChain + limiter *dsp.StereoLimiter // slow compressor (raises average, clips catch peaks) + cleanupLPF_L *dsp.FilterChain // 14kHz 8th-order (post-clip cleanup) + cleanupLPF_R *dsp.FilterChain + mpxNotch19 *dsp.FilterChain // composite clipper protection + mpxNotch57 *dsp.FilterChain + bs412 *dsp.BS412Limiter // ITU-R BS.412 MPX power limiter (optional) + compositeClip *dsp.CompositeClipper // ITU-R SM.1268 iterative composite clipper (optional) // Pre-allocated frame buffer — reused every GenerateFrame call. frameBuf *output.CompositeFrame @@ -134,20 +134,35 @@ type Generator struct { licenseState *license.State jingleFrames []license.JingleFrame - // Watermark: STFT-domain spread-spectrum (Kirovski & Malvar 2003). - stftEmbedder *watermark.STFTEmbedder - wmDecimLPF *dsp.FilterChain // anti-alias LPF for 228k→12k decimation - wmInterpLPF *dsp.FilterChain // image-rejection LPF for 12k→228k upsample + // Watermark: explicit opt-in STFT-domain spread-spectrum (Kirovski & Malvar 2003). + watermarkEnabled bool + watermarkKey string + stftEmbedder *watermark.STFTEmbedder + wmDecimLPF *dsp.FilterChain // anti-alias LPF for composite→12k decimation + wmInterpLPF *dsp.FilterChain // image-rejection LPF for 12k→composite upsample } func NewGenerator(cfg cfgpkg.Config) *Generator { return &Generator{cfg: cfg} } -// SetLicense configures license state (jingle) and creates the STFT watermark -// embedder. Must be called before the first GenerateFrame. -func (g *Generator) SetLicense(state *license.State, key string) { +// SetLicense configures license state for evaluation-jingle behavior only. +// It intentionally does not enable or instantiate the audio watermark path. +func (g *Generator) SetLicense(state *license.State) { g.licenseState = state +} + +// ConfigureWatermark explicitly enables or disables the optional STFT watermark. +// Must be called before the first GenerateFrame. +func (g *Generator) ConfigureWatermark(enabled bool, key string) { + g.watermarkEnabled = enabled + g.watermarkKey = key + if !enabled { + g.stftEmbedder = nil + g.wmDecimLPF = nil + g.wmInterpLPF = nil + return + } g.stftEmbedder = watermark.NewSTFTEmbedder(key) } @@ -196,6 +211,15 @@ func (g *Generator) init() { if g.sampleRate <= 0 { g.sampleRate = 228000 } + if g.watermarkEnabled { + if g.stftEmbedder == nil { + g.stftEmbedder = watermark.NewSTFTEmbedder(g.watermarkKey) + } + } else { + g.stftEmbedder = nil + g.wmDecimLPF = nil + g.wmInterpLPF = nil + } rawSource, _ := g.sourceFor(g.sampleRate) g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain) g.stereoEncoder = stereo.NewStereoEncoder(g.sampleRate) @@ -252,7 +276,9 @@ func (g *Generator) init() { } } ceiling := g.cfg.FM.LimiterCeiling - if ceiling <= 0 { ceiling = 1.0 } + if ceiling <= 0 { + ceiling = 1.0 + } // Broadcast clip-filter-clip chain: // Pre-clip: 14kHz LPF (8th-order) + 19kHz double-notch (per channel) @@ -307,19 +333,19 @@ func (g *Generator) init() { // Seed initial live params from config g.liveParams.Store(&LiveParams{ - OutputDrive: g.cfg.FM.OutputDrive, - StereoEnabled: g.cfg.FM.StereoEnabled, - StereoMode: g.cfg.FM.StereoMode, - PilotLevel: g.cfg.FM.PilotLevel, - RDSInjection: g.cfg.FM.RDSInjection, - RDSEnabled: g.cfg.RDS.Enabled, - 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, + OutputDrive: g.cfg.FM.OutputDrive, + StereoEnabled: g.cfg.FM.StereoEnabled, + StereoMode: g.cfg.FM.StereoMode, + PilotLevel: g.cfg.FM.PilotLevel, + RDSInjection: g.cfg.FM.RDSInjection, + RDSEnabled: g.cfg.RDS.Enabled, + 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, CompositeClipperEnabled: g.cfg.FM.CompositeClipper.Enabled, }) @@ -363,7 +389,9 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame g.init() samples := int(duration.Seconds() * g.sampleRate) - if samples <= 0 { samples = int(g.sampleRate / 10) } + if samples <= 0 { + samples = int(g.sampleRate / 10) + } // Reuse buffer — grow only if needed, never shrink if g.frameBuf == nil || g.bufCap < samples { @@ -393,7 +421,7 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame // 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.LeftFreq = lp.ToneLeftHz g.toneSource.RightFreq = lp.ToneRightHz g.toneSource.Amplitude = lp.ToneAmplitude } @@ -420,7 +448,9 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame // Guard band depth at 19kHz: LPF₁(-21dB) + Notch(-60dB) + LPF₂(-21dB) // + CompNotch(-60dB) → broadband floor -42dB, exact 19kHz >-90dB ceiling := lp.LimiterCeiling - if ceiling <= 0 { ceiling = 1.0 } + if ceiling <= 0 { + ceiling = 1.0 + } // Pilot and RDS are FIXED injection levels, independent of OutputDrive. // Config values directly represent percentage of ±75kHz deviation: // pilotLevel: 0.09 = 9% = ±6.75kHz (ITU standard) diff --git a/internal/offline/generator_test.go b/internal/offline/generator_test.go index 637ac29..afaa0b2 100644 --- a/internal/offline/generator_test.go +++ b/internal/offline/generator_test.go @@ -9,79 +9,113 @@ import ( "time" cfgpkg "github.com/jan/fm-rds-tx/internal/config" + "github.com/jan/fm-rds-tx/internal/license" ) func TestGenerateFrame(t *testing.T) { g := NewGenerator(cfgpkg.Default()) frame := g.GenerateFrame(50 * time.Millisecond) - if frame == nil || len(frame.Samples) == 0 { t.Fatal("expected samples") } + if frame == nil || len(frame.Samples) == 0 { + t.Fatal("expected samples") + } } func TestGenerateFrameFMIQ(t *testing.T) { - cfg := cfgpkg.Default(); cfg.FM.FMModulationEnabled = true + cfg := cfgpkg.Default() + cfg.FM.FMModulationEnabled = true frame := NewGenerator(cfg).GenerateFrame(10 * time.Millisecond) for i := 100; i < len(frame.Samples) && i < 200; i++ { s := frame.Samples[i] mag := math.Sqrt(float64(s.I)*float64(s.I) + float64(s.Q)*float64(s.Q)) - if math.Abs(mag-1.0) > 0.01 { t.Fatalf("sample %d: mag=%.4f", i, mag) } + if math.Abs(mag-1.0) > 0.01 { + t.Fatalf("sample %d: mag=%.4f", i, mag) + } } } func TestGenerateFrameCompositeOnly(t *testing.T) { - cfg := cfgpkg.Default(); cfg.FM.FMModulationEnabled = false + cfg := cfgpkg.Default() + cfg.FM.FMModulationEnabled = false frame := NewGenerator(cfg).GenerateFrame(10 * time.Millisecond) for i := 0; i < len(frame.Samples) && i < 100; i++ { - if frame.Samples[i].Q != 0 { t.Fatalf("sample %d: Q=%.6f", i, frame.Samples[i].Q) } + if frame.Samples[i].Q != 0 { + t.Fatalf("sample %d: Q=%.6f", i, frame.Samples[i].Q) + } } } func TestStereoDisabled(t *testing.T) { - cfgS := cfgpkg.Default(); cfgS.FM.FMModulationEnabled = false; cfgS.FM.StereoEnabled = true - cfgM := cfgS; cfgM.FM.StereoEnabled = false + cfgS := cfgpkg.Default() + cfgS.FM.FMModulationEnabled = false + cfgS.FM.StereoEnabled = true + cfgM := cfgS + cfgM.FM.StereoEnabled = false sf := NewGenerator(cfgS).GenerateFrame(20 * time.Millisecond) mf := NewGenerator(cfgM).GenerateFrame(20 * time.Millisecond) var diffEnergy float64 for i := range sf.Samples { - d := float64(sf.Samples[i].I - mf.Samples[i].I); diffEnergy += d * d + d := float64(sf.Samples[i].I - mf.Samples[i].I) + diffEnergy += d * d + } + if diffEnergy == 0 { + t.Fatal("expected difference") } - if diffEnergy == 0 { t.Fatal("expected difference") } } func TestWriteFile(t *testing.T) { cfg := cfgpkg.Default() out := filepath.Join(t.TempDir(), "test.iqf32") - if err := NewGenerator(cfg).WriteFile(out, 20*time.Millisecond); err != nil { t.Fatal(err) } + if err := NewGenerator(cfg).WriteFile(out, 20*time.Millisecond); err != nil { + t.Fatal(err) + } info, _ := os.Stat(out) - if info.Size() == 0 { t.Fatal("empty file") } + if info.Size() == 0 { + t.Fatal("empty file") + } } func TestSummaryTones(t *testing.T) { - cfg := cfgpkg.Default(); cfg.Audio.InputPath = "" + cfg := cfgpkg.Default() + cfg.Audio.InputPath = "" s := NewGenerator(cfg).Summary(10 * time.Millisecond) - if !strings.Contains(s, "source=tones") { t.Fatalf("unexpected: %s", s) } + if !strings.Contains(s, "source=tones") { + t.Fatalf("unexpected: %s", s) + } } func TestSummaryToneFallback(t *testing.T) { - cfg := cfgpkg.Default(); cfg.Audio.InputPath = "missing.wav" + cfg := cfgpkg.Default() + cfg.Audio.InputPath = "missing.wav" s := NewGenerator(cfg).Summary(10 * time.Millisecond) - if !strings.Contains(s, "source=tone-fallback") { t.Fatalf("unexpected: %s", s) } + if !strings.Contains(s, "source=tone-fallback") { + t.Fatalf("unexpected: %s", s) + } } func TestSummaryPreemph(t *testing.T) { - cfg := cfgpkg.Default(); cfg.FM.PreEmphasisTauUS = 50 - if !strings.Contains(NewGenerator(cfg).Summary(10*time.Millisecond), "preemph=50µs") { t.Fatal("missing preemph") } + cfg := cfgpkg.Default() + cfg.FM.PreEmphasisTauUS = 50 + if !strings.Contains(NewGenerator(cfg).Summary(10*time.Millisecond), "preemph=50µs") { + t.Fatal("missing preemph") + } } func TestSummaryFMIQ(t *testing.T) { - cfg := cfgpkg.Default(); cfg.FM.FMModulationEnabled = true - if !strings.Contains(NewGenerator(cfg).Summary(10*time.Millisecond), "FM-IQ") { t.Fatal("missing FM-IQ") } + cfg := cfgpkg.Default() + cfg.FM.FMModulationEnabled = true + if !strings.Contains(NewGenerator(cfg).Summary(10*time.Millisecond), "FM-IQ") { + t.Fatal("missing FM-IQ") + } } func TestLimiterPreventsClipping(t *testing.T) { cfg := cfgpkg.Default() - cfg.FM.LimiterEnabled = true; cfg.FM.LimiterCeiling = 1.0 + cfg.FM.LimiterEnabled = true + cfg.FM.LimiterCeiling = 1.0 cfg.FM.FMModulationEnabled = false - cfg.Audio.ToneAmplitude = 0.9; cfg.Audio.Gain = 2.0; cfg.FM.OutputDrive = 1.0 + cfg.Audio.ToneAmplitude = 0.9 + cfg.Audio.Gain = 2.0 + cfg.FM.OutputDrive = 1.0 frame := NewGenerator(cfg).GenerateFrame(50 * time.Millisecond) // Audio clipped to ceiling, pilot+RDS added on top (standard broadcast). // Total = ceiling + pilotLevel*drive + rdsInjection*drive @@ -89,29 +123,40 @@ func TestLimiterPreventsClipping(t *testing.T) { cfg.FM.PilotLevel*cfg.FM.OutputDrive + cfg.FM.RDSInjection*cfg.FM.OutputDrive + 0.02 for i, s := range frame.Samples { - if math.Abs(float64(s.I)) > maxAllowed { t.Fatalf("sample %d: %.4f exceeds max %.4f", i, s.I, maxAllowed) } + if math.Abs(float64(s.I)) > maxAllowed { + t.Fatalf("sample %d: %.4f exceeds max %.4f", i, s.I, maxAllowed) + } } } // --- Operator truth tests --- func TestRDSDisabledSuppressesRDSEnergy(t *testing.T) { - cfgOn := cfgpkg.Default(); cfgOn.FM.FMModulationEnabled = false; cfgOn.RDS.Enabled = true - cfgOff := cfgOn; cfgOff.RDS.Enabled = false + cfgOn := cfgpkg.Default() + cfgOn.FM.FMModulationEnabled = false + cfgOn.RDS.Enabled = true + cfgOff := cfgOn + cfgOff.RDS.Enabled = false fOn := NewGenerator(cfgOn).GenerateFrame(20 * time.Millisecond) fOff := NewGenerator(cfgOff).GenerateFrame(20 * time.Millisecond) var diff float64 for i := range fOn.Samples { - d := float64(fOn.Samples[i].I - fOff.Samples[i].I); diff += d * d + d := float64(fOn.Samples[i].I - fOff.Samples[i].I) + diff += d * d + } + if diff == 0 { + t.Fatal("rds.enabled=false should produce different output") } - if diff == 0 { t.Fatal("rds.enabled=false should produce different output") } } func TestFMModDisabledMeansComposite(t *testing.T) { - cfg := cfgpkg.Default(); cfg.FM.FMModulationEnabled = false + cfg := cfgpkg.Default() + cfg.FM.FMModulationEnabled = false frame := NewGenerator(cfg).GenerateFrame(10 * time.Millisecond) for i := 0; i < 100; i++ { - if frame.Samples[i].Q != 0 { t.Fatal("Q should be 0 when FM mod is off") } + if frame.Samples[i].Q != 0 { + t.Fatal("Q should be 0 when FM mod is off") + } } } @@ -121,22 +166,62 @@ func TestClipFilterClipAlwaysActive(t *testing.T) { // produce the same peak level. base := cfgpkg.Default() base.FM.FMModulationEnabled = false - base.Audio.ToneAmplitude = 0.9; base.Audio.Gain = 2.0; base.FM.OutputDrive = 1.0 + base.Audio.ToneAmplitude = 0.9 + base.Audio.Gain = 2.0 + base.FM.OutputDrive = 1.0 - cfgA := base; cfgA.FM.LimiterEnabled = true; cfgA.FM.LimiterCeiling = 1.0 - cfgB := base; cfgB.FM.LimiterEnabled = false; cfgB.FM.LimiterCeiling = 1.0 + cfgA := base + cfgA.FM.LimiterEnabled = true + cfgA.FM.LimiterCeiling = 1.0 + cfgB := base + cfgB.FM.LimiterEnabled = false + cfgB.FM.LimiterCeiling = 1.0 fA := NewGenerator(cfgA).GenerateFrame(50 * time.Millisecond) fB := NewGenerator(cfgB).GenerateFrame(50 * time.Millisecond) var maxA, maxB float64 - for _, s := range fA.Samples { if math.Abs(float64(s.I)) > maxA { maxA = math.Abs(float64(s.I)) } } - for _, s := range fB.Samples { if math.Abs(float64(s.I)) > maxB { maxB = math.Abs(float64(s.I)) } } + for _, s := range fA.Samples { + if math.Abs(float64(s.I)) > maxA { + maxA = math.Abs(float64(s.I)) + } + } + for _, s := range fB.Samples { + if math.Abs(float64(s.I)) > maxB { + maxB = math.Abs(float64(s.I)) + } + } // Both should be within ceiling + pilot + RDS maxAllowed := cfgA.FM.LimiterCeiling + cfgA.FM.PilotLevel*cfgA.FM.OutputDrive + cfgA.FM.RDSInjection*cfgA.FM.OutputDrive + 0.02 - if maxA > maxAllowed { t.Fatalf("cfgA peak %.4f exceeds %.4f", maxA, maxAllowed) } - if maxB > maxAllowed { t.Fatalf("cfgB peak %.4f exceeds %.4f", maxB, maxAllowed) } + if maxA > maxAllowed { + t.Fatalf("cfgA peak %.4f exceeds %.4f", maxA, maxAllowed) + } + if maxB > maxAllowed { + t.Fatalf("cfgB peak %.4f exceeds %.4f", maxB, maxAllowed) + } +} + +func TestSetLicenseDoesNotImplicitlyEnableWatermark(t *testing.T) { + cfg := cfgpkg.Default() + g := NewGenerator(cfg) + g.SetLicense(license.NewState("")) + g.init() + if g.stftEmbedder != nil { + t.Fatal("watermark should remain disabled unless explicitly configured") + } +} + +func TestConfigureWatermarkExplicitOptIn(t *testing.T) { + cfg := cfgpkg.Default() + cfg.FM.WatermarkEnabled = true + g := NewGenerator(cfg) + g.SetLicense(license.NewState("test-key")) + g.ConfigureWatermark(true, "test-key") + g.init() + if g.stftEmbedder == nil { + t.Fatal("expected watermark embedder after explicit opt-in") + } } diff --git a/internal/stereo/encoder.go b/internal/stereo/encoder.go index 8e00625..7c05af3 100644 --- a/internal/stereo/encoder.go +++ b/internal/stereo/encoder.go @@ -7,13 +7,15 @@ import ( "github.com/jan/fm-rds-tx/internal/dsp" ) +const ssbHilbertTaps = 127 + // Mode selects the stereo subcarrier modulation method. type Mode int const ( ModeDSB Mode = iota // Standard DSB-SC (FCC §73.322 compliant) ModeSSB // SSB-SC LSB only (Foti/Tarsio, experimental) - ModeVSB // Vestigial SB (0-200Hz DSB, above SSB; Omnia-style) + ModeVSB // Vestigial SB (0-200Hz DSB, above SSB; experimental) ) // ParseMode converts a string to Mode. Returns ModeDSB for unknown values. @@ -48,6 +50,42 @@ type Components struct { Pilot float64 // sin(pilotPhase), unity amplitude } +// sampleDelay is a simple fixed-sample delay line. +type sampleDelay struct { + buf []float64 + pos int +} + +func newSampleDelay(samples int) *sampleDelay { + if samples <= 0 { + return nil + } + return &sampleDelay{buf: make([]float64, samples)} +} + +func (d *sampleDelay) Process(in float64) float64 { + if d == nil || len(d.buf) == 0 { + return in + } + out := d.buf[d.pos] + d.buf[d.pos] = in + d.pos++ + if d.pos >= len(d.buf) { + d.pos = 0 + } + return out +} + +func (d *sampleDelay) Reset() { + if d == nil { + return + } + for i := range d.buf { + d.buf[i] = 0 + } + d.pos = 0 +} + // StereoEncoder generates stereo MPX primitives from stereo audio frames. // Supports DSB-SC (standard), SSB-SC (lower sideband only), and VSB modes. type StereoEncoder struct { @@ -55,13 +93,16 @@ type StereoEncoder struct { lastPhase float64 mode Mode - // SSB/VSB: Hilbert transform for quadrature modulation - hilbert *dsp.HilbertFilter + // SSB/VSB paths use a Hilbert transformer. The Hilbert FIR introduces a + // fixed group delay, so the mono path must be delayed by the same amount. + hilbert *dsp.HilbertFilter + monoDelay *sampleDelay - // VSB: crossover filter splits L-R into low (<200Hz, DSB) and high (>200Hz, SSB) - vsbLPF *dsp.FilterChain // 200 Hz LPF for VSB low band - vsbHPF *dsp.FilterChain // 200 Hz HPF for VSB high band (derived from allpass - LPF) - hilbertHi *dsp.HilbertFilter // separate Hilbert for high band + // VSB remains experimental. The low band is kept as DSB, while the high band + // is encoded as SSB. Mono delay compensation still applies so the decoded + // stereo matrix does not produce comb/reverb artefacts. + vsbLPF *dsp.FilterChain + hilbertHi *dsp.HilbertFilter } // NewStereoEncoder creates a StereoEncoder configured for the provided sample rate. @@ -72,18 +113,23 @@ func NewStereoEncoder(sampleRate float64) StereoEncoder { } } -// SetMode changes the stereo encoding mode. Creates Hilbert filter if needed. +// SetMode changes the stereo encoding mode and (re)initializes internal state. func (s *StereoEncoder) SetMode(mode Mode, sampleRate float64) { s.mode = mode - if mode == ModeSSB || mode == ModeVSB { - // 127-tap Hilbert FIR at 228kHz: group delay = 63 samples = 0.276ms - s.hilbert = dsp.NewHilbertFilter(127) - } - if mode == ModeVSB { - // Crossover at 200 Hz for vestigial sideband + s.hilbert = nil + s.hilbertHi = nil + s.vsbLPF = nil + s.monoDelay = nil + + switch mode { + case ModeSSB: + s.hilbert = dsp.NewHilbertFilter(ssbHilbertTaps) + s.monoDelay = newSampleDelay((ssbHilbertTaps - 1) / 2) + case ModeVSB: + s.hilbert = dsp.NewHilbertFilter(ssbHilbertTaps) + s.hilbertHi = dsp.NewHilbertFilter(ssbHilbertTaps) s.vsbLPF = dsp.NewLPF4(200, sampleRate) - s.vsbHPF = dsp.NewLPF4(200, sampleRate) // we'll subtract to get HPF - s.hilbertHi = dsp.NewHilbertFilter(127) + s.monoDelay = newSampleDelay((ssbHilbertTaps - 1) / 2) } } @@ -98,38 +144,41 @@ func (s *StereoEncoder) Encode(frame audio.Frame) Components { diff := float64(frame.Difference()) mono := float64(frame.Mono()) + monoOut := mono var stereoOut float64 switch s.mode { case ModeSSB: if s.hilbert == nil { - // Fallback to DSB if not initialized stereoOut = diff * sub38sin break } + monoOut = s.monoDelay.Process(mono) // SSB-LSB: s(t) = m_delayed(t)·sin(ωt) - m̂(t)·cos(ωt) // The - sign selects LSB (below 38 kHz). - // ×2 compensates for removed USB (+6 dB). - delayed, hilb := s.hilbert.Process(diff) - stereoOut = 2 * (delayed*sub38sin - hilb*sub38cos) + // ×2 compensates for the removed upper sideband (+6 dB). + delayedDiff, hilb := s.hilbert.Process(diff) + stereoOut = 2 * (delayedDiff*sub38sin - hilb*sub38cos) case ModeVSB: - if s.hilbert == nil || s.vsbLPF == nil { + if s.hilbertHi == nil || s.vsbLPF == nil { stereoOut = diff * sub38sin break } - // VSB: 0-200Hz → DSB, 200Hz+ → SSB-LSB + monoOut = s.monoDelay.Process(mono) + + // Experimental VSB split: + // - 0..200 Hz remains DSB to avoid aggressive low-frequency image shift. + // - Above 200 Hz the residual is encoded as SSB-LSB. + // The critical reverb bug was the mono-vs-diff timing mismatch; that is + // fixed here by delaying mono by the Hilbert group delay. lo := s.vsbLPF.Process(diff) - hiRef := s.vsbHPF.Process(diff) // same LPF for HPF derivation - hi := diff - hiRef // highpass = original - lowpass (requires matching delay, approximate) + hi := diff - lo - // Low band: standard DSB dsbPart := lo * sub38sin - - // High band: SSB-LSB with Hilbert - delayed, hilb := s.hilbertHi.Process(hi) - ssbPart := 2 * (delayed*sub38sin - hilb*sub38cos) + delayedHi, hilbHi := s.hilbertHi.Process(hi) + ssbPart := 2 * (delayedHi*sub38sin - hilbHi*sub38cos) stereoOut = dsbPart + ssbPart @@ -138,7 +187,7 @@ func (s *StereoEncoder) Encode(frame audio.Frame) Components { } return Components{ - Mono: mono, + Mono: monoOut, Stereo: stereoOut, Pilot: pilot, } @@ -153,6 +202,12 @@ func (s *StereoEncoder) Reset() { if s.hilbertHi != nil { s.hilbertHi.Reset() } + if s.vsbLPF != nil { + s.vsbLPF.Reset() + } + if s.monoDelay != nil { + s.monoDelay.Reset() + } } // PilotPhase returns the pilot phase used in the most recent Encode() call. @@ -164,4 +219,4 @@ func (s *StereoEncoder) PilotPhase() float64 { // phase-locked to the pilot, as required by the RDS standard. func (s *StereoEncoder) RDSCarrier() float64 { return math.Sin(2 * math.Pi * 3 * s.lastPhase) -} +} \ No newline at end of file