| @@ -94,8 +94,17 @@ type Encoder struct { | |||||
| // Live-updatable text — written by control API, read at group boundaries. | // Live-updatable text — written by control API, read at group boundaries. | ||||
| // Zero-contention: atomic swap, checked once per RDS group (~88ms at 228kHz). | // Zero-contention: atomic swap, checked once per RDS group (~88ms at 228kHz). | ||||
| livePS atomic.Value // string | |||||
| liveRT atomic.Value // string | |||||
| // pendingText.set distinguishes "no pending update" from "update to empty string" | |||||
| // so that PS/RT can be explicitly cleared via UpdateText. | |||||
| livePS atomic.Value // pendingText | |||||
| liveRT atomic.Value // pendingText | |||||
| } | |||||
| // pendingText carries a pending text update for PS or RT. | |||||
| // set=false means no update is pending; set=true means apply val (even if empty). | |||||
| type pendingText struct { | |||||
| val string | |||||
| set bool | |||||
| } | } | ||||
| func NewEncoder(cfg RDSConfig) (*Encoder, error) { | func NewEncoder(cfg RDSConfig) (*Encoder, error) { | ||||
| @@ -163,16 +172,29 @@ func (e *Encoder) Reset() { | |||||
| // UpdateText hot-swaps PS and/or RT. Thread-safe — called from HTTP handlers, | // UpdateText hot-swaps PS and/or RT. Thread-safe — called from HTTP handlers, | ||||
| // applied at the next RDS group boundary by the DSP goroutine. | // applied at the next RDS group boundary by the DSP goroutine. | ||||
| // Pass empty string to leave a field unchanged. | |||||
| // | |||||
| // Pass empty string to leave a field unchanged. To explicitly clear a field | |||||
| // (set PS to 8 spaces, or RT to empty), use ClearPS/ClearRT instead. | |||||
| func (e *Encoder) UpdateText(ps, rt string) { | func (e *Encoder) UpdateText(ps, rt string) { | ||||
| if ps != "" { | if ps != "" { | ||||
| e.livePS.Store(normalizePS(ps)) | |||||
| e.livePS.Store(pendingText{val: normalizePS(ps), set: true}) | |||||
| } | } | ||||
| if rt != "" { | if rt != "" { | ||||
| e.liveRT.Store(normalizeRT(rt)) | |||||
| e.liveRT.Store(pendingText{val: normalizeRT(rt), set: true}) | |||||
| } | } | ||||
| } | } | ||||
| // ClearPS resets the Program Service name to 8 spaces at the next group boundary. | |||||
| func (e *Encoder) ClearPS() { | |||||
| e.livePS.Store(pendingText{val: normalizePS(""), set: true}) | |||||
| } | |||||
| // ClearRT resets RadioText to an empty string at the next group boundary. | |||||
| // Per RDS spec, an empty RT causes receivers to clear their display. | |||||
| func (e *Encoder) ClearRT() { | |||||
| e.liveRT.Store(pendingText{val: "", set: true}) | |||||
| } | |||||
| // NextSample returns the next RDS subcarrier sample at the configured rate. | // NextSample returns the next RDS subcarrier sample at the configured rate. | ||||
| // Uses the internal free-running 57 kHz carrier. Prefer NextSampleWithCarrier | // Uses the internal free-running 57 kHz carrier. Prefer NextSampleWithCarrier | ||||
| // for phase-locked operation in a stereo MPX chain. | // for phase-locked operation in a stereo MPX chain. | ||||
| @@ -192,15 +214,15 @@ func (e *Encoder) NextSampleWithCarrier(carrier float64) float64 { | |||||
| // Apply live text updates at group boundaries (~88ms at 228kHz). | // Apply live text updates at group boundaries (~88ms at 228kHz). | ||||
| // Atomics are consumed (cleared) after reading to prevent | // Atomics are consumed (cleared) after reading to prevent | ||||
| // re-applying the same text every group and toggling A/B flag. | // re-applying the same text every group and toggling A/B flag. | ||||
| if ps, ok := e.livePS.Load().(string); ok && ps != "" { | |||||
| e.scheduler.cfg.PS = ps | |||||
| e.livePS.Store("") // consumed | |||||
| if pt, ok := e.livePS.Load().(pendingText); ok && pt.set { | |||||
| e.scheduler.cfg.PS = pt.val | |||||
| e.livePS.Store(pendingText{}) // consumed | |||||
| } | } | ||||
| if rt, ok := e.liveRT.Load().(string); ok && rt != "" { | |||||
| e.scheduler.cfg.RT = rt | |||||
| if pt, ok := e.liveRT.Load().(pendingText); ok && pt.set { | |||||
| e.scheduler.cfg.RT = pt.val | |||||
| e.scheduler.rtIdx = 0 // restart RT transmission for new text | e.scheduler.rtIdx = 0 // restart RT transmission for new text | ||||
| e.scheduler.rtABFlag = !e.scheduler.rtABFlag // toggle A/B per RDS spec | e.scheduler.rtABFlag = !e.scheduler.rtABFlag // toggle A/B per RDS spec | ||||
| e.liveRT.Store("") // consumed | |||||
| e.liveRT.Store(pendingText{}) // consumed | |||||
| } | } | ||||
| e.getRDSGroup() | e.getRDSGroup() | ||||
| e.bitPos = 0 | e.bitPos = 0 | ||||
| @@ -240,12 +262,27 @@ func (e *Encoder) Generate(n int) []float64 { | |||||
| out := make([]float64, n); for i := range out { out[i] = e.NextSample() }; return out | out := make([]float64, n); for i := range out { out[i] = e.NextSample() }; return out | ||||
| } | } | ||||
| func (e *Encoder) Symbol() float64 { | func (e *Encoder) Symbol() float64 { | ||||
| if e.bitPos >= bitsPerGroup { return -1 } | |||||
| sym := 1.0; if e.bitBuffer[e.bitPos] == 0 { sym = -1.0 } | |||||
| // Populate the bit buffer on first call (bitPos starts at bitsPerGroup | |||||
| // after NewEncoder/Reset, so the guard below would return -1 immediately | |||||
| // without this bootstrap step). | |||||
| if e.bitPos >= bitsPerGroup { | |||||
| e.getRDSGroup() | |||||
| e.bitPos = 0 | |||||
| } | |||||
| sym := 1.0 | |||||
| if e.bitBuffer[e.bitPos] == 0 { | |||||
| sym = -1.0 | |||||
| } | |||||
| e.sampleCount++ | e.sampleCount++ | ||||
| if e.sampleCount >= e.spb { e.sampleCount = 0; e.bitPos++ | |||||
| if e.bitPos >= bitsPerGroup { e.getRDSGroup(); e.bitPos = 0 } | |||||
| }; return sym | |||||
| if e.sampleCount >= e.spb { | |||||
| e.sampleCount = 0 | |||||
| e.bitPos++ | |||||
| if e.bitPos >= bitsPerGroup { | |||||
| e.getRDSGroup() | |||||
| e.bitPos = 0 | |||||
| } | |||||
| } | |||||
| return sym | |||||
| } | } | ||||
| func (e *Encoder) getRDSGroup() { | func (e *Encoder) getRDSGroup() { | ||||