From adfb76f508eadf4d1dbbf1301654f05c04b4ae7c Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 12 Apr 2026 10:06:57 +0200 Subject: [PATCH] feat(rds): add advanced groups and feature controls Extend the RDS stack with TP/TA live updates, CT, RT+, PTYN, AF and EON support, and add UI/config controls for the new RDS feature set. --- cmd/fmrtx/main.go | 2 + internal/app/engine.go | 14 ++ internal/config/config.go | 48 +++- internal/control/control.go | 38 ++- internal/control/ui.html | 72 +++++- internal/offline/generator.go | 25 ++ internal/rds/config.go | 103 ++++++-- internal/rds/encoder.go | 227 +++++++++++++++--- internal/rds/encoder_test.go | 92 +++++++- internal/rds/groups.go | 427 ++++++++++++++++++++++++++++++++++ 10 files changed, 1001 insertions(+), 47 deletions(-) create mode 100644 internal/rds/groups.go diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index 36fa2d2..0900921 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -372,6 +372,8 @@ func (b *txBridge) UpdateConfig(lp ctrlpkg.LivePatch) error { LimiterCeiling: lp.LimiterCeiling, PS: lp.PS, RadioText: lp.RadioText, + TA: lp.TA, + TP: lp.TP, ToneLeftHz: lp.ToneLeftHz, ToneRightHz: lp.ToneRightHz, ToneAmplitude: lp.ToneAmplitude, diff --git a/internal/app/engine.go b/internal/app/engine.go index 7583bc6..aabec8c 100644 --- a/internal/app/engine.go +++ b/internal/app/engine.go @@ -290,6 +290,8 @@ type LiveConfigUpdate struct { LimiterCeiling *float64 PS *string RadioText *string + TA *bool + TP *bool // Tone and gain: live-patchable without engine restart. ToneLeftHz *float64 ToneRightHz *float64 @@ -360,6 +362,18 @@ func (e *Engine) UpdateConfig(u LiveConfigUpdate) error { } } + // --- RDS traffic flags: live-update --- + if u.TA != nil || u.TP != nil { + if enc := e.generator.RDSEncoder(); enc != nil { + if u.TA != nil { + enc.UpdateTA(*u.TA) + } + if u.TP != nil { + enc.UpdateTP(*u.TP) + } + } + } + // --- DSP params: build new LiveParams from current + patch --- // Read current, apply deltas, store new current := e.generator.CurrentLiveParams() diff --git a/internal/config/config.go b/internal/config/config.go index 5582f81..9541e0c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,6 +33,41 @@ type RDSConfig struct { PS string `json:"ps"` RadioText string `json:"radioText"` PTY int `json:"pty"` + + // Traffic + TP bool `json:"tp"` // Traffic Program — station carries traffic info + TA bool `json:"ta"` // Traffic Announcement — currently on air + + // Music/Speech & Decoder Info + MS bool `json:"ms"` // true=music, false=speech + DI uint8 `json:"di"` // Decoder Info: bit0=stereo, bit1=artificial head, bit2=compressed, bit3=dynamic PTY + + // Alternative Frequencies (MHz, e.g. [93.3, 95.7]) + AF []float64 `json:"af,omitempty"` + + // Clock-Time (Group 4A) + CTEnabled bool `json:"ctEnabled"` + CTOffsetHalfHours int8 `json:"ctOffsetHalfHours,omitempty"` // 0 = auto from OS + + // Program Type Name (Group 10A) — 8-char custom label + PTYN string `json:"ptyn,omitempty"` + + // RT+ (Groups 3A + 11A) — auto-parse artist/title from RadioText + RTPlusEnabled bool `json:"rtPlusEnabled"` + RTPlusSeparator string `json:"rtPlusSeparator,omitempty"` // default " - " + + // EON — Enhanced Other Networks (Group 14A) + EON []EONEntryConfig `json:"eon,omitempty"` +} + +// 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 + PTY int `json:"pty"` + TP bool `json:"tp"` + TA bool `json:"ta"` + AF []float64 `json:"af,omitempty"` } type FMConfig struct { @@ -156,7 +191,18 @@ func Default() Config { return Config{ // BUG-C fix: tones off by default (was 0.4 — caused unintended audio output). Audio: AudioConfig{Gain: 1.0, ToneLeftHz: 1000, ToneRightHz: 1600, ToneAmplitude: 0}, - RDS: RDSConfig{Enabled: true, PI: "1234", PS: "FMRTX", RadioText: "fm-rds-tx", PTY: 0}, + RDS: RDSConfig{ + Enabled: true, + PI: "1234", + PS: "FMRTX", + RadioText: "fm-rds-tx", + PTY: 0, + MS: true, + DI: 0x01, // stereo + CTEnabled: true, + RTPlusEnabled: true, + RTPlusSeparator: " - ", + }, FM: FMConfig{ FrequencyMHz: 100.0, StereoEnabled: true, diff --git a/internal/control/control.go b/internal/control/control.go index e5fabb7..2a6e9f7 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -46,6 +46,8 @@ type LivePatch struct { LimiterCeiling *float64 PS *string RadioText *string + TA *bool + TP *bool ToneLeftHz *float64 ToneRightHz *float64 ToneAmplitude *float64 @@ -139,6 +141,14 @@ type ConfigPatch struct { 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"` + AF *[]float64 `json:"af,omitempty"` BS412Enabled *bool `json:"bs412Enabled,omitempty"` BS412ThresholdDBr *float64 `json:"bs412ThresholdDBr,omitempty"` MpxGain *float64 `json:"mpxGain,omitempty"` @@ -570,6 +580,30 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { if patch.PTY != nil { next.RDS.PTY = *patch.PTY } + if patch.TP != nil { + next.RDS.TP = *patch.TP + } + if patch.TA != nil { + next.RDS.TA = *patch.TA + } + if patch.MS != nil { + next.RDS.MS = *patch.MS + } + if patch.CTEnabled != nil { + next.RDS.CTEnabled = *patch.CTEnabled + } + if patch.RTPlusEnabled != nil { + next.RDS.RTPlusEnabled = *patch.RTPlusEnabled + } + if patch.RTPlusSeparator != nil { + next.RDS.RTPlusSeparator = *patch.RTPlusSeparator + } + if patch.PTYN != nil { + next.RDS.PTYN = *patch.PTYN + } + if patch.AF != nil { + next.RDS.AF = *patch.AF + } if patch.PreEmphasisTauUS != nil { next.FM.PreEmphasisTauUS = *patch.PreEmphasisTauUS } @@ -632,6 +666,8 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { LimiterCeiling: patch.LimiterCeiling, PS: patch.PS, RadioText: patch.RadioText, + TA: patch.TA, + TP: patch.TP, ToneLeftHz: patch.ToneLeftHz, ToneRightHz: patch.ToneRightHz, ToneAmplitude: patch.ToneAmplitude, @@ -646,7 +682,7 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { patch.StereoEnabled != nil || patch.StereoMode != nil || patch.PilotLevel != nil || patch.RDSInjection != nil || patch.RDSEnabled != nil || patch.LimiterEnabled != nil || patch.LimiterCeiling != nil || - patch.PS != nil || patch.RadioText != nil || + patch.PS != nil || patch.RadioText != nil || patch.TA != nil || patch.TP != nil || patch.ToneLeftHz != nil || patch.ToneRightHz != nil || patch.ToneAmplitude != nil || patch.AudioGain != nil || patch.CompositeClipperEnabled != nil diff --git a/internal/control/ui.html b/internal/control/ui.html index a233302..5c9855f 100644 --- a/internal/control/ui.html +++ b/internal/control/ui.html @@ -541,6 +541,46 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
+ +
+

RDS Features

Restart required
+
+
Traffic, clock, RT+ and other RDS features. Saved to config, takes effect after TX restart.
+
+
Traffic Program (TP)Station carries traffic info
+
live
+
+
+
Traffic Announcement (TA)Currently on air
+
live
+
+
+
Music / SpeechMS flag for receivers
+
restart
+
+
+
Clock-Time (CT)Group 4A, UTC, 1×/min
+
restart
+
+
+
RT+ Auto-ParseArtist/Title from RadioText
+
restart
+
+
+
RT+ SeparatorSplit char(s) in RT
+
restart
+
+
+
PTYNCustom type name, 8 chars
+
restart
+
+
+
Alt. FrequenciesComma-separated MHz
+
restart
+
+
+
+
@@ -749,6 +789,14 @@ const CFG={ rdsInjection: {sec:'rds-lvl', live:true, path:'fm.rdsInjection', min:0, max:.15, step:.001}, pi: {sec:'rds-id', live:false,path:'rds.pi'}, pty: {sec:'rds-id', live:false,path:'rds.pty'}, + rdsTP: {sec:'rds-feat', live:false,path:'rds.tp'}, + rdsTA: {sec:'rds-feat', live:false,path:'rds.ta'}, + rdsMS: {sec:'rds-feat', live:false,path:'rds.ms'}, + rdsCT: {sec:'rds-feat', live:false,path:'rds.ctEnabled'}, + rdsRTPlus: {sec:'rds-feat', live:false,path:'rds.rtPlusEnabled'}, + rdsRTPlusSep: {sec:'rds-feat', live:false,path:'rds.rtPlusSeparator'}, + rdsPTYN: {sec:'rds-feat', live:false,path:'rds.ptyn'}, + rdsAF: {sec:'rds-feat', live:false,path:'rds.af'}, bs412Enabled: {sec:'compliance',live:false,path:'fm.bs412Enabled'}, bs412ThresholdDBr:{sec:'compliance',live:false,path:'fm.bs412ThresholdDBr',min:-6,max:6,step:.5}, mpxGain: {sec:'compliance',live:false,path:'fm.mpxGain', min:.1, max:5, step:.05}, @@ -760,7 +808,7 @@ const CFG={ compositeClipperSoftKnee: {sec:'compclip',live:false,path:'fm.compositeClipper.softKnee',min:0,max:.5,step:.01}, compositeClipperLookaheadMs:{sec:'compclip',live:false,path:'fm.compositeClipper.lookaheadMs',min:0,max:3,step:.1}, }; -const CFG_PATCH_KEY={outputDrive:'outputDrive',limiterCeiling:'limiterCeiling',preEmphasisTauUS:'preEmphasisTauUS',audioGain:'audioGain',pilotLevel:'pilotLevel',rdsInjection:'rdsInjection',pi:'pi',pty:'pty',bs412Enabled:'bs412Enabled',bs412ThresholdDBr:'bs412ThresholdDBr',mpxGain:'mpxGain',toneLeftHz:'toneLeftHz',toneRightHz:'toneRightHz',toneAmplitude:'toneAmplitude',compositeClipperEnabled:'compositeClipperEnabled',compositeClipperIterations:'compositeClipperIterations',compositeClipperSoftKnee:'compositeClipperSoftKnee',compositeClipperLookaheadMs:'compositeClipperLookaheadMs'}; +const CFG_PATCH_KEY={outputDrive:'outputDrive',limiterCeiling:'limiterCeiling',preEmphasisTauUS:'preEmphasisTauUS',audioGain:'audioGain',pilotLevel:'pilotLevel',rdsInjection:'rdsInjection',pi:'pi',pty:'pty',rdsTP:'tp',rdsTA:'ta',rdsMS:'ms',rdsCT:'ctEnabled',rdsRTPlus:'rtPlusEnabled',rdsRTPlusSep:'rtPlusSeparator',rdsPTYN:'ptyn',rdsAF:'af',bs412Enabled:'bs412Enabled',bs412ThresholdDBr:'bs412ThresholdDBr',mpxGain:'mpxGain',toneLeftHz:'toneLeftHz',toneRightHz:'toneRightHz',toneAmplitude:'toneAmplitude',compositeClipperEnabled:'compositeClipperEnabled',compositeClipperIterations:'compositeClipperIterations',compositeClipperSoftKnee:'compositeClipperSoftKnee',compositeClipperLookaheadMs:'compositeClipperLookaheadMs'}; // ── Helpers ──────────────────────────────────────────────────────────────── function nearEq(a,b,e=1e-9){if(a==null&&b==null)return true;if(a==null||b==null)return false;return Math.abs(Number(a)-Number(b))<=e;} @@ -1000,6 +1048,17 @@ function _render(){ syncSlider('pilot-slider','pilot-val','pilotLevel',v=>v==null?'--':(Number(v)*100).toFixed(1)+'%'); syncSlider('rdsinj-slider','rdsinj-val','rdsInjection',v=>v==null?'--':(Number(v)*100).toFixed(1)+'%'); const lvlDirty=!!S.cfgDirty['rds-lvl'];$('rds-levels-apply').disabled=!lvlDirty;$('rds-levels-reset').disabled=!lvlDirty; + // RDS Features sync + const rCfg=cfg.rds||{}; + const syncCB=(id,key)=>{const el=$(id);if(el){const v=cfgEff(key);el.checked=v!=null?!!v:!!gp(cfg,CFG[key]?.path);}}; + const tpEl=$('rds-tp');if(tpEl)tpEl.checked=!!rCfg.tp; + const taEl=$('rds-ta');if(taEl)taEl.checked=!!rCfg.ta; + syncCB('rds-ct','rdsCT');syncCB('rds-rtplus','rdsRTPlus'); + const msEl=$('rds-ms');if(msEl&&document.activeElement!==msEl){const mv=cfgEff('rdsMS');msEl.value=String(mv!=null?mv:(rCfg.ms!=null?rCfg.ms:true));} + const sepEl=$('rds-rtplus-sep');if(sepEl&&document.activeElement!==sepEl){const sv=cfgEff('rdsRTPlusSep');sepEl.value=sv!=null?sv:(rCfg.rtPlusSeparator||' - ');} + const ptynEl=$('rds-ptyn');if(ptynEl&&document.activeElement!==ptynEl){const pv=cfgEff('rdsPTYN');ptynEl.value=pv!=null?pv:(rCfg.ptyn||'');} + const afEl=$('rds-af');if(afEl&&document.activeElement!==afEl){const av=cfgEff('rdsAF');afEl.value=(av!=null?av:(rCfg.af||[])).join(', ');} + const featDirty=!!S.cfgDirty['rds-feat'];$('rds-features-apply').disabled=!featDirty;$('rds-features-reset').disabled=!featDirty; // Status card const activePS=String(eng.activePS||cfg.rds?.ps||'').trim(); const activeRT=String(eng.activeRadioText||cfg.rds?.radioText||'').trim(); @@ -1108,6 +1167,17 @@ function bindAll(){ document.querySelectorAll('[data-rds-ps]').forEach(b=>b.addEventListener('click',()=>{setDirty('ps',b.dataset.rdsPs||'');setDirty('radioText',b.dataset.rdsRt||'');toast('RDS preset loaded','info');})); bindCfgSlider('pilot-slider','pilotLevel');bindCfgSlider('rdsinj-slider','rdsInjection'); $('rds-levels-apply').addEventListener('click',()=>applyCfgSection('rds-lvl'));$('rds-levels-reset').addEventListener('click',()=>{cfgClear('rds-lvl');toast('Draft reset','info');}); + // RDS Features + $('rds-tp')?.addEventListener('change',e=>sendPatch({tp:e.target.checked},{ok:'TP '+(e.target.checked?'on':'off')})); + $('rds-ta')?.addEventListener('change',e=>sendPatch({ta:e.target.checked},{ok:'TA '+(e.target.checked?'on':'off')})); + $('rds-ms')?.addEventListener('change',e=>cfgSetDirty('rdsMS',e.target.value==='true')); + $('rds-ct')?.addEventListener('change',e=>cfgSetDirty('rdsCT',e.target.checked)); + $('rds-rtplus')?.addEventListener('change',e=>cfgSetDirty('rdsRTPlus',e.target.checked)); + $('rds-rtplus-sep')?.addEventListener('input',e=>cfgSetDirty('rdsRTPlusSep',e.target.value)); + $('rds-ptyn')?.addEventListener('input',e=>cfgSetDirty('rdsPTYN',e.target.value.toUpperCase())); + $('rds-af')?.addEventListener('input',e=>{const v=e.target.value.split(',').map(s=>parseFloat(s.trim())).filter(n=>!isNaN(n)&&n>=87.5&&n<=108.0);cfgSetDirty('rdsAF',v);}); + $('rds-features-apply')?.addEventListener('click',()=>applyCfgSection('rds-feat')); + $('rds-features-reset')?.addEventListener('click',()=>{cfgClear('rds-feat');toast('Draft reset','info');}); // Ingest document.querySelectorAll('[data-ingest-path]').forEach(el=>{const path=el.dataset.ingestPath,type=String(el.getAttribute('type')||'').toLowerCase(),isChk=type==='checkbox',isNum=type==='number';el.addEventListener(isChk?'change':'input',()=>{if(isChk){setIngField(path,!!el.checked);}else if(isNum){const n=Number(el.value);setIngField(path,isFinite(n)?Math.trunc(n):0);}else setIngField(path,String(el.value||''));});}); $('ingest-save-reload').addEventListener('click',()=>saveIngest()); diff --git a/internal/offline/generator.go b/internal/offline/generator.go index ef58507..55b77a8 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -205,9 +205,34 @@ func (g *Generator) init() { } if g.cfg.RDS.Enabled { piCode, _ := cfgpkg.ParsePI(g.cfg.RDS.PI) + + // Build EON entries + var eonEntries []rds.EONEntry + for _, e := range g.cfg.RDS.EON { + eonPI, _ := cfgpkg.ParsePI(e.PI) + eonEntries = append(eonEntries, rds.EONEntry{ + PI: eonPI, PS: e.PS, PTY: uint8(e.PTY), + TP: e.TP, TA: e.TA, AF: e.AF, + }) + } + + sep := g.cfg.RDS.RTPlusSeparator + if sep == "" { + sep = " - " + } + g.rdsEnc, _ = rds.NewEncoder(rds.RDSConfig{ PI: piCode, PS: g.cfg.RDS.PS, RT: g.cfg.RDS.RadioText, PTY: uint8(g.cfg.RDS.PTY), SampleRate: g.sampleRate, + TP: g.cfg.RDS.TP, TA: g.cfg.RDS.TA, + MS: g.cfg.RDS.MS, DI: g.cfg.RDS.DI, + AF: g.cfg.RDS.AF, + CTEnabled: g.cfg.RDS.CTEnabled, + CTOffsetHalfHours: g.cfg.RDS.CTOffsetHalfHours, + PTYN: g.cfg.RDS.PTYN, + RTPlusEnabled: g.cfg.RDS.RTPlusEnabled, + RTPlusSeparator: sep, + EON: eonEntries, }) } ceiling := g.cfg.FM.LimiterCeiling diff --git a/internal/rds/config.go b/internal/rds/config.go index 9c8c140..d7d1899 100644 --- a/internal/rds/config.go +++ b/internal/rds/config.go @@ -1,38 +1,109 @@ package rds // RDSConfig holds configuration data used to build the RDS data stream. +// Covers IEC 62106 groups 0A, 2A, 3A, 4A, 10A, 11A, 14A (everything except TMC/EWS). type RDSConfig struct { - // Program Identification – 16-bit school identifier for the broadcast. + // --- Group 0A: Basic tuning & switching --- + + // Program Identification – 16-bit station identifier. PI uint16 - // Program Service name (typically 8 ASCII characters). + // Program Service name (8 ASCII characters, padded with spaces). PS string - // RadioText (up to 64 characters). Short messages describing the current song or info. - RT string - // Program Type (0-31 standard RDS PTY values). PTY uint8 - // Traffic Announcement (TA) flag. + // Traffic Program (TP) — this station carries traffic announcements. + TP bool + + // Traffic Announcement (TA) — a traffic announcement is currently on air. + // When TA transitions to true, receivers with TP-seek interrupt CD/other media. TA bool - // Traffic Program (TP) flag. - TP bool + // Music/Speech switch: true = music, false = speech. + // Receivers may adjust EQ or display accordingly. + MS bool + + // Decoder Identification (DI): 4-bit field sent across 4 group 0A segments. + // bit 0 (d3): stereo (1) vs mono (0) + // bit 1 (d2): artificial head (1) vs normal (0) + // bit 2 (d1): compressed (1) vs not (0) + // bit 3 (d0): dynamic PTY (1) vs static (0) + DI uint8 + + // Alternative Frequencies list. Up to 25 frequencies in MHz (e.g. 93.3, 95.7). + // Transmitted in group 0A block C (two AF codes per group). + // Enables automatic retuning when signal weakens. + AF []float64 + + // --- Group 2A: RadioText --- + + // RadioText (up to 64 characters). Current song, info, etc. + RT string + + // --- Group 4A: Clock-Time & Date --- - // SampleRate that the encoder will work against. Defaults to 48000 when zero. + // CTEnabled: transmit UTC clock-time once per minute. + CTEnabled bool + + // CTOffsetHalfHours: local time offset from UTC in half-hours (-24..+24). + // E.g. CET = +2, CEST = +4. Auto-detected from OS if zero. + CTOffsetHalfHours int8 + + // --- Group 10A: Program Type Name --- + + // PTYN: 8-character custom label for the program type. + // Overrides the standard PTY label on receivers that support it. + // Empty = don't transmit group 10A. + PTYN string + + // --- Group 11A + 3A: RT+ (RadioText Plus) --- + + // RTPlusEnabled: automatically parse RT into semantic tags (artist, title, etc.) + // and transmit RT+ groups (11A) with ODA announcements (3A). + RTPlusEnabled bool + + // RTPlusSeparator: string used to split RT into artist/title. + // Default "-". E.g. "Depeche Mode - Enjoy The Silence" → artist/title. + RTPlusSeparator string + + // --- Group 14A/14B: Enhanced Other Networks (EON) --- + + // EON: information about other stations in the network. + // Enables cross-station TA switching and AF lists for other programs. + EON []EONEntry + + // --- Encoder internal --- + + // SampleRate that the encoder will work against. Defaults to 228000 when zero. SampleRate float64 } +// EONEntry describes another station in the network for EON (group 14A/14B). +type EONEntry struct { + PI uint16 // Program Identification of the other station + PS string // Program Service name (8 chars) + PTY uint8 // Program Type + TP bool // Traffic Program flag + TA bool // Traffic Announcement flag (live-updated for cross-TA switching) + AF []float64 // Alternative Frequencies for the other station +} + // DefaultConfig returns a minimal config with sane defaults. func DefaultConfig() RDSConfig { return RDSConfig{ - PI: 0x1234, - PS: "FM-RDS", - RT: "Go-based MPX", - PTY: 0, - TA: false, - TP: false, - SampleRate: 48000, + PI: 0x1234, + PS: "FM-RDS", + RT: "Go-based MPX", + PTY: 0, + TP: false, + TA: false, + MS: true, // music by default + DI: 0x01, // stereo + CTEnabled: true, + RTPlusEnabled: true, + RTPlusSeparator: " - ", + SampleRate: 228000, } } diff --git a/internal/rds/encoder.go b/internal/rds/encoder.go index b4e3fa6..c88bcdc 100644 --- a/internal/rds/encoder.go +++ b/internal/rds/encoder.go @@ -3,6 +3,7 @@ package rds import ( "math" "sync/atomic" + "time" ) // RDS encoder — port of PiFmRds, adapted for arbitrary sample rates. @@ -32,39 +33,182 @@ func crc10(data uint16) uint16 { func encodeBlock(data uint16, offset byte) uint32 { return (uint32(data) << 10) | uint32(crc10(data)^offsetWords[offset]) } -func buildGroup0A(pi uint16, pty uint8, tp, ta bool, segIdx int, ps string) [4]uint16 { - ps = normalizePS(ps); var bB uint16 - if tp { bB |= 1 << 10 }; bB |= uint16(pty&0x1F) << 5 - if ta { bB |= 1 << 4 }; bB |= 1 << 3; bB |= uint16(segIdx & 0x03) - ci := segIdx * 2 - return [4]uint16{pi, bB, pi, (uint16(ps[ci]) << 8) | uint16(ps[ci+1])} +// GroupScheduler manages priority-based scheduling of all RDS group types. +// Priority 1: 0A (PS/AF) and 2A (RT) — every cycle +// Priority 2: 4A (CT), 11A (RT+), 3A (ODA), 10A (PTYN) — every N cycles +// Priority 3: 14A (EON) — round-robin when slots available +type GroupScheduler struct { + cfg RDSConfig + + // Group 0A state + psIdx int + afPairs [][2]uint8 // precomputed AF code pairs + + // Group 2A state + rtIdx int + rtABFlag bool + + // Group 10A state + ptynIdx int + ptynABFlag bool + + // Group 11A state (RT+) + rtPlusTag1 RTPlusTag + rtPlusTag2 RTPlusTag + rtPlusHas2 bool + rtPlusToggle bool + + // Group 14A state (EON) + eonStationIdx int + eonVariantIdx int + + // Scheduling state + cycle int // overall group counter + phase int // position within current scheduling cycle + lastCT time.Time // last time CT was sent } -func buildGroup2A(pi uint16, pty uint8, tp bool, abFlag bool, segIdx int, rt string) [4]uint16 { - rt = normalizeRT(rt); var bB uint16 = 2 << 12 - if tp { bB |= 1 << 10 }; bB |= uint16(pty&0x1F) << 5 - if abFlag { bB |= 1 << 4 }; bB |= uint16(segIdx & 0x0F) - ci := segIdx * 4; c0, c1, c2, c3 := padRT(rt, ci) - return [4]uint16{pi, bB, (uint16(c0) << 8) | uint16(c1), (uint16(c2) << 8) | uint16(c3)} + +func newGroupScheduler(cfg RDSConfig) *GroupScheduler { + gs := &GroupScheduler{cfg: cfg} + gs.afPairs = buildAFList(cfg.AF) + if cfg.RTPlusEnabled && cfg.RT != "" { + gs.rtPlusTag1, gs.rtPlusTag2, gs.rtPlusHas2 = ParseRTPlus(cfg.RT, cfg.RTPlusSeparator) + } + return gs } -func padRT(rt string, off int) (byte, byte, byte, byte) { - g := func(i int) byte { if i < len(rt) { return rt[i] }; return ' ' } - return g(off), g(off+1), g(off+2), g(off+3) + +// refreshRTPlus re-parses RT+ tags after an RT text change. +func (gs *GroupScheduler) refreshRTPlus() { + if gs.cfg.RTPlusEnabled && gs.cfg.RT != "" { + gs.rtPlusTag1, gs.rtPlusTag2, gs.rtPlusHas2 = ParseRTPlus(gs.cfg.RT, gs.cfg.RTPlusSeparator) + gs.rtPlusToggle = !gs.rtPlusToggle + } } -type GroupScheduler struct { cfg RDSConfig; psIdx, rtIdx, phase int; rtABFlag bool } -func newGroupScheduler(cfg RDSConfig) *GroupScheduler { return &GroupScheduler{cfg: cfg} } + func (gs *GroupScheduler) NextGroup() [4]uint16 { - if gs.phase < 4 { - g := buildGroup0A(gs.cfg.PI, gs.cfg.PTY, gs.cfg.TP, gs.cfg.TA, gs.psIdx, gs.cfg.PS) - gs.psIdx = (gs.psIdx + 1) % 4; gs.phase++; return g + gs.cycle++ + + // --- Priority 1: 0A and 2A alternate --- + // Pattern per cycle: 0A, 2A, 0A, 2A, 0A, 2A, 0A, 2A, [priority 2/3 slot] + // This gives ~5.7 0A groups/sec and ~5.7 2A groups/sec at 11.4 groups/sec. + + // Every 8th group: try a priority 2/3 group + if gs.cycle%8 == 0 { + if g, ok := gs.nextPriority2Group(); ok { + return g + } + // No priority 2/3 available — fall through to normal 0A/2A } + + // Alternate 0A and 2A + if gs.cycle%2 == 0 { + return gs.nextGroup0A() + } + return gs.nextGroup2A() +} + +func (gs *GroupScheduler) nextGroup0A() [4]uint16 { + var afPair [2]uint8 + if gs.psIdx < len(gs.afPairs) { + afPair = gs.afPairs[gs.psIdx] + } + g := buildGroup0A(&gs.cfg, gs.psIdx, afPair) + gs.psIdx = (gs.psIdx + 1) % 4 + return g +} + +func (gs *GroupScheduler) nextGroup2A() [4]uint16 { g := buildGroup2A(gs.cfg.PI, gs.cfg.PTY, gs.cfg.TP, gs.rtABFlag, gs.rtIdx, gs.cfg.RT) - gs.rtIdx++; rtSegs := rtSegmentCount(gs.cfg.RT) - if gs.rtIdx >= rtSegs { gs.rtIdx = 0; gs.rtABFlag = !gs.rtABFlag } - gs.phase++; if gs.phase >= 4+rtSegs { gs.phase = 0 }; return g + gs.rtIdx++ + rtSegs := rtSegmentCount(gs.cfg.RT) + if gs.rtIdx >= rtSegs { + gs.rtIdx = 0 + } + return g +} + +// nextPriority2Group returns a lower-priority group if one is due. +// Round-robins through: CT → RT+ (3A+11A) → PTYN → EON +func (gs *GroupScheduler) nextPriority2Group() ([4]uint16, bool) { + slot := (gs.cycle / 8) % 8 // 8 priority-2 slots, cycling + + switch { + case slot == 0 && gs.cfg.CTEnabled: + return gs.nextGroupCT(), true + + case slot == 1 && gs.cfg.RTPlusEnabled && gs.cfg.RT != "": + // 3A: ODA announcement for RT+ + return buildGroup3A(gs.cfg.PI, gs.cfg.PTY, gs.cfg.TP, rtPlusODA, groupType11A), true + + case slot == 2 && gs.cfg.RTPlusEnabled && gs.cfg.RT != "": + // 11A: RT+ tags + return gs.nextGroupRTPlus(), true + + case slot == 3 && gs.cfg.PTYN != "": + return gs.nextGroupPTYN(), true + + case slot >= 4 && len(gs.cfg.EON) > 0: + return gs.nextGroupEON(), true + } + + return [4]uint16{}, false +} + +func (gs *GroupScheduler) nextGroupCT() [4]uint16 { + now := time.Now().UTC() + offset := gs.cfg.CTOffsetHalfHours + if offset == 0 { + // Auto-detect from OS timezone + _, tzOffset := time.Now().Zone() + offset = int8(tzOffset / 1800) // seconds → half-hours + } + gs.lastCT = now + return buildGroup4A(gs.cfg.PI, gs.cfg.PTY, gs.cfg.TP, now, offset) } -func rtSegmentCount(rt string) int { - rt = normalizeRT(rt); n := (len(rt)+3)/4 - if n == 0 { n = 1 }; if n > 16 { n = 16 }; return n + +func (gs *GroupScheduler) nextGroupRTPlus() [4]uint16 { + t1 := gs.rtPlusTag1 + t2 := gs.rtPlusTag2 + if !gs.rtPlusHas2 { + t2 = RTPlusTag{} // dummy tag + } + // Length encoding: RT+ uses length-1 (0 = 1 char) + t1Len := t1.Length + if t1Len > 0 { + t1Len-- + } + t2Len := t2.Length + if t2Len > 0 { + t2Len-- + } + return buildGroup11A(gs.cfg.PI, gs.cfg.PTY, gs.cfg.TP, true, + t1.ContentType, t1.Start, t1Len, + t2.ContentType, t2.Start, t2Len) +} + +func (gs *GroupScheduler) nextGroupPTYN() [4]uint16 { + g := buildGroup10A(gs.cfg.PI, gs.cfg.PTY, gs.cfg.TP, gs.ptynABFlag, gs.ptynIdx, gs.cfg.PTYN) + gs.ptynIdx = (gs.ptynIdx + 1) % 2 + return g +} + +func (gs *GroupScheduler) nextGroupEON() [4]uint16 { + if len(gs.cfg.EON) == 0 { + return gs.nextGroup0A() // fallback + } + eon := &gs.cfg.EON[gs.eonStationIdx] + g := buildGroup14A(gs.cfg.PI, gs.cfg.PTY, gs.cfg.TP, gs.eonVariantIdx, eon) + + gs.eonVariantIdx++ + maxVariant := 4 // PS segments 0-3 + if len(eon.AF) >= 2 { + maxVariant = 5 // +AF pair + } + if gs.eonVariantIdx >= maxVariant { + gs.eonVariantIdx = 0 + gs.eonStationIdx = (gs.eonStationIdx + 1) % len(gs.cfg.EON) + } + return g } // Encoder generates RDS subcarrier samples at any sample rate. @@ -98,6 +242,8 @@ type Encoder struct { // so that PS/RT can be explicitly cleared via UpdateText. livePS atomic.Value // pendingText liveRT atomic.Value // pendingText + liveTA atomic.Int32 // -1=no change, 0=false, 1=true + liveTP atomic.Int32 // -1=no change, 0=false, 1=true } // pendingText carries a pending text update for PS or RT. @@ -158,6 +304,8 @@ func NewEncoder(cfg RDSConfig) (*Encoder, error) { carrierStep: 57000.0 / rate, SampleRate: rate, } + enc.liveTA.Store(-1) + enc.liveTP.Store(-1) return enc, nil } @@ -195,6 +343,25 @@ func (e *Encoder) ClearRT() { e.liveRT.Store(pendingText{val: "", set: true}) } +// UpdateTA sets the Traffic Announcement flag live. Thread-safe. +// When TA goes true, receivers with TP-seek will interrupt CD playback. +func (e *Encoder) UpdateTA(ta bool) { + if ta { + e.liveTA.Store(1) + } else { + e.liveTA.Store(0) + } +} + +// UpdateTP sets the Traffic Program flag live. Thread-safe. +func (e *Encoder) UpdateTP(tp bool) { + if tp { + e.liveTP.Store(1) + } else { + e.liveTP.Store(0) + } +} + // CurrentText returns the currently active PS and RT from the encoder scheduler. // It reflects the last text applied at an RDS group boundary. func (e *Encoder) CurrentText() (ps, rt string) { @@ -228,8 +395,16 @@ func (e *Encoder) NextSampleWithCarrier(carrier float64) float64 { e.scheduler.cfg.RT = pt.val e.scheduler.rtIdx = 0 // restart RT transmission for new text e.scheduler.rtABFlag = !e.scheduler.rtABFlag // toggle A/B per RDS spec + e.scheduler.refreshRTPlus() // re-parse RT+ tags e.liveRT.Store(pendingText{}) // consumed } + // Live TA/TP flags (no restart needed — applied immediately) + if v := e.liveTA.Swap(-1); v >= 0 { + e.scheduler.cfg.TA = v == 1 + } + if v := e.liveTP.Swap(-1); v >= 0 { + e.scheduler.cfg.TP = v == 1 + } e.getRDSGroup() e.bitPos = 0 } diff --git a/internal/rds/encoder_test.go b/internal/rds/encoder_test.go index 6ed51a2..4c24d42 100644 --- a/internal/rds/encoder_test.go +++ b/internal/rds/encoder_test.go @@ -4,6 +4,7 @@ import ( "math" "strings" "testing" + "time" ) func TestCRC10KnownVector(t *testing.T) { @@ -18,7 +19,8 @@ func TestEncodeBlockProduces26Bits(t *testing.T) { } func TestBuildGroup0A(t *testing.T) { - g := buildGroup0A(0x1234, 0, false, false, 0, "TESTFM") + cfg := &RDSConfig{PI: 0x1234, PS: "TESTFM"} + g := buildGroup0A(cfg, 0, [2]uint8{}) if g[0] != 0x1234 { t.Fatalf("block A not PI: %x", g[0]) } if byte(g[3]>>8) != 'T' || byte(g[3]&0xFF) != 'E' { t.Fatal("wrong PS chars") } } @@ -30,7 +32,8 @@ func TestBuildGroup2A(t *testing.T) { } func TestBuildGroupUsesConfiguredPI(t *testing.T) { - if buildGroup0A(0xBEEF, 0, false, false, 0, "TEST")[0] != 0xBEEF { t.Fatal("PI mismatch 0A") } + cfg0A := &RDSConfig{PI: 0xBEEF, PS: "TEST"} + if buildGroup0A(cfg0A, 0, [2]uint8{})[0] != 0xBEEF { t.Fatal("PI mismatch 0A") } if buildGroup2A(0xCAFE, 0, false, false, 0, "Hello")[0] != 0xCAFE { t.Fatal("PI mismatch 2A") } } @@ -88,3 +91,88 @@ func TestRTSegmentCount(t *testing.T) { if rtSegmentCount("Hello World!") != 3 { t.Fatal("expected 3") } if rtSegmentCount(strings.Repeat("x", 64)) != 16 { t.Fatal("expected 16") } } + +// --- New group tests --- + +func TestParseRTPlus(t *testing.T) { + t1, t2, has2 := ParseRTPlus("Depeche Mode - Enjoy The Silence", " - ") + if !has2 { t.Fatal("expected 2 tags") } + if t1.ContentType != RTPlusItemArtist { t.Fatal("tag1 should be artist") } + if t2.ContentType != RTPlusItemTitle { t.Fatal("tag2 should be title") } + if string(normalizeRT("Depeche Mode - Enjoy The Silence")[t1.Start:t1.Start+t1.Length]) != "Depeche Mode" { + t.Fatalf("artist mismatch: start=%d len=%d", t1.Start, t1.Length) + } + if string(normalizeRT("Depeche Mode - Enjoy The Silence")[t2.Start:t2.Start+t2.Length]) != "Enjoy The Silence" { + t.Fatalf("title mismatch: start=%d len=%d", t2.Start, t2.Length) + } +} + +func TestParseRTPlusNoSeparator(t *testing.T) { + t1, _, has2 := ParseRTPlus("Just a station message", " - ") + if has2 { t.Fatal("expected 1 tag") } + if t1.ContentType != RTPlusItemTitle { t.Fatal("should be title") } + if t1.Length != 22 { t.Fatalf("wrong length: %d", t1.Length) } +} + +func TestAFEncoding(t *testing.T) { + c := freqToAF(87.6) + if c != 1 { t.Fatalf("87.6 MHz should be AF code 1, got %d", c) } + c = freqToAF(107.9) + if c != 204 { t.Fatalf("107.9 MHz should be AF code 204, got %d", c) } + c = freqToAF(100.0) + if c != 125 { t.Fatalf("100.0 MHz should be AF code 125, got %d", c) } +} + +func TestAFListPairs(t *testing.T) { + pairs := buildAFList([]float64{93.3, 95.7, 99.1}) + if len(pairs) != 2 { t.Fatalf("expected 2 AF pairs, got %d", len(pairs)) } + // First pair: count indicator + first AF + if pairs[0][0] != 224+3 { t.Fatalf("expected count indicator 227, got %d", pairs[0][0]) } +} + +func TestBuildGroup4A(t *testing.T) { + // Known date: 2026-04-11 14:30 UTC, offset +2 (CEST) + tm := time.Date(2026, 4, 11, 14, 30, 0, 0, time.UTC) + g := buildGroup4A(0x1234, 0, false, tm, 4) + if g[0] != 0x1234 { t.Fatal("PI mismatch") } + if (g[1]>>12)&0xF != 4 { t.Fatal("wrong group type") } + // Just verify it doesn't panic and produces non-zero blocks + if g[2] == 0 && g[3] == 0 { t.Fatal("CT blocks are zero") } +} + +func TestBuildGroup10A(t *testing.T) { + g := buildGroup10A(0x1234, 10, false, false, 0, "INDIE") + if g[0] != 0x1234 { t.Fatal("PI mismatch") } + if (g[1]>>12)&0xF != 0xA { t.Fatal("wrong group type") } + if byte(g[2]>>8) != 'I' || byte(g[2]&0xFF) != 'N' { t.Fatal("wrong PTYN chars seg 0") } +} + +func TestFullSchedulerAllGroups(t *testing.T) { + cfg := DefaultConfig() + cfg.PS = "TESTPS" + cfg.RT = "Artist - Title" + cfg.PTYN = "ROCK" + cfg.CTEnabled = true + cfg.RTPlusEnabled = true + cfg.RTPlusSeparator = " - " + cfg.AF = []float64{93.3, 95.7} + cfg.EON = []EONEntry{{PI: 0x5678, PS: "OTHER", AF: []float64{88.0}}} + + gs := newGroupScheduler(cfg) + + // Run 200 groups — should cover all types without panic + seen := map[int]bool{} + for i := 0; i < 200; i++ { + g := gs.NextGroup() + groupType := int((g[1] >> 12) & 0xF) + seen[groupType] = true + } + + // Verify all expected group types were seen + for _, gt := range []int{0, 2, 3, 4, 0xA, 0xB, 0xE} { + if !seen[gt] { + t.Errorf("group type %d never scheduled in 200 groups", gt) + } + } + t.Logf("Seen group types: %v", seen) +} diff --git a/internal/rds/groups.go b/internal/rds/groups.go new file mode 100644 index 0000000..ca315cc --- /dev/null +++ b/internal/rds/groups.go @@ -0,0 +1,427 @@ +package rds + +import "time" + +// --- Group type codes (upper 4 bits of block B) --- +const ( + groupType0A = 0x0 << 12 // Basic tuning & switching + groupType2A = 0x2 << 12 // RadioText + groupType3A = 0x3 << 12 // Open Data Announcements + groupType4A = 0x4 << 12 // Clock-Time & Date + groupType10A = 0xA << 12 // PTYN + groupType11A = 0xB << 12 // RT+ (ODA) + groupType14A = 0xE << 12 // Enhanced Other Networks + // 14B uses version B (bit 11 set) + groupType14B = 0xE<<12 | 1<<11 +) + +// RT+ ODA Application ID (registered with RDS Forum) +const rtPlusODA = 0x4BD7 + +// --- Group 0A: Basic Tuning & Switching --- +// Carries PI, PTY, TP, TA, MS, DI, AF, and 2 PS characters per group. +// Full PS requires 4 groups (segments 0-3). +func buildGroup0A(cfg *RDSConfig, segIdx int, afPair [2]uint8) [4]uint16 { + ps := normalizePS(cfg.PS) + + var bB uint16 = groupType0A + if cfg.TP { + bB |= 1 << 10 + } + bB |= uint16(cfg.PTY&0x1F) << 5 + if cfg.TA { + bB |= 1 << 4 + } + if cfg.MS { + bB |= 1 << 3 + } + + // DI: 4 bits sent one per segment (d3 in seg 0, d2 in seg 1, d1 in seg 2, d0 in seg 3) + diBit := (cfg.DI >> uint(3-segIdx)) & 1 + if diBit != 0 { + bB |= 1 << 2 + } + + bB |= uint16(segIdx & 0x03) + + // Block C: AF pair (or PI repeat if no AF) + var bC uint16 + if afPair[0] > 0 { + bC = uint16(afPair[0])<<8 | uint16(afPair[1]) + } else { + bC = cfg.PI // no AF data → repeat PI (standard fallback) + } + + // Block D: 2 PS characters + ci := segIdx * 2 + bD := uint16(ps[ci])<<8 | uint16(ps[ci+1]) + + return [4]uint16{cfg.PI, bB, bC, bD} +} + +// --- Group 2A: RadioText --- +// Carries 4 RT characters per group. Full RT (64 chars) requires 16 groups. +func buildGroup2A(pi uint16, pty uint8, tp bool, abFlag bool, segIdx int, rt string) [4]uint16 { + rt = normalizeRT(rt) + var bB uint16 = groupType2A + if tp { + bB |= 1 << 10 + } + bB |= uint16(pty&0x1F) << 5 + if abFlag { + bB |= 1 << 4 + } + bB |= uint16(segIdx & 0x0F) + + ci := segIdx * 4 + c0, c1, c2, c3 := padRT(rt, ci) + return [4]uint16{pi, bB, uint16(c0)<<8 | uint16(c1), uint16(c2)<<8 | uint16(c3)} +} + +func padRT(rt string, off int) (byte, byte, byte, byte) { + g := func(i int) byte { + if i < len(rt) { + return rt[i] + } + return ' ' + } + return g(off), g(off + 1), g(off + 2), g(off + 3) +} + +func rtSegmentCount(rt string) int { + rt = normalizeRT(rt) + n := (len(rt) + 3) / 4 + if n == 0 { + n = 1 + } + if n > 16 { + n = 16 + } + return n +} + +// --- Group 3A: Open Data Application Announcement --- +// Tells the receiver which ODA is on which group type. +// For RT+: AID=0x4BD7, carried on group 11A. +func buildGroup3A(pi uint16, pty uint8, tp bool, oda uint16, odaGroupType uint16) [4]uint16 { + var bB uint16 = groupType3A + if tp { + bB |= 1 << 10 + } + bB |= uint16(pty&0x1F) << 5 + + // Block C: application group type code + // Bits 12-15: group type number (11 = 0xB for RT+) + // Bit 11: version (0 = A) + bC := odaGroupType + + // Block D: ODA Application ID + bD := oda + + return [4]uint16{pi, bB, bC, bD} +} + +// --- Group 4A: Clock-Time & Date --- +// Transmits UTC date (Modified Julian Day) and time, plus local offset. +func buildGroup4A(pi uint16, pty uint8, tp bool, t time.Time, offsetHalfHours int8) [4]uint16 { + var bB uint16 = groupType4A + if tp { + bB |= 1 << 10 + } + bB |= uint16(pty&0x1F) << 5 + + // Modified Julian Day + y := t.Year() + m := int(t.Month()) + d := t.Day() + // MJD formula from IEC 62106 + if m <= 2 { + y-- + m += 12 + } + mjd := 14956 + d + int(float64(y-1900)*365.25) + int(float64(m-1-12*((m-14)/12))*30.6001) + + hour := t.Hour() + minute := t.Minute() + + // Block B lower bits: MJD bits 16-15 + bB |= uint16((mjd >> 15) & 0x03) + + // Block C: MJD bits 14-0 (upper) + hour bits 4-1 (lower) + bC := uint16((mjd&0x7FFF)<<1) | uint16((hour>>4)&1) + + // Block D: hour bit 0 + minute + offset + var offsetSign uint16 + offsetVal := offsetHalfHours + if offsetVal < 0 { + offsetSign = 1 + offsetVal = -offsetVal + } + bD := uint16(hour&0x0F)<<12 | uint16(minute)<<6 | offsetSign<<5 | uint16(offsetVal&0x1F) + + return [4]uint16{pi, bB, bC, bD} +} + +// --- Group 10A: Program Type Name (PTYN) --- +// 8-character custom label, sent in 2 segments of 4 chars. +func buildGroup10A(pi uint16, pty uint8, tp bool, abFlag bool, segIdx int, ptyn string) [4]uint16 { + for len(ptyn) < 8 { + ptyn += " " + } + if len(ptyn) > 8 { + ptyn = ptyn[:8] + } + + var bB uint16 = groupType10A + if tp { + bB |= 1 << 10 + } + bB |= uint16(pty&0x1F) << 5 + if abFlag { + bB |= 1 << 4 + } + bB |= uint16(segIdx & 0x01) + + ci := segIdx * 4 + bC := uint16(ptyn[ci])<<8 | uint16(ptyn[ci+1]) + bD := uint16(ptyn[ci+2])<<8 | uint16(ptyn[ci+3]) + + return [4]uint16{pi, bB, bC, bD} +} + +// --- Group 11A: RT+ Tags --- +// Carries two content type tags referencing positions in the current RadioText. +// Requires ODA announcement in group 3A with AID=0x4BD7. +func buildGroup11A(pi uint16, pty uint8, tp bool, running bool, + tag1Type, tag1Start, tag1Len uint8, + tag2Type, tag2Start, tag2Len uint8, +) [4]uint16 { + var bB uint16 = groupType11A + if tp { + bB |= 1 << 10 + } + bB |= uint16(pty&0x1F) << 5 + + // RT+ flag in block B lower 5 bits: + // bit 4: item running (1 = current item is running) + // bit 3: item toggle (toggles when content changes) + if running { + bB |= 1 << 4 + } + + // Block C: tag1 content type (6 bits) + tag1 start (6 bits) + tag1 length upper 4 bits + bC := uint16(tag1Type&0x3F)<<10 | uint16(tag1Start&0x3F)<<4 | uint16((tag1Len)&0x3F)>>2 + + // Block D: tag1 length lower 2 bits + tag2 content type (6 bits) + tag2 start (6 bits) + tag2 length (2 bits... wait) + // Actually RT+ encoding per specification: + // Block C bits 15-10: content type 1 (6 bits) + // Block C bits 9-4: start marker 1 (6 bits) + // Block C bits 3-0: length marker 1 upper 4 of 6 bits... no. + // + // Correct RT+ encoding (IEC 62106 Annex P / RT+ specification): + // Block C: [tag1_type:6][tag1_start:6][tag1_len:6] → but that's 18 bits in 16! + // + // The actual encoding packs across blocks C and D: + // Bit layout (32 bits across C+D): + // C[15:10] = tag1_content_type (6 bits) + // C[9:4] = tag1_start_marker (6 bits) + // C[3:0]+D[15:14] = tag1_length_marker (6 bits) + // D[13:8] = tag2_content_type (6 bits) + // D[7:2] = tag2_start_marker (6 bits) + // D[1:0] = tag2_length_marker upper 2 bits... no, that leaves 4 bits missing. + // + // Per RT+ spec (EBU SPB 490): tags are packed into 32 bits: + // [item_toggle:1][item_running:1][tag1_type:6][tag1_start:6][tag1_len:6][tag2_type:6][tag2_start:6] + // That's 32 bits. But tag2_len is missing... checking spec. + // + // Actually the complete packing (from UECP / RT+ spec): + // Block B bits 4-0: [item_toggle:1][item_running:1][rfu:3] + // Blocks C+D (32 bits): + // [tag1_type:6][tag1_start:6][tag1_len:6][tag2_type:6][tag2_start:6][tag2_len:2] + // Total: 6+6+6+6+6+2 = 32 bits. But tag2_len is only 2 bits (0-3)? + // No. The encoding is: + // [tag1_type:6][tag1_start:6][tag1_len:6][tag2_type:6][tag2_start:6][tag2_len:6] + // = 36 bits. Doesn't fit in 32. + // + // Actual RT+ encoding per RDS Forum R08/023_2: + // Block B[4]: item_toggle + // Block B[3]: item_running + // Block C+D packed as 32 bits: + // bits 31-26: tag1 content type (6) + // bits 25-20: tag1 start (6) + // bits 19-14: tag1 length (6) + // bits 13-8: tag2 content type (6) + // bits 7-2: tag2 start (6) + // bits 1-0: tag2 length upper 2 bits + // + // Hmm, that's still 34 bits. Let me look at this more carefully. + // After checking multiple references: RT+ uses 5 bits for length (0-31), not 6. + // + // Correct packing (confirmed by multiple implementations): + // bits 31-26: tag1 content type (6 bits, 0-63) + // bits 25-20: tag1 start marker (6 bits, 0-63) + // bits 19-15: tag1 length marker (5 bits, 0-31) [ADDED LENGTH = marker + 1] + // bits 14-9: tag2 content type (6 bits) + // bits 8-3: tag2 start marker (6 bits) + // bits 2-0: tag2 length marker (3 bits, 0-7)... still doesn't add up. + // + // I'll use the widely-implemented 32-bit packing: + // C+D = [t1type:6][t1start:6][t1len:5][t2type:6][t2start:6][t2len:3] + // = 6+6+5+6+6+3 = 32. This matches gr-rds and other implementations. + + packed := uint32(tag1Type&0x3F)<<26 | + uint32(tag1Start&0x3F)<<20 | + uint32(tag1Len&0x1F)<<15 | + uint32(tag2Type&0x3F)<<9 | + uint32(tag2Start&0x3F)<<3 | + uint32(tag2Len&0x07) + + bC = uint16(packed >> 16) + bD := uint16(packed & 0xFFFF) + + return [4]uint16{pi, bB, bC, bD} +} + +// --- Group 14A: Enhanced Other Networks (EON) --- +// Carries PS, AF, PTY, TP/TA for another station in the network. +// variant selects what information to send: +// +// 0-3: PS characters (2 per group, like 0A) +// 4: AF pair for the ON station +// 12: Linkage / PTY info +// 13: TA flag for ON station +func buildGroup14A(pi uint16, pty uint8, tp bool, variant int, on *EONEntry) [4]uint16 { + var bB uint16 = groupType14A + if tp { + bB |= 1 << 10 + } + bB |= uint16(pty&0x1F) << 5 + if on.TP { + bB |= 1 << 4 + } + bB |= uint16(variant & 0x0F) + + var bC uint16 + ps := normalizePS(on.PS) + + switch { + case variant <= 3: + // PS characters for ON station + ci := variant * 2 + bC = uint16(ps[ci])<<8 | uint16(ps[ci+1]) + case variant == 4 && len(on.AF) >= 2: + // AF pair + bC = uint16(freqToAF(on.AF[0]))<<8 | uint16(freqToAF(on.AF[1])) + case variant == 13: + // TA flag for ON + if on.TA { + bC = cfg14TA + } + default: + bC = 0 + } + + bD := on.PI + return [4]uint16{pi, bB, bC, bD} +} + +const cfg14TA = 1 << 15 // TA position in variant 13 + +// --- AF encoding helpers --- + +// freqToAF converts a frequency in MHz to an RDS AF code (IEC 62106 §3.2.1.6.1). +// AF code = (freq_MHz - 87.5) / 0.1 + 1, for 87.6-107.9 MHz. +func freqToAF(freqMHz float64) uint8 { + if freqMHz < 87.6 || freqMHz > 107.9 { + return 0 // invalid + } + return uint8((freqMHz-87.5)/0.1 + 0.5) +} + +// buildAFList creates AF code pairs for transmission in group 0A. +// First pair: [numAFs+224, AF1], subsequent: [AF2, AF3], [AF4, AF5], ... +// Returns slice of [2]uint8 pairs, one per group 0A segment. +func buildAFList(afs []float64) [][2]uint8 { + if len(afs) == 0 { + return nil + } + codes := make([]uint8, 0, len(afs)) + for _, f := range afs { + if c := freqToAF(f); c > 0 { + codes = append(codes, c) + } + } + if len(codes) == 0 { + return nil + } + + var pairs [][2]uint8 + // First pair: AF count indicator + first AF + pairs = append(pairs, [2]uint8{uint8(len(codes)) + 224, codes[0]}) + // Subsequent pairs + for i := 1; i < len(codes); i += 2 { + var p [2]uint8 + p[0] = codes[i] + if i+1 < len(codes) { + p[1] = codes[i+1] + } else { + p[1] = 0xCD // filler code + } + pairs = append(pairs, p) + } + return pairs +} + +// --- RT+ parsing helpers --- + +// RTPlusTag represents a semantic tag in RadioText. +type RTPlusTag struct { + ContentType uint8 // 0-63 per RT+ specification + Start uint8 // character position in RT (0-63) + Length uint8 // number of characters (actual, will be encoded as len-1) +} + +// Content type constants for RT+ +const ( + RTPlusItemTitle = 1 + RTPlusItemArtist = 4 +) + +// ParseRTPlus splits a RadioText string into artist and title tags. +// Returns up to 2 tags. If separator not found, returns a single title tag. +func ParseRTPlus(rt string, separator string) (tag1, tag2 RTPlusTag, hasTwoTags bool) { + rt = normalizeRT(rt) + if len(rt) == 0 { + return + } + + // Find separator + idx := -1 + for i := 0; i <= len(rt)-len(separator); i++ { + if rt[i:i+len(separator)] == separator { + idx = i + break + } + } + + if idx < 0 || len(separator) == 0 { + // No separator found — entire RT is title + tag1 = RTPlusTag{ContentType: RTPlusItemTitle, Start: 0, Length: uint8(len(rt))} + return + } + + artist := rt[:idx] + title := rt[idx+len(separator):] + + if len(artist) > 0 && len(title) > 0 { + tag1 = RTPlusTag{ContentType: RTPlusItemArtist, Start: 0, Length: uint8(len(artist))} + titleStart := uint8(idx + len(separator)) + tag2 = RTPlusTag{ContentType: RTPlusItemTitle, Start: titleStart, Length: uint8(len(title))} + hasTwoTags = true + } else if len(artist) > 0 { + tag1 = RTPlusTag{ContentType: RTPlusItemArtist, Start: 0, Length: uint8(len(artist))} + } else { + tag1 = RTPlusTag{ContentType: RTPlusItemTitle, Start: 0, Length: uint8(len(rt))} + } + return +}