ソースを参照

fix(tx): decouple watermark from evaluation jingle

Add an explicit fm.watermarkEnabled switch, validate fm.stereoMode values, and only persist /config snapshots after a live update succeeds. Also separate evaluation-jingle licensing behavior from watermark handling.
main
Jan 1ヶ月前
コミット
6724eff968
9個のファイルの変更454行の追加228行の削除
  1. +20
    -19
      cmd/fmrtx/main.go
  2. +16
    -10
      internal/app/engine.go
  3. +22
    -16
      internal/config/config.go
  4. +18
    -0
      internal/config/config_test.go
  5. +79
    -80
      internal/control/control.go
  6. +26
    -0
      internal/control/control_test.go
  7. +67
    -37
      internal/offline/generator.go
  8. +120
    -35
      internal/offline/generator_test.go
  9. +86
    -31
      internal/stereo/encoder.go

+ 20
- 19
cmd/fmrtx/main.go ファイルの表示

@@ -12,7 +12,6 @@ import (
"time"

apppkg "github.com/jan/fm-rds-tx/internal/app"
"github.com/jan/fm-rds-tx/internal/license"
"github.com/jan/fm-rds-tx/internal/audio"
cfgpkg "github.com/jan/fm-rds-tx/internal/config"
ctrlpkg "github.com/jan/fm-rds-tx/internal/control"
@@ -20,6 +19,7 @@ import (
"github.com/jan/fm-rds-tx/internal/ingest"
"github.com/jan/fm-rds-tx/internal/ingest/adapters/icecast"
ingestfactory "github.com/jan/fm-rds-tx/internal/ingest/factory"
"github.com/jan/fm-rds-tx/internal/license"
"github.com/jan/fm-rds-tx/internal/platform"
"github.com/jan/fm-rds-tx/internal/platform/plutosdr"
"github.com/jan/fm-rds-tx/internal/platform/soapysdr"
@@ -35,7 +35,7 @@ func main() {
simulateDuration := flag.Duration("simulate-duration", 500*time.Millisecond, "simulated transmit duration")
txMode := flag.Bool("tx", false, "start real TX mode (requires hardware + build tags)")
txAutoStart := flag.Bool("tx-auto-start", false, "auto-start TX on launch")
licenseKey := flag.String("license", "", "fm-rds-tx license key (omit for evaluation mode with jingle)")
licenseKey := flag.String("license", "", "fm-rds-tx license key (omit for evaluation mode with jingle)")
listDevices := flag.Bool("list-devices", false, "enumerate SoapySDR devices and exit")
audioStdin := flag.Bool("audio-stdin", false, "read S16LE stereo PCM audio from stdin")
audioRate := flag.Int("audio-rate", 44100, "sample rate of stdin audio input (Hz)")
@@ -185,6 +185,7 @@ func runTXMode(cfg cfgpkg.Config, configPath string, driver platform.SoapyDriver
log.Printf("license: no valid key — evaluation jingle every %d minutes", license.JingleIntervalMinutes)
}
engine.SetLicenseState(licState, licenseKey)
engine.ConfigureWatermark(cfg.FM.WatermarkEnabled, licenseKey)
cfg = applyLegacyAudioFlags(cfg, audioStdin, audioRate, audioHTTP)

var streamSrc *audio.StreamSource
@@ -361,23 +362,23 @@ func (b *txBridge) TXStats() map[string]any {
}
func (b *txBridge) UpdateConfig(lp ctrlpkg.LivePatch) error {
return b.engine.UpdateConfig(apppkg.LiveConfigUpdate{
FrequencyMHz: lp.FrequencyMHz,
OutputDrive: lp.OutputDrive,
StereoEnabled: lp.StereoEnabled,
StereoMode: lp.StereoMode,
PilotLevel: lp.PilotLevel,
RDSInjection: lp.RDSInjection,
RDSEnabled: lp.RDSEnabled,
LimiterEnabled: lp.LimiterEnabled,
LimiterCeiling: lp.LimiterCeiling,
PS: lp.PS,
RadioText: lp.RadioText,
TA: lp.TA,
TP: lp.TP,
ToneLeftHz: lp.ToneLeftHz,
ToneRightHz: lp.ToneRightHz,
ToneAmplitude: lp.ToneAmplitude,
AudioGain: lp.AudioGain,
FrequencyMHz: lp.FrequencyMHz,
OutputDrive: lp.OutputDrive,
StereoEnabled: lp.StereoEnabled,
StereoMode: lp.StereoMode,
PilotLevel: lp.PilotLevel,
RDSInjection: lp.RDSInjection,
RDSEnabled: lp.RDSEnabled,
LimiterEnabled: lp.LimiterEnabled,
LimiterCeiling: lp.LimiterCeiling,
PS: lp.PS,
RadioText: lp.RadioText,
TA: lp.TA,
TP: lp.TP,
ToneLeftHz: lp.ToneLeftHz,
ToneRightHz: lp.ToneRightHz,
ToneAmplitude: lp.ToneAmplitude,
AudioGain: lp.AudioGain,
CompositeClipperEnabled: lp.CompositeClipperEnabled,
})
}


+ 16
- 10
internal/app/engine.go ファイルの表示

@@ -12,8 +12,8 @@ import (

"github.com/jan/fm-rds-tx/internal/audio"
cfgpkg "github.com/jan/fm-rds-tx/internal/config"
"github.com/jan/fm-rds-tx/internal/license"
"github.com/jan/fm-rds-tx/internal/dsp"
"github.com/jan/fm-rds-tx/internal/license"
offpkg "github.com/jan/fm-rds-tx/internal/offline"
"github.com/jan/fm-rds-tx/internal/output"
"github.com/jan/fm-rds-tx/internal/platform"
@@ -123,7 +123,7 @@ const (
queueMutedRecoveryThreshold = queueCriticalStreakThreshold
queueFaultedStreakThreshold = queueCriticalStreakThreshold
faultRepeatWindow = 1 * time.Second
lateBufferStreakThreshold = 3 // consecutive late writes required before alerting
lateBufferStreakThreshold = 3 // consecutive late writes required before alerting
faultHistoryCapacity = 8
runtimeTransitionHistoryCapacity = 8
)
@@ -206,10 +206,16 @@ func (e *Engine) SetStreamSource(src *audio.StreamSource) {
src.SampleRate, compositeRate, src.Stats().Capacity)
}

// SetLicenseState passes the license/jingle state and raw key to the generator.
// Must be called before Start(). key is used to derive the watermark payload.
func (e *Engine) SetLicenseState(s *license.State, key string) {
e.generator.SetLicense(s, key)
// SetLicenseState passes license/jingle state to the generator.
// Must be called before Start(). It does not implicitly enable watermarking.
func (e *Engine) SetLicenseState(s *license.State, _ string) {
e.generator.SetLicense(s)
}

// ConfigureWatermark explicitly enables or disables the optional program-audio watermark.
// Must be called before Start().
func (e *Engine) ConfigureWatermark(enabled bool, key string) {
e.generator.ConfigureWatermark(enabled, key)
}

// StreamSource returns the live audio stream source, or nil.
@@ -293,10 +299,10 @@ type LiveConfigUpdate struct {
TA *bool
TP *bool
// Tone and gain: live-patchable without engine restart.
ToneLeftHz *float64
ToneRightHz *float64
ToneAmplitude *float64
AudioGain *float64
ToneLeftHz *float64
ToneRightHz *float64
ToneAmplitude *float64
AudioGain *float64
CompositeClipperEnabled *bool
}



+ 22
- 16
internal/config/config.go ファイルの表示

@@ -74,8 +74,8 @@ type RDSConfig struct {

// EONEntryConfig describes another station for EON transmission.
type EONEntryConfig struct {
PI string `json:"pi"` // hex PI code
PS string `json:"ps"` // 8-char station name
PI string `json:"pi"` // hex PI code
PS string `json:"ps"` // 8-char station name
PTY int `json:"pty"`
TP bool `json:"tp"`
TA bool `json:"ta"`
@@ -83,20 +83,21 @@ type EONEntryConfig struct {
}

type FMConfig struct {
FrequencyMHz float64 `json:"frequencyMHz"`
StereoEnabled bool `json:"stereoEnabled"`
StereoMode string `json:"stereoMode"` // "DSB" (standard), "SSB" (experimental LSB), "VSB" (vestigial)
PilotLevel float64 `json:"pilotLevel"` // fraction of ±75kHz deviation (0.09 = 9%, ITU standard)
RDSInjection float64 `json:"rdsInjection"` // fraction of ±75kHz deviation (0.04 = 4%, typical)
PreEmphasisTauUS float64 `json:"preEmphasisTauUS"` // time constant in µs: 50 (EU) or 75 (US), 0=off
OutputDrive float64 `json:"outputDrive"`
CompositeRateHz int `json:"compositeRateHz"` // internal DSP/MPX sample rate
MaxDeviationHz float64 `json:"maxDeviationHz"`
LimiterEnabled bool `json:"limiterEnabled"`
LimiterCeiling float64 `json:"limiterCeiling"`
FMModulationEnabled bool `json:"fmModulationEnabled"`
MpxGain float64 `json:"mpxGain"` // hardware calibration: scales entire composite output (default 1.0)
BS412Enabled bool `json:"bs412Enabled"` // ITU-R BS.412 MPX power limiter (EU requirement)
FrequencyMHz float64 `json:"frequencyMHz"`
StereoEnabled bool `json:"stereoEnabled"`
StereoMode string `json:"stereoMode"` // "DSB" (standard), "SSB" (experimental LSB), "VSB" (vestigial)
PilotLevel float64 `json:"pilotLevel"` // fraction of ±75kHz deviation (0.09 = 9%, ITU standard)
RDSInjection float64 `json:"rdsInjection"` // fraction of ±75kHz deviation (0.04 = 4%, typical)
PreEmphasisTauUS float64 `json:"preEmphasisTauUS"` // time constant in µs: 50 (EU) or 75 (US), 0=off
OutputDrive float64 `json:"outputDrive"`
CompositeRateHz int `json:"compositeRateHz"` // internal DSP/MPX sample rate
MaxDeviationHz float64 `json:"maxDeviationHz"`
LimiterEnabled bool `json:"limiterEnabled"`
LimiterCeiling float64 `json:"limiterCeiling"`
FMModulationEnabled bool `json:"fmModulationEnabled"`
WatermarkEnabled bool `json:"watermarkEnabled"` // explicit opt-in for STFT program-audio watermarking
MpxGain float64 `json:"mpxGain"` // hardware calibration: scales entire composite output (default 1.0)
BS412Enabled bool `json:"bs412Enabled"` // ITU-R BS.412 MPX power limiter (EU requirement)
BS412ThresholdDBr float64 `json:"bs412ThresholdDBr"` // power limit in dBr (0 = standard, +3 = relaxed)
CompositeClipper CompositeClipperConfig `json:"compositeClipper"` // ITU-R SM.1268 iterative composite clipper
}
@@ -388,6 +389,11 @@ func (c Config) Validate() error {
if c.FM.PreEmphasisTauUS < 0 || c.FM.PreEmphasisTauUS > 100 {
return fmt.Errorf("fm.preEmphasisTauUS out of range (0=off, 50=EU, 75=US)")
}
switch mode := strings.ToUpper(strings.TrimSpace(c.FM.StereoMode)); mode {
case "DSB", "SSB", "VSB":
default:
return fmt.Errorf("fm.stereoMode invalid: %q (expected DSB, SSB, or VSB)", c.FM.StereoMode)
}
if c.FM.MaxDeviationHz < 0 || c.FM.MaxDeviationHz > 150000 {
return fmt.Errorf("fm.maxDeviationHz out of range")
}


+ 18
- 0
internal/config/config_test.go ファイルの表示

@@ -298,3 +298,21 @@ func TestValidateRejectsZeroMpxGain(t *testing.T) {
t.Fatal("expected mpxGain error")
}
}

func TestValidateRejectsInvalidStereoMode(t *testing.T) {
cfg := Default()
cfg.FM.StereoMode = "weird"
if err := cfg.Validate(); err == nil {
t.Fatal("expected stereoMode validation error")
}
}

func TestValidateAcceptsStereoModes(t *testing.T) {
for _, mode := range []string{"DSB", "SSB", "VSB"} {
cfg := Default()
cfg.FM.StereoMode = mode
if err := cfg.Validate(); err != nil {
t.Fatalf("mode %s should validate: %v", mode, err)
}
}
}

+ 79
- 80
internal/control/control.go ファイルの表示

@@ -35,23 +35,23 @@ type TXController interface {
// LivePatch mirrors the patchable fields from ConfigPatch for the engine.
// nil = no change.
type LivePatch struct {
FrequencyMHz *float64
OutputDrive *float64
StereoEnabled *bool
StereoMode *string
PilotLevel *float64
RDSInjection *float64
RDSEnabled *bool
LimiterEnabled *bool
LimiterCeiling *float64
PS *string
RadioText *string
TA *bool
TP *bool
ToneLeftHz *float64
ToneRightHz *float64
ToneAmplitude *float64
AudioGain *float64
FrequencyMHz *float64
OutputDrive *float64
StereoEnabled *bool
StereoMode *string
PilotLevel *float64
RDSInjection *float64
RDSEnabled *bool
LimiterEnabled *bool
LimiterCeiling *float64
PS *string
RadioText *string
TA *bool
TP *bool
ToneLeftHz *float64
ToneRightHz *float64
ToneAmplitude *float64
AudioGain *float64
CompositeClipperEnabled *bool
}

@@ -68,7 +68,7 @@ type Server struct {
// BUG-F fix: reloadPending prevents multiple concurrent goroutines from
// calling hardReload when handleIngestSave is hit multiple times quickly.
reloadPending atomic.Bool
audit auditCounters
audit auditCounters
}

type AudioIngress interface {
@@ -123,44 +123,44 @@ func isJSONContentType(r *http.Request) bool {
}

type ConfigPatch struct {
FrequencyMHz *float64 `json:"frequencyMHz,omitempty"`
OutputDrive *float64 `json:"outputDrive,omitempty"`
StereoEnabled *bool `json:"stereoEnabled,omitempty"`
StereoMode *string `json:"stereoMode,omitempty"`
PilotLevel *float64 `json:"pilotLevel,omitempty"`
RDSInjection *float64 `json:"rdsInjection,omitempty"`
RDSEnabled *bool `json:"rdsEnabled,omitempty"`
ToneLeftHz *float64 `json:"toneLeftHz,omitempty"`
ToneRightHz *float64 `json:"toneRightHz,omitempty"`
ToneAmplitude *float64 `json:"toneAmplitude,omitempty"`
PS *string `json:"ps,omitempty"`
RadioText *string `json:"radioText,omitempty"`
PreEmphasisTauUS *float64 `json:"preEmphasisTauUS,omitempty"`
LimiterEnabled *bool `json:"limiterEnabled,omitempty"`
LimiterCeiling *float64 `json:"limiterCeiling,omitempty"`
AudioGain *float64 `json:"audioGain,omitempty"`
PI *string `json:"pi,omitempty"`
PTY *int `json:"pty,omitempty"`
TP *bool `json:"tp,omitempty"`
TA *bool `json:"ta,omitempty"`
MS *bool `json:"ms,omitempty"`
CTEnabled *bool `json:"ctEnabled,omitempty"`
RTPlusEnabled *bool `json:"rtPlusEnabled,omitempty"`
RTPlusSeparator *string `json:"rtPlusSeparator,omitempty"`
PTYN *string `json:"ptyn,omitempty"`
LPS *string `json:"lps,omitempty"`
ERTEnabled *bool `json:"ertEnabled,omitempty"`
ERT *string `json:"ert,omitempty"`
RDS2Enabled *bool `json:"rds2Enabled,omitempty"`
StationLogoPath *string `json:"stationLogoPath,omitempty"`
AF *[]float64 `json:"af,omitempty"`
BS412Enabled *bool `json:"bs412Enabled,omitempty"`
BS412ThresholdDBr *float64 `json:"bs412ThresholdDBr,omitempty"`
MpxGain *float64 `json:"mpxGain,omitempty"`
CompositeClipperEnabled *bool `json:"compositeClipperEnabled,omitempty"`
CompositeClipperIterations *int `json:"compositeClipperIterations,omitempty"`
CompositeClipperSoftKnee *float64 `json:"compositeClipperSoftKnee,omitempty"`
CompositeClipperLookaheadMs *float64 `json:"compositeClipperLookaheadMs,omitempty"`
FrequencyMHz *float64 `json:"frequencyMHz,omitempty"`
OutputDrive *float64 `json:"outputDrive,omitempty"`
StereoEnabled *bool `json:"stereoEnabled,omitempty"`
StereoMode *string `json:"stereoMode,omitempty"`
PilotLevel *float64 `json:"pilotLevel,omitempty"`
RDSInjection *float64 `json:"rdsInjection,omitempty"`
RDSEnabled *bool `json:"rdsEnabled,omitempty"`
ToneLeftHz *float64 `json:"toneLeftHz,omitempty"`
ToneRightHz *float64 `json:"toneRightHz,omitempty"`
ToneAmplitude *float64 `json:"toneAmplitude,omitempty"`
PS *string `json:"ps,omitempty"`
RadioText *string `json:"radioText,omitempty"`
PreEmphasisTauUS *float64 `json:"preEmphasisTauUS,omitempty"`
LimiterEnabled *bool `json:"limiterEnabled,omitempty"`
LimiterCeiling *float64 `json:"limiterCeiling,omitempty"`
AudioGain *float64 `json:"audioGain,omitempty"`
PI *string `json:"pi,omitempty"`
PTY *int `json:"pty,omitempty"`
TP *bool `json:"tp,omitempty"`
TA *bool `json:"ta,omitempty"`
MS *bool `json:"ms,omitempty"`
CTEnabled *bool `json:"ctEnabled,omitempty"`
RTPlusEnabled *bool `json:"rtPlusEnabled,omitempty"`
RTPlusSeparator *string `json:"rtPlusSeparator,omitempty"`
PTYN *string `json:"ptyn,omitempty"`
LPS *string `json:"lps,omitempty"`
ERTEnabled *bool `json:"ertEnabled,omitempty"`
ERT *string `json:"ert,omitempty"`
RDS2Enabled *bool `json:"rds2Enabled,omitempty"`
StationLogoPath *string `json:"stationLogoPath,omitempty"`
AF *[]float64 `json:"af,omitempty"`
BS412Enabled *bool `json:"bs412Enabled,omitempty"`
BS412ThresholdDBr *float64 `json:"bs412ThresholdDBr,omitempty"`
MpxGain *float64 `json:"mpxGain,omitempty"`
CompositeClipperEnabled *bool `json:"compositeClipperEnabled,omitempty"`
CompositeClipperIterations *int `json:"compositeClipperIterations,omitempty"`
CompositeClipperSoftKnee *float64 `json:"compositeClipperSoftKnee,omitempty"`
CompositeClipperLookaheadMs *float64 `json:"compositeClipperLookaheadMs,omitempty"`
}

type IngestSaveRequest struct {
@@ -675,23 +675,23 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
return
}
lp := LivePatch{
FrequencyMHz: patch.FrequencyMHz,
OutputDrive: patch.OutputDrive,
StereoEnabled: patch.StereoEnabled,
StereoMode: patch.StereoMode,
PilotLevel: patch.PilotLevel,
RDSInjection: patch.RDSInjection,
RDSEnabled: patch.RDSEnabled,
LimiterEnabled: patch.LimiterEnabled,
LimiterCeiling: patch.LimiterCeiling,
PS: patch.PS,
RadioText: patch.RadioText,
TA: patch.TA,
TP: patch.TP,
ToneLeftHz: patch.ToneLeftHz,
ToneRightHz: patch.ToneRightHz,
ToneAmplitude: patch.ToneAmplitude,
AudioGain: patch.AudioGain,
FrequencyMHz: patch.FrequencyMHz,
OutputDrive: patch.OutputDrive,
StereoEnabled: patch.StereoEnabled,
StereoMode: patch.StereoMode,
PilotLevel: patch.PilotLevel,
RDSInjection: patch.RDSInjection,
RDSEnabled: patch.RDSEnabled,
LimiterEnabled: patch.LimiterEnabled,
LimiterCeiling: patch.LimiterCeiling,
PS: patch.PS,
RadioText: patch.RadioText,
TA: patch.TA,
TP: patch.TP,
ToneLeftHz: patch.ToneLeftHz,
ToneRightHz: patch.ToneRightHz,
ToneAmplitude: patch.ToneAmplitude,
AudioGain: patch.AudioGain,
CompositeClipperEnabled: patch.CompositeClipperEnabled,
}
// NEU-02 fix: determine whether any live-patchable fields are present,
@@ -706,19 +706,18 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
patch.ToneLeftHz != nil || patch.ToneRightHz != nil ||
patch.ToneAmplitude != nil || patch.AudioGain != nil ||
patch.CompositeClipperEnabled != nil
s.cfg = next
s.mu.Unlock()
// Apply live fields to running engine outside the lock.
var updateErr error
if tx != nil && hasLiveFields {
if err := tx.UpdateConfig(lp); err != nil {
updateErr = err
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
if updateErr != nil {
http.Error(w, updateErr.Error(), http.StatusBadRequest)
return
}
// Commit the server snapshot only after validation and any required live update succeeded.
s.mu.Lock()
s.cfg = next
s.mu.Unlock()
// NEU-03 fix: report live=true only when live-patchable fields were applied.
live := tx != nil && hasLiveFields
w.Header().Set("Content-Type", "application/json")


+ 26
- 0
internal/control/control_test.go ファイルの表示

@@ -137,6 +137,32 @@ func TestConfigPatch(t *testing.T) {
}
}

func TestConfigPatchFailedLiveUpdateDoesNotMutateSnapshot(t *testing.T) {
cfg := cfgpkg.Default()
srv := NewServer(cfg)
srv.SetTXController(&fakeTXController{updateErr: errors.New("boom")})

body := []byte(`{"stereoMode":"SSB"}`)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, newConfigPostRequest(body))
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
}

cfgRec := httptest.NewRecorder()
srv.Handler().ServeHTTP(cfgRec, httptest.NewRequest(http.MethodGet, "/config", nil))
if cfgRec.Code != http.StatusOK {
t.Fatalf("config status: %d", cfgRec.Code)
}
var got cfgpkg.Config
if err := json.Unmarshal(cfgRec.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal config: %v", err)
}
if got.FM.StereoMode != cfg.FM.StereoMode {
t.Fatalf("snapshot mutated on failed live update: got %q want %q", got.FM.StereoMode, cfg.FM.StereoMode)
}
}

func TestConfigPatchRejectsOversizeBody(t *testing.T) {
srv := NewServer(cfgpkg.Default())
rec := httptest.NewRecorder()


+ 67
- 37
internal/offline/generator.go ファイルの表示

@@ -11,13 +11,13 @@ import (

"github.com/jan/fm-rds-tx/internal/audio"
cfgpkg "github.com/jan/fm-rds-tx/internal/config"
"github.com/jan/fm-rds-tx/internal/license"
"github.com/jan/fm-rds-tx/internal/watermark"
"github.com/jan/fm-rds-tx/internal/dsp"
"github.com/jan/fm-rds-tx/internal/license"
"github.com/jan/fm-rds-tx/internal/mpx"
"github.com/jan/fm-rds-tx/internal/output"
"github.com/jan/fm-rds-tx/internal/rds"
"github.com/jan/fm-rds-tx/internal/stereo"
"github.com/jan/fm-rds-tx/internal/watermark"
)

type frameSource interface {
@@ -103,17 +103,17 @@ type Generator struct {
// → Stereo Encode → Composite Clip → Notch₁₉ → Notch₅₇
// → + Pilot → + RDS → FM
//
audioLPF_L *dsp.FilterChain // 14kHz 8th-order (pre-clip)
audioLPF_R *dsp.FilterChain
pilotNotchL *dsp.FilterChain // 19kHz double-notch (guard band)
pilotNotchR *dsp.FilterChain
limiter *dsp.StereoLimiter // slow compressor (raises average, clips catch peaks)
cleanupLPF_L *dsp.FilterChain // 14kHz 8th-order (post-clip cleanup)
cleanupLPF_R *dsp.FilterChain
mpxNotch19 *dsp.FilterChain // composite clipper protection
mpxNotch57 *dsp.FilterChain
bs412 *dsp.BS412Limiter // ITU-R BS.412 MPX power limiter (optional)
compositeClip *dsp.CompositeClipper // ITU-R SM.1268 iterative composite clipper (optional)
audioLPF_L *dsp.FilterChain // 14kHz 8th-order (pre-clip)
audioLPF_R *dsp.FilterChain
pilotNotchL *dsp.FilterChain // 19kHz double-notch (guard band)
pilotNotchR *dsp.FilterChain
limiter *dsp.StereoLimiter // slow compressor (raises average, clips catch peaks)
cleanupLPF_L *dsp.FilterChain // 14kHz 8th-order (post-clip cleanup)
cleanupLPF_R *dsp.FilterChain
mpxNotch19 *dsp.FilterChain // composite clipper protection
mpxNotch57 *dsp.FilterChain
bs412 *dsp.BS412Limiter // ITU-R BS.412 MPX power limiter (optional)
compositeClip *dsp.CompositeClipper // ITU-R SM.1268 iterative composite clipper (optional)

// Pre-allocated frame buffer — reused every GenerateFrame call.
frameBuf *output.CompositeFrame
@@ -134,20 +134,35 @@ type Generator struct {
licenseState *license.State
jingleFrames []license.JingleFrame

// Watermark: STFT-domain spread-spectrum (Kirovski & Malvar 2003).
stftEmbedder *watermark.STFTEmbedder
wmDecimLPF *dsp.FilterChain // anti-alias LPF for 228k→12k decimation
wmInterpLPF *dsp.FilterChain // image-rejection LPF for 12k→228k upsample
// Watermark: explicit opt-in STFT-domain spread-spectrum (Kirovski & Malvar 2003).
watermarkEnabled bool
watermarkKey string
stftEmbedder *watermark.STFTEmbedder
wmDecimLPF *dsp.FilterChain // anti-alias LPF for composite→12k decimation
wmInterpLPF *dsp.FilterChain // image-rejection LPF for 12k→composite upsample
}

func NewGenerator(cfg cfgpkg.Config) *Generator {
return &Generator{cfg: cfg}
}

// SetLicense configures license state (jingle) and creates the STFT watermark
// embedder. Must be called before the first GenerateFrame.
func (g *Generator) SetLicense(state *license.State, key string) {
// SetLicense configures license state for evaluation-jingle behavior only.
// It intentionally does not enable or instantiate the audio watermark path.
func (g *Generator) SetLicense(state *license.State) {
g.licenseState = state
}

// ConfigureWatermark explicitly enables or disables the optional STFT watermark.
// Must be called before the first GenerateFrame.
func (g *Generator) ConfigureWatermark(enabled bool, key string) {
g.watermarkEnabled = enabled
g.watermarkKey = key
if !enabled {
g.stftEmbedder = nil
g.wmDecimLPF = nil
g.wmInterpLPF = nil
return
}
g.stftEmbedder = watermark.NewSTFTEmbedder(key)
}

@@ -196,6 +211,15 @@ func (g *Generator) init() {
if g.sampleRate <= 0 {
g.sampleRate = 228000
}
if g.watermarkEnabled {
if g.stftEmbedder == nil {
g.stftEmbedder = watermark.NewSTFTEmbedder(g.watermarkKey)
}
} else {
g.stftEmbedder = nil
g.wmDecimLPF = nil
g.wmInterpLPF = nil
}
rawSource, _ := g.sourceFor(g.sampleRate)
g.source = NewPreEmphasizedSource(rawSource, g.cfg.FM.PreEmphasisTauUS, g.sampleRate, g.cfg.Audio.Gain)
g.stereoEncoder = stereo.NewStereoEncoder(g.sampleRate)
@@ -252,7 +276,9 @@ func (g *Generator) init() {
}
}
ceiling := g.cfg.FM.LimiterCeiling
if ceiling <= 0 { ceiling = 1.0 }
if ceiling <= 0 {
ceiling = 1.0
}

// Broadcast clip-filter-clip chain:
// Pre-clip: 14kHz LPF (8th-order) + 19kHz double-notch (per channel)
@@ -307,19 +333,19 @@ func (g *Generator) init() {

// Seed initial live params from config
g.liveParams.Store(&LiveParams{
OutputDrive: g.cfg.FM.OutputDrive,
StereoEnabled: g.cfg.FM.StereoEnabled,
StereoMode: g.cfg.FM.StereoMode,
PilotLevel: g.cfg.FM.PilotLevel,
RDSInjection: g.cfg.FM.RDSInjection,
RDSEnabled: g.cfg.RDS.Enabled,
LimiterEnabled: g.cfg.FM.LimiterEnabled,
LimiterCeiling: ceiling,
MpxGain: g.cfg.FM.MpxGain,
ToneLeftHz: g.cfg.Audio.ToneLeftHz,
ToneRightHz: g.cfg.Audio.ToneRightHz,
ToneAmplitude: g.cfg.Audio.ToneAmplitude,
AudioGain: g.cfg.Audio.Gain,
OutputDrive: g.cfg.FM.OutputDrive,
StereoEnabled: g.cfg.FM.StereoEnabled,
StereoMode: g.cfg.FM.StereoMode,
PilotLevel: g.cfg.FM.PilotLevel,
RDSInjection: g.cfg.FM.RDSInjection,
RDSEnabled: g.cfg.RDS.Enabled,
LimiterEnabled: g.cfg.FM.LimiterEnabled,
LimiterCeiling: ceiling,
MpxGain: g.cfg.FM.MpxGain,
ToneLeftHz: g.cfg.Audio.ToneLeftHz,
ToneRightHz: g.cfg.Audio.ToneRightHz,
ToneAmplitude: g.cfg.Audio.ToneAmplitude,
AudioGain: g.cfg.Audio.Gain,
CompositeClipperEnabled: g.cfg.FM.CompositeClipper.Enabled,
})

@@ -363,7 +389,9 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame
g.init()

samples := int(duration.Seconds() * g.sampleRate)
if samples <= 0 { samples = int(g.sampleRate / 10) }
if samples <= 0 {
samples = int(g.sampleRate / 10)
}

// Reuse buffer — grow only if needed, never shrink
if g.frameBuf == nil || g.bufCap < samples {
@@ -393,7 +421,7 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame
// Apply live tone and gain updates each chunk. GenerateFrame runs on a
// single goroutine so these field writes are safe without additional locking.
if g.toneSource != nil {
g.toneSource.LeftFreq = lp.ToneLeftHz
g.toneSource.LeftFreq = lp.ToneLeftHz
g.toneSource.RightFreq = lp.ToneRightHz
g.toneSource.Amplitude = lp.ToneAmplitude
}
@@ -420,7 +448,9 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame
// Guard band depth at 19kHz: LPF₁(-21dB) + Notch(-60dB) + LPF₂(-21dB)
// + CompNotch(-60dB) → broadband floor -42dB, exact 19kHz >-90dB
ceiling := lp.LimiterCeiling
if ceiling <= 0 { ceiling = 1.0 }
if ceiling <= 0 {
ceiling = 1.0
}
// Pilot and RDS are FIXED injection levels, independent of OutputDrive.
// Config values directly represent percentage of ±75kHz deviation:
// pilotLevel: 0.09 = 9% = ±6.75kHz (ITU standard)


+ 120
- 35
internal/offline/generator_test.go ファイルの表示

@@ -9,79 +9,113 @@ import (
"time"

cfgpkg "github.com/jan/fm-rds-tx/internal/config"
"github.com/jan/fm-rds-tx/internal/license"
)

func TestGenerateFrame(t *testing.T) {
g := NewGenerator(cfgpkg.Default())
frame := g.GenerateFrame(50 * time.Millisecond)
if frame == nil || len(frame.Samples) == 0 { t.Fatal("expected samples") }
if frame == nil || len(frame.Samples) == 0 {
t.Fatal("expected samples")
}
}

func TestGenerateFrameFMIQ(t *testing.T) {
cfg := cfgpkg.Default(); cfg.FM.FMModulationEnabled = true
cfg := cfgpkg.Default()
cfg.FM.FMModulationEnabled = true
frame := NewGenerator(cfg).GenerateFrame(10 * time.Millisecond)
for i := 100; i < len(frame.Samples) && i < 200; i++ {
s := frame.Samples[i]
mag := math.Sqrt(float64(s.I)*float64(s.I) + float64(s.Q)*float64(s.Q))
if math.Abs(mag-1.0) > 0.01 { t.Fatalf("sample %d: mag=%.4f", i, mag) }
if math.Abs(mag-1.0) > 0.01 {
t.Fatalf("sample %d: mag=%.4f", i, mag)
}
}
}

func TestGenerateFrameCompositeOnly(t *testing.T) {
cfg := cfgpkg.Default(); cfg.FM.FMModulationEnabled = false
cfg := cfgpkg.Default()
cfg.FM.FMModulationEnabled = false
frame := NewGenerator(cfg).GenerateFrame(10 * time.Millisecond)
for i := 0; i < len(frame.Samples) && i < 100; i++ {
if frame.Samples[i].Q != 0 { t.Fatalf("sample %d: Q=%.6f", i, frame.Samples[i].Q) }
if frame.Samples[i].Q != 0 {
t.Fatalf("sample %d: Q=%.6f", i, frame.Samples[i].Q)
}
}
}

func TestStereoDisabled(t *testing.T) {
cfgS := cfgpkg.Default(); cfgS.FM.FMModulationEnabled = false; cfgS.FM.StereoEnabled = true
cfgM := cfgS; cfgM.FM.StereoEnabled = false
cfgS := cfgpkg.Default()
cfgS.FM.FMModulationEnabled = false
cfgS.FM.StereoEnabled = true
cfgM := cfgS
cfgM.FM.StereoEnabled = false
sf := NewGenerator(cfgS).GenerateFrame(20 * time.Millisecond)
mf := NewGenerator(cfgM).GenerateFrame(20 * time.Millisecond)
var diffEnergy float64
for i := range sf.Samples {
d := float64(sf.Samples[i].I - mf.Samples[i].I); diffEnergy += d * d
d := float64(sf.Samples[i].I - mf.Samples[i].I)
diffEnergy += d * d
}
if diffEnergy == 0 {
t.Fatal("expected difference")
}
if diffEnergy == 0 { t.Fatal("expected difference") }
}

func TestWriteFile(t *testing.T) {
cfg := cfgpkg.Default()
out := filepath.Join(t.TempDir(), "test.iqf32")
if err := NewGenerator(cfg).WriteFile(out, 20*time.Millisecond); err != nil { t.Fatal(err) }
if err := NewGenerator(cfg).WriteFile(out, 20*time.Millisecond); err != nil {
t.Fatal(err)
}
info, _ := os.Stat(out)
if info.Size() == 0 { t.Fatal("empty file") }
if info.Size() == 0 {
t.Fatal("empty file")
}
}

func TestSummaryTones(t *testing.T) {
cfg := cfgpkg.Default(); cfg.Audio.InputPath = ""
cfg := cfgpkg.Default()
cfg.Audio.InputPath = ""
s := NewGenerator(cfg).Summary(10 * time.Millisecond)
if !strings.Contains(s, "source=tones") { t.Fatalf("unexpected: %s", s) }
if !strings.Contains(s, "source=tones") {
t.Fatalf("unexpected: %s", s)
}
}

func TestSummaryToneFallback(t *testing.T) {
cfg := cfgpkg.Default(); cfg.Audio.InputPath = "missing.wav"
cfg := cfgpkg.Default()
cfg.Audio.InputPath = "missing.wav"
s := NewGenerator(cfg).Summary(10 * time.Millisecond)
if !strings.Contains(s, "source=tone-fallback") { t.Fatalf("unexpected: %s", s) }
if !strings.Contains(s, "source=tone-fallback") {
t.Fatalf("unexpected: %s", s)
}
}

func TestSummaryPreemph(t *testing.T) {
cfg := cfgpkg.Default(); cfg.FM.PreEmphasisTauUS = 50
if !strings.Contains(NewGenerator(cfg).Summary(10*time.Millisecond), "preemph=50µs") { t.Fatal("missing preemph") }
cfg := cfgpkg.Default()
cfg.FM.PreEmphasisTauUS = 50
if !strings.Contains(NewGenerator(cfg).Summary(10*time.Millisecond), "preemph=50µs") {
t.Fatal("missing preemph")
}
}

func TestSummaryFMIQ(t *testing.T) {
cfg := cfgpkg.Default(); cfg.FM.FMModulationEnabled = true
if !strings.Contains(NewGenerator(cfg).Summary(10*time.Millisecond), "FM-IQ") { t.Fatal("missing FM-IQ") }
cfg := cfgpkg.Default()
cfg.FM.FMModulationEnabled = true
if !strings.Contains(NewGenerator(cfg).Summary(10*time.Millisecond), "FM-IQ") {
t.Fatal("missing FM-IQ")
}
}

func TestLimiterPreventsClipping(t *testing.T) {
cfg := cfgpkg.Default()
cfg.FM.LimiterEnabled = true; cfg.FM.LimiterCeiling = 1.0
cfg.FM.LimiterEnabled = true
cfg.FM.LimiterCeiling = 1.0
cfg.FM.FMModulationEnabled = false
cfg.Audio.ToneAmplitude = 0.9; cfg.Audio.Gain = 2.0; cfg.FM.OutputDrive = 1.0
cfg.Audio.ToneAmplitude = 0.9
cfg.Audio.Gain = 2.0
cfg.FM.OutputDrive = 1.0
frame := NewGenerator(cfg).GenerateFrame(50 * time.Millisecond)
// Audio clipped to ceiling, pilot+RDS added on top (standard broadcast).
// Total = ceiling + pilotLevel*drive + rdsInjection*drive
@@ -89,29 +123,40 @@ func TestLimiterPreventsClipping(t *testing.T) {
cfg.FM.PilotLevel*cfg.FM.OutputDrive +
cfg.FM.RDSInjection*cfg.FM.OutputDrive + 0.02
for i, s := range frame.Samples {
if math.Abs(float64(s.I)) > maxAllowed { t.Fatalf("sample %d: %.4f exceeds max %.4f", i, s.I, maxAllowed) }
if math.Abs(float64(s.I)) > maxAllowed {
t.Fatalf("sample %d: %.4f exceeds max %.4f", i, s.I, maxAllowed)
}
}
}

// --- Operator truth tests ---

func TestRDSDisabledSuppressesRDSEnergy(t *testing.T) {
cfgOn := cfgpkg.Default(); cfgOn.FM.FMModulationEnabled = false; cfgOn.RDS.Enabled = true
cfgOff := cfgOn; cfgOff.RDS.Enabled = false
cfgOn := cfgpkg.Default()
cfgOn.FM.FMModulationEnabled = false
cfgOn.RDS.Enabled = true
cfgOff := cfgOn
cfgOff.RDS.Enabled = false
fOn := NewGenerator(cfgOn).GenerateFrame(20 * time.Millisecond)
fOff := NewGenerator(cfgOff).GenerateFrame(20 * time.Millisecond)
var diff float64
for i := range fOn.Samples {
d := float64(fOn.Samples[i].I - fOff.Samples[i].I); diff += d * d
d := float64(fOn.Samples[i].I - fOff.Samples[i].I)
diff += d * d
}
if diff == 0 {
t.Fatal("rds.enabled=false should produce different output")
}
if diff == 0 { t.Fatal("rds.enabled=false should produce different output") }
}

func TestFMModDisabledMeansComposite(t *testing.T) {
cfg := cfgpkg.Default(); cfg.FM.FMModulationEnabled = false
cfg := cfgpkg.Default()
cfg.FM.FMModulationEnabled = false
frame := NewGenerator(cfg).GenerateFrame(10 * time.Millisecond)
for i := 0; i < 100; i++ {
if frame.Samples[i].Q != 0 { t.Fatal("Q should be 0 when FM mod is off") }
if frame.Samples[i].Q != 0 {
t.Fatal("Q should be 0 when FM mod is off")
}
}
}

@@ -121,22 +166,62 @@ func TestClipFilterClipAlwaysActive(t *testing.T) {
// produce the same peak level.
base := cfgpkg.Default()
base.FM.FMModulationEnabled = false
base.Audio.ToneAmplitude = 0.9; base.Audio.Gain = 2.0; base.FM.OutputDrive = 1.0
base.Audio.ToneAmplitude = 0.9
base.Audio.Gain = 2.0
base.FM.OutputDrive = 1.0

cfgA := base; cfgA.FM.LimiterEnabled = true; cfgA.FM.LimiterCeiling = 1.0
cfgB := base; cfgB.FM.LimiterEnabled = false; cfgB.FM.LimiterCeiling = 1.0
cfgA := base
cfgA.FM.LimiterEnabled = true
cfgA.FM.LimiterCeiling = 1.0
cfgB := base
cfgB.FM.LimiterEnabled = false
cfgB.FM.LimiterCeiling = 1.0

fA := NewGenerator(cfgA).GenerateFrame(50 * time.Millisecond)
fB := NewGenerator(cfgB).GenerateFrame(50 * time.Millisecond)

var maxA, maxB float64
for _, s := range fA.Samples { if math.Abs(float64(s.I)) > maxA { maxA = math.Abs(float64(s.I)) } }
for _, s := range fB.Samples { if math.Abs(float64(s.I)) > maxB { maxB = math.Abs(float64(s.I)) } }
for _, s := range fA.Samples {
if math.Abs(float64(s.I)) > maxA {
maxA = math.Abs(float64(s.I))
}
}
for _, s := range fB.Samples {
if math.Abs(float64(s.I)) > maxB {
maxB = math.Abs(float64(s.I))
}
}

// Both should be within ceiling + pilot + RDS
maxAllowed := cfgA.FM.LimiterCeiling +
cfgA.FM.PilotLevel*cfgA.FM.OutputDrive +
cfgA.FM.RDSInjection*cfgA.FM.OutputDrive + 0.02
if maxA > maxAllowed { t.Fatalf("cfgA peak %.4f exceeds %.4f", maxA, maxAllowed) }
if maxB > maxAllowed { t.Fatalf("cfgB peak %.4f exceeds %.4f", maxB, maxAllowed) }
if maxA > maxAllowed {
t.Fatalf("cfgA peak %.4f exceeds %.4f", maxA, maxAllowed)
}
if maxB > maxAllowed {
t.Fatalf("cfgB peak %.4f exceeds %.4f", maxB, maxAllowed)
}
}

func TestSetLicenseDoesNotImplicitlyEnableWatermark(t *testing.T) {
cfg := cfgpkg.Default()
g := NewGenerator(cfg)
g.SetLicense(license.NewState(""))
g.init()
if g.stftEmbedder != nil {
t.Fatal("watermark should remain disabled unless explicitly configured")
}
}

func TestConfigureWatermarkExplicitOptIn(t *testing.T) {
cfg := cfgpkg.Default()
cfg.FM.WatermarkEnabled = true
g := NewGenerator(cfg)
g.SetLicense(license.NewState("test-key"))
g.ConfigureWatermark(true, "test-key")
g.init()
if g.stftEmbedder == nil {
t.Fatal("expected watermark embedder after explicit opt-in")
}
}

+ 86
- 31
internal/stereo/encoder.go ファイルの表示

@@ -7,13 +7,15 @@ import (
"github.com/jan/fm-rds-tx/internal/dsp"
)

const ssbHilbertTaps = 127

// Mode selects the stereo subcarrier modulation method.
type Mode int

const (
ModeDSB Mode = iota // Standard DSB-SC (FCC §73.322 compliant)
ModeSSB // SSB-SC LSB only (Foti/Tarsio, experimental)
ModeVSB // Vestigial SB (0-200Hz DSB, above SSB; Omnia-style)
ModeVSB // Vestigial SB (0-200Hz DSB, above SSB; experimental)
)

// ParseMode converts a string to Mode. Returns ModeDSB for unknown values.
@@ -48,6 +50,42 @@ type Components struct {
Pilot float64 // sin(pilotPhase), unity amplitude
}

// sampleDelay is a simple fixed-sample delay line.
type sampleDelay struct {
buf []float64
pos int
}

func newSampleDelay(samples int) *sampleDelay {
if samples <= 0 {
return nil
}
return &sampleDelay{buf: make([]float64, samples)}
}

func (d *sampleDelay) Process(in float64) float64 {
if d == nil || len(d.buf) == 0 {
return in
}
out := d.buf[d.pos]
d.buf[d.pos] = in
d.pos++
if d.pos >= len(d.buf) {
d.pos = 0
}
return out
}

func (d *sampleDelay) Reset() {
if d == nil {
return
}
for i := range d.buf {
d.buf[i] = 0
}
d.pos = 0
}

// StereoEncoder generates stereo MPX primitives from stereo audio frames.
// Supports DSB-SC (standard), SSB-SC (lower sideband only), and VSB modes.
type StereoEncoder struct {
@@ -55,13 +93,16 @@ type StereoEncoder struct {
lastPhase float64
mode Mode

// SSB/VSB: Hilbert transform for quadrature modulation
hilbert *dsp.HilbertFilter
// SSB/VSB paths use a Hilbert transformer. The Hilbert FIR introduces a
// fixed group delay, so the mono path must be delayed by the same amount.
hilbert *dsp.HilbertFilter
monoDelay *sampleDelay

// VSB: crossover filter splits L-R into low (<200Hz, DSB) and high (>200Hz, SSB)
vsbLPF *dsp.FilterChain // 200 Hz LPF for VSB low band
vsbHPF *dsp.FilterChain // 200 Hz HPF for VSB high band (derived from allpass - LPF)
hilbertHi *dsp.HilbertFilter // separate Hilbert for high band
// VSB remains experimental. The low band is kept as DSB, while the high band
// is encoded as SSB. Mono delay compensation still applies so the decoded
// stereo matrix does not produce comb/reverb artefacts.
vsbLPF *dsp.FilterChain
hilbertHi *dsp.HilbertFilter
}

// NewStereoEncoder creates a StereoEncoder configured for the provided sample rate.
@@ -72,18 +113,23 @@ func NewStereoEncoder(sampleRate float64) StereoEncoder {
}
}

// SetMode changes the stereo encoding mode. Creates Hilbert filter if needed.
// SetMode changes the stereo encoding mode and (re)initializes internal state.
func (s *StereoEncoder) SetMode(mode Mode, sampleRate float64) {
s.mode = mode
if mode == ModeSSB || mode == ModeVSB {
// 127-tap Hilbert FIR at 228kHz: group delay = 63 samples = 0.276ms
s.hilbert = dsp.NewHilbertFilter(127)
}
if mode == ModeVSB {
// Crossover at 200 Hz for vestigial sideband
s.hilbert = nil
s.hilbertHi = nil
s.vsbLPF = nil
s.monoDelay = nil

switch mode {
case ModeSSB:
s.hilbert = dsp.NewHilbertFilter(ssbHilbertTaps)
s.monoDelay = newSampleDelay((ssbHilbertTaps - 1) / 2)
case ModeVSB:
s.hilbert = dsp.NewHilbertFilter(ssbHilbertTaps)
s.hilbertHi = dsp.NewHilbertFilter(ssbHilbertTaps)
s.vsbLPF = dsp.NewLPF4(200, sampleRate)
s.vsbHPF = dsp.NewLPF4(200, sampleRate) // we'll subtract to get HPF
s.hilbertHi = dsp.NewHilbertFilter(127)
s.monoDelay = newSampleDelay((ssbHilbertTaps - 1) / 2)
}
}

@@ -98,38 +144,41 @@ func (s *StereoEncoder) Encode(frame audio.Frame) Components {

diff := float64(frame.Difference())
mono := float64(frame.Mono())
monoOut := mono

var stereoOut float64

switch s.mode {
case ModeSSB:
if s.hilbert == nil {
// Fallback to DSB if not initialized
stereoOut = diff * sub38sin
break
}
monoOut = s.monoDelay.Process(mono)
// SSB-LSB: s(t) = m_delayed(t)·sin(ωt) - m̂(t)·cos(ωt)
// The - sign selects LSB (below 38 kHz).
// ×2 compensates for removed USB (+6 dB).
delayed, hilb := s.hilbert.Process(diff)
stereoOut = 2 * (delayed*sub38sin - hilb*sub38cos)
// ×2 compensates for the removed upper sideband (+6 dB).
delayedDiff, hilb := s.hilbert.Process(diff)
stereoOut = 2 * (delayedDiff*sub38sin - hilb*sub38cos)

case ModeVSB:
if s.hilbert == nil || s.vsbLPF == nil {
if s.hilbertHi == nil || s.vsbLPF == nil {
stereoOut = diff * sub38sin
break
}
// VSB: 0-200Hz → DSB, 200Hz+ → SSB-LSB
monoOut = s.monoDelay.Process(mono)

// Experimental VSB split:
// - 0..200 Hz remains DSB to avoid aggressive low-frequency image shift.
// - Above 200 Hz the residual is encoded as SSB-LSB.
// The critical reverb bug was the mono-vs-diff timing mismatch; that is
// fixed here by delaying mono by the Hilbert group delay.
lo := s.vsbLPF.Process(diff)
hiRef := s.vsbHPF.Process(diff) // same LPF for HPF derivation
hi := diff - hiRef // highpass = original - lowpass (requires matching delay, approximate)
hi := diff - lo

// Low band: standard DSB
dsbPart := lo * sub38sin

// High band: SSB-LSB with Hilbert
delayed, hilb := s.hilbertHi.Process(hi)
ssbPart := 2 * (delayed*sub38sin - hilb*sub38cos)
delayedHi, hilbHi := s.hilbertHi.Process(hi)
ssbPart := 2 * (delayedHi*sub38sin - hilbHi*sub38cos)

stereoOut = dsbPart + ssbPart

@@ -138,7 +187,7 @@ func (s *StereoEncoder) Encode(frame audio.Frame) Components {
}

return Components{
Mono: mono,
Mono: monoOut,
Stereo: stereoOut,
Pilot: pilot,
}
@@ -153,6 +202,12 @@ func (s *StereoEncoder) Reset() {
if s.hilbertHi != nil {
s.hilbertHi.Reset()
}
if s.vsbLPF != nil {
s.vsbLPF.Reset()
}
if s.monoDelay != nil {
s.monoDelay.Reset()
}
}

// PilotPhase returns the pilot phase used in the most recent Encode() call.
@@ -164,4 +219,4 @@ func (s *StereoEncoder) PilotPhase() float64 {
// phase-locked to the pilot, as required by the RDS standard.
func (s *StereoEncoder) RDSCarrier() float64 {
return math.Sin(2 * math.Pi * 3 * s.lastPhase)
}
}

読み込み中…
キャンセル
保存