diff --git a/docs/config.plutosdr.json b/docs/config.plutosdr.json index 3241830..6f1fc09 100644 --- a/docs/config.plutosdr.json +++ b/docs/config.plutosdr.json @@ -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, diff --git a/docs/rds-standards/IEC_62106-10_2021_UECP.pdf b/docs/rds-standards/IEC_62106-10_2021_UECP.pdf new file mode 100644 index 0000000..dc04326 Binary files /dev/null and b/docs/rds-standards/IEC_62106-10_2021_UECP.pdf differ diff --git a/docs/rds-standards/IEC_62106-3_2018_ODA.pdf b/docs/rds-standards/IEC_62106-3_2018_ODA.pdf new file mode 100644 index 0000000..acd1e20 Binary files /dev/null and b/docs/rds-standards/IEC_62106-3_2018_ODA.pdf differ diff --git a/docs/rds-standards/RDS2_infotainment_2016.pdf b/docs/rds-standards/RDS2_infotainment_2016.pdf new file mode 100644 index 0000000..1946ba3 Binary files /dev/null and b/docs/rds-standards/RDS2_infotainment_2016.pdf differ diff --git a/docs/rds-standards/RDS2_overview_2014.pdf b/docs/rds-standards/RDS2_overview_2014.pdf new file mode 100644 index 0000000..b08d162 Binary files /dev/null and b/docs/rds-standards/RDS2_overview_2014.pdf differ diff --git a/docs/RDS 2 - what it is_251014_7.pdf b/docs/rds-standards/RDS2_what_it_is_2025.pdf similarity index 100% rename from docs/RDS 2 - what it is_251014_7.pdf rename to docs/rds-standards/RDS2_what_it_is_2025.pdf diff --git a/docs/rds-standards/RDS_eBook_2018.pdf b/docs/rds-standards/RDS_eBook_2018.pdf new file mode 100644 index 0000000..0cbeda5 Binary files /dev/null and b/docs/rds-standards/RDS_eBook_2018.pdf differ diff --git a/docs/rds-standards/RDS_eBook_2025.pdf b/docs/rds-standards/RDS_eBook_2025.pdf new file mode 100644 index 0000000..82ae607 Binary files /dev/null and b/docs/rds-standards/RDS_eBook_2025.pdf differ diff --git a/docs/ourdev_680886RNY3D8.pdf b/docs/rds-standards/ourdev_680886RNY3D8.pdf similarity index 100% rename from docs/ourdev_680886RNY3D8.pdf rename to docs/rds-standards/ourdev_680886RNY3D8.pdf diff --git a/docs/rd040912.pdf b/docs/rds-standards/rd040912.pdf similarity index 100% rename from docs/rd040912.pdf rename to docs/rds-standards/rd040912.pdf diff --git a/docs/rds-standards/rds-logo.png b/docs/rds-standards/rds-logo.png new file mode 100644 index 0000000..978e53c Binary files /dev/null and b/docs/rds-standards/rds-logo.png differ diff --git a/internal/config/config.go b/internal/config/config.go index 9541e0c..6c7f9bf 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` } diff --git a/internal/control/control.go b/internal/control/control.go index 2a6e9f7..8923a27 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -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 } diff --git a/internal/control/ui.html b/internal/control/ui.html index 5c9855f..597a745 100644 --- a/internal/control/ui.html +++ b/internal/control/ui.html @@ -574,13 +574,41 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
PTYNCustom type name, 8 chars
restart
+
+
Long PS (LPS)UTF-8 station name, 32 bytes
+
restart
+
Alt. FrequenciesComma-separated MHz
restart
+
+
eRT (Enhanced RT)UTF-8, 128 bytes, ODA
+
restart
+
+
+
eRT TextUTF-8 multilingual text
+
restart
+
+ +
+

RDS2 (Streams 1-3)

Restart required
+
+
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.
+
+
RDS2 EnableActivate streams 1-3
+
restart
+
+
+
Station LogoPNG/JPEG path on server
+
restart
+
+
+
+
@@ -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()); diff --git a/internal/offline/generator.go b/internal/offline/generator.go index 55b77a8..2697131 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -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) diff --git a/internal/rds/config.go b/internal/rds/config.go index d7d1899..61e217c 100644 --- a/internal/rds/config.go +++ b/internal/rds/config.go @@ -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, } } diff --git a/internal/rds/encoder.go b/internal/rds/encoder.go index c88bcdc..a48956a 100644 --- a/internal/rds/encoder.go +++ b/internal/rds/encoder.go @@ -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 diff --git a/internal/rds/encoder_test.go b/internal/rds/encoder_test.go index 4c24d42..a161b5a 100644 --- a/internal/rds/encoder_test.go +++ b/internal/rds/encoder_test.go @@ -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) +} diff --git a/internal/rds/groups.go b/internal/rds/groups.go index ca315cc..fadbb68 100644 --- a/internal/rds/groups.go +++ b/internal/rds/groups.go @@ -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 +) diff --git a/internal/rds/rds2.go b/internal/rds/rds2.go new file mode 100644 index 0000000..9b1f09f --- /dev/null +++ b/internal/rds/rds2.go @@ -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++ + } + } +} diff --git a/stream_tx.bat b/stream_tx.bat index be5180e..1dc2e0d 100644 --- a/stream_tx.bat +++ b/stream_tx.bat @@ -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 \ No newline at end of file +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 \ No newline at end of file