From 0321e0cca3523c3e405121f5620fdc51c8ebe83f Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Fri, 3 Apr 2026 12:18:39 +0200 Subject: [PATCH] fix: align Pluto TX buffer writes and lock stereo carrier to pilot phase Write PlutoSDR TX samples via per-channel buffer pointers from iio_buffer_first instead of assuming a fixed interleaved layout. Also derive the 38 kHz stereo subcarrier directly from the pilot phase to guarantee phase coherence for the FM stereo multiplex. --- internal/platform/plutosdr/pluto_windows.go | 41 +++++++++++--------- internal/stereo/encoder.go | 42 ++++++++++++++++----- 2 files changed, 55 insertions(+), 28 deletions(-) diff --git a/internal/platform/plutosdr/pluto_windows.go b/internal/platform/plutosdr/pluto_windows.go index d8fe56a..7bf5b57 100644 --- a/internal/platform/plutosdr/pluto_windows.go +++ b/internal/platform/plutosdr/pluto_windows.go @@ -287,6 +287,8 @@ func (d *PlutoDriver) Write(_ context.Context, frame *output.CompositeFrame) (in d.mu.Lock() lib := d.lib buf := d.buf + chanI := d.chanI + chanQ := d.chanQ started := d.started bufSize := d.bufSize d.mu.Unlock() @@ -307,35 +309,38 @@ func (d *PlutoDriver) Write(_ context.Context, frame *output.CompositeFrame) (in chunk = bufSize } - // Get buffer pointers - start := d.bufferStart(buf) - end := d.bufferEnd(buf) step := d.bufferStep(buf) + if step == 0 { + return written, fmt.Errorf("pluto: buffer step is 0") + } - if start == 0 || end == 0 || step == 0 { - return written, fmt.Errorf("pluto: invalid buffer pointers") + // iio_buffer_first gives the pointer to the first sample for each channel. + // For interleaved buffers (step=4 with 2x int16), ptrI and ptrQ may + // differ by 2 bytes (I at offset 0, Q at offset 2 within each step). + // For non-interleaved, they point to separate memory regions. + ptrI := d.bufferFirst(buf, chanI) + ptrQ := d.bufferFirst(buf, chanQ) + if ptrI == 0 || ptrQ == 0 { + return written, fmt.Errorf("pluto: buffer_first returned null (I=%d Q=%d)", ptrI, ptrQ) } - // Fill buffer with interleaved I/Q as int16 (PlutoSDR native format) - // IQSample is {I float32, Q float32} normalized to [-1,+1] - // PlutoSDR expects int16 interleaved: I0 Q0 I1 Q1 ... - bufLen := (end - start) / uintptr(step) - if int(bufLen) < chunk { - chunk = int(bufLen) + end := d.bufferEnd(buf) + if end > 0 { + bufSamples := int((end - ptrI) / uintptr(step)) + if bufSamples > 0 && chunk > bufSamples { + chunk = bufSamples + } } - ptr := start for i := 0; i < chunk; i++ { s := frame.Samples[written+i] // Scale float32 [-1,+1] to int16 [-32767,+32767] - iVal := int16(float32(s.I) * 32767) - qVal := int16(float32(s.Q) * 32767) - *(*int16)(unsafe.Pointer(ptr)) = iVal - *(*int16)(unsafe.Pointer(ptr + 2)) = qVal - ptr += uintptr(step) + *(*int16)(unsafe.Pointer(ptrI)) = int16(s.I * 32767) + *(*int16)(unsafe.Pointer(ptrQ)) = int16(s.Q * 32767) + ptrI += uintptr(step) + ptrQ += uintptr(step) } - // Push buffer to hardware pushed := d.bufferPush(buf) if pushed < 0 { d.mu.Lock() diff --git a/internal/stereo/encoder.go b/internal/stereo/encoder.go index 7881f6b..b7dff54 100644 --- a/internal/stereo/encoder.go +++ b/internal/stereo/encoder.go @@ -1,6 +1,8 @@ package stereo import ( + "math" + "github.com/jan/fm-rds-tx/internal/audio" "github.com/jan/fm-rds-tx/internal/dsp" ) @@ -9,28 +11,37 @@ import ( // All outputs are unity-normalized. The combiner controls actual injection levels. type Components struct { Mono float64 // (L+R)/2 baseband - Stereo float64 // (L-R)/2 * sin(2π·38kHz·t), unity subcarrier - Pilot float64 // sin(2π·19kHz·t), unity amplitude + Stereo float64 // (L-R)/2 * sin(2 * pilotPhase), unity subcarrier + Pilot float64 // sin(pilotPhase), unity amplitude } // StereoEncoder generates stereo MPX primitives from stereo audio frames. +// The 38 kHz subcarrier is derived from the pilot phase (2× multiplication), +// guaranteeing perfect phase coherence as required by the FM stereo standard. type StereoEncoder struct { - pilot dsp.PilotGenerator - subcarrier dsp.Oscillator + pilot dsp.PilotGenerator } // NewStereoEncoder creates a StereoEncoder configured for the provided sample rate. func NewStereoEncoder(sampleRate float64) StereoEncoder { return StereoEncoder{ - pilot: dsp.NewPilotGenerator(sampleRate), - subcarrier: dsp.Oscillator{Frequency: 38000, SampleRate: sampleRate}, + pilot: dsp.NewPilotGenerator(sampleRate), } } // Encode converts a stereo frame into MPX components. +// The 38 kHz subcarrier is sin(2*pilotPhase), derived directly from the pilot +// oscillator's phase — not from a separate oscillator. func (s *StereoEncoder) Encode(frame audio.Frame) Components { - pilot := s.pilot.Sample() - sub38 := s.subcarrier.Tick() + // Advance pilot and capture its phase BEFORE generating the sample + pilot := s.pilot.Sample() // sin(2π * 19000 * t) + pilotPhase := s.pilot.Phase() + + // 38 kHz subcarrier = sin(2 * pilotPhase * 2π) = sin(4π * 19000 * t) + // This is mathematically identical to sin(2π * 38000 * t) but guaranteed + // phase-locked to the pilot. FM receivers PLL onto the pilot and derive + // the 38 kHz reference this exact same way. + sub38 := math.Sin(2 * math.Pi * 2 * pilotPhase) return Components{ Mono: float64(frame.Mono()), @@ -39,8 +50,19 @@ func (s *StereoEncoder) Encode(frame audio.Frame) Components { } } -// Reset restarts the pilot and subcarrier generators. +// Reset restarts the pilot generator. func (s *StereoEncoder) Reset() { s.pilot.Reset() - s.subcarrier.Reset() +} + +// PilotPhase returns the current pilot oscillator phase in [0, 1). +// Used to derive phase-coherent subcarriers (38 kHz = 2×, 57 kHz = 3×). +func (s *StereoEncoder) PilotPhase() float64 { + return s.pilot.Phase() +} + +// RDSCarrier returns sin(3 * pilotPhase * 2π) — the 57 kHz carrier +// phase-locked to the pilot, as required by the RDS standard. +func (s *StereoEncoder) RDSCarrier() float64 { + return math.Sin(2 * math.Pi * 3 * s.pilot.Phase()) }