diff --git a/internal/control/ui.html b/internal/control/ui.html index d6da32a..e46f9c0 100644 --- a/internal/control/ui.html +++ b/internal/control/ui.html @@ -110,7 +110,6 @@ button,input,select{font:inherit}button{user-select:none} /* Tags */ .tag{display:inline-flex;align-items:center;height:16px;padding:0 6px;border-radius:4px;font-size:9px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;flex-shrink:0} .tag-live{background:rgba(13,148,74,.12);color:var(--green)} -.tag-saved{background:rgba(31,77,157,.12);color:var(--accent)} .tag-restart{background:rgba(183,121,31,.12);color:var(--amber)} .tag-reload{background:rgba(17,23,36,.08);color:var(--text-dim)} /* Buttons */ @@ -326,9 +325,9 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
-

Frequency

Live + Saved
+

Frequency

Applies immediately
-
Tune without restarting — when TX is running, the change takes effect at the next chunk boundary (~50ms). The desired frequency is also written into config.
+
Takes effect without restarting. When TX is running, the change applies at the next chunk boundary (~50 ms) and is written into config.
@@ -338,7 +337,7 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
TX Freq65–110 MHz
-
MHzlive
+
MHz
@@ -346,9 +345,9 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
-

Audio & Drive

Mixed Apply Modes
+

Audio & Drive

Immediate + restart-only
-
Output Drive and Limiter Ceiling apply live. Pre-emphasis and Input Gain are saved to config and require TX restart to affect the DSP path.
+
Output Drive and Limiter Ceiling take effect immediately. Pre-emphasis and Input Gain require TX restart before they affect the DSP path.
Output Drive0 – 10
--live
@@ -371,20 +370,20 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
-

Test Tones

Diagnostic
+

Test Tones

Applies immediately
-
Tone settings apply live to the running generator path and are also written into config. No TX restart is required for the new tone values to take effect. Set amplitude to 0 to disable.
+
Tone settings take effect immediately in the running generator path. No TX restart is required. Set amplitude to 0 to disable.
Left (Hz)
-
Hzlive
+
Hz
Right (Hz)
-
Hzlive
+
Hz
Amplitude0 – 1.0
-
--live
+
--
@@ -393,7 +392,7 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
-

Switches

Live
+

Switches

Apply immediately
Stereo
19 kHz pilot + 38 kHz DSB-SC
@@ -411,9 +410,9 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
-

MPX Compliance

BS.412
+

MPX Compliance

Restart required
-
ITU-R BS.412 limits total MPX power. Mandatory for licensed FM in EU/CH. Changes require TX restart.
+
ITU-R BS.412 limits total MPX power. Mandatory for licensed FM in EU/CH. Changes in this panel require TX restart.
BS.412 Limiter
60 s rolling power window  restart
--
@@ -432,9 +431,9 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
-

Composite Clipper

SM.1268
+

Composite Clipper

Immediate + restart-only
-
ITU-R SM.1268 iterative composite clipper. Enable/disable is live. Iterations, knee, and look-ahead require TX restart.
+
ITU-R SM.1268 iterative composite clipper. Enable/disable takes effect immediately; iterations, knee and look-ahead require TX restart.
Composite Clipper
Iterative clip-filter-clip + look-ahead  live
--
@@ -482,12 +481,12 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
-

Station Identity

Restart required
+

Station Identity

Mixed runtime impact
-
RDS enable applies live. PI and PTY are saved to config and take effect after the next TX restart.
+
RDS enable takes effect immediately. PI and PTY require TX restart before they change on-air.
Enable RDS57 kHz subcarrier
-
--
live
+
--
PI CodeProgramme Identifier (hex)
@@ -515,9 +514,9 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
-

On-Air Text

Saved + Runtime
+

On-Air Text

