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" | "time" | ||||
| apppkg "github.com/jan/fm-rds-tx/internal/app" | 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" | "github.com/jan/fm-rds-tx/internal/audio" | ||||
| cfgpkg "github.com/jan/fm-rds-tx/internal/config" | cfgpkg "github.com/jan/fm-rds-tx/internal/config" | ||||
| ctrlpkg "github.com/jan/fm-rds-tx/internal/control" | 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" | ||||
| "github.com/jan/fm-rds-tx/internal/ingest/adapters/icecast" | "github.com/jan/fm-rds-tx/internal/ingest/adapters/icecast" | ||||
| ingestfactory "github.com/jan/fm-rds-tx/internal/ingest/factory" | 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" | ||||
| "github.com/jan/fm-rds-tx/internal/platform/plutosdr" | "github.com/jan/fm-rds-tx/internal/platform/plutosdr" | ||||
| "github.com/jan/fm-rds-tx/internal/platform/soapysdr" | "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") | simulateDuration := flag.Duration("simulate-duration", 500*time.Millisecond, "simulated transmit duration") | ||||
| txMode := flag.Bool("tx", false, "start real TX mode (requires hardware + build tags)") | 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") | 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") | listDevices := flag.Bool("list-devices", false, "enumerate SoapySDR devices and exit") | ||||
| audioStdin := flag.Bool("audio-stdin", false, "read S16LE stereo PCM audio from stdin") | 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)") | 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) | log.Printf("license: no valid key — evaluation jingle every %d minutes", license.JingleIntervalMinutes) | ||||
| } | } | ||||
| engine.SetLicenseState(licState, licenseKey) | engine.SetLicenseState(licState, licenseKey) | ||||
| engine.ConfigureWatermark(cfg.FM.WatermarkEnabled, licenseKey) | |||||
| cfg = applyLegacyAudioFlags(cfg, audioStdin, audioRate, audioHTTP) | cfg = applyLegacyAudioFlags(cfg, audioStdin, audioRate, audioHTTP) | ||||
| var streamSrc *audio.StreamSource | var streamSrc *audio.StreamSource | ||||
| @@ -361,23 +362,23 @@ func (b *txBridge) TXStats() map[string]any { | |||||
| } | } | ||||
| func (b *txBridge) UpdateConfig(lp ctrlpkg.LivePatch) error { | func (b *txBridge) UpdateConfig(lp ctrlpkg.LivePatch) error { | ||||
| return b.engine.UpdateConfig(apppkg.LiveConfigUpdate{ | 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, | CompositeClipperEnabled: lp.CompositeClipperEnabled, | ||||
| }) | }) | ||||
| } | } | ||||
| @@ -12,8 +12,8 @@ import ( | |||||
| "github.com/jan/fm-rds-tx/internal/audio" | "github.com/jan/fm-rds-tx/internal/audio" | ||||
| cfgpkg "github.com/jan/fm-rds-tx/internal/config" | 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/dsp" | ||||
| "github.com/jan/fm-rds-tx/internal/license" | |||||
| offpkg "github.com/jan/fm-rds-tx/internal/offline" | offpkg "github.com/jan/fm-rds-tx/internal/offline" | ||||
| "github.com/jan/fm-rds-tx/internal/output" | "github.com/jan/fm-rds-tx/internal/output" | ||||
| "github.com/jan/fm-rds-tx/internal/platform" | "github.com/jan/fm-rds-tx/internal/platform" | ||||
| @@ -123,7 +123,7 @@ const ( | |||||
| queueMutedRecoveryThreshold = queueCriticalStreakThreshold | queueMutedRecoveryThreshold = queueCriticalStreakThreshold | ||||
| queueFaultedStreakThreshold = queueCriticalStreakThreshold | queueFaultedStreakThreshold = queueCriticalStreakThreshold | ||||
| faultRepeatWindow = 1 * time.Second | faultRepeatWindow = 1 * time.Second | ||||
| lateBufferStreakThreshold = 3 // consecutive late writes required before alerting | |||||
| lateBufferStreakThreshold = 3 // consecutive late writes required before alerting | |||||
| faultHistoryCapacity = 8 | faultHistoryCapacity = 8 | ||||
| runtimeTransitionHistoryCapacity = 8 | runtimeTransitionHistoryCapacity = 8 | ||||
| ) | ) | ||||
| @@ -206,10 +206,16 @@ func (e *Engine) SetStreamSource(src *audio.StreamSource) { | |||||
| src.SampleRate, compositeRate, src.Stats().Capacity) | 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. | // StreamSource returns the live audio stream source, or nil. | ||||
| @@ -293,10 +299,10 @@ type LiveConfigUpdate struct { | |||||
| TA *bool | TA *bool | ||||
| TP *bool | TP *bool | ||||
| // Tone and gain: live-patchable without engine restart. | // 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 | CompositeClipperEnabled *bool | ||||
| } | } | ||||
| @@ -74,8 +74,8 @@ type RDSConfig struct { | |||||
| // EONEntryConfig describes another station for EON transmission. | // EONEntryConfig describes another station for EON transmission. | ||||
| type EONEntryConfig struct { | 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"` | PTY int `json:"pty"` | ||||
| TP bool `json:"tp"` | TP bool `json:"tp"` | ||||
| TA bool `json:"ta"` | TA bool `json:"ta"` | ||||
| @@ -83,20 +83,21 @@ type EONEntryConfig struct { | |||||
| } | } | ||||
| type FMConfig 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) | BS412ThresholdDBr float64 `json:"bs412ThresholdDBr"` // power limit in dBr (0 = standard, +3 = relaxed) | ||||
| CompositeClipper CompositeClipperConfig `json:"compositeClipper"` // ITU-R SM.1268 iterative composite clipper | 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 { | if c.FM.PreEmphasisTauUS < 0 || c.FM.PreEmphasisTauUS > 100 { | ||||
| return fmt.Errorf("fm.preEmphasisTauUS out of range (0=off, 50=EU, 75=US)") | 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 { | if c.FM.MaxDeviationHz < 0 || c.FM.MaxDeviationHz > 150000 { | ||||
| return fmt.Errorf("fm.maxDeviationHz out of range") | return fmt.Errorf("fm.maxDeviationHz out of range") | ||||
| } | } | ||||
| @@ -298,3 +298,21 @@ func TestValidateRejectsZeroMpxGain(t *testing.T) { | |||||
| t.Fatal("expected mpxGain error") | 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. | // LivePatch mirrors the patchable fields from ConfigPatch for the engine. | ||||
| // nil = no change. | // nil = no change. | ||||
| type LivePatch struct { | 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 | CompositeClipperEnabled *bool | ||||
| } | } | ||||
| @@ -68,7 +68,7 @@ type Server struct { | |||||
| // BUG-F fix: reloadPending prevents multiple concurrent goroutines from | // BUG-F fix: reloadPending prevents multiple concurrent goroutines from | ||||
| // calling hardReload when handleIngestSave is hit multiple times quickly. | // calling hardReload when handleIngestSave is hit multiple times quickly. | ||||
| reloadPending atomic.Bool | reloadPending atomic.Bool | ||||
| audit auditCounters | |||||
| audit auditCounters | |||||
| } | } | ||||
| type AudioIngress interface { | type AudioIngress interface { | ||||
| @@ -123,44 +123,44 @@ func isJSONContentType(r *http.Request) bool { | |||||
| } | } | ||||
| type ConfigPatch struct { | 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 { | type IngestSaveRequest struct { | ||||
| @@ -675,23 +675,23 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { | |||||
| return | return | ||||
| } | } | ||||
| lp := LivePatch{ | 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, | CompositeClipperEnabled: patch.CompositeClipperEnabled, | ||||
| } | } | ||||
| // NEU-02 fix: determine whether any live-patchable fields are present, | // 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.ToneLeftHz != nil || patch.ToneRightHz != nil || | ||||
| patch.ToneAmplitude != nil || patch.AudioGain != nil || | patch.ToneAmplitude != nil || patch.AudioGain != nil || | ||||
| patch.CompositeClipperEnabled != nil | patch.CompositeClipperEnabled != nil | ||||
| s.cfg = next | |||||
| s.mu.Unlock() | s.mu.Unlock() | ||||
| // Apply live fields to running engine outside the lock. | // Apply live fields to running engine outside the lock. | ||||
| var updateErr error | |||||
| if tx != nil && hasLiveFields { | if tx != nil && hasLiveFields { | ||||
| if err := tx.UpdateConfig(lp); err != nil { | 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. | // NEU-03 fix: report live=true only when live-patchable fields were applied. | ||||
| live := tx != nil && hasLiveFields | live := tx != nil && hasLiveFields | ||||
| w.Header().Set("Content-Type", "application/json") | 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) { | func TestConfigPatchRejectsOversizeBody(t *testing.T) { | ||||
| srv := NewServer(cfgpkg.Default()) | srv := NewServer(cfgpkg.Default()) | ||||
| rec := httptest.NewRecorder() | rec := httptest.NewRecorder() | ||||
| @@ -11,13 +11,13 @@ import ( | |||||
| "github.com/jan/fm-rds-tx/internal/audio" | "github.com/jan/fm-rds-tx/internal/audio" | ||||
| cfgpkg "github.com/jan/fm-rds-tx/internal/config" | 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/dsp" | ||||
| "github.com/jan/fm-rds-tx/internal/license" | |||||
| "github.com/jan/fm-rds-tx/internal/mpx" | "github.com/jan/fm-rds-tx/internal/mpx" | ||||
| "github.com/jan/fm-rds-tx/internal/output" | "github.com/jan/fm-rds-tx/internal/output" | ||||
| "github.com/jan/fm-rds-tx/internal/rds" | "github.com/jan/fm-rds-tx/internal/rds" | ||||
| "github.com/jan/fm-rds-tx/internal/stereo" | "github.com/jan/fm-rds-tx/internal/stereo" | ||||
| "github.com/jan/fm-rds-tx/internal/watermark" | |||||
| ) | ) | ||||
| type frameSource interface { | type frameSource interface { | ||||
| @@ -103,17 +103,17 @@ type Generator struct { | |||||
| // → Stereo Encode → Composite Clip → Notch₁₉ → Notch₅₇ | // → Stereo Encode → Composite Clip → Notch₁₉ → Notch₅₇ | ||||
| // → + Pilot → + RDS → FM | // → + 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. | // Pre-allocated frame buffer — reused every GenerateFrame call. | ||||
| frameBuf *output.CompositeFrame | frameBuf *output.CompositeFrame | ||||
| @@ -134,20 +134,35 @@ type Generator struct { | |||||
| licenseState *license.State | licenseState *license.State | ||||
| jingleFrames []license.JingleFrame | 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 { | func NewGenerator(cfg cfgpkg.Config) *Generator { | ||||
| return &Generator{cfg: cfg} | 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 | 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) | g.stftEmbedder = watermark.NewSTFTEmbedder(key) | ||||
| } | } | ||||
| @@ -196,6 +211,15 @@ func (g *Generator) init() { | |||||
| if g.sampleRate <= 0 { | if g.sampleRate <= 0 { | ||||
| g.sampleRate = 228000 | 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) | rawSource, _ := g.sourceFor(g.sampleRate) | ||||
| g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain) | g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain) | ||||
| g.stereoEncoder = stereo.NewStereoEncoder(g.sampleRate) | g.stereoEncoder = stereo.NewStereoEncoder(g.sampleRate) | ||||
| @@ -252,7 +276,9 @@ func (g *Generator) init() { | |||||
| } | } | ||||
| } | } | ||||
| ceiling := g.cfg.FM.LimiterCeiling | ceiling := g.cfg.FM.LimiterCeiling | ||||
| if ceiling <= 0 { ceiling = 1.0 } | |||||
| if ceiling <= 0 { | |||||
| ceiling = 1.0 | |||||
| } | |||||
| // Broadcast clip-filter-clip chain: | // Broadcast clip-filter-clip chain: | ||||
| // Pre-clip: 14kHz LPF (8th-order) + 19kHz double-notch (per channel) | // 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 | // Seed initial live params from config | ||||
| g.liveParams.Store(&LiveParams{ | 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, | CompositeClipperEnabled: g.cfg.FM.CompositeClipper.Enabled, | ||||
| }) | }) | ||||
| @@ -363,7 +389,9 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame | |||||
| g.init() | g.init() | ||||
| samples := int(duration.Seconds() * g.sampleRate) | 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 | // Reuse buffer — grow only if needed, never shrink | ||||
| if g.frameBuf == nil || g.bufCap < samples { | 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 | // Apply live tone and gain updates each chunk. GenerateFrame runs on a | ||||
| // single goroutine so these field writes are safe without additional locking. | // single goroutine so these field writes are safe without additional locking. | ||||
| if g.toneSource != nil { | if g.toneSource != nil { | ||||
| g.toneSource.LeftFreq = lp.ToneLeftHz | |||||
| g.toneSource.LeftFreq = lp.ToneLeftHz | |||||
| g.toneSource.RightFreq = lp.ToneRightHz | g.toneSource.RightFreq = lp.ToneRightHz | ||||
| g.toneSource.Amplitude = lp.ToneAmplitude | 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) | // Guard band depth at 19kHz: LPF₁(-21dB) + Notch(-60dB) + LPF₂(-21dB) | ||||
| // + CompNotch(-60dB) → broadband floor -42dB, exact 19kHz >-90dB | // + CompNotch(-60dB) → broadband floor -42dB, exact 19kHz >-90dB | ||||
| ceiling := lp.LimiterCeiling | 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. | // Pilot and RDS are FIXED injection levels, independent of OutputDrive. | ||||
| // Config values directly represent percentage of ±75kHz deviation: | // Config values directly represent percentage of ±75kHz deviation: | ||||
| // pilotLevel: 0.09 = 9% = ±6.75kHz (ITU standard) | // pilotLevel: 0.09 = 9% = ±6.75kHz (ITU standard) | ||||
| @@ -9,79 +9,113 @@ import ( | |||||
| "time" | "time" | ||||
| cfgpkg "github.com/jan/fm-rds-tx/internal/config" | cfgpkg "github.com/jan/fm-rds-tx/internal/config" | ||||
| "github.com/jan/fm-rds-tx/internal/license" | |||||
| ) | ) | ||||
| func TestGenerateFrame(t *testing.T) { | func TestGenerateFrame(t *testing.T) { | ||||
| g := NewGenerator(cfgpkg.Default()) | g := NewGenerator(cfgpkg.Default()) | ||||
| frame := g.GenerateFrame(50 * time.Millisecond) | 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) { | 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) | frame := NewGenerator(cfg).GenerateFrame(10 * time.Millisecond) | ||||
| for i := 100; i < len(frame.Samples) && i < 200; i++ { | for i := 100; i < len(frame.Samples) && i < 200; i++ { | ||||
| s := frame.Samples[i] | s := frame.Samples[i] | ||||
| mag := math.Sqrt(float64(s.I)*float64(s.I) + float64(s.Q)*float64(s.Q)) | 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) { | 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) | frame := NewGenerator(cfg).GenerateFrame(10 * time.Millisecond) | ||||
| for i := 0; i < len(frame.Samples) && i < 100; i++ { | 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) { | 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) | sf := NewGenerator(cfgS).GenerateFrame(20 * time.Millisecond) | ||||
| mf := NewGenerator(cfgM).GenerateFrame(20 * time.Millisecond) | mf := NewGenerator(cfgM).GenerateFrame(20 * time.Millisecond) | ||||
| var diffEnergy float64 | var diffEnergy float64 | ||||
| for i := range sf.Samples { | 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) { | func TestWriteFile(t *testing.T) { | ||||
| cfg := cfgpkg.Default() | cfg := cfgpkg.Default() | ||||
| out := filepath.Join(t.TempDir(), "test.iqf32") | 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) | 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) { | func TestSummaryTones(t *testing.T) { | ||||
| cfg := cfgpkg.Default(); cfg.Audio.InputPath = "" | |||||
| cfg := cfgpkg.Default() | |||||
| cfg.Audio.InputPath = "" | |||||
| s := NewGenerator(cfg).Summary(10 * time.Millisecond) | 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) { | 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) | 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) { | 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) { | 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) { | func TestLimiterPreventsClipping(t *testing.T) { | ||||
| cfg := cfgpkg.Default() | 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.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) | frame := NewGenerator(cfg).GenerateFrame(50 * time.Millisecond) | ||||
| // Audio clipped to ceiling, pilot+RDS added on top (standard broadcast). | // Audio clipped to ceiling, pilot+RDS added on top (standard broadcast). | ||||
| // Total = ceiling + pilotLevel*drive + rdsInjection*drive | // Total = ceiling + pilotLevel*drive + rdsInjection*drive | ||||
| @@ -89,29 +123,40 @@ func TestLimiterPreventsClipping(t *testing.T) { | |||||
| cfg.FM.PilotLevel*cfg.FM.OutputDrive + | cfg.FM.PilotLevel*cfg.FM.OutputDrive + | ||||
| cfg.FM.RDSInjection*cfg.FM.OutputDrive + 0.02 | cfg.FM.RDSInjection*cfg.FM.OutputDrive + 0.02 | ||||
| for i, s := range frame.Samples { | 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 --- | // --- Operator truth tests --- | ||||
| func TestRDSDisabledSuppressesRDSEnergy(t *testing.T) { | 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) | fOn := NewGenerator(cfgOn).GenerateFrame(20 * time.Millisecond) | ||||
| fOff := NewGenerator(cfgOff).GenerateFrame(20 * time.Millisecond) | fOff := NewGenerator(cfgOff).GenerateFrame(20 * time.Millisecond) | ||||
| var diff float64 | var diff float64 | ||||
| for i := range fOn.Samples { | 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) { | 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) | frame := NewGenerator(cfg).GenerateFrame(10 * time.Millisecond) | ||||
| for i := 0; i < 100; i++ { | 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. | // produce the same peak level. | ||||
| base := cfgpkg.Default() | base := cfgpkg.Default() | ||||
| base.FM.FMModulationEnabled = false | 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) | fA := NewGenerator(cfgA).GenerateFrame(50 * time.Millisecond) | ||||
| fB := NewGenerator(cfgB).GenerateFrame(50 * time.Millisecond) | fB := NewGenerator(cfgB).GenerateFrame(50 * time.Millisecond) | ||||
| var maxA, maxB float64 | 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 | // Both should be within ceiling + pilot + RDS | ||||
| maxAllowed := cfgA.FM.LimiterCeiling + | maxAllowed := cfgA.FM.LimiterCeiling + | ||||
| cfgA.FM.PilotLevel*cfgA.FM.OutputDrive + | cfgA.FM.PilotLevel*cfgA.FM.OutputDrive + | ||||
| cfgA.FM.RDSInjection*cfgA.FM.OutputDrive + 0.02 | 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" | "github.com/jan/fm-rds-tx/internal/dsp" | ||||
| ) | ) | ||||
| const ssbHilbertTaps = 127 | |||||
| // Mode selects the stereo subcarrier modulation method. | // Mode selects the stereo subcarrier modulation method. | ||||
| type Mode int | type Mode int | ||||
| const ( | const ( | ||||
| ModeDSB Mode = iota // Standard DSB-SC (FCC §73.322 compliant) | ModeDSB Mode = iota // Standard DSB-SC (FCC §73.322 compliant) | ||||
| ModeSSB // SSB-SC LSB only (Foti/Tarsio, experimental) | 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. | // ParseMode converts a string to Mode. Returns ModeDSB for unknown values. | ||||
| @@ -48,6 +50,42 @@ type Components struct { | |||||
| Pilot float64 // sin(pilotPhase), unity amplitude | 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. | // StereoEncoder generates stereo MPX primitives from stereo audio frames. | ||||
| // Supports DSB-SC (standard), SSB-SC (lower sideband only), and VSB modes. | // Supports DSB-SC (standard), SSB-SC (lower sideband only), and VSB modes. | ||||
| type StereoEncoder struct { | type StereoEncoder struct { | ||||
| @@ -55,13 +93,16 @@ type StereoEncoder struct { | |||||
| lastPhase float64 | lastPhase float64 | ||||
| mode Mode | 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. | // 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) { | func (s *StereoEncoder) SetMode(mode Mode, sampleRate float64) { | ||||
| s.mode = mode | 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.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()) | diff := float64(frame.Difference()) | ||||
| mono := float64(frame.Mono()) | mono := float64(frame.Mono()) | ||||
| monoOut := mono | |||||
| var stereoOut float64 | var stereoOut float64 | ||||
| switch s.mode { | switch s.mode { | ||||
| case ModeSSB: | case ModeSSB: | ||||
| if s.hilbert == nil { | if s.hilbert == nil { | ||||
| // Fallback to DSB if not initialized | |||||
| stereoOut = diff * sub38sin | stereoOut = diff * sub38sin | ||||
| break | break | ||||
| } | } | ||||
| monoOut = s.monoDelay.Process(mono) | |||||
| // SSB-LSB: s(t) = m_delayed(t)·sin(ωt) - m̂(t)·cos(ωt) | // SSB-LSB: s(t) = m_delayed(t)·sin(ωt) - m̂(t)·cos(ωt) | ||||
| // The - sign selects LSB (below 38 kHz). | // 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: | case ModeVSB: | ||||
| if s.hilbert == nil || s.vsbLPF == nil { | |||||
| if s.hilbertHi == nil || s.vsbLPF == nil { | |||||
| stereoOut = diff * sub38sin | stereoOut = diff * sub38sin | ||||
| break | 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) | 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 | 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 | stereoOut = dsbPart + ssbPart | ||||
| @@ -138,7 +187,7 @@ func (s *StereoEncoder) Encode(frame audio.Frame) Components { | |||||
| } | } | ||||
| return Components{ | return Components{ | ||||
| Mono: mono, | |||||
| Mono: monoOut, | |||||
| Stereo: stereoOut, | Stereo: stereoOut, | ||||
| Pilot: pilot, | Pilot: pilot, | ||||
| } | } | ||||
| @@ -153,6 +202,12 @@ func (s *StereoEncoder) Reset() { | |||||
| if s.hilbertHi != nil { | if s.hilbertHi != nil { | ||||
| s.hilbertHi.Reset() | 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. | // 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. | // phase-locked to the pilot, as required by the RDS standard. | ||||
| func (s *StereoEncoder) RDSCarrier() float64 { | func (s *StereoEncoder) RDSCarrier() float64 { | ||||
| return math.Sin(2 * math.Pi * 3 * s.lastPhase) | return math.Sin(2 * math.Pi * 3 * s.lastPhase) | ||||
| } | |||||
| } | |||||