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.main
| @@ -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, | |||
| }) | |||
| } | |||
| @@ -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 | |||
| } | |||
| @@ -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") | |||
| } | |||
| @@ -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) | |||
| } | |||
| } | |||
| } | |||
| @@ -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") | |||
| @@ -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() | |||
| @@ -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) | |||
| @@ -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") | |||
| } | |||
| } | |||
| @@ -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) | |||
| } | |||
| } | |||