Applies on-air
-
PS and RadioText apply at the next RDS group boundary (~88ms). Edits stay local until you apply, then update the live encoder and config snapshot together. When StreamTitle relay is enabled, the active on-air RadioText can temporarily differ from the saved config value shown in the editor.
+
PS and RadioText apply at the next RDS group boundary (~88 ms). Edits stay local until you apply. With StreamTitle relay enabled, the active on-air RadioText can temporarily differ from the editor value.
@@ -526,13 +525,13 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
-
Program Service (PS)live
+
Program Service (PS)
0/8
-
RadioText (RT)live
+
RadioText (RT)
0/64
@@ -544,16 +543,16 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
-

RDS Features

Restart required
+

RDS Features

Immediate + restart-only
-
Traffic, clock, RT+ and other RDS features. TP/TA apply live and are also saved to config; the remaining fields in this panel are saved to config and take effect after TX restart.
+
Traffic, clock, RT+ and other RDS features. TP/TA take effect immediately; the remaining fields in this panel require TX restart.
Traffic Program (TP)Station carries traffic info
-
livesaved
+
live
Traffic Announcement (TA)Currently on air
-
livesaved
+
live
Music / SpeechMS flag for receivers
@@ -581,7 +580,7 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
Alt. FrequenciesComma-separated MHz
-
restart
+
restart
eRT (Enhanced RT)UTF-8, 128 bytes, ODA
@@ -614,16 +613,16 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
-

Injection Levels

Live + Saved
+

Injection Levels

Apply immediately
-
Fixed percentages of ±75 kHz deviation. ITU standard: pilot 9%, RDS 4%. When TX is running, changes hot-apply; they are also written back into config.
+
Fixed percentages of ±75 kHz deviation. ITU standard: pilot 9%, RDS 4%. When TX is running, changes apply immediately.
Pilot Level19 kHz, 0–20%
-
--%devlive
+
--%dev
RDS Injection57 kHz, 0–15%
-
--%devlive
+
--%dev
@@ -666,9 +665,9 @@ input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.1
-

Ingest Config

Saved + Hard Reload
+

Ingest Config

