Extend the RDS feature set with LPS and eRT support, add initial RDS2/logo-transfer wiring, update the control UI and refresh the PlutoSDR example configuration and reference docs.main
| @@ -9,13 +9,23 @@ | |||
| "rds": { | |||
| "enabled": true, | |||
| "pi": "BEEF", | |||
| "ps": "PLUTO-TX", | |||
| "radioText": "TESTATSSENDUNG 1mW", | |||
| "pty": 0 | |||
| "ps": "MIKE-BE", | |||
| "radioText": "TESTAUSSENDUNG 1mW", | |||
| "pty": 0, | |||
| "tp": true, | |||
| "ta": false, | |||
| "ms": true, | |||
| "di": 1, | |||
| "ctEnabled": true, | |||
| "rtPlusEnabled": true, | |||
| "rtPlusSeparator": " - ", | |||
| "ertEnabled": false, | |||
| "rds2Enabled": false | |||
| }, | |||
| "fm": { | |||
| "frequencyMHz": 102.8, | |||
| "stereoEnabled": true, | |||
| "stereoMode": "DSB", | |||
| "pilotLevel": 0.09, | |||
| "rdsInjection": 0.04, | |||
| "preEmphasisTauUS": 50, | |||
| @@ -27,7 +37,13 @@ | |||
| "fmModulationEnabled": true, | |||
| "mpxGain": 1, | |||
| "bs412Enabled": true, | |||
| "bs412ThresholdDBr": 0 | |||
| "bs412ThresholdDBr": 0, | |||
| "compositeClipper": { | |||
| "enabled": true, | |||
| "iterations": 3, | |||
| "softKnee": 0.15, | |||
| "lookaheadMs": 1 | |||
| } | |||
| }, | |||
| "backend": { | |||
| "kind": "pluto", | |||
| @@ -61,7 +77,7 @@ | |||
| "format": "s16le" | |||
| }, | |||
| "icecast": { | |||
| "url": "http://192.168.1.40:8000/stream", | |||
| "url": "https://stream.streambase.ch/radiofm1/mp3-192/direct/", | |||
| "decoder": "native", | |||
| "radioText": { | |||
| "enabled": true, | |||
| @@ -52,10 +52,22 @@ type RDSConfig struct { | |||
| // Program Type Name (Group 10A) — 8-char custom label | |||
| PTYN string `json:"ptyn,omitempty"` | |||
| // Long Programme Service name (Group 15A) — up to 32 bytes UTF-8. | |||
| // Static station name, complements PS. Receivers may display instead of PS. | |||
| LPS string `json:"lps,omitempty"` | |||
| // RT+ (Groups 3A + 11A) — auto-parse artist/title from RadioText | |||
| RTPlusEnabled bool `json:"rtPlusEnabled"` | |||
| RTPlusSeparator string `json:"rtPlusSeparator,omitempty"` // default " - " | |||
| // eRT — Enhanced RadioText (ODA, UTF-8, 128 bytes) | |||
| ERTEnabled bool `json:"ertEnabled"` | |||
| ERT string `json:"ert,omitempty"` | |||
| // RDS2 — additional subcarriers | |||
| RDS2Enabled bool `json:"rds2Enabled"` | |||
| StationLogoPath string `json:"stationLogoPath,omitempty"` | |||
| // EON — Enhanced Other Networks (Group 14A) | |||
| EON []EONEntryConfig `json:"eon,omitempty"` | |||
| } | |||
| @@ -148,6 +148,11 @@ type ConfigPatch struct { | |||
| RTPlusEnabled *bool `json:"rtPlusEnabled,omitempty"` | |||
| RTPlusSeparator *string `json:"rtPlusSeparator,omitempty"` | |||
| PTYN *string `json:"ptyn,omitempty"` | |||
| LPS *string `json:"lps,omitempty"` | |||
| ERTEnabled *bool `json:"ertEnabled,omitempty"` | |||
| ERT *string `json:"ert,omitempty"` | |||
| RDS2Enabled *bool `json:"rds2Enabled,omitempty"` | |||
| StationLogoPath *string `json:"stationLogoPath,omitempty"` | |||
| AF *[]float64 `json:"af,omitempty"` | |||
| BS412Enabled *bool `json:"bs412Enabled,omitempty"` | |||
| BS412ThresholdDBr *float64 `json:"bs412ThresholdDBr,omitempty"` | |||
| @@ -601,6 +606,21 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { | |||
| if patch.PTYN != nil { | |||
| next.RDS.PTYN = *patch.PTYN | |||
| } | |||
| if patch.LPS != nil { | |||
| next.RDS.LPS = *patch.LPS | |||
| } | |||
| if patch.ERTEnabled != nil { | |||
| next.RDS.ERTEnabled = *patch.ERTEnabled | |||
| } | |||
| if patch.ERT != nil { | |||
| next.RDS.ERT = *patch.ERT | |||
| } | |||
| if patch.RDS2Enabled != nil { | |||
| next.RDS.RDS2Enabled = *patch.RDS2Enabled | |||
| } | |||
| if patch.StationLogoPath != nil { | |||
| next.RDS.StationLogoPath = *patch.StationLogoPath | |||
| } | |||
| if patch.AF != nil { | |||
| next.RDS.AF = *patch.AF | |||
| } | |||
| @@ -574,13 +574,41 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1 | |||
| <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">Long PS (LPS)</span><span class="ctrl-sub">UTF-8 station name, 32 bytes</span></div> | |||
| <div class="ctrl-input"><input type="text" id="rds-lps" maxlength="32" placeholder="My Radio Station" style="width:200px;font-family:var(--mono);font-size:12px"><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="ctrl-row"> | |||
| <div class="ctrl-label-wrap"><span class="ctrl-label">eRT (Enhanced RT)</span><span class="ctrl-sub">UTF-8, 128 bytes, ODA</span></div> | |||
| <div class="ctrl-input"><input type="checkbox" id="rds-ert-on"><span class="tag tag-restart">restart</span></div> | |||
| </div> | |||
| <div class="ctrl-row"> | |||
| <div class="ctrl-label-wrap"><span class="ctrl-label">eRT Text</span><span class="ctrl-sub">UTF-8 multilingual text</span></div> | |||
| <div class="ctrl-input"><input type="text" id="rds-ert" maxlength="128" placeholder="Ràdio en català..." style="width:280px;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> | |||
| <!-- RDS2 --> | |||
| <div class="card panel" data-panel-key="rds2"> | |||
| <div class="panel-head" data-panel><div class="led on-amber" style="width:6px;height:6px"></div><h2>RDS2 (Streams 1-3)</h2><div class="meta">Restart required</div><span class="chevron">▼</span></div> | |||
| <div class="panel-body"> | |||
| <div class="section-note">Three additional BPSK subcarriers at 66.5, 71.25, 76 kHz (IEC 62106-1:2018). Station logo via RFT file transfer. Requires RDS2-capable receivers.</div> | |||
| <div class="ctrl-row"> | |||
| <div class="ctrl-label-wrap"><span class="ctrl-label">RDS2 Enable</span><span class="ctrl-sub">Activate streams 1-3</span></div> | |||
| <div class="ctrl-input"><input type="checkbox" id="rds2-on"><span class="tag tag-restart">restart</span></div> | |||
| </div> | |||
| <div class="ctrl-row"> | |||
| <div class="ctrl-label-wrap"><span class="ctrl-label">Station Logo</span><span class="ctrl-sub">PNG/JPEG path on server</span></div> | |||
| <div class="ctrl-input"><input type="text" id="rds2-logo" placeholder="/path/to/logo.png" style="width:250px;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="rds2-apply" type="button">Save RDS2</button><button class="apply-btn secondary" id="rds2-reset" type="button">Reset Draft</button></div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="stack"> | |||
| <!-- Injection Levels --> | |||
| @@ -796,6 +824,11 @@ const CFG={ | |||
| 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'}, | |||
| rdsLPS: {sec:'rds-feat', live:false,path:'rds.lps'}, | |||
| rdsERT: {sec:'rds-feat', live:false,path:'rds.ert'}, | |||
| rdsERTEnabled: {sec:'rds-feat', live:false,path:'rds.ertEnabled'}, | |||
| rdsRDS2Enabled:{sec:'rds2', live:false,path:'rds.rds2Enabled'}, | |||
| rdsLogoPath: {sec:'rds2', live:false,path:'rds.stationLogoPath'}, | |||
| 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}, | |||
| @@ -808,7 +841,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',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'}; | |||
| 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',rdsLPS:'lps',rdsERT:'ert',rdsERTEnabled:'ertEnabled',rdsRDS2Enabled:'rds2Enabled',rdsLogoPath:'stationLogoPath',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;} | |||
| @@ -1057,8 +1090,15 @@ function _render(){ | |||
| 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 lpsEl=$('rds-lps');if(lpsEl&&document.activeElement!==lpsEl){const lv=cfgEff('rdsLPS');lpsEl.value=lv!=null?lv:(rCfg.lps||'');} | |||
| const afEl=$('rds-af');if(afEl&&document.activeElement!==afEl){const av=cfgEff('rdsAF');afEl.value=(av!=null?av:(rCfg.af||[])).join(', ');} | |||
| const ertOnEl=$('rds-ert-on');if(ertOnEl){const ev=cfgEff('rdsERTEnabled');ertOnEl.checked=ev!=null?!!ev:!!rCfg.ertEnabled;} | |||
| const ertEl=$('rds-ert');if(ertEl&&document.activeElement!==ertEl){const etv=cfgEff('rdsERT');ertEl.value=etv!=null?etv:(rCfg.ert||'');} | |||
| const featDirty=!!S.cfgDirty['rds-feat'];$('rds-features-apply').disabled=!featDirty;$('rds-features-reset').disabled=!featDirty; | |||
| // RDS2 | |||
| const r2On=$('rds2-on');if(r2On){const r2v=cfgEff('rdsRDS2Enabled');r2On.checked=r2v!=null?!!r2v:!!rCfg.rds2Enabled;} | |||
| const r2Logo=$('rds2-logo');if(r2Logo&&document.activeElement!==r2Logo){const lv=cfgEff('rdsLogoPath');r2Logo.value=lv!=null?lv:(rCfg.stationLogoPath||'');} | |||
| const r2Dirty=!!S.cfgDirty['rds2'];if($('rds2-apply')){$('rds2-apply').disabled=!r2Dirty;$('rds2-reset').disabled=!r2Dirty;} | |||
| // Status card | |||
| const activePS=String(eng.activePS||cfg.rds?.ps||'').trim(); | |||
| const activeRT=String(eng.activeRadioText||cfg.rds?.radioText||'').trim(); | |||
| @@ -1175,9 +1215,17 @@ function bindAll(){ | |||
| $('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-lps')?.addEventListener('input',e=>cfgSetDirty('rdsLPS',e.target.value)); | |||
| $('rds-ert-on')?.addEventListener('change',e=>cfgSetDirty('rdsERTEnabled',e.target.checked)); | |||
| $('rds-ert')?.addEventListener('input',e=>cfgSetDirty('rdsERT',e.target.value)); | |||
| $('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');}); | |||
| // RDS2 | |||
| $('rds2-on')?.addEventListener('change',e=>cfgSetDirty('rdsRDS2Enabled',e.target.checked)); | |||
| $('rds2-logo')?.addEventListener('input',e=>cfgSetDirty('rdsLogoPath',e.target.value)); | |||
| $('rds2-apply')?.addEventListener('click',()=>applyCfgSection('rds2')); | |||
| $('rds2-reset')?.addEventListener('click',()=>{cfgClear('rds2');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()); | |||
| @@ -88,6 +88,7 @@ type Generator struct { | |||
| source *PreEmphasizedSource | |||
| stereoEncoder stereo.StereoEncoder | |||
| rdsEnc *rds.Encoder | |||
| rds2Enc *rds.RDS2Encoder | |||
| combiner mpx.DefaultCombiner | |||
| fmMod *dsp.FMModulator | |||
| sampleRate float64 | |||
| @@ -230,10 +231,25 @@ func (g *Generator) init() { | |||
| CTEnabled: g.cfg.RDS.CTEnabled, | |||
| CTOffsetHalfHours: g.cfg.RDS.CTOffsetHalfHours, | |||
| PTYN: g.cfg.RDS.PTYN, | |||
| LPS: g.cfg.RDS.LPS, | |||
| RTPlusEnabled: g.cfg.RDS.RTPlusEnabled, | |||
| RTPlusSeparator: sep, | |||
| ERTEnabled: g.cfg.RDS.ERTEnabled, | |||
| ERT: g.cfg.RDS.ERT, | |||
| ERTGroupType: 12, // default: Group 12A | |||
| EON: eonEntries, | |||
| }) | |||
| // RDS2: additional subcarriers (66.5, 71.25, 76 kHz) | |||
| if g.cfg.RDS.RDS2Enabled { | |||
| g.rds2Enc = rds.NewRDS2Encoder(g.sampleRate) | |||
| g.rds2Enc.Enable(true) | |||
| if g.cfg.RDS.StationLogoPath != "" { | |||
| if err := g.rds2Enc.LoadLogo(g.cfg.RDS.StationLogoPath); err != nil { | |||
| log.Printf("rds2: failed to load station logo: %v", err) | |||
| } | |||
| } | |||
| } | |||
| } | |||
| ceiling := g.cfg.FM.LimiterCeiling | |||
| if ceiling <= 0 { ceiling = 1.0 } | |||
| @@ -503,6 +519,12 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame | |||
| } | |||
| // --- Pass 2: Stereo encode + composite processing --- | |||
| // Feed RDS2 groups once per frame (not per sample) | |||
| if g.rds2Enc != nil && g.rds2Enc.Enabled() { | |||
| g.rds2Enc.FeedGroups() | |||
| } | |||
| for i := 0; i < samples; i++ { | |||
| l := lBuf[i] | |||
| r := rBuf[i] | |||
| @@ -543,6 +565,15 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame | |||
| composite += rdsAmp * rdsValue | |||
| } | |||
| // RDS2: three additional subcarriers (66.5, 71.25, 76 kHz) | |||
| // Each at ≤3.5% injection (ITU-R BS.450-3 limit: 10% total for all RDS) | |||
| if g.rds2Enc != nil && g.rds2Enc.Enabled() { | |||
| pilotPhase := g.stereoEncoder.PilotPhase() | |||
| rds2Value := g.rds2Enc.NextSampleWithPilot(pilotPhase) | |||
| // rds2Injection: ~3% per stream × 3 streams, split evenly | |||
| composite += rdsAmp * 0.75 * rds2Value // 75% of RDS injection per stream | |||
| } | |||
| // Jingle: injected when unlicensed, bypasses drive/gain controls. | |||
| if g.licenseState != nil && len(g.jingleFrames) > 0 { | |||
| composite += g.licenseState.NextSample(g.jingleFrames) | |||
| @@ -22,16 +22,21 @@ type RDSConfig struct { | |||
| TA bool | |||
| // Music/Speech switch: true = music, false = speech. | |||
| // Receivers may adjust EQ or display accordingly. | |||
| // LEGACY: Deleted from IEC 62106-2:2018. Retained for pre-2018 receivers. | |||
| 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) | |||
| // bit 0 (d3): dynamic PTY (1) vs static (0) — ONLY THIS BIT REMAINS in IEC 62106-2:2018 | |||
| // bit 1-3: stereo/artificial head/compressed — DELETED from standard | |||
| // LEGACY: Only dynamic PTY indicator (bit 3) is current standard. | |||
| DI uint8 | |||
| // Long Programme Service name (LPS) — Group Type 15A, IEC 62106-2:2018. | |||
| // Up to 32 bytes UTF-8, max ~16 display characters. Static station name. | |||
| // Complements PS (8 chars); receivers may display LPS instead of PS. | |||
| // Runs on Stream 0 — no RDS2 required. | |||
| LPS string | |||
| // 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. | |||
| @@ -68,6 +73,22 @@ type RDSConfig struct { | |||
| // Default "-". E.g. "Depeche Mode - Enjoy The Silence" → artist/title. | |||
| RTPlusSeparator string | |||
| // --- eRT: Enhanced RadioText (ODA, AID 0x6552) --- | |||
| // ERTEnabled: transmit eRT as ODA on Stream 0 (parallel to RT). | |||
| ERTEnabled bool | |||
| // ERT: UTF-8 text, max 128 bytes. For non-Latin scripts (Cyrillic, Arabic, CJK). | |||
| ERT string | |||
| // ERTGroupType: allocated group type for eRT data (default 12). | |||
| ERTGroupType uint8 | |||
| // --- RDS2 (Streams 1-3, IEC 62106-1:2018) --- | |||
| // RDS2Enabled: activate additional subcarriers (66.5, 71.25, 76 kHz). | |||
| RDS2Enabled bool | |||
| // StationLogoPath: PNG/JPEG file. Transmitted via RFT on RDS2 streams. | |||
| StationLogoPath string | |||
| // --- Group 14A/14B: Enhanced Other Networks (EON) --- | |||
| // EON: information about other stations in the network. | |||
| @@ -104,6 +125,7 @@ func DefaultConfig() RDSConfig { | |||
| CTEnabled: true, | |||
| RTPlusEnabled: true, | |||
| RTPlusSeparator: " - ", | |||
| ERTGroupType: 12, // Group 12A for eRT data | |||
| SampleRate: 228000, | |||
| } | |||
| } | |||
| @@ -52,6 +52,16 @@ type GroupScheduler struct { | |||
| ptynIdx int | |||
| ptynABFlag bool | |||
| // Group 15A state (LPS) | |||
| lpsIdx int | |||
| lpsABFlag bool | |||
| lpsBytes []byte // cached UTF-8 bytes | |||
| // eRT state (ODA on allocated group type) | |||
| ertIdx int | |||
| ertABFlag bool | |||
| ertBytes []byte // cached UTF-8 bytes | |||
| // Group 11A state (RT+) | |||
| rtPlusTag1 RTPlusTag | |||
| rtPlusTag2 RTPlusTag | |||
| @@ -74,6 +84,20 @@ func newGroupScheduler(cfg RDSConfig) *GroupScheduler { | |||
| if cfg.RTPlusEnabled && cfg.RT != "" { | |||
| gs.rtPlusTag1, gs.rtPlusTag2, gs.rtPlusHas2 = ParseRTPlus(cfg.RT, cfg.RTPlusSeparator) | |||
| } | |||
| if cfg.LPS != "" { | |||
| lps := []byte(cfg.LPS) | |||
| if len(lps) > 32 { | |||
| lps = lps[:32] | |||
| } | |||
| gs.lpsBytes = lps | |||
| } | |||
| if cfg.ERTEnabled && cfg.ERT != "" { | |||
| ert := []byte(cfg.ERT) | |||
| if len(ert) > 128 { | |||
| ert = ert[:128] | |||
| } | |||
| gs.ertBytes = ert | |||
| } | |||
| return gs | |||
| } | |||
| @@ -130,7 +154,7 @@ func (gs *GroupScheduler) nextGroup2A() [4]uint16 { | |||
| // 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 | |||
| slot := (gs.cycle / 8) % 10 // 10 priority-2 slots, cycling | |||
| switch { | |||
| case slot == 0 && gs.cfg.CTEnabled: | |||
| @@ -147,7 +171,18 @@ func (gs *GroupScheduler) nextPriority2Group() ([4]uint16, bool) { | |||
| case slot == 3 && gs.cfg.PTYN != "": | |||
| return gs.nextGroupPTYN(), true | |||
| case slot >= 4 && len(gs.cfg.EON) > 0: | |||
| case slot == 4 && len(gs.lpsBytes) > 0: | |||
| return gs.nextGroupLPS(), true | |||
| case slot == 5 && gs.cfg.ERTEnabled && len(gs.ertBytes) > 0: | |||
| // 3A: ODA announcement for eRT | |||
| ertGT := uint16(gs.cfg.ERTGroupType) << 12 // allocated group type for eRT | |||
| return buildGroup3A(gs.cfg.PI, gs.cfg.PTY, gs.cfg.TP, eRTODA, ertGT), true | |||
| case slot == 6 && gs.cfg.ERTEnabled && len(gs.ertBytes) > 0: | |||
| return gs.nextGroupERT(), true | |||
| case slot >= 7 && len(gs.cfg.EON) > 0: | |||
| return gs.nextGroupEON(), true | |||
| } | |||
| @@ -192,6 +227,30 @@ func (gs *GroupScheduler) nextGroupPTYN() [4]uint16 { | |||
| return g | |||
| } | |||
| func (gs *GroupScheduler) nextGroupLPS() [4]uint16 { | |||
| g := buildGroup15A(gs.cfg.PI, gs.cfg.PTY, gs.cfg.TP, gs.lpsABFlag, gs.lpsIdx, gs.lpsBytes) | |||
| gs.lpsIdx++ | |||
| segs := lpsSegmentCount(gs.lpsBytes) | |||
| if gs.lpsIdx >= segs { | |||
| gs.lpsIdx = 0 | |||
| } | |||
| return g | |||
| } | |||
| func (gs *GroupScheduler) nextGroupERT() [4]uint16 { | |||
| gt := gs.cfg.ERTGroupType | |||
| if gt == 0 { | |||
| gt = 12 // default: group type 12A | |||
| } | |||
| g := buildGroupERT(gs.cfg.PI, gs.cfg.PTY, gs.cfg.TP, gt, gs.ertABFlag, gs.ertIdx, gs.ertBytes) | |||
| gs.ertIdx++ | |||
| segs := ertSegmentCount(gs.ertBytes) | |||
| if gs.ertIdx >= segs { | |||
| gs.ertIdx = 0 | |||
| } | |||
| return g | |||
| } | |||
| func (gs *GroupScheduler) nextGroupEON() [4]uint16 { | |||
| if len(gs.cfg.EON) == 0 { | |||
| return gs.nextGroup0A() // fallback | |||
| @@ -176,3 +176,97 @@ func TestFullSchedulerAllGroups(t *testing.T) { | |||
| } | |||
| t.Logf("Seen group types: %v", seen) | |||
| } | |||
| func TestBuildGroup15A(t *testing.T) { | |||
| lps := []byte("Mike-Be Radio") | |||
| g := buildGroup15A(0x1234, 10, false, false, 0, lps) | |||
| if g[0] != 0x1234 { t.Fatal("PI mismatch") } | |||
| if (g[1]>>12)&0xF != 0xF { t.Fatalf("wrong group type: %x", (g[1]>>12)&0xF) } | |||
| // Segment 0: first 2 bytes in block C, next 2 in block D | |||
| if byte(g[2]>>8) != 'M' || byte(g[2]&0xFF) != 'i' { t.Fatalf("wrong LPS chars C: %x", g[2]) } | |||
| if byte(g[3]>>8) != 'k' || byte(g[3]&0xFF) != 'e' { t.Fatalf("wrong LPS chars D: %x", g[3]) } | |||
| } | |||
| func TestLPSSegmentCount(t *testing.T) { | |||
| if lpsSegmentCount([]byte("Hi")) != 1 { t.Fatal("expected 1") } | |||
| if lpsSegmentCount([]byte("Hello World!")) != 4 { t.Fatal("expected 4") } // 12+1=13 bytes, /4=4 | |||
| if lpsSegmentCount([]byte("Mike-Be Radio Live Stream")) != 7 { t.Fatalf("expected 7, got %d", lpsSegmentCount([]byte("Mike-Be Radio Live Stream"))) } | |||
| } | |||
| func TestFullSchedulerWithLPS(t *testing.T) { | |||
| cfg := DefaultConfig() | |||
| cfg.PS = "TESTPS" | |||
| cfg.RT = "Artist - Title" | |||
| cfg.LPS = "Mike-Be Radio Live" | |||
| cfg.PTYN = "ROCK" | |||
| cfg.CTEnabled = true | |||
| cfg.RTPlusEnabled = true | |||
| cfg.RTPlusSeparator = " - " | |||
| cfg.AF = []float64{93.3, 95.7} | |||
| gs := newGroupScheduler(cfg) | |||
| seen := map[int]bool{} | |||
| for i := 0; i < 300; i++ { | |||
| g := gs.NextGroup() | |||
| groupType := int((g[1] >> 12) & 0xF) | |||
| seen[groupType] = true | |||
| } | |||
| // Verify LPS (group type 15 = 0xF) was scheduled | |||
| if !seen[0xF] { | |||
| t.Error("group type 15A (LPS) never scheduled") | |||
| } | |||
| t.Logf("Seen group types: %v", seen) | |||
| } | |||
| func TestBuildGroupERT(t *testing.T) { | |||
| ert := []byte("Привет мир") // Russian "Hello world" in UTF-8 | |||
| g := buildGroupERT(0xD314, 10, false, 12, false, 0, ert) | |||
| if g[0] != 0xD314 { t.Fatal("PI mismatch") } | |||
| if (g[1]>>12)&0xF != 12 { t.Fatalf("wrong group type: %d", (g[1]>>12)&0xF) } | |||
| // First 2 bytes of UTF-8 "Привет" in block C | |||
| if g[2] != uint16(ert[0])<<8|uint16(ert[1]) { t.Fatalf("wrong eRT chars: %x vs %x", g[2], uint16(ert[0])<<8|uint16(ert[1])) } | |||
| } | |||
| func TestERTSegmentCount(t *testing.T) { | |||
| if ertSegmentCount([]byte("Hi")) != 1 { t.Fatal("expected 1") } | |||
| // 20 bytes Russian text + 1 terminator = 21, /4 = 6 | |||
| ert := []byte("Привет мир") // 19 bytes UTF-8 | |||
| n := ertSegmentCount(ert) | |||
| if n != 5 { t.Fatalf("expected 5, got %d (len=%d)", n, len(ert)) } | |||
| } | |||
| func TestGroupC(t *testing.T) { | |||
| gc := GroupC{ | |||
| FH: 0x80, // FuncID=2 (RFT), FuncNum=0 | |||
| Data: [7]byte{0x00, 0x00, 0x00, 0x05, 0x00, 0x89, 0x50}, | |||
| } | |||
| blocks := buildGroupC(gc) | |||
| if blocks[0] != 0x8000 { t.Fatalf("block 0: %04x", blocks[0]) } // FH<<8 | data[0] | |||
| if blocks[1] != 0x0000 { t.Fatalf("block 1: %04x", blocks[1]) } // data[1]<<8 | data[2] | |||
| } | |||
| func TestRFTSegmentation(t *testing.T) { | |||
| // Small test file: 20 bytes | |||
| data := make([]byte, 20) | |||
| for i := range data { data[i] = byte(i) } | |||
| rft := SegmentFile(data, 3, RFTFileTypePNG) | |||
| if rft.FileID != 3 { t.Fatal("wrong fileID") } | |||
| if rft.Total < 2 { t.Fatalf("expected >= 2 segments, got %d", rft.Total) } | |||
| // First segment has header | |||
| if rft.Segments[0].FH != (FuncIDRFT<<6)|3 { t.Fatalf("wrong FH: %02x", rft.Segments[0].FH) } | |||
| t.Logf("RFT: %d bytes → %d segments", len(data), rft.Total) | |||
| } | |||
| func TestRDS2Encoder(t *testing.T) { | |||
| enc := NewRDS2Encoder(228000) | |||
| enc.Enable(true) | |||
| // Generate some samples | |||
| var sum float64 | |||
| for i := 0; i < 1000; i++ { | |||
| sum += enc.NextSample() | |||
| } | |||
| // With no groups fed, output should be near zero (just carrier × empty envelope) | |||
| t.Logf("RDS2 1000 samples sum: %f", sum) | |||
| } | |||
| @@ -425,3 +425,221 @@ func ParseRTPlus(rt string, separator string) (tag1, tag2 RTPlusTag, hasTwoTags | |||
| } | |||
| return | |||
| } | |||
| // --- Group 15A: Long Programme Service Name (LPS) --- | |||
| // IEC 62106-2:2018 §3.1.5.19. UTF-8, max 32 bytes, static station name. | |||
| // Terminated with 0x0D if shorter than 32 bytes. | |||
| // 8 segments of 4 bytes each, sent on Stream 0. | |||
| func buildGroup15A(pi uint16, pty uint8, tp bool, abFlag bool, segIdx int, lps []byte) [4]uint16 { | |||
| var bB uint16 = 0xF << 12 // group type 15, version A | |||
| if tp { | |||
| bB |= 1 << 10 | |||
| } | |||
| bB |= uint16(pty&0x1F) << 5 | |||
| if abFlag { | |||
| bB |= 1 << 4 | |||
| } | |||
| bB |= uint16(segIdx & 0x07) | |||
| // 4 bytes per segment | |||
| off := segIdx * 4 | |||
| getB := func(i int) byte { | |||
| if i < len(lps) { | |||
| return lps[i] | |||
| } | |||
| // Pad with 0x0D (terminator) then 0x00 | |||
| if i == len(lps) { | |||
| return 0x0D | |||
| } | |||
| return 0x00 | |||
| } | |||
| bC := uint16(getB(off))<<8 | uint16(getB(off+1)) | |||
| bD := uint16(getB(off+2))<<8 | uint16(getB(off+3)) | |||
| return [4]uint16{pi, bB, bC, bD} | |||
| } | |||
| // lpsSegmentCount returns how many 15A groups are needed for the LPS string. | |||
| func lpsSegmentCount(lps []byte) int { | |||
| // Include terminator byte 0x0D | |||
| dataLen := len(lps) + 1 // +1 for 0x0D terminator | |||
| n := (dataLen + 3) / 4 | |||
| if n > 8 { | |||
| n = 8 | |||
| } | |||
| if n == 0 { | |||
| n = 1 | |||
| } | |||
| return n | |||
| } | |||
| // --- eRT: Enhanced RadioText (ODA, AID 0x6552) --- | |||
| // UTF-8, up to 128 bytes. Carried as ODA on an allocated group type. | |||
| // Uses same segment structure as RT but with 32 segments of 4 bytes. | |||
| const eRTODA = 0x6552 | |||
| // buildGroupERT builds an eRT ODA data group. | |||
| // Uses the same structure as group 2A but for the allocated ODA group type. | |||
| // groupTypeCode is the 4-bit type allocated for eRT (e.g. 12 = 0xC). | |||
| func buildGroupERT(pi uint16, pty uint8, tp bool, groupTypeCode uint8, abFlag bool, segIdx int, ert []byte) [4]uint16 { | |||
| var bB uint16 = uint16(groupTypeCode&0xF) << 12 // version A (bit 11 = 0) | |||
| if tp { | |||
| bB |= 1 << 10 | |||
| } | |||
| bB |= uint16(pty&0x1F) << 5 | |||
| if abFlag { | |||
| bB |= 1 << 4 | |||
| } | |||
| bB |= uint16(segIdx & 0x1F) // 5 bits for 32 segments | |||
| off := segIdx * 4 | |||
| getB := func(i int) byte { | |||
| if i < len(ert) { | |||
| return ert[i] | |||
| } | |||
| if i == len(ert) { | |||
| return 0x0D // terminator | |||
| } | |||
| return 0x00 | |||
| } | |||
| bC := uint16(getB(off))<<8 | uint16(getB(off+1)) | |||
| bD := uint16(getB(off+2))<<8 | uint16(getB(off+3)) | |||
| return [4]uint16{pi, bB, bC, bD} | |||
| } | |||
| func ertSegmentCount(ert []byte) int { | |||
| dataLen := len(ert) + 1 // +1 for 0x0D terminator | |||
| n := (dataLen + 3) / 4 | |||
| if n > 32 { | |||
| n = 32 | |||
| } | |||
| if n == 0 { | |||
| n = 1 | |||
| } | |||
| return n | |||
| } | |||
| // --- RDS2: Group Type C (Streams 1-3) --- | |||
| // 56 data bits = Function Header (8 bits) + 7 bytes payload. | |||
| // No PI code in block 1 (unlike Type A/B). | |||
| // FH = Function ID (2 bits) + Function Number (6 bits). | |||
| // GroupC represents a Type C group for RDS2 streams 1-3. | |||
| type GroupC struct { | |||
| FH uint8 // Function Header: FuncID(2) + FuncNum(6) | |||
| Data [7]byte // 7 bytes payload | |||
| } | |||
| // buildGroupC encodes a Type C group into 4 blocks for BPSK transmission. | |||
| // Block layout: | |||
| // Block 1: [FH:8][Data0:8] + checkword+offsetA | |||
| // Block 2: [Data1:8][Data2:8] + checkword+offsetB | |||
| // Block 3: [Data3:8][Data4:8] + checkword+offsetC | |||
| // Block 4: [Data5:8][Data6:8] + checkword+offsetD | |||
| func buildGroupC(gc GroupC) [4]uint16 { | |||
| return [4]uint16{ | |||
| uint16(gc.FH)<<8 | uint16(gc.Data[0]), | |||
| uint16(gc.Data[1])<<8 | uint16(gc.Data[2]), | |||
| uint16(gc.Data[3])<<8 | uint16(gc.Data[4]), | |||
| uint16(gc.Data[5])<<8 | uint16(gc.Data[6]), | |||
| } | |||
| } | |||
| // RDS2 Function IDs (2 bits) | |||
| const ( | |||
| FuncIDTuning = 0 // Tuning/switching related | |||
| FuncIDODA = 1 // ODA data | |||
| FuncIDRFT = 2 // RDS2 File Transfer | |||
| FuncIDReserved = 3 | |||
| ) | |||
| // --- RDS2 RFT: File Transfer Protocol --- | |||
| // Segments a file (logo, cover art) into 7-byte Group C payloads. | |||
| // RFTSegment represents one RFT segment ready for Group C transmission. | |||
| type RFTSegment struct { | |||
| FH uint8 // Function Header | |||
| Payload [7]byte // 7 bytes | |||
| } | |||
| // RFTFile holds a file segmented for RDS2 file transfer. | |||
| type RFTFile struct { | |||
| Segments []RFTSegment | |||
| FileID uint8 // identifies this file (0-63) | |||
| Total int // total segments | |||
| } | |||
| // SegmentFile breaks a file into RFT segments for Group C transmission. | |||
| // Format per segment: | |||
| // FH: [FuncID=2:2][FileID:6] | |||
| // Data[0]: segment counter high byte | |||
| // Data[1]: segment counter low byte | |||
| // Data[2-6]: 5 bytes of file data | |||
| // | |||
| // First segment (counter=0) contains header: | |||
| // Data[2]: total segments high byte | |||
| // Data[3]: total segments low byte | |||
| // Data[4]: file type (0=PNG, 1=JPEG, 2=BMP) | |||
| // Data[5-6]: reserved (CRC16 of file, or 0) | |||
| func SegmentFile(data []byte, fileID uint8, fileType uint8) *RFTFile { | |||
| const payloadPerSeg = 5 // 7 bytes - 2 bytes segment counter | |||
| // Calculate total segments needed | |||
| // First segment has 3 bytes overhead (total_hi, total_lo, filetype) | |||
| // so only 2 bytes of file data | |||
| firstPayload := 2 | |||
| remaining := len(data) - firstPayload | |||
| if remaining < 0 { | |||
| remaining = 0 | |||
| } | |||
| totalSegs := 1 + (remaining+payloadPerSeg-1)/payloadPerSeg | |||
| if len(data) <= firstPayload { | |||
| totalSegs = 1 | |||
| } | |||
| rft := &RFTFile{ | |||
| FileID: fileID & 0x3F, | |||
| Total: totalSegs, | |||
| } | |||
| fh := uint8(FuncIDRFT<<6) | (fileID & 0x3F) | |||
| filePos := 0 | |||
| for seg := 0; seg < totalSegs; seg++ { | |||
| s := RFTSegment{FH: fh} | |||
| s.Payload[0] = byte(seg >> 8) | |||
| s.Payload[1] = byte(seg & 0xFF) | |||
| if seg == 0 { | |||
| // Header segment | |||
| s.Payload[2] = byte(totalSegs >> 8) | |||
| s.Payload[3] = byte(totalSegs & 0xFF) | |||
| s.Payload[4] = fileType | |||
| // Data bytes 5-6: first 2 bytes of file | |||
| for i := 5; i < 7; i++ { | |||
| if filePos < len(data) { | |||
| s.Payload[i] = data[filePos] | |||
| filePos++ | |||
| } | |||
| } | |||
| } else { | |||
| // Data segment: 5 bytes of file data | |||
| for i := 2; i < 7; i++ { | |||
| if filePos < len(data) { | |||
| s.Payload[i] = data[filePos] | |||
| filePos++ | |||
| } | |||
| } | |||
| } | |||
| rft.Segments = append(rft.Segments, s) | |||
| } | |||
| return rft | |||
| } | |||
| // RFT file type constants | |||
| const ( | |||
| RFTFileTypePNG = 0 | |||
| RFTFileTypeJPEG = 1 | |||
| RFTFileTypeBMP = 2 | |||
| ) | |||
| @@ -0,0 +1,266 @@ | |||
| package rds | |||
| import ( | |||
| "math" | |||
| "os" | |||
| ) | |||
| // RDS2 subcarrier frequencies (IEC 62106-1:2018, Figure 3). | |||
| // All are integer multiples of 19 kHz / 2. | |||
| const ( | |||
| RDS2Stream1Freq = 66500.0 // 3.5 × 19 kHz | |||
| RDS2Stream2Freq = 71250.0 // 3.75 × 19 kHz | |||
| RDS2Stream3Freq = 76000.0 // 4 × 19 kHz | |||
| ) | |||
| // RDS2Encoder generates the three additional RDS2 subcarrier signals. | |||
| // Each stream carries Group Type C data at 1187.5 bps. | |||
| // The output is added to the composite MPX signal. | |||
| type RDS2Encoder struct { | |||
| streams [3]*streamEncoder | |||
| sampleRate float64 | |||
| enabled bool | |||
| // RFT state: file segments to transmit | |||
| rftFile *RFTFile | |||
| rftSegIdx int | |||
| } | |||
| // streamEncoder handles one RDS2 subcarrier stream. | |||
| type streamEncoder struct { | |||
| sampleRate float64 | |||
| carrierFreq float64 | |||
| carrierPhase float64 | |||
| carrierStep float64 | |||
| // Biphase encoding (same as Stream 0) | |||
| spb int // samples per bit | |||
| waveform []float64 // resampled PiFmRds biphase waveform | |||
| wfLen int | |||
| ring []float64 | |||
| ringSize int | |||
| bitBuffer [bitsPerGroup]int | |||
| bitPos int | |||
| prevOutput int | |||
| curOutput int | |||
| sampleCount int | |||
| inSampleIdx int | |||
| outSampleIdx int | |||
| // Group C queue | |||
| groups []GroupC | |||
| groupIdx int | |||
| } | |||
| // NewRDS2Encoder creates an RDS2 encoder for the given sample rate. | |||
| // Call LoadLogo() to set up the station logo for RFT transmission. | |||
| func NewRDS2Encoder(sampleRate float64) *RDS2Encoder { | |||
| freqs := [3]float64{RDS2Stream1Freq, RDS2Stream2Freq, RDS2Stream3Freq} | |||
| e := &RDS2Encoder{sampleRate: sampleRate} | |||
| for i := 0; i < 3; i++ { | |||
| e.streams[i] = newStreamEncoder(sampleRate, freqs[i]) | |||
| } | |||
| return e | |||
| } | |||
| func newStreamEncoder(sampleRate, carrierFreq float64) *streamEncoder { | |||
| spb := int(math.Round(sampleRate / rdsBitRate)) | |||
| ratio := sampleRate / refRate | |||
| // Resample PiFmRds waveform (same as main RDS encoder) | |||
| wfLen := int(math.Round(float64(refFilterSize) * ratio)) | |||
| waveform := make([]float64, wfLen) | |||
| for i := range waveform { | |||
| srcPos := float64(i) / ratio | |||
| idx := int(srcPos) | |||
| frac := srcPos - float64(idx) | |||
| if idx+1 < refFilterSize { | |||
| waveform[i] = refWaveform[idx]*(1-frac) + refWaveform[idx+1]*frac | |||
| } else if idx < refFilterSize { | |||
| waveform[i] = refWaveform[idx] | |||
| } | |||
| } | |||
| // Normalize to peak=1.0 | |||
| var peak float64 | |||
| for _, v := range waveform { | |||
| if a := math.Abs(v); a > peak { | |||
| peak = a | |||
| } | |||
| } | |||
| if peak > 0 { | |||
| for i := range waveform { | |||
| waveform[i] /= peak | |||
| } | |||
| } | |||
| ringSize := spb + wfLen | |||
| return &streamEncoder{ | |||
| sampleRate: sampleRate, | |||
| carrierFreq: carrierFreq, | |||
| carrierPhase: 0, | |||
| carrierStep: carrierFreq / sampleRate, | |||
| spb: spb, | |||
| waveform: waveform, | |||
| wfLen: wfLen, | |||
| ring: make([]float64, ringSize), | |||
| ringSize: ringSize, | |||
| bitPos: bitsPerGroup, | |||
| sampleCount: spb, | |||
| outSampleIdx: ringSize - 1, | |||
| } | |||
| } | |||
| // Enable activates or deactivates RDS2 output. | |||
| func (e *RDS2Encoder) Enable(on bool) { | |||
| e.enabled = on | |||
| } | |||
| // Enabled returns whether RDS2 is active. | |||
| func (e *RDS2Encoder) Enabled() bool { | |||
| return e.enabled | |||
| } | |||
| // LoadLogo reads a logo file and segments it for RFT transmission on Stream 1. | |||
| func (e *RDS2Encoder) LoadLogo(path string) error { | |||
| data, err := os.ReadFile(path) | |||
| if err != nil { | |||
| return err | |||
| } | |||
| // Detect file type | |||
| ft := uint8(RFTFileTypePNG) | |||
| if len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { | |||
| ft = RFTFileTypeJPEG | |||
| } | |||
| e.rftFile = SegmentFile(data, 0, ft) // fileID=0 = station logo | |||
| e.rftSegIdx = 0 | |||
| return nil | |||
| } | |||
| // NextSample returns the combined RDS2 subcarrier sample for all 3 streams. | |||
| // Add this to the composite MPX signal with appropriate injection level. | |||
| func (e *RDS2Encoder) NextSample() float64 { | |||
| if !e.enabled { | |||
| return 0 | |||
| } | |||
| var sum float64 | |||
| for i := 0; i < 3; i++ { | |||
| sum += e.streams[i].nextSample() | |||
| } | |||
| return sum | |||
| } | |||
| // NextSampleWithPilot returns the RDS2 sample using pilot-locked carriers. | |||
| // pilotPhase is the current pilot oscillator phase (0-1 range). | |||
| func (e *RDS2Encoder) NextSampleWithPilot(pilotPhase float64) float64 { | |||
| if !e.enabled { | |||
| return 0 | |||
| } | |||
| // RDS2 carriers are at 3.5×, 3.75×, 4× pilot frequency | |||
| // sin(2π × N × pilotPhase) where N = 3.5, 3.75, 4.0 | |||
| multipliers := [3]float64{3.5, 3.75, 4.0} | |||
| var sum float64 | |||
| for i := 0; i < 3; i++ { | |||
| carrier := math.Sin(2 * math.Pi * multipliers[i] * pilotPhase) | |||
| sum += e.streams[i].nextSampleWithCarrier(carrier) | |||
| } | |||
| return sum | |||
| } | |||
| // FeedGroups distributes Group C data to the stream encoders. | |||
| // Call periodically (e.g. once per frame) to keep streams fed. | |||
| func (e *RDS2Encoder) FeedGroups() { | |||
| if !e.enabled { | |||
| return | |||
| } | |||
| // Stream 1: Station logo via RFT (if loaded) | |||
| if e.rftFile != nil && len(e.rftFile.Segments) > 0 { | |||
| seg := e.rftFile.Segments[e.rftSegIdx] | |||
| gc := GroupC{FH: seg.FH, Data: seg.Payload} | |||
| e.streams[0].pushGroup(gc) | |||
| e.rftSegIdx = (e.rftSegIdx + 1) % len(e.rftFile.Segments) | |||
| } | |||
| // Streams 2 & 3: available for future ODAs | |||
| // For now, send idle groups (FH=0, all zeros) | |||
| } | |||
| // --- streamEncoder methods --- | |||
| func (se *streamEncoder) pushGroup(gc GroupC) { | |||
| se.groups = append(se.groups, gc) | |||
| } | |||
| func (se *streamEncoder) nextSample() float64 { | |||
| carrier := math.Sin(2 * math.Pi * se.carrierPhase) | |||
| se.carrierPhase += se.carrierStep | |||
| if se.carrierPhase >= 1.0 { | |||
| se.carrierPhase -= 1.0 | |||
| } | |||
| return se.nextSampleWithCarrier(carrier) | |||
| } | |||
| func (se *streamEncoder) nextSampleWithCarrier(carrier float64) float64 { | |||
| if se.sampleCount >= se.spb { | |||
| if se.bitPos >= bitsPerGroup { | |||
| se.getNextGroup() | |||
| se.bitPos = 0 | |||
| } | |||
| curBit := se.bitBuffer[se.bitPos] | |||
| se.prevOutput = se.curOutput | |||
| se.curOutput = se.prevOutput ^ curBit | |||
| inverting := (se.curOutput == 1) | |||
| idx := se.inSampleIdx | |||
| for j := 0; j < se.wfLen; j++ { | |||
| val := se.waveform[j] | |||
| if inverting { | |||
| val = -val | |||
| } | |||
| se.ring[idx] += val | |||
| idx++ | |||
| if idx >= se.ringSize { | |||
| idx = 0 | |||
| } | |||
| } | |||
| se.inSampleIdx += se.spb | |||
| if se.inSampleIdx >= se.ringSize { | |||
| se.inSampleIdx -= se.ringSize | |||
| } | |||
| se.bitPos++ | |||
| se.sampleCount = 0 | |||
| } | |||
| envelope := se.ring[se.outSampleIdx] | |||
| se.ring[se.outSampleIdx] = 0 | |||
| se.outSampleIdx++ | |||
| if se.outSampleIdx >= se.ringSize { | |||
| se.outSampleIdx = 0 | |||
| } | |||
| se.sampleCount++ | |||
| return envelope * carrier | |||
| } | |||
| func (se *streamEncoder) getNextGroup() { | |||
| var group [4]uint16 | |||
| if se.groupIdx < len(se.groups) { | |||
| gc := se.groups[se.groupIdx] | |||
| group = buildGroupC(gc) | |||
| se.groupIdx++ | |||
| if se.groupIdx >= len(se.groups) { | |||
| se.groupIdx = 0 // loop | |||
| } | |||
| } | |||
| // else: idle group (all zeros) | |||
| // Encode group into bit buffer using same CRC/offset as Stream 0 | |||
| pos := 0 | |||
| for blk, off := range [4]byte{'A', 'B', 'C', 'D'} { | |||
| encoded := encodeBlock(group[blk], off) | |||
| for bit := bitsPerBlock - 1; bit >= 0; bit-- { | |||
| se.bitBuffer[pos] = int((encoded >> uint(bit)) & 1) | |||
| pos++ | |||
| } | |||
| } | |||
| } | |||
| @@ -1,2 +1,2 @@ | |||
| @echo off | |||
| ffmpeg -i "http://svabi.ch:8443/stream" -f s16le -ar 44100 -ac 2 - | fmrtx.exe --tx --tx-auto-start --audio-stdin --config docs/config.plutosdr.json | |||
| ffmpeg -i "https://stream.streambase.ch/radiofm1/mp3-192/direct/" -f s16le -ar 44100 -ac 2 - | fmrtx.exe --tx --tx-auto-start --audio-stdin --config docs/config.plutosdr.json | |||