Kaynağa Gözat

feat(rds2): add LPS, eRT and RDS2 controls

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
Jan 1 ay önce
ebeveyn
işleme
9eca50c3f4
21 değiştirilmiş dosya ile 800 ekleme ve 14 silme
  1. +21
    -5
      docs/config.plutosdr.json
  2. BIN
      docs/rds-standards/IEC_62106-10_2021_UECP.pdf
  3. BIN
      docs/rds-standards/IEC_62106-3_2018_ODA.pdf
  4. BIN
      docs/rds-standards/RDS2_infotainment_2016.pdf
  5. BIN
      docs/rds-standards/RDS2_overview_2014.pdf
  6. +0
    -0
      docs/rds-standards/RDS2_what_it_is_2025.pdf
  7. BIN
      docs/rds-standards/RDS_eBook_2018.pdf
  8. BIN
      docs/rds-standards/RDS_eBook_2025.pdf
  9. +0
    -0
      docs/rds-standards/ourdev_680886RNY3D8.pdf
  10. +0
    -0
      docs/rds-standards/rd040912.pdf
  11. BIN
      docs/rds-standards/rds-logo.png
  12. +12
    -0
      internal/config/config.go
  13. +20
    -0
      internal/control/control.go
  14. +49
    -1
      internal/control/ui.html
  15. +31
    -0
      internal/offline/generator.go
  16. +27
    -5
      internal/rds/config.go
  17. +61
    -2
      internal/rds/encoder.go
  18. +94
    -0
      internal/rds/encoder_test.go
  19. +218
    -0
      internal/rds/groups.go
  20. +266
    -0
      internal/rds/rds2.go
  21. +1
    -1
      stream_tx.bat

+ 21
- 5
docs/config.plutosdr.json Dosyayı Görüntüle

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


BIN
docs/rds-standards/IEC_62106-10_2021_UECP.pdf Dosyayı Görüntüle


BIN
docs/rds-standards/IEC_62106-3_2018_ODA.pdf Dosyayı Görüntüle


BIN
docs/rds-standards/RDS2_infotainment_2016.pdf Dosyayı Görüntüle


BIN
docs/rds-standards/RDS2_overview_2014.pdf Dosyayı Görüntüle


docs/RDS 2 - what it is_251014_7.pdf → docs/rds-standards/RDS2_what_it_is_2025.pdf Dosyayı Görüntüle


BIN
docs/rds-standards/RDS_eBook_2018.pdf Dosyayı Görüntüle


BIN
docs/rds-standards/RDS_eBook_2025.pdf Dosyayı Görüntüle


docs/ourdev_680886RNY3D8.pdf → docs/rds-standards/ourdev_680886RNY3D8.pdf Dosyayı Görüntüle


docs/rd040912.pdf → docs/rds-standards/rd040912.pdf Dosyayı Görüntüle


BIN
docs/rds-standards/rds-logo.png Dosyayı Görüntüle

Önce Sonra
Genişlik: 320  |  Yükseklik: 320  |  Boyut: 7.7KB

+ 12
- 0
internal/config/config.go Dosyayı Görüntüle

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


+ 20
- 0
internal/control/control.go Dosyayı Görüntüle

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


+ 49
- 1
internal/control/ui.html Dosyayı Görüntüle

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


+ 31
- 0
internal/offline/generator.go Dosyayı Görüntüle

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


+ 27
- 5
internal/rds/config.go Dosyayı Görüntüle

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

+ 61
- 2
internal/rds/encoder.go Dosyayı Görüntüle

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


+ 94
- 0
internal/rds/encoder_test.go Dosyayı Görüntüle

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

+ 218
- 0
internal/rds/groups.go Dosyayı Görüntüle

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

+ 266
- 0
internal/rds/rds2.go Dosyayı Görüntüle

@@ -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
- 1
stream_tx.bat Dosyayı Görüntüle

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

Yükleniyor…
İptal
Kaydet