Hard reload required
-
Changes are saved to the config file and take effect only after a hard reload of the service.
+
Changes in this panel take effect only after a hard reload of the service.
Kind
Prebufferms
@@ -795,6 +794,7 @@ let toastTimer=null; const S={ server:{config:null,runtime:null,configOk:false,runtimeOk:false,lastConfigAt:0,lastRuntimeAt:0}, lastRTState:'',draft:{},errors:{},dirty:new Set(), + fieldErrors:{}, pending:0,txBusy:false,faultBusy:false,toggleBusy:{}, cfgDraft:{},cfgDirty:{},cfgErrors:{}, ingestDraft:null,ingestDirty:false,ingestSaving:false,ingestError:'', @@ -863,6 +863,11 @@ function cfgSetDirty(key,val){ S.cfgDirty[f.sec]=Object.keys(CFG).filter(k=>CFG[k].sec===f.sec).some(k=>!cfgEq(k,S.cfgDraft[k]??cfgSrvVal(k),cfgSrvVal(k))); render(); } +function setFieldError(key,msg){ + if(msg) S.fieldErrors[key]=msg; + else delete S.fieldErrors[key]; + render(); +} function cfgClear(sec){Object.keys(CFG).filter(k=>CFG[k].sec===sec).forEach(k=>delete S.cfgDraft[k]);S.cfgDirty[sec]=false;if(sec==='rds-id'&&S.cfgErrors)delete S.cfgErrors.pi;render();} function cfgPatch(sec){const p={};Object.keys(CFG).filter(k=>CFG[k].sec===sec).forEach(k=>{const v=S.cfgDraft[k];if(v!==undefined&&!cfgEq(k,v,cfgSrvVal(k)))p[CFG_PATCH_KEY[k]]=v;});return p;} function cfgHasRestart(sec){return Object.keys(CFG).filter(k=>CFG[k].sec===sec&&!CFG[k].live).some(k=>S.cfgDraft[k]!==undefined);} @@ -937,6 +942,19 @@ function endReq(){S.pending=Math.max(0,S.pending-1);setConn(S.server.configOk||S async function sendPatch(patch,{ok='Applied',clearKeys=[]}={}){beginReq();try{const res=await api('/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)});clearDirty(clearKeys);render();toast(ok+(res.live?' · live':''),'ok');log('PATCH '+JSON.stringify(patch)+(res.live?' [live]':' [saved]'),'ok');await Promise.allSettled([loadConfig({silent:true}),loadRuntime({silent:true})]);return res;}catch(e){toast(e.message,'err');log('PATCH failed: '+e.message,'err');throw e;}finally{endReq();}} async function applySection(sec){if(secErrors(sec)){toast('Fix validation errors first','warn');return;}const patch=secPatch(sec),keys=Object.keys(patch);if(!keys.length){toast('No changes','info');return;}const msg=sec==='freq'?'Frequency updated':sec==='rds'?'RDS text updated':'Applied';await sendPatch(patch,{ok:msg,clearKeys:keys});} function resetSection(sec){clearDirty(Object.keys(FIELDS).filter(k=>FIELDS[k].section===sec));toast('Draft reset','info');} +function parseAFInput(raw){ + const text=String(raw||'').trim(); + if(text==='') return {values:[], error:''}; + const parts=text.split(',').map(s=>s.trim()).filter(Boolean); + const values=[]; + for(const part of parts){ + const n=Number(part); + if(!isFinite(n)) return {values:null, error:`Invalid AF entry: ${part}`}; + if(n<87.5||n>108.0) return {values:null, error:`AF out of FM band: ${part}`}; + values.push(n); + } + return {values, error:''}; +} async function applyCfgSection(sec){if(sec==='rds-id'&&S.cfgErrors?.pi){toast('Fix validation errors first','warn');return;}const patch=cfgPatch(sec);if(!Object.keys(patch).length){toast('No changes','info');return;}const hasR=cfgHasRestart(sec);beginReq();try{const res=await api('/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)});Object.keys(CFG).filter(k=>CFG[k].sec===sec).forEach(k=>delete S.cfgDraft[k]);S.cfgDirty[sec]=false;if(sec==='rds-id'&&S.cfgErrors)delete S.cfgErrors.pi;toast(hasR?'Saved (restart required)':'Applied live','ok');log('CFG '+sec+' '+JSON.stringify(patch)+(hasR?' [restart]':' [live]'),'ok');await Promise.allSettled([loadConfig({silent:true}),loadRuntime({silent:true})]);return res;}catch(e){toast(e.message,'err');log('CFG failed: '+e.message,'err');throw e;}finally{endReq();}} async function txAction(action){ if(S.txBusy)return; @@ -1027,7 +1045,7 @@ function _render(){ syncDirtyInput('freq-slider','frequencyMHz',v=>typeof v==='number'?v.toFixed(1):'100.0'); syncDirtyInput('freq-num','frequencyMHz',v=>typeof v==='number'?v.toFixed(1):'100.0'); $('freq-apply').disabled=!secDirty('freq')||secErrors('freq');$('freq-reset').disabled=!secDirty('freq'); - setText('freq-meta',secErrors('freq')?'Validation error':secDirty('freq')?'Draft pending':'Live + Saved'); + setText('freq-meta',secErrors('freq')?'Validation error':secDirty('freq')?'Apply immediately · draft pending':'Applies immediately'); const fe=$('freq-error');if(fe){fe.textContent=S.errors.frequencyMHz||'';fe.classList.toggle('show',!!S.errors.frequencyMHz);} refreshFreqPresets(); // Audio sliders @@ -1035,7 +1053,7 @@ function _render(){ syncSlider('lim-ceiling-slider','lim-ceiling-val','limiterCeiling',v=>v==null?'--':Number(v).toFixed(2)); syncSlider('gain-slider','gain-val','audioGain',v=>v==null?'--':Number(v).toFixed(2)); const peEl=$('preemph-select');if(peEl&&document.activeElement!==peEl)peEl.value=String(cfgEff('preEmphasisTauUS')??50); - setText('audio-meta',S.cfgDirty['audio']?'Draft pending':'Mixed Apply Modes'); + setText('audio-meta',S.cfgDirty['audio']?'Immediate + restart-only · draft pending':'Immediate + restart-only'); $('audio-apply').disabled=!S.cfgDirty['audio'];$('audio-reset').disabled=!S.cfgDirty['audio']; // Tones syncSlider('tone-amp-slider','tone-amp-val','toneAmplitude',v=>v==null?'--':Number(v).toFixed(2)); @@ -1048,14 +1066,14 @@ function _render(){ syncCfgToggle('tog-bs412','bs412-label','bs412Enabled'); syncSlider('bs412-threshold-slider','bs412-threshold-val','bs412ThresholdDBr',v=>v==null?'--':Number(v).toFixed(1)); syncSlider('mpxgain-slider','mpxgain-val','mpxGain',v=>v==null?'--':Number(v).toFixed(2)); - setText('compliance-meta',S.cfgDirty['compliance']?'Draft pending':'Saved + Restart Required'); + setText('compliance-meta',S.cfgDirty['compliance']?'Restart required · draft pending':'Restart required'); $('compliance-apply').disabled=!S.cfgDirty['compliance'];$('compliance-reset').disabled=!S.cfgDirty['compliance']; // Composite Clipper syncToggle('tog-compclip','compclip-label','compositeClipperEnabled'); syncSlider('compclip-iter-slider','compclip-iter-val','compositeClipperIterations',v=>v==null?'--':String(Math.round(Number(v)))); syncSlider('compclip-knee-slider','compclip-knee-val','compositeClipperSoftKnee',v=>v==null?'--':Number(v).toFixed(2)); syncSlider('compclip-la-slider','compclip-la-val','compositeClipperLookaheadMs',v=>v==null?'--':Number(v).toFixed(1)); - setText('compclip-meta',S.cfgDirty['compclip']?'Draft pending':'SM.1268'); + setText('compclip-meta',S.cfgDirty['compclip']?'Immediate + restart-only · draft pending':'Immediate + restart-only'); $('compclip-apply').disabled=!S.cfgDirty['compclip'];$('compclip-reset').disabled=!S.cfgDirty['compclip']; // Danger $('danger-stop').disabled=S.txBusy;const rfl=$('danger-reset-fault');if(rfl){rfl.disabled=S.faultBusy||!S.server.runtimeOk;rfl.textContent=S.faultBusy?'Resetting...':'Reset Fault';} @@ -1069,13 +1087,13 @@ function _render(){ const piErr=$('pi-error');if(piErr){const e=S.cfgErrors?.pi||'';piErr.textContent=e;piErr.classList.toggle('show',!!e);} // PTY const ptyEl=$('rds-pty');if(ptyEl&&document.activeElement!==ptyEl)ptyEl.value=String(cfgEff('pty')??cfg.rds?.pty??0); - const idDirty=!!S.cfgDirty['rds-id'];setText('rds-identity-meta',S.cfgErrors?.pi?'Validation error':idDirty?'Draft pending':'Saved + Restart Required'); + const idDirty=!!S.cfgDirty['rds-id'];setText('rds-identity-meta',S.cfgErrors?.pi?'Validation error':idDirty?'Mixed runtime impact · draft pending':'Mixed runtime impact'); $('rds-identity-apply').disabled=!idDirty||!!S.cfgErrors?.pi;$('rds-identity-reset').disabled=!idDirty; // Text syncDirtyInput('rds-ps','ps',v=>String(v??''));syncDirtyInput('rds-rt','radioText',v=>String(v??'')); const psV=String(effVal('ps')??cfg.rds?.ps??''),rtV=String(effVal('radioText')??cfg.rds?.radioText??''); setText('ps-count',psV.length);setText('rt-count',rtV.length); - const rdsD=secDirty('rds');setText('rds-text-meta',secErrors('rds')?'Validation error':rdsD?'Draft pending':'Saved + Runtime'); + const rdsD=secDirty('rds');setText('rds-text-meta',secErrors('rds')?'Validation error':rdsD?'Applies on-air · draft pending':'Applies on-air'); $('rds-apply').disabled=!rdsD||secErrors('rds');$('rds-reset').disabled=!rdsD; const psErr=$('ps-error');if(psErr){psErr.textContent=S.errors.ps||'';psErr.classList.toggle('show',!!S.errors.ps);} const rtErr=$('rt-error');if(rtErr){rtErr.textContent=S.errors.radioText||'';rtErr.classList.toggle('show',!!S.errors.radioText);} @@ -1093,10 +1111,11 @@ function _render(){ 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 afEl=$('rds-af');if(afEl&&document.activeElement!==afEl){const av=cfgEff('rdsAF');afEl.value=(av!=null?av:(rCfg.af||[])).join(', ');afEl.classList.toggle('input-error',!!S.fieldErrors.rdsAF);} + const afErr=$('rds-af-error');if(afErr){afErr.textContent=S.fieldErrors.rdsAF||'';afErr.classList.toggle('show',!!S.fieldErrors.rdsAF);} 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; + const featDirty=!!S.cfgDirty['rds-feat'];$('rds-features-apply').disabled=!featDirty||!!S.fieldErrors.rdsAF;$('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||'');} @@ -1125,7 +1144,7 @@ function _render(){ syncIngInput('ing-aes67-group','aes67.multicastGroup',v=>String(v??''));syncIngInput('ing-aes67-port','aes67.port',v=>isFinite(Number(v))?Number(v):5004);syncIngInput('ing-aes67-sdppath','aes67.sdpPath',v=>String(v??''));syncIngInput('ing-aes67-rate','aes67.sampleRateHz',v=>isFinite(Number(v))?Number(v):48000);syncIngInput('ing-aes67-channels','aes67.channels',v=>isFinite(Number(v))?Number(v):2);syncIngInput('ing-aes67-encoding','aes67.encoding',v=>String(v??'L24'));syncIngInput('ing-aes67-jitter','aes67.jitterDepthPackets',v=>isFinite(Number(v))?Number(v):8);syncIngChk('ing-aes67-discovery-enabled','aes67.discovery.enabled');syncIngInput('ing-aes67-discovery-name','aes67.discovery.streamName',v=>String(v??'')); const kind=String(ingVal('kind')||'none').toLowerCase(); $('ing-group-icecast').style.display=kind==='icecast'?'':'none';$('ing-group-srt').style.display=kind==='srt'?'':'none';$('ing-group-aes67').style.display=kind==='aes67'?'':'none'; - setText('ingest-meta',S.ingestSaving?'Saving…':S.ingestDirty?'Draft pending':'Saved + Hard Reload'); + setText('ingest-meta',S.ingestSaving?'Saving…':S.ingestDirty?'Hard reload required · draft pending':'Hard reload required'); $('ingest-save-reload').disabled=!S.ingestDirty||S.ingestSaving||!S.server.configOk;$('ingest-save-reload').textContent=S.ingestSaving?'Saving...':'Save + Hard Reload'; $('ingest-reset').disabled=!S.ingestDirty||S.ingestSaving; const iErr=$('ingest-error');if(iErr){iErr.textContent=S.ingestError||'';iErr.classList.toggle('show',!!S.ingestError);} @@ -1221,9 +1240,9 @@ function bindAll(){ $('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-af')?.addEventListener('input',e=>{const parsed=parseAFInput(e.target.value);if(parsed.error){setFieldError('rdsAF',parsed.error);return;}setFieldError('rdsAF','');cfgSetDirty('rdsAF',parsed.values);}); $('rds-features-apply')?.addEventListener('click',()=>applyCfgSection('rds-feat')); - $('rds-features-reset')?.addEventListener('click',()=>{cfgClear('rds-feat');toast('Draft reset','info');}); + $('rds-features-reset')?.addEventListener('click',()=>{cfgClear('rds-feat');setFieldError('rdsAF','');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));