Переглянути джерело

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.
tags/v0.7.0-pre
Jan Svabenik 1 місяць тому
джерело
коміт
0321e0cca3
2 змінених файлів з 55 додано та 28 видалено
  1. +23
    -18
      internal/platform/plutosdr/pluto_windows.go
  2. +32
    -10
      internal/stereo/encoder.go

+ 23
- 18
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()


+ 32
- 10
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())
}

Завантаження…
Відмінити
Зберегти