Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

1259 lignes
120KB

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>fm-rds-tx</title>
  7. <style>
  8. @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap');
  9. :root {
  10. --bg:#f7f8fb; --surface:#ffffff; --surface2:#f4f6f8; --surface3:#e8ecf1;
  11. --border:#d7dcdf; --border-strong:#bcc5ce;
  12. --text:#111724; --text-dim:#4c5a6a; --text-muted:#6b7683;
  13. --accent:#1f4d9d; --accent-soft:rgba(31,77,157,.08);
  14. --green:#0d944a; --amber:#b7791f; --red:#b03030;
  15. --green-soft:rgba(13,148,74,.1); --amber-soft:rgba(183,121,31,.12); --red-soft:rgba(176,48,48,.1);
  16. --mono:'JetBrains Mono',monospace; --display:'Inter',sans-serif;
  17. --radius:8px; --shadow:0 8px 24px rgba(15,23,42,.08);
  18. }
  19. *{box-sizing:border-box;margin:0;padding:0}
  20. html{color-scheme:light}
  21. body{background:linear-gradient(180deg,#fbfcfe 0%,var(--bg) 100%);color:var(--text);font-family:var(--display);font-size:14px;line-height:1.5;min-height:100vh;overflow-x:hidden}
  22. button,input,select{font:inherit}button{user-select:none}
  23. .app{max-width:1200px;margin:0 auto;padding:24px}
  24. /* Header */
  25. .header{display:flex;align-items:flex-start;justify-content:space-between;gap:18px;padding:4px 0 20px;border-bottom:1px solid var(--border);margin-bottom:20px}
  26. .header-main{display:flex;flex-direction:column;gap:8px}
  27. .header h1{font-size:28px;font-weight:800;letter-spacing:-.03em}
  28. .header-note{font-size:13px;color:var(--text-muted)}
  29. .header-sub{display:flex;flex-wrap:wrap;gap:8px}
  30. .badge{display:inline-flex;align-items:center;gap:8px;min-height:28px;padding:0 10px;border:1px solid var(--border);border-radius:999px;background:var(--surface2);color:var(--text-dim);font-size:11px;text-transform:uppercase;letter-spacing:.08em}
  31. .badge strong{white-space:nowrap}
  32. .badge .tag{margin-left:2px}
  33. .badge strong{color:var(--text);font-weight:700}
  34. .header-status{display:flex;align-items:center;gap:10px;padding-top:6px}
  35. /* LED */
  36. .led{width:10px;height:10px;border-radius:50%;background:#333;transition:all .25s;flex-shrink:0}
  37. .led.on-green{background:var(--green);box-shadow:0 0 0 3px rgba(13,148,74,.16)}
  38. .led.on-red{background:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.14)}
  39. .led.on-amber{background:var(--amber);box-shadow:0 0 0 3px rgba(183,121,31,.14)}
  40. .led.on-blue{background:var(--accent);box-shadow:0 0 0 3px rgba(31,77,157,.14)}
  41. .status-text{font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.08em}
  42. /* Tabs */
  43. .tab-bar{display:flex;gap:2px;border-bottom:1px solid var(--border);margin:0 0 20px;flex-wrap:wrap;position:sticky;top:0;background:rgba(247,248,251,.95);backdrop-filter:blur(8px);z-index:10}
  44. .tab-btn{border:none;border-bottom:3px solid transparent;border-radius:0;background:transparent;color:var(--text-dim);font-size:13px;font-weight:700;padding:12px 14px 11px;cursor:pointer;transition:color .16s,border-color .16s}
  45. .tab-btn.active{border-bottom-color:var(--accent);color:var(--accent)}
  46. .tab-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
  47. .tab-panels{display:flex;flex-direction:column;gap:14px}
  48. .tab-panel{display:none}.tab-panel.active{display:block}
  49. .tab-columns{display:grid;gap:16px}
  50. .tab-columns.two{grid-template-columns:minmax(0,1.35fr) minmax(0,.85fr)}
  51. .tab-columns.one{grid-template-columns:1fr}
  52. .stack{display:flex;flex-direction:column;gap:12px}
  53. /* Cards */
  54. .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow)}
  55. .hero{padding:16px}
  56. /* TX Bar */
  57. .tx-bar{position:relative;z-index:1;display:grid;grid-template-columns:minmax(180px,250px) 1fr auto;gap:14px;align-items:center}
  58. .freq-display-label{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim)}
  59. .freq-display{font-size:40px;color:var(--text);letter-spacing:-.04em;line-height:1;font-weight:800}
  60. .freq-display .unit{font-family:var(--mono);font-size:14px;color:var(--text-dim);margin-left:5px}
  61. .freq-note{display:flex;gap:12px;margin-top:6px;font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.08em}
  62. .freq-note-item{display:inline-flex;align-items:center;gap:4px}
  63. .freq-note.mismatch .freq-note-item{color:var(--amber)}
  64. .tx-actions{display:flex;flex-wrap:wrap;gap:10px}
  65. .tx-state-wrap{display:flex;flex-direction:column;align-items:flex-end;gap:6px}
  66. .tx-state{font-size:11px;text-transform:uppercase;letter-spacing:2px;color:var(--text-dim)}
  67. .tx-state.running{color:var(--green)}.tx-state.working,.tx-state.starting,.tx-state.stopping{color:var(--amber)}
  68. .tx-state.faulted,.tx-state.error{color:var(--red)}
  69. .status-hint{font-size:10px;color:var(--text-muted);text-align:right}
  70. /* Quick grid */
  71. .quick-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px;margin-top:16px}
  72. .quick-item{padding:12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface2)}
  73. .quick-item .label{font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:6px}
  74. .quick-item .value{font-size:18px;font-weight:700}
  75. .quick-item .value.warn{color:var(--amber)}.quick-item .value.err{color:var(--accent)}.quick-item .value.good{color:var(--green)}
  76. /* Signal grid */
  77. .signal-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:12px}
  78. .signal-card{padding:12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface2)}
  79. .signal-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px}
  80. .signal-title{font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.08em}
  81. .signal-value{font-size:11px;font-weight:700}
  82. /* Meters */
  83. .meter{width:100%;height:10px;border-radius:999px;background:#e7ebf0;border:1px solid var(--border);overflow:hidden}
  84. .meter-fill{height:100%;width:0%;transition:width .25s,background-color .25s;background:linear-gradient(90deg,var(--green),#3dbd75)}
  85. .meter-fill.warn{background:linear-gradient(90deg,var(--amber),#d9a14a)}.meter-fill.err{background:linear-gradient(90deg,var(--red),#d85c5c)}
  86. /* Sparklines */
  87. .spark{width:100%;height:34px;margin-top:10px;border-radius:6px;background:#f7f9fb;border:1px solid #e3e8ee}
  88. .spark path.line{fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
  89. .spark path.area{opacity:.14}
  90. .spark.good path.line{stroke:var(--green)}.spark.good path.area{fill:var(--green)}
  91. .spark.warn path.line{stroke:var(--amber)}.spark.warn path.area{fill:var(--amber)}
  92. .spark.err path.line{stroke:var(--accent)}.spark.err path.area{fill:var(--accent)}
  93. /* Panels */
  94. .panel{overflow:hidden}
  95. .panel-head{display:flex;align-items:center;gap:8px;padding:12px 14px;border-bottom:1px solid var(--border);background:var(--surface2);cursor:pointer;user-select:none}
  96. .panel-head:hover{background:#eef2f6}
  97. .panel-head h2{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1.6px;color:var(--text-dim)}
  98. .panel-head .meta{margin-left:auto;margin-right:8px;font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px;text-align:right}
  99. .panel-head .chevron{color:var(--text-dim);transition:transform .2s;font-size:10px}
  100. .panel-head.collapsed .chevron{transform:rotate(-90deg)}
  101. .panel-body{padding:14px}.panel-body.collapsed{display:none}
  102. /* Controls */
  103. .section-note{font-size:11px;color:var(--text-muted);margin-bottom:12px}
  104. .ctrl-row{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border)}
  105. .ctrl-row:last-child{border-bottom:none}
  106. .ctrl-label-wrap{min-width:140px;display:flex;flex-direction:column;gap:2px}
  107. .ctrl-label{font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.8px}
  108. .ctrl-sub{font-size:10px;color:var(--text-muted)}
  109. .ctrl-input{flex:1;display:flex;align-items:center;gap:10px}
  110. /* Tags */
  111. .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}
  112. .tag-live{background:rgba(13,148,74,.12);color:var(--green)}
  113. .tag-saved{background:rgba(31,77,157,.12);color:var(--accent)}
  114. .tag-restart{background:rgba(183,121,31,.12);color:var(--amber)}
  115. .tag-reload{background:rgba(17,23,36,.08);color:var(--text-dim)}
  116. /* Buttons */
  117. .tx-btn,.ghost-btn,.apply-btn,.preset-btn,.danger-btn{min-height:40px;padding:0 18px;border-radius:var(--radius);border:1px solid var(--border);background:var(--surface2);color:var(--text);cursor:pointer;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;transition:transform .16s,border-color .16s,background .16s}
  118. .tx-btn:hover:not(:disabled),.ghost-btn:hover:not(:disabled),.apply-btn:hover:not(:disabled),.preset-btn:hover:not(:disabled),.danger-btn:hover:not(:disabled){transform:translateY(-1px);border-color:var(--border-strong)}
  119. .tx-btn:disabled,.ghost-btn:disabled,.apply-btn:disabled,.preset-btn:disabled,.danger-btn:disabled{opacity:.45;cursor:not-allowed;transform:none}
  120. .tx-btn.start{background:var(--green);color:#fff;border-color:transparent}
  121. .tx-btn.start:hover:not(:disabled){background:#0b7f40}
  122. .tx-btn.stop{background:#fff;color:var(--red);border-color:rgba(176,48,48,.35)}
  123. .tx-btn.stop:hover:not(:disabled){background:var(--red-soft)}
  124. .ghost-btn{color:var(--text-dim)}
  125. .danger-btn{border-color:rgba(176,48,48,.35);color:var(--red);background:var(--red-soft)}
  126. .danger-btn:hover:not(:disabled){background:rgba(176,48,48,.16)}
  127. .apply-btn{background:var(--accent);border-color:transparent;color:#fff}
  128. .apply-btn.secondary{background:var(--surface2);color:var(--text-dim);border-color:var(--border)}
  129. .apply-btn:hover:not(:disabled){opacity:.88}
  130. /* Inputs */
  131. input[type="range"]{-webkit-appearance:none;appearance:none;flex:1;height:6px;background:linear-gradient(90deg,var(--border),var(--surface3));border-radius:999px;outline:none}
  132. input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%;background:var(--text);border:2px solid var(--bg);cursor:pointer;transition:background .15s,transform .15s}
  133. input[type="range"]::-webkit-slider-thumb:hover{background:var(--accent);transform:scale(1.06)}
  134. input[type="number"],input[type="text"],select{background:#fff;border:1px solid var(--border);border-radius:6px;color:var(--text);padding:8px 10px;outline:none;transition:border-color .15s,box-shadow .15s}
  135. input[type="number"]{width:92px;text-align:right}input[type="text"]{width:100%}
  136. select{min-width:120px}
  137. input:focus,select:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(31,77,157,.12)}
  138. input.input-dirty{border-color:var(--amber);box-shadow:0 0 0 3px rgba(183,121,31,.08)}
  139. input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.14);background:rgba(176,48,48,.04)}
  140. .val-display{min-width:64px;text-align:right;font-size:12px;font-weight:700}
  141. .unit-label{font-size:11px;color:var(--text-dim);min-width:44px}
  142. .field-error{display:none;margin-top:8px;font-size:11px;color:var(--accent)}
  143. .field-error.show{display:block}
  144. /* Toggles */
  145. .toggle-row{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 0;border-bottom:1px solid var(--border)}
  146. .toggle-row:last-child{border-bottom:none}
  147. .toggle-copy{display:flex;flex-direction:column;gap:3px}
  148. .toggle-copy .title{font-size:12px;font-weight:700}
  149. .toggle-copy .sub{font-size:10px;color:var(--text-muted)}
  150. .toggle-ctl{display:flex;align-items:center;gap:10px}
  151. .toggle{position:relative;width:42px;height:24px;background:var(--border);border-radius:999px;cursor:pointer;transition:all .2s;flex-shrink:0}
  152. .toggle::after{content:'';position:absolute;top:3px;left:3px;width:18px;height:18px;background:var(--text);border-radius:50%;transition:transform .2s}
  153. .toggle.on{background:var(--green)}.toggle.on::after{transform:translateX(18px)}
  154. .toggle.busy{opacity:.55;pointer-events:none}
  155. .toggle-state{min-width:36px;text-align:right;font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px}
  156. /* RDS */
  157. .rds-grid{display:grid;gap:12px}
  158. .rds-field{display:flex;flex-direction:column;gap:6px}
  159. .rds-input{width:100%;background:#fff;border:1px solid var(--border);border-radius:6px;color:var(--accent);font-family:var(--mono);font-size:15px;font-weight:700;padding:10px 12px;outline:none;letter-spacing:2px;text-transform:uppercase;transition:border-color .15s,box-shadow .15s}
  160. .rds-input.rt{color:var(--text);text-transform:none;letter-spacing:.5px;font-size:12px;font-weight:500}
  161. .rds-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(31,77,157,.12)}
  162. .rds-charcount{font-size:10px;color:var(--text-dim);text-align:right}
  163. .pi-display{font-family:var(--mono);font-size:22px;font-weight:700;color:var(--accent);letter-spacing:4px;padding:8px 0}
  164. /* Presets */
  165. .preset-row{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px}
  166. .preset-btn{min-height:34px;padding:0 12px;font-size:11px;letter-spacing:.08em;color:var(--text-dim)}
  167. .preset-btn.active{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
  168. .preset-btn.rds{text-transform:none;font-weight:600}
  169. .actions-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px}
  170. /* Sidebar/KV */
  171. .sidebar-card{padding:14px}
  172. .sidebar-section+.sidebar-section{margin-top:14px;padding-top:14px;border-top:1px solid var(--border)}
  173. .sidebar-title{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:10px}
  174. .kv{display:grid;grid-template-columns:auto 1fr;gap:8px 12px;align-items:start}
  175. .kv .k{font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.08em}
  176. .kv .v{font-size:12px;word-break:break-word}
  177. /* Health */
  178. .health-line{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 0;border-bottom:1px solid var(--border)}
  179. .health-line:last-child{border-bottom:none}
  180. .health-line .name{font-size:11px;color:var(--text-dim)}
  181. .health-line .val{font-size:11px;text-align:right}
  182. .health-line .val.good{color:var(--green)}.health-line .val.warn{color:var(--amber)}.health-line .val.err{color:var(--accent)}
  183. .health-trend{margin-top:10px}
  184. .health-trend-label{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted);margin-bottom:6px}
  185. /* History */
  186. .fault-history,.transition-history{margin-top:12px;padding:10px;border:1px solid var(--border);border-radius:6px;background:var(--surface2);font-size:11px;max-height:200px;overflow-y:auto;line-height:1.3}
  187. .fault-history-entry,.transition-history-entry{display:flex;justify-content:space-between;gap:10px;padding:4px 0;border-bottom:1px solid var(--border)}
  188. .fault-history-entry:last-child,.transition-history-entry:last-child{border-bottom:none}
  189. .fault-history-entry .fault-history-time,.transition-history-entry .transition-history-time{color:var(--text-dim)}
  190. .fault-history-entry.ok,.transition-history-entry.good{color:var(--green)}
  191. .fault-history-entry.warn,.transition-history-entry.warn{color:var(--amber)}
  192. .fault-history-entry.err,.transition-history-entry.err{color:var(--accent)}
  193. .fault-history-desc,.transition-history-desc{font-size:10px;flex:1;text-transform:uppercase;letter-spacing:.5px}
  194. .fault-history-empty,.transition-history-empty{padding:6px 0;color:var(--text-muted);font-size:11px}
  195. /* Audit */
  196. .audit-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 0;border-bottom:1px solid var(--border)}
  197. .audit-row:last-child{border-bottom:none}
  198. .audit-name{font-size:11px;color:var(--text-dim)}
  199. .audit-val{font-size:11px;font-weight:700}
  200. /* Ingest */
  201. .ingest-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}
  202. .ingest-grid .ctrl-row{align-items:flex-start;padding:0;border-bottom:none}
  203. .ingest-group{margin-top:12px;padding:10px;border:1px solid var(--border);border-radius:6px;background:var(--surface2)}
  204. .ingest-group-title{margin-bottom:8px;font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.08em}
  205. /* Log */
  206. .log{background:var(--surface2);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:11px;color:var(--text-dim);max-height:220px;overflow-y:auto;white-space:pre-wrap;word-break:break-word}
  207. .log .entry{padding:3px 0}
  208. .log .entry.err{color:var(--accent)}.log .entry.ok{color:var(--green)}.log .entry.warn{color:var(--amber)}
  209. .empty-log{color:var(--text-muted)}
  210. /* Shortcuts */
  211. .shortcuts-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 12px}
  212. .shortcut-line{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:7px 0;border-bottom:1px solid var(--border)}
  213. .shortcut-line:last-child{border-bottom:none}
  214. .shortcut-line .name{font-size:11px;color:var(--text-dim)}
  215. .shortcut-line .keys{display:inline-flex;gap:6px}
  216. .kbd{min-width:28px;padding:3px 7px;border:1px solid var(--border);border-bottom-width:2px;border-radius:6px;background:var(--surface2);font-size:10px;text-align:center}
  217. .section-note.reset-hint{font-size:11px;color:var(--text-dim);margin-top:10px;padding:10px 12px;border:1px dashed var(--border);border-radius:6px;background:#fbfcfe}
  218. /* Toast */
  219. .toast{position:fixed;right:16px;bottom:16px;max-width:min(420px,calc(100vw - 24px));padding:12px 15px;border-radius:var(--radius);font-size:12px;font-weight:700;z-index:2000;transform:translateY(60px);opacity:0;transition:all .25s;box-shadow:var(--shadow)}
  220. .toast.show{transform:translateY(0);opacity:1}
  221. .toast.ok{background:var(--green);color:var(--bg)}.toast.err{background:var(--accent);color:#fff}
  222. .toast.warn{background:var(--amber);color:#141414}.toast.info{background:var(--text-dim);color:#fff}
  223. /* Responsive */
  224. @media(max-width:980px){.tab-columns.two{grid-template-columns:1fr}.tx-bar{grid-template-columns:1fr}.tx-state-wrap{align-items:flex-start}.status-hint{text-align:left}.quick-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.signal-grid{grid-template-columns:1fr}}
  225. @media(max-width:640px){.app{padding:12px}.header{flex-direction:column;gap:10px}.header h1{font-size:22px}.badge{width:100%;justify-content:space-between}.badge strong{max-width:52%;overflow:hidden;text-overflow:ellipsis}.quick-grid{grid-template-columns:1fr 1fr;gap:8px}.ctrl-row{flex-direction:column;align-items:stretch}.ctrl-label-wrap{min-width:auto}.ctrl-input{flex-wrap:wrap}.ingest-grid{grid-template-columns:1fr}input[type="number"]{width:100%;text-align:left}.actions-row,.tx-actions{flex-direction:column}.tx-btn,.ghost-btn,.apply-btn,.preset-btn,.danger-btn{width:100%}.freq-display{font-size:31px}.shortcuts-grid{grid-template-columns:1fr}}
  226. </style>
  227. </head>
  228. <body>
  229. <div class="app">
  230. <div class="header">
  231. <div class="header-main">
  232. <h1>FM-RDS-TX Control Plane</h1>
  233. <div class="header-note">Operate confidently: tune fast, inspect state instantly, diagnose only when needed.</div>
  234. <div class="header-sub">
  235. <div class="badge"><span>Backend</span><strong id="badge-backend">--</strong></div>
  236. <div class="badge"><span>Mode</span><strong id="badge-mode">Control Plane</strong></div>
  237. <div class="badge"><span>Live Config</span><strong id="badge-live">--</strong></div>
  238. </div>
  239. </div>
  240. <div class="header-status">
  241. <div class="led" id="led-conn"></div>
  242. <div class="status-text" id="conn-label">connecting</div>
  243. </div>
  244. </div>
  245. <div class="tab-bar">
  246. <button class="tab-btn active" data-tab="overview" type="button">Overview</button>
  247. <button class="tab-btn" data-tab="tx" type="button">TX Control</button>
  248. <button class="tab-btn" data-tab="rds" type="button">RDS</button>
  249. <button class="tab-btn" data-tab="ingest" type="button">Ingest</button>
  250. <button class="tab-btn" data-tab="diagnostics" type="button">Diagnostics</button>
  251. <button class="tab-btn" data-tab="activity" type="button">Activity</button>
  252. </div>
  253. <div class="tab-panels">
  254. <!-- OVERVIEW TAB -->
  255. <section class="tab-panel active" data-tab-panel="overview">
  256. <div class="tab-columns two">
  257. <div class="stack">
  258. <div class="card hero" id="hero-card">
  259. <div class="tx-bar">
  260. <div>
  261. <div class="freq-display-label">Carrier</div>
  262. <div class="freq-display" id="freq-display">---.-<span class="unit">MHz</span></div>
  263. <div class="freq-note" id="freq-note">
  264. <span class="freq-note-item" id="freq-applied">Applied: --</span>
  265. <span class="freq-note-item" id="freq-desired">Desired: --</span>
  266. </div>
  267. </div>
  268. <div class="tx-actions">
  269. <button class="tx-btn start" id="btn-start" type="button">TX ON</button>
  270. <button class="tx-btn stop" id="btn-stop" type="button">TX OFF</button>
  271. <button class="ghost-btn" id="btn-refresh" type="button">Refresh</button>
  272. </div>
  273. <div class="tx-state-wrap">
  274. <div class="tx-state idle" id="tx-state">IDLE</div>
  275. <div class="status-hint" id="tx-hint">Waiting for runtime telemetry</div>
  276. </div>
  277. </div>
  278. <div class="quick-grid">
  279. <div class="quick-item"><div class="label">Chunks</div><div class="value" id="t-chunks">--</div></div>
  280. <div class="quick-item"><div class="label">Samples</div><div class="value" id="t-samples">--</div></div>
  281. <div class="quick-item"><div class="label">Underruns</div><div class="value" id="t-underruns">--</div></div>
  282. <div class="quick-item"><div class="label">Uptime</div><div class="value" id="t-uptime">--</div></div>
  283. <div class="quick-item"><div class="label">Rate</div><div class="value" id="t-rate">--</div></div>
  284. </div>
  285. <div class="signal-grid">
  286. <div class="signal-card"><div class="signal-head"><div class="signal-title">Audio Buffer</div><div class="signal-value" id="meter-audio-text">--</div></div><div class="meter"><div class="meter-fill" id="meter-audio-fill"></div></div><svg class="spark good" id="spark-audio" viewBox="0 0 160 34" preserveAspectRatio="none"></svg></div>
  287. <div class="signal-card"><div class="signal-head"><div class="signal-title">Stream Health</div><div class="signal-value" id="meter-stream-text">--</div></div><div class="meter"><div class="meter-fill" id="meter-stream-fill"></div></div><svg class="spark warn" id="spark-underruns" viewBox="0 0 160 34" preserveAspectRatio="none"></svg></div>
  288. <div class="signal-card"><div class="signal-head"><div class="signal-title">TX Activity</div><div class="signal-value" id="meter-tx-text">--</div></div><div class="meter"><div class="meter-fill" id="meter-tx-fill"></div></div><svg class="spark good" id="spark-tx" viewBox="0 0 160 34" preserveAspectRatio="none"></svg></div>
  289. </div>
  290. </div>
  291. </div>
  292. <div class="stack">
  293. <div class="card sidebar-card">
  294. <div class="sidebar-section">
  295. <div class="sidebar-title">Runtime Snapshot</div>
  296. <div class="kv">
  297. <div class="k">Backend</div><div class="v" id="info-backend">--</div>
  298. <div class="k">Frequency</div><div class="v" id="info-freq">--</div>
  299. <div class="k">Pre-emphasis</div><div class="v" id="info-preemph">--</div>
  300. <div class="k">RDS PI</div><div class="v" id="info-pi" style="font-family:var(--mono);font-weight:700">--</div>
  301. <div class="k">RDS PTY</div><div class="v" id="info-pty">--</div>
  302. <div class="k">State Age</div><div class="v" id="info-runtime-age">--</div>
  303. <div class="k">Last Alert</div><div class="v" id="info-last-alert">--</div>
  304. </div>
  305. </div>
  306. <div class="sidebar-section">
  307. <div class="sidebar-title">Signal Chain</div>
  308. <div class="kv">
  309. <div class="k">Output Drive</div><div class="v" id="info-drive">--</div>
  310. <div class="k">Limiter</div><div class="v" id="info-limiter">--</div>
  311. <div class="k">Pilot Level</div><div class="v" id="info-pilot">--</div>
  312. <div class="k">RDS Inj.</div><div class="v" id="info-rdsinj">--</div>
  313. <div class="k">MPX Gain</div><div class="v" id="info-mpxgain">--</div>
  314. <div class="k">BS.412</div><div class="v" id="info-bs412">--</div>
  315. <div class="k">Comp. Clip</div><div class="v" id="info-compclip">--</div>
  316. </div>
  317. </div>
  318. </div>
  319. </div>
  320. </div>
  321. </section>
  322. <!-- TX CONTROL TAB -->
  323. <section class="tab-panel" data-tab-panel="tx">
  324. <div class="tab-columns two">
  325. <div class="stack">
  326. <!-- Frequency -->
  327. <div class="card panel" data-panel-key="frequency">
  328. <div class="panel-head" data-panel><div class="led on-green" style="width:6px;height:6px"></div><h2>Frequency</h2><div class="meta" id="freq-meta">Live + Saved</div><span class="chevron">▼</span></div>
  329. <div class="panel-body">
  330. <div class="section-note">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.</div>
  331. <div class="preset-row" id="freq-presets">
  332. <button class="preset-btn" type="button" data-freq-preset="87.6">87.6</button>
  333. <button class="preset-btn" type="button" data-freq-preset="94.5">94.5</button>
  334. <button class="preset-btn" type="button" data-freq-preset="99.5">99.5</button>
  335. <button class="preset-btn" type="button" data-freq-preset="100.0">100.0</button>
  336. <button class="preset-btn" type="button" data-freq-preset="107.9">107.9</button>
  337. </div>
  338. <div class="ctrl-row">
  339. <div class="ctrl-label-wrap"><span class="ctrl-label">TX Freq</span><span class="ctrl-sub">65–110 MHz</span></div>
  340. <div class="ctrl-input"><input type="range" min="65" max="110" step="0.1" id="freq-slider"><input type="number" min="65" max="110" step="0.1" id="freq-num"><span class="unit-label">MHz</span><span class="tag tag-live">live</span></div>
  341. </div>
  342. <div class="field-error" id="freq-error"></div>
  343. <div class="actions-row"><button class="apply-btn" id="freq-apply" type="button">Apply + Save Frequency</button><button class="apply-btn secondary" id="freq-reset" type="button">Reset Draft</button></div>
  344. </div>
  345. </div>
  346. <!-- Audio & Drive -->
  347. <div class="card panel" data-panel-key="audio">
  348. <div class="panel-head" data-panel><div class="led on-green" style="width:6px;height:6px"></div><h2>Audio &amp; Drive</h2><div class="meta" id="audio-meta">Mixed Apply Modes</div><span class="chevron">▼</span></div>
  349. <div class="panel-body">
  350. <div class="section-note">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.</div>
  351. <div class="ctrl-row">
  352. <div class="ctrl-label-wrap"><span class="ctrl-label">Output Drive</span><span class="ctrl-sub">0 – 10</span></div>
  353. <div class="ctrl-input"><input type="range" min="0" max="10" step="0.05" id="drive-slider"><span class="val-display" id="drive-val">--</span><span class="tag tag-live">live</span></div>
  354. </div>
  355. <div class="ctrl-row">
  356. <div class="ctrl-label-wrap"><span class="ctrl-label">Limiter Ceiling</span><span class="ctrl-sub">0.5 – 2.0</span></div>
  357. <div class="ctrl-input"><input type="range" min="0.5" max="2.0" step="0.05" id="lim-ceiling-slider"><span class="val-display" id="lim-ceiling-val">--</span><span class="tag tag-live">live</span></div>
  358. </div>
  359. <div class="ctrl-row">
  360. <div class="ctrl-label-wrap"><span class="ctrl-label">Pre-emphasis</span><span class="ctrl-sub">Region standard</span></div>
  361. <div class="ctrl-input"><select id="preemph-select"><option value="0">Off (0 µs)</option><option value="50">EU / CH (50 µs)</option><option value="75">US / JP (75 µs)</option></select><span class="tag tag-restart">restart</span></div>
  362. </div>
  363. <div class="ctrl-row">
  364. <div class="ctrl-label-wrap"><span class="ctrl-label">Input Gain</span><span class="ctrl-sub">0 – 4</span></div>
  365. <div class="ctrl-input"><input type="range" min="0" max="4" step="0.05" id="gain-slider"><span class="val-display" id="gain-val">--</span><span class="tag tag-restart">restart</span></div>
  366. </div>
  367. <div class="actions-row"><button class="apply-btn" id="audio-apply" type="button">Apply / Save Audio Settings</button><button class="apply-btn secondary" id="audio-reset" type="button">Reset Draft</button></div>
  368. <div class="section-note reset-hint">Live fields update immediately. Restart-tagged fields become effective after TX restart.</div>
  369. </div>
  370. </div>
  371. <!-- Test Tones -->
  372. <div class="card panel" data-panel-key="tones">
  373. <div class="panel-head" data-panel><h2>Test Tones</h2><div class="meta">Diagnostic</div><span class="chevron">▼</span></div>
  374. <div class="panel-body">
  375. <div class="section-note">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.</div>
  376. <div class="ctrl-row">
  377. <div class="ctrl-label-wrap"><span class="ctrl-label">Left (Hz)</span></div>
  378. <div class="ctrl-input"><input type="range" min="0" max="20000" step="10" id="tone-l-slider"><input type="number" min="0" max="20000" step="10" id="tone-l-num"><span class="unit-label">Hz</span><span class="tag tag-live">live</span></div>
  379. </div>
  380. <div class="ctrl-row">
  381. <div class="ctrl-label-wrap"><span class="ctrl-label">Right (Hz)</span></div>
  382. <div class="ctrl-input"><input type="range" min="0" max="20000" step="10" id="tone-r-slider"><input type="number" min="0" max="20000" step="10" id="tone-r-num"><span class="unit-label">Hz</span><span class="tag tag-live">live</span></div>
  383. </div>
  384. <div class="ctrl-row">
  385. <div class="ctrl-label-wrap"><span class="ctrl-label">Amplitude</span><span class="ctrl-sub">0 – 1.0</span></div>
  386. <div class="ctrl-input"><input type="range" min="0" max="1" step="0.01" id="tone-amp-slider"><span class="val-display" id="tone-amp-val">--</span><span class="tag tag-live">live</span></div>
  387. </div>
  388. <div class="actions-row"><button class="apply-btn" id="tones-apply" type="button">Save Tone Config</button><button class="apply-btn secondary" id="tones-off" type="button">Disable Tones</button></div>
  389. </div>
  390. </div>
  391. </div>
  392. <div class="stack">
  393. <!-- Switches -->
  394. <div class="card panel" data-panel-key="switches">
  395. <div class="panel-head" data-panel><div class="led on-green" style="width:6px;height:6px"></div><h2>Switches</h2><div class="meta">Live</div><span class="chevron">▼</span></div>
  396. <div class="panel-body">
  397. <div class="toggle-row">
  398. <div class="toggle-copy"><div class="title">Stereo</div><div class="sub">19 kHz pilot + 38 kHz DSB-SC</div></div>
  399. <div class="toggle-ctl"><div class="toggle" id="tog-stereo" data-toggle="stereoEnabled" role="switch" aria-checked="false" tabindex="0"></div><div class="toggle-state" id="stereo-label">--</div></div>
  400. </div>
  401. <div class="toggle-row">
  402. <div class="toggle-copy"><div class="title">Stereo Mode</div><div class="sub">Subcarrier modulation</div></div>
  403. <div class="toggle-ctl"><select id="sel-stereo-mode" style="font-size:13px;padding:4px 8px;border-radius:6px;border:1px solid var(--border);background:var(--bg-card);color:var(--fg)"><option value="DSB">DSB-SC (Standard)</option><option value="SSB">SSB-SC LSB</option><option value="VSB">VSB (Vestigial)</option></select></div>
  404. </div>
  405. <div class="toggle-row">
  406. <div class="toggle-copy"><div class="title">Limiter</div><div class="sub">Stereo limiter stage only; hard clips remain active</div></div>
  407. <div class="toggle-ctl"><div class="toggle" id="tog-limiter" data-toggle="limiterEnabled" role="switch" aria-checked="false" tabindex="0"></div><div class="toggle-state" id="limiter-label">--</div></div>
  408. </div>
  409. </div>
  410. </div>
  411. <!-- MPX Compliance -->
  412. <div class="card panel" data-panel-key="compliance">
  413. <div class="panel-head" data-panel><h2>MPX Compliance</h2><div class="meta" id="compliance-meta">BS.412</div><span class="chevron">▼</span></div>
  414. <div class="panel-body">
  415. <div class="section-note">ITU-R BS.412 limits total MPX power. Mandatory for licensed FM in EU/CH. Changes require TX restart.</div>
  416. <div class="toggle-row">
  417. <div class="toggle-copy"><div class="title">BS.412 Limiter</div><div class="sub">60 s rolling power window &nbsp;<span class="tag tag-restart">restart</span></div></div>
  418. <div class="toggle-ctl"><div class="toggle" id="tog-bs412" data-toggle-cfg="bs412Enabled" role="switch" aria-checked="false" tabindex="0"></div><div class="toggle-state" id="bs412-label">--</div></div>
  419. </div>
  420. <div class="ctrl-row">
  421. <div class="ctrl-label-wrap"><span class="ctrl-label">Threshold</span><span class="ctrl-sub">dBr — 0 = standard</span></div>
  422. <div class="ctrl-input"><input type="range" min="-6" max="6" step="0.5" id="bs412-threshold-slider"><span class="val-display" id="bs412-threshold-val">--</span><span class="unit-label">dBr</span><span class="tag tag-restart">restart</span></div>
  423. </div>
  424. <div class="ctrl-row">
  425. <div class="ctrl-label-wrap"><span class="ctrl-label">MPX Gain</span><span class="ctrl-sub">Hardware calibration</span></div>
  426. <div class="ctrl-input"><input type="range" min="0.1" max="5.0" step="0.05" id="mpxgain-slider"><span class="val-display" id="mpxgain-val">--</span><span class="tag tag-restart">restart</span></div>
  427. </div>
  428. <div class="actions-row"><button class="apply-btn" id="compliance-apply" type="button">Save Compliance Settings</button><button class="apply-btn secondary" id="compliance-reset" type="button">Reset Draft</button></div>
  429. <div class="section-note reset-hint">Compliance changes are persisted to config and require TX restart before they affect the modulation chain.</div>
  430. </div>
  431. </div>
  432. <!-- Composite Clipper -->
  433. <div class="card panel" data-panel-key="compclip">
  434. <div class="panel-head" data-panel><h2>Composite Clipper</h2><div class="meta" id="compclip-meta">SM.1268</div><span class="chevron">▼</span></div>
  435. <div class="panel-body">
  436. <div class="section-note">ITU-R SM.1268 iterative composite clipper. Enable/disable is live. Iterations, knee, and look-ahead require TX restart.</div>
  437. <div class="toggle-row">
  438. <div class="toggle-copy"><div class="title">Composite Clipper</div><div class="sub">Iterative clip-filter-clip + look-ahead &nbsp;<span class="tag tag-live">live</span></div></div>
  439. <div class="toggle-ctl"><div class="toggle" id="tog-compclip" data-toggle="compositeClipperEnabled" role="switch" aria-checked="false" tabindex="0"></div><div class="toggle-state" id="compclip-label">--</div></div>
  440. </div>
  441. <div class="ctrl-row">
  442. <div class="ctrl-label-wrap"><span class="ctrl-label">Iterations</span><span class="ctrl-sub">clip-filter passes (1-5)</span></div>
  443. <div class="ctrl-input"><input type="range" min="1" max="5" step="1" id="compclip-iter-slider"><span class="val-display" id="compclip-iter-val">--</span><span class="tag tag-restart">restart</span></div>
  444. </div>
  445. <div class="ctrl-row">
  446. <div class="ctrl-label-wrap"><span class="ctrl-label">Soft Knee</span><span class="ctrl-sub">0 = hard clip, 0.3 = gentle</span></div>
  447. <div class="ctrl-input"><input type="range" min="0" max="0.5" step="0.01" id="compclip-knee-slider"><span class="val-display" id="compclip-knee-val">--</span><span class="tag tag-restart">restart</span></div>
  448. </div>
  449. <div class="ctrl-row">
  450. <div class="ctrl-label-wrap"><span class="ctrl-label">Look-ahead</span><span class="ctrl-sub">0 = off, 1.0 ms = typical</span></div>
  451. <div class="ctrl-input"><input type="range" min="0" max="3" step="0.1" id="compclip-la-slider"><span class="val-display" id="compclip-la-val">--</span><span class="unit-label">ms</span><span class="tag tag-restart">restart</span></div>
  452. </div>
  453. <div class="actions-row"><button class="apply-btn" id="compclip-apply" type="button">Save Clipper Settings</button><button class="apply-btn secondary" id="compclip-reset" type="button">Reset Draft</button></div>
  454. <div class="section-note reset-hint">Structural clipper changes (iterations, knee, look-ahead) are persisted to config and require TX restart.</div>
  455. </div>
  456. </div>
  457. <!-- Danger -->
  458. <div class="card panel" data-panel-key="danger">
  459. <div class="panel-head" data-panel><h2>Danger Zone</h2><div class="meta">emergency</div><span class="chevron">▼</span></div>
  460. <div class="panel-body">
  461. <div class="actions-row" style="margin-top:0"><button class="danger-btn" id="danger-stop" type="button">Emergency Stop TX</button><button class="danger-btn" id="danger-reset-fault" type="button">Reset Fault</button></div>
  462. <div class="section-note reset-hint" id="reset-hint">Reset Fault moves the runtime back to DEGRADED while the queue settles.</div>
  463. </div>
  464. </div>
  465. <!-- Shortcuts -->
  466. <div class="card panel" data-panel-key="shortcuts">
  467. <div class="panel-head" data-panel><h2>Shortcuts</h2><div class="meta">keyboard</div><span class="chevron">▼</span></div>
  468. <div class="panel-body">
  469. <div class="shortcuts-grid">
  470. <div><div class="shortcut-line"><span class="name">Start TX</span><span class="keys"><span class="kbd">t</span></span></div><div class="shortcut-line"><span class="name">Stop TX</span><span class="keys"><span class="kbd">T</span></span></div><div class="shortcut-line"><span class="name">Refresh</span><span class="keys"><span class="kbd">r</span></span></div></div>
  471. <div><div class="shortcut-line"><span class="name">Next Preset</span><span class="keys"><span class="kbd">]</span></span></div><div class="shortcut-line"><span class="name">Prev Preset</span><span class="keys"><span class="kbd">[</span></span></div><div class="shortcut-line"><span class="name">Apply Draft</span><span class="keys"><span class="kbd">Enter</span></span></div></div>
  472. </div>
  473. </div>
  474. </div>
  475. </div>
  476. </div>
  477. </section>
  478. <!-- RDS TAB -->
  479. <section class="tab-panel" data-tab-panel="rds">
  480. <div class="tab-columns two">
  481. <div class="stack">
  482. <!-- Station Identity -->
  483. <div class="card panel" data-panel-key="rds-identity">
  484. <div class="panel-head" data-panel><div class="led on-amber" style="width:6px;height:6px"></div><h2>Station Identity</h2><div class="meta" id="rds-identity-meta">Restart required</div><span class="chevron">▼</span></div>
  485. <div class="panel-body">
  486. <div class="section-note">RDS enable applies live. PI and PTY are saved to config and take effect after the next TX restart.</div>
  487. <div class="ctrl-row">
  488. <div class="ctrl-label-wrap"><span class="ctrl-label">Enable RDS</span><span class="ctrl-sub">57 kHz subcarrier</span></div>
  489. <div class="ctrl-input"><div class="toggle" id="tog-rds" data-toggle="rdsEnabled" role="switch" aria-checked="false" tabindex="0"></div><div class="toggle-state" id="rds-label">--</div><span class="tag tag-live" style="margin-left:4px">live</span></div>
  490. </div>
  491. <div class="ctrl-row">
  492. <div class="ctrl-label-wrap"><span class="ctrl-label">PI Code</span><span class="ctrl-sub">Programme Identifier (hex)</span></div>
  493. <div class="ctrl-input" style="flex-direction:column;align-items:flex-start;gap:6px">
  494. <div style="display:flex;align-items:center;gap:10px;width:100%">
  495. <input type="text" id="rds-pi" maxlength="4" placeholder="1234" spellcheck="false" style="width:90px;font-family:var(--mono);font-size:15px;font-weight:700;letter-spacing:3px;text-transform:uppercase">
  496. <div class="pi-display" id="pi-display">0x----</div>
  497. <span class="tag tag-restart">restart</span>
  498. </div>
  499. <div class="field-error" id="pi-error"></div>
  500. </div>
  501. </div>
  502. <div class="ctrl-row">
  503. <div class="ctrl-label-wrap"><span class="ctrl-label">Programme Type</span><span class="ctrl-sub">PTY 0–31</span></div>
  504. <div class="ctrl-input">
  505. <select id="rds-pty" style="min-width:200px">
  506. <option value="0">0 — None</option><option value="1">1 — News</option><option value="2">2 — Current Affairs</option><option value="3">3 — Information</option><option value="4">4 — Sport</option><option value="5">5 — Education</option><option value="6">6 — Drama</option><option value="7">7 — Culture</option><option value="8">8 — Science</option><option value="9">9 — Varied</option><option value="10">10 — Pop Music</option><option value="11">11 — Rock Music</option><option value="12">12 — Easy Listening</option><option value="13">13 — Light Classical</option><option value="14">14 — Serious Classical</option><option value="15">15 — Other Music</option><option value="16">16 — Weather</option><option value="17">17 — Finance</option><option value="18">18 — Children's</option><option value="19">19 — Social Affairs</option><option value="20">20 — Religion</option><option value="21">21 — Phone-In</option><option value="22">22 — Travel</option><option value="23">23 — Leisure</option><option value="24">24 — Jazz Music</option><option value="25">25 — Country Music</option><option value="26">26 — National Music</option><option value="27">27 — Oldies Music</option><option value="28">28 — Folk Music</option><option value="29">29 — Documentary</option><option value="30">30 — Alarm Test</option><option value="31">31 — Alarm</option>
  507. </select>
  508. <span class="tag tag-restart">restart</span>
  509. </div>
  510. </div>
  511. <div class="actions-row"><button class="apply-btn" id="rds-identity-apply" type="button">Save Identity</button><button class="apply-btn secondary" id="rds-identity-reset" type="button">Reset Draft</button></div>
  512. <div class="section-note reset-hint">Identity settings persist immediately in config, but PI / PTY changes appear on-air only after TX restart.</div>
  513. </div>
  514. </div>
  515. <!-- On-Air Text -->
  516. <div class="card panel" data-panel-key="rds-text">
  517. <div class="panel-head" data-panel><div class="led on-green" style="width:6px;height:6px"></div><h2>On-Air Text</h2><div class="meta" id="rds-text-meta">Saved + Runtime</div><span class="chevron">▼</span></div>
  518. <div class="panel-body">
  519. <div class="section-note">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.</div>
  520. <div class="preset-row">
  521. <button class="preset-btn rds" type="button" data-rds-ps="FMRTX" data-rds-rt="fm-rds-tx live">Station ID</button>
  522. <button class="preset-btn rds" type="button" data-rds-ps="ONAIR" data-rds-rt="Now broadcasting">On Air</button>
  523. <button class="preset-btn rds" type="button" data-rds-ps="LIVE" data-rds-rt="Live set in progress">Live Set</button>
  524. <button class="preset-btn rds" type="button" data-rds-ps="TEST" data-rds-rt="RDS test transmission">Test</button>
  525. </div>
  526. <div class="rds-grid">
  527. <div class="rds-field">
  528. <div style="display:flex;align-items:center;justify-content:space-between"><span class="ctrl-label">Program Service (PS)</span><span class="tag tag-live">live</span></div>
  529. <input type="text" class="rds-input" id="rds-ps" maxlength="8" placeholder="STATION" spellcheck="false">
  530. <div class="rds-charcount"><span id="ps-count">0</span>/8</div>
  531. <div class="field-error" id="ps-error"></div>
  532. </div>
  533. <div class="rds-field">
  534. <div style="display:flex;align-items:center;justify-content:space-between"><span class="ctrl-label">RadioText (RT)</span><span class="tag tag-live">live</span></div>
  535. <input type="text" class="rds-input rt" id="rds-rt" maxlength="64" placeholder="Now playing..." spellcheck="false">
  536. <div class="rds-charcount"><span id="rt-count">0</span>/64</div>
  537. <div class="field-error" id="rt-error"></div>
  538. </div>
  539. </div>
  540. <div class="actions-row"><button class="apply-btn" id="rds-apply" type="button">Apply + Save RDS Text</button><button class="apply-btn secondary" id="rds-reset" type="button">Reset Draft</button></div>
  541. <div class="section-note">Saved config: <span id="rds-saved-rt">--</span><br>Active on-air text: <span id="rds-active-rt-inline">--</span></div>
  542. </div>
  543. </div>
  544. <!-- RDS Features -->
  545. <div class="card panel" data-panel-key="rds-features">
  546. <div class="panel-head" data-panel><div class="led on-amber" style="width:6px;height:6px"></div><h2>RDS Features</h2><div class="meta">Restart required</div><span class="chevron">▼</span></div>
  547. <div class="panel-body">
  548. <div class="section-note">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.</div>
  549. <div class="ctrl-row">
  550. <div class="ctrl-label-wrap"><span class="ctrl-label">Traffic Program (TP)</span><span class="ctrl-sub">Station carries traffic info</span></div>
  551. <div class="ctrl-input"><input type="checkbox" id="rds-tp"><span class="tag tag-live">live</span><span class="tag tag-saved">saved</span></div>
  552. </div>
  553. <div class="ctrl-row">
  554. <div class="ctrl-label-wrap"><span class="ctrl-label">Traffic Announcement (TA)</span><span class="ctrl-sub">Currently on air</span></div>
  555. <div class="ctrl-input"><input type="checkbox" id="rds-ta"><span class="tag tag-live">live</span><span class="tag tag-saved">saved</span></div>
  556. </div>
  557. <div class="ctrl-row">
  558. <div class="ctrl-label-wrap"><span class="ctrl-label">Music / Speech</span><span class="ctrl-sub">MS flag for receivers</span></div>
  559. <div class="ctrl-input"><select id="rds-ms"><option value="true">Music</option><option value="false">Speech</option></select><span class="tag tag-restart">restart</span></div>
  560. </div>
  561. <div class="ctrl-row">
  562. <div class="ctrl-label-wrap"><span class="ctrl-label">Clock-Time (CT)</span><span class="ctrl-sub">Group 4A, UTC, 1×/min</span></div>
  563. <div class="ctrl-input"><input type="checkbox" id="rds-ct"><span class="tag tag-restart">restart</span></div>
  564. </div>
  565. <div class="ctrl-row">
  566. <div class="ctrl-label-wrap"><span class="ctrl-label">RT+ Auto-Parse</span><span class="ctrl-sub">Artist/Title from RadioText</span></div>
  567. <div class="ctrl-input"><input type="checkbox" id="rds-rtplus"><span class="tag tag-restart">restart</span></div>
  568. </div>
  569. <div class="ctrl-row">
  570. <div class="ctrl-label-wrap"><span class="ctrl-label">RT+ Separator</span><span class="ctrl-sub">Split char(s) in RT</span></div>
  571. <div class="ctrl-input"><input type="text" id="rds-rtplus-sep" maxlength="5" value=" - " style="width:60px;font-family:var(--mono);text-align:center"><span class="tag tag-restart">restart</span></div>
  572. </div>
  573. <div class="ctrl-row">
  574. <div class="ctrl-label-wrap"><span class="ctrl-label">PTYN</span><span class="ctrl-sub">Custom type name, 8 chars</span></div>
  575. <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>
  576. </div>
  577. <div class="ctrl-row">
  578. <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>
  579. <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>
  580. </div>
  581. <div class="ctrl-row">
  582. <div class="ctrl-label-wrap"><span class="ctrl-label">Alt. Frequencies</span><span class="ctrl-sub">Comma-separated MHz</span></div>
  583. <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>
  584. </div>
  585. <div class="ctrl-row">
  586. <div class="ctrl-label-wrap"><span class="ctrl-label">eRT (Enhanced RT)</span><span class="ctrl-sub">UTF-8, 128 bytes, ODA</span></div>
  587. <div class="ctrl-input"><input type="checkbox" id="rds-ert-on"><span class="tag tag-restart">restart</span></div>
  588. </div>
  589. <div class="ctrl-row">
  590. <div class="ctrl-label-wrap"><span class="ctrl-label">eRT Text</span><span class="ctrl-sub">UTF-8 multilingual text</span></div>
  591. <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>
  592. </div>
  593. <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>
  594. </div>
  595. </div>
  596. <!-- RDS2 -->
  597. <div class="card panel" data-panel-key="rds2">
  598. <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>
  599. <div class="panel-body">
  600. <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>
  601. <div class="ctrl-row">
  602. <div class="ctrl-label-wrap"><span class="ctrl-label">RDS2 Enable</span><span class="ctrl-sub">Activate streams 1-3</span></div>
  603. <div class="ctrl-input"><input type="checkbox" id="rds2-on"><span class="tag tag-restart">restart</span></div>
  604. </div>
  605. <div class="ctrl-row">
  606. <div class="ctrl-label-wrap"><span class="ctrl-label">Station Logo</span><span class="ctrl-sub">PNG/JPEG path on server</span></div>
  607. <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>
  608. </div>
  609. <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>
  610. </div>
  611. </div>
  612. </div>
  613. <div class="stack">
  614. <!-- Injection Levels -->
  615. <div class="card panel" data-panel-key="rds-levels">
  616. <div class="panel-head" data-panel><div class="led on-green" style="width:6px;height:6px"></div><h2>Injection Levels</h2><div class="meta">Live + Saved</div><span class="chevron">▼</span></div>
  617. <div class="panel-body">
  618. <div class="section-note">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.</div>
  619. <div class="ctrl-row">
  620. <div class="ctrl-label-wrap"><span class="ctrl-label">Pilot Level</span><span class="ctrl-sub">19 kHz, 0–20%</span></div>
  621. <div class="ctrl-input"><input type="range" min="0" max="0.2" step="0.001" id="pilot-slider"><span class="val-display" id="pilot-val">--</span><span class="unit-label">%dev</span><span class="tag tag-live">live</span></div>
  622. </div>
  623. <div class="ctrl-row">
  624. <div class="ctrl-label-wrap"><span class="ctrl-label">RDS Injection</span><span class="ctrl-sub">57 kHz, 0–15%</span></div>
  625. <div class="ctrl-input"><input type="range" min="0" max="0.15" step="0.001" id="rdsinj-slider"><span class="val-display" id="rdsinj-val">--</span><span class="unit-label">%dev</span><span class="tag tag-live">live</span></div>
  626. </div>
  627. <div class="actions-row"><button class="apply-btn" id="rds-levels-apply" type="button">Apply + Save Levels</button><button class="apply-btn secondary" id="rds-levels-reset" type="button">Reset Draft</button></div>
  628. </div>
  629. </div>
  630. <!-- RDS Status -->
  631. <div class="card sidebar-card">
  632. <div class="sidebar-section">
  633. <div class="sidebar-title">RDS Runtime</div>
  634. <div class="kv">
  635. <div class="k">Enabled</div><div class="v" id="rds-stat-enabled">--</div>
  636. <div class="k">PI Code</div><div class="v" id="rds-stat-pi" style="font-family:var(--mono);font-weight:700">--</div>
  637. <div class="k">PTY</div><div class="v" id="rds-stat-pty">--</div>
  638. <div class="k">PS</div><div class="v" id="rds-stat-ps" style="font-family:var(--mono);font-weight:700;letter-spacing:1px">--</div>
  639. <div class="k">Active RadioText</div><div class="v" id="rds-stat-rt">--</div>
  640. <div class="k">Saved RadioText</div><div class="v" id="rds-stat-rt-saved">--</div>
  641. <div class="k">Pilot</div><div class="v" id="rds-stat-pilot">--</div>
  642. <div class="k">RDS Inj.</div><div class="v" id="rds-stat-inj">--</div>
  643. </div>
  644. </div>
  645. </div>
  646. </div>
  647. </div>
  648. </section>
  649. <!-- INGEST TAB -->
  650. <section class="tab-panel" data-tab-panel="ingest">
  651. <div class="tab-columns one"><div class="stack">
  652. <div class="card sidebar-card">
  653. <div class="sidebar-section">
  654. <div class="sidebar-title">Active Ingest Summary</div>
  655. <div class="section-note">Runtime snapshot. Deep metrics in Diagnostics.</div>
  656. <div class="kv">
  657. <div class="k">State</div><div class="v" id="ingest-summary-state">--</div>
  658. <div class="k">Source</div><div class="v" id="ingest-summary-source">--</div>
  659. <div class="k">Signal</div><div class="v" id="ingest-summary-signal">--</div>
  660. <div class="k">Detail</div><div class="v" id="ingest-summary-detail">--</div>
  661. <div class="k">Origin</div><div class="v" id="ingest-summary-origin">--</div>
  662. <div class="k">Last Chunk</div><div class="v" id="ingest-summary-last">--</div>
  663. </div>
  664. </div>
  665. </div>
  666. <div class="card panel" data-panel-key="ingest">
  667. <div class="panel-head" data-panel><div class="led on-blue" style="width:6px;height:6px"></div><h2>Ingest Config</h2><div class="meta" id="ingest-meta">Saved + Hard Reload</div><span class="chevron">▼</span></div>
  668. <div class="panel-body">
  669. <div class="section-note">Changes are saved to the config file and take effect only after a hard reload of the service.</div>
  670. <div class="ingest-grid">
  671. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">Kind</span></div><div class="ctrl-input"><select id="ing-kind" data-ingest-path="kind"><option value="none">none</option><option value="icecast">icecast</option><option value="srt">srt</option><option value="aes67">aes67</option><option value="stdin">stdin</option><option value="http-raw">http-raw</option></select></div></div>
  672. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">Prebuffer</span><span class="ctrl-sub">ms</span></div><div class="ctrl-input"><input type="number" id="ing-prebuffer" data-ingest-path="prebufferMs"></div></div>
  673. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">Reconnect</span></div><div class="ctrl-input"><label><input type="checkbox" id="ing-reconnect-enabled" data-ingest-path="reconnect.enabled"> Enabled</label></div></div>
  674. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">Backoff Initial</span><span class="ctrl-sub">ms</span></div><div class="ctrl-input"><input type="number" id="ing-reconnect-initial" data-ingest-path="reconnect.initialBackoffMs"></div></div>
  675. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">Backoff Max</span><span class="ctrl-sub">ms</span></div><div class="ctrl-input"><input type="number" id="ing-reconnect-max" data-ingest-path="reconnect.maxBackoffMs"></div></div>
  676. </div>
  677. <div class="ingest-group" id="ing-group-icecast">
  678. <div class="ingest-group-title">Icecast</div>
  679. <div class="ingest-grid">
  680. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">URL</span></div><div class="ctrl-input"><input type="text" id="ing-icecast-url" data-ingest-path="icecast.url" spellcheck="false"></div></div>
  681. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">Decoder</span></div><div class="ctrl-input"><select id="ing-icecast-decoder" data-ingest-path="icecast.decoder"><option value="auto">auto</option><option value="native">native</option><option value="ffmpeg">ffmpeg</option></select></div></div>
  682. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">RT Relay</span></div><div class="ctrl-input"><label><input type="checkbox" id="ing-icecast-rt-enabled" data-ingest-path="icecast.radioText.enabled"> Enabled</label></div></div>
  683. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">RT Prefix</span></div><div class="ctrl-input"><input type="text" id="ing-icecast-rt-prefix" data-ingest-path="icecast.radioText.prefix"></div></div>
  684. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">RT MaxLen</span></div><div class="ctrl-input"><input type="number" id="ing-icecast-rt-maxlen" data-ingest-path="icecast.radioText.maxLen"></div></div>
  685. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">Only On Change</span></div><div class="ctrl-input"><label><input type="checkbox" id="ing-icecast-rt-only-change" data-ingest-path="icecast.radioText.onlyOnChange"> Enabled</label></div></div>
  686. </div>
  687. </div>
  688. <div class="ingest-group" id="ing-group-srt">
  689. <div class="ingest-group-title">SRT</div>
  690. <div class="ingest-grid">
  691. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">URL</span></div><div class="ctrl-input"><input type="text" id="ing-srt-url" data-ingest-path="srt.url" spellcheck="false"></div></div>
  692. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">Mode</span></div><div class="ctrl-input"><select id="ing-srt-mode" data-ingest-path="srt.mode"><option value="listener">listener</option><option value="caller">caller</option><option value="rendezvous">rendezvous</option></select></div></div>
  693. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">Sample Rate</span></div><div class="ctrl-input"><input type="number" id="ing-srt-rate" data-ingest-path="srt.sampleRateHz"></div></div>
  694. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">Channels</span></div><div class="ctrl-input"><input type="number" id="ing-srt-channels" data-ingest-path="srt.channels"></div></div>
  695. </div>
  696. </div>
  697. <div class="ingest-group" id="ing-group-aes67">
  698. <div class="ingest-group-title">AES67</div>
  699. <div class="ingest-grid">
  700. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">Multicast Group</span></div><div class="ctrl-input"><input type="text" id="ing-aes67-group" data-ingest-path="aes67.multicastGroup" spellcheck="false"></div></div>
  701. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">Port</span></div><div class="ctrl-input"><input type="number" id="ing-aes67-port" data-ingest-path="aes67.port"></div></div>
  702. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">SDP Path</span></div><div class="ctrl-input"><input type="text" id="ing-aes67-sdppath" data-ingest-path="aes67.sdpPath" spellcheck="false"></div></div>
  703. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">Sample Rate</span></div><div class="ctrl-input"><input type="number" id="ing-aes67-rate" data-ingest-path="aes67.sampleRateHz"></div></div>
  704. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">Channels</span></div><div class="ctrl-input"><input type="number" id="ing-aes67-channels" data-ingest-path="aes67.channels"></div></div>
  705. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">Encoding</span></div><div class="ctrl-input"><input type="text" id="ing-aes67-encoding" data-ingest-path="aes67.encoding"></div></div>
  706. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">Jitter Depth</span></div><div class="ctrl-input"><input type="number" id="ing-aes67-jitter" data-ingest-path="aes67.jitterDepthPackets"></div></div>
  707. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">Discovery</span></div><div class="ctrl-input"><label><input type="checkbox" id="ing-aes67-discovery-enabled" data-ingest-path="aes67.discovery.enabled"> Enabled</label></div></div>
  708. <div class="ctrl-row"><div class="ctrl-label-wrap"><span class="ctrl-label">Stream Name</span></div><div class="ctrl-input"><input type="text" id="ing-aes67-discovery-name" data-ingest-path="aes67.discovery.streamName"></div></div>
  709. </div>
  710. </div>
  711. <div class="field-error" id="ingest-error"></div>
  712. <div class="actions-row"><button class="apply-btn" id="ingest-save-reload" type="button">Save + Hard Reload Service</button><button class="apply-btn secondary" id="ingest-reset" type="button">Reset Draft</button></div>
  713. <div class="section-note reset-hint">Ingest changes are not hot-applied. Saving writes config and schedules a hard service reload.</div>
  714. </div>
  715. </div>
  716. </div></div>
  717. </section>
  718. <!-- DIAGNOSTICS TAB -->
  719. <section class="tab-panel" data-tab-panel="diagnostics">
  720. <div class="tab-columns two">
  721. <div class="stack">
  722. <div class="card sidebar-card">
  723. <div class="sidebar-section">
  724. <div class="sidebar-title">Health</div>
  725. <div class="health-line"><div class="name">HTTP</div><div class="val" id="health-http">--</div></div>
  726. <div class="health-line"><div class="name">Runtime State</div><div class="val" id="health-runtime">--</div></div>
  727. <div class="health-line"><div class="name">State Age</div><div class="val" id="health-state-age">--</div></div>
  728. <div class="health-line"><div class="name">Runtime Signal</div><div class="val" id="health-indicator">--</div></div>
  729. <div class="health-line"><div class="name">Runtime Alert</div><div class="val" id="health-alert">--</div></div>
  730. <div class="health-line"><div class="name">Transitions D/M/F</div><div class="val" id="health-transitions">--</div></div>
  731. <div class="health-line"><div class="name">Fault Count</div><div class="val" id="health-fault-count">--</div></div>
  732. <div class="health-line"><div class="name">Last Fault</div><div class="val" id="health-last-fault">--</div></div>
  733. <div class="health-line"><div class="name">Audio Buffer</div><div class="val" id="health-audio">--</div></div>
  734. <div class="health-line"><div class="name">Buffer Duration</div><div class="val" id="health-buffer-duration">--</div></div>
  735. <div class="health-line"><div class="name">High Watermark</div><div class="val" id="health-buffer-highwater">--</div></div>
  736. <div class="health-line"><div class="name">Queue Fill</div><div class="val" id="health-queue-fill">--</div></div>
  737. <div class="health-line"><div class="name">Underrun Streak</div><div class="val" id="health-underrun-streak">--</div></div>
  738. <div class="health-line"><div class="name">Last Update</div><div class="val" id="health-last">--</div></div>
  739. <div class="health-trend"><div class="health-trend-label">High Watermark Trend</div><svg class="spark warn" id="spark-high-watermark" viewBox="0 0 160 34" preserveAspectRatio="none"></svg></div>
  740. <div class="health-trend"><div class="health-trend-label">Queue Fill Trend</div><svg class="spark good" id="spark-queue-fill" viewBox="0 0 160 34" preserveAspectRatio="none"></svg></div>
  741. </div>
  742. </div>
  743. <div class="card sidebar-card">
  744. <div class="sidebar-section">
  745. <div class="sidebar-title">Control Audit</div>
  746. <div class="section-note">4xx reject counts from the control API.</div>
  747. <div class="audit-row"><span class="audit-name">Total rejects</span><span class="audit-val" id="audit-total">--</span></div>
  748. <div class="audit-row"><span class="audit-name">405 Method Not Allowed</span><span class="audit-val" id="audit-methodNotAllowed">--</span></div>
  749. <div class="audit-row"><span class="audit-name">415 Unsupported Media</span><span class="audit-val" id="audit-unsupportedMediaType">--</span></div>
  750. <div class="audit-row"><span class="audit-name">413 Body Too Large</span><span class="audit-val" id="audit-bodyTooLarge">--</span></div>
  751. <div class="audit-row"><span class="audit-name">400 Unexpected Body</span><span class="audit-val" id="audit-unexpectedBody">--</span></div>
  752. </div>
  753. </div>
  754. </div>
  755. <div class="stack">
  756. <div class="card panel" data-panel-key="transition-history">
  757. <div class="panel-head" data-panel><h2>Transition History</h2><div class="meta">state shifts</div><span class="chevron">▼</span></div>
  758. <div class="panel-body"><div class="transition-history" id="transition-history"><div class="transition-history-empty">No state transitions recorded yet.</div></div></div>
  759. </div>
  760. <div class="card panel" data-panel-key="fault-history">
  761. <div class="panel-head" data-panel><h2>Fault History</h2><div class="meta">recent faults</div><span class="chevron">▼</span></div>
  762. <div class="panel-body"><div class="fault-history" id="fault-history"><div class="fault-history-empty">No faults recorded yet.</div></div></div>
  763. </div>
  764. </div>
  765. </div>
  766. </section>
  767. <!-- ACTIVITY TAB -->
  768. <section class="tab-panel" data-tab-panel="activity">
  769. <div class="tab-columns one"><div class="stack">
  770. <div class="card panel" data-panel-key="log">
  771. <div class="panel-head" data-panel><h2>Activity Log</h2><div class="meta" id="log-meta">recent events</div><span class="chevron">▼</span></div>
  772. <div class="panel-body">
  773. <div class="actions-row" style="margin-top:0;margin-bottom:12px"><button class="ghost-btn" id="btn-clear-log" type="button">Clear Log</button></div>
  774. <div class="log" id="log"><div class="empty-log">No activity recorded yet.</div></div>
  775. </div>
  776. </div>
  777. </div></div>
  778. </section>
  779. </div><!-- /tab-panels -->
  780. </div><!-- /app -->
  781. <div class="toast" id="toast"></div>
  782. <script>
  783. 'use strict';
  784. const $=id=>document.getElementById(id);
  785. const RUNTIME_MS=1000,CONFIG_MS=8000,SPARK_LIMIT=40,TRANS_LIMIT=6;
  786. const FREQ_PRESETS=[87.6,94.5,99.5,100.0,107.9];
  787. const PTY_NAMES=['None','News','Current Affairs','Information','Sport','Education','Drama','Culture','Science','Varied','Pop Music','Rock Music','Easy Listening','Light Classical','Serious Classical','Other Music','Weather','Finance',"Children's",'Social Affairs','Religion','Phone-In','Travel','Leisure','Jazz Music','Country Music','National Music','Oldies Music','Folk Music','Documentary','Alarm Test','Alarm'];
  788. const mobileMq=window.matchMedia('(max-width:640px)');
  789. let toastTimer=null;
  790. const S={
  791. server:{config:null,runtime:null,configOk:false,runtimeOk:false,lastConfigAt:0,lastRuntimeAt:0},
  792. lastRTState:'',draft:{},errors:{},dirty:new Set(),
  793. pending:0,txBusy:false,faultBusy:false,toggleBusy:{},
  794. cfgDraft:{},cfgDirty:{},cfgErrors:{},
  795. ingestDraft:null,ingestDirty:false,ingestSaving:false,ingestError:'',
  796. pollersStarted:false,mobilePanelsApplied:false,freqPresetIndex:0,
  797. charts:{audio:[],underruns:[],tx:[],hw:[],qf:[]},
  798. transitions:[],
  799. };
  800. // ── Field definitions ──────────────────────────────────────────────────────
  801. const FIELDS={
  802. frequencyMHz:{section:'freq',eq:(a,b)=>nearEq(a,b,1e-4)},
  803. ps:{section:'rds',eq:(a,b)=>String(a??'')===String(b??'')},
  804. radioText:{section:'rds',eq:(a,b)=>String(a??'')===String(b??'')},
  805. };
  806. // CFG_FIELDS: fields that go through /config PATCH but aren't in the main draft system
  807. const CFG={
  808. outputDrive: {sec:'audio', live:true, path:'fm.outputDrive', min:0, max:10, step:.05},
  809. limiterCeiling:{sec:'audio', live:true, path:'fm.limiterCeiling', min:.5, max:2, step:.05},
  810. preEmphasisTauUS:{sec:'audio', live:false,path:'fm.preEmphasisTauUS'},
  811. audioGain: {sec:'audio', live:false,path:'audio.gain', min:0, max:4, step:.05},
  812. pilotLevel: {sec:'rds-lvl', live:true, path:'fm.pilotLevel', min:0, max:.2, step:.001},
  813. rdsInjection: {sec:'rds-lvl', live:true, path:'fm.rdsInjection', min:0, max:.15, step:.001},
  814. pi: {sec:'rds-id', live:false,path:'rds.pi'},
  815. pty: {sec:'rds-id', live:false,path:'rds.pty'},
  816. rdsTP: {sec:'rds-feat', live:false,path:'rds.tp'},
  817. rdsTA: {sec:'rds-feat', live:false,path:'rds.ta'},
  818. rdsMS: {sec:'rds-feat', live:false,path:'rds.ms'},
  819. rdsCT: {sec:'rds-feat', live:false,path:'rds.ctEnabled'},
  820. rdsRTPlus: {sec:'rds-feat', live:false,path:'rds.rtPlusEnabled'},
  821. rdsRTPlusSep: {sec:'rds-feat', live:false,path:'rds.rtPlusSeparator'},
  822. rdsPTYN: {sec:'rds-feat', live:false,path:'rds.ptyn'},
  823. rdsLPS: {sec:'rds-feat', live:false,path:'rds.lps'},
  824. rdsERT: {sec:'rds-feat', live:false,path:'rds.ert'},
  825. rdsERTEnabled: {sec:'rds-feat', live:false,path:'rds.ertEnabled'},
  826. rdsRDS2Enabled:{sec:'rds2', live:false,path:'rds.rds2Enabled'},
  827. rdsLogoPath: {sec:'rds2', live:false,path:'rds.stationLogoPath'},
  828. rdsAF: {sec:'rds-feat', live:false,path:'rds.af'},
  829. bs412Enabled: {sec:'compliance',live:false,path:'fm.bs412Enabled'},
  830. bs412ThresholdDBr:{sec:'compliance',live:false,path:'fm.bs412ThresholdDBr',min:-6,max:6,step:.5},
  831. mpxGain: {sec:'compliance',live:false,path:'fm.mpxGain', min:.1, max:5, step:.05},
  832. toneLeftHz: {sec:'tones', live:true, path:'audio.toneLeftHz', min:0, max:20000,step:10},
  833. toneRightHz: {sec:'tones', live:true, path:'audio.toneRightHz', min:0, max:20000,step:10},
  834. toneAmplitude: {sec:'tones', live:true, path:'audio.toneAmplitude', min:0, max:1, step:.01},
  835. compositeClipperEnabled: {sec:'compclip',live:true, path:'fm.compositeClipper.enabled'},
  836. compositeClipperIterations: {sec:'compclip',live:false,path:'fm.compositeClipper.iterations',min:1,max:5,step:1},
  837. compositeClipperSoftKnee: {sec:'compclip',live:false,path:'fm.compositeClipper.softKnee',min:0,max:.5,step:.01},
  838. compositeClipperLookaheadMs:{sec:'compclip',live:false,path:'fm.compositeClipper.lookaheadMs',min:0,max:3,step:.1},
  839. };
  840. 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'};
  841. // ── Helpers ────────────────────────────────────────────────────────────────
  842. 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;}
  843. function clone(o){return JSON.parse(JSON.stringify(o??{}));}
  844. function gp(o,path){const ps=String(path||'').split('.');let c=o;for(const p of ps){if(!p)continue;if(c==null||typeof c!=='object')return undefined;c=c[p];}return c;}
  845. function sp(o,path,v){const ps=String(path||'').split('.');let c=o;for(let i=0;i<ps.length;i++){const p=ps[i];if(!p)continue;if(i===ps.length-1){c[p]=v;return;}if(!c[p]||typeof c[p]!=='object')c[p]={};c=c[p];}}
  846. function srvVal(key){const cfg=S.server.config||{};switch(key){case 'frequencyMHz':return cfg.fm?.frequencyMHz;case 'ps':return cfg.rds?.ps??'';case 'radioText':return cfg.rds?.radioText??'';case 'stereoEnabled':return cfg.fm?.stereoEnabled;case 'stereoMode':return cfg.fm?.stereoMode??'DSB';case 'rdsEnabled':return cfg.rds?.enabled;case 'limiterEnabled':return cfg.fm?.limiterEnabled;case 'compositeClipperEnabled':return cfg.fm?.compositeClipper?.enabled;}return undefined;}
  847. function cfgSrvVal(key){const cfg=S.server.config||{};const f=CFG[key];return f?gp(cfg,f.path):undefined;}
  848. function effVal(key){return S.dirty.has(key)?S.draft[key]:srvVal(key);}
  849. function cfgEff(key){return S.cfgDraft[key]!==undefined?S.cfgDraft[key]:cfgSrvVal(key);}
  850. function cfgEq(key,a,b){if(key==='pi')return String(a??'').trim().toUpperCase()===String(b??'').trim().toUpperCase();if(key==='pty'||key==='bs412Enabled'||key==='compositeClipperEnabled'||key==='compositeClipperIterations')return a===b;return nearEq(a,b,1e-5);}
  851. function cfgSetDirty(key,val){
  852. const f=CFG[key];if(!f)return;
  853. S.cfgDraft[key]=val;
  854. 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)));
  855. render();
  856. }
  857. 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();}
  858. 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;}
  859. function cfgHasRestart(sec){return Object.keys(CFG).filter(k=>CFG[k].sec===sec&&!CFG[k].live).some(k=>S.cfgDraft[k]!==undefined);}
  860. function validate(key,val){switch(key){case 'frequencyMHz':{const n=Number(val);if(isNaN(n))return 'Enter a valid number.';if(n<65||n>110)return 'Must be 65–110 MHz.';return'';}case 'ps':return String(val??'').length>8?'Max 8 characters.':'';case 'radioText':return String(val??'').length>64?'Max 64 characters.':'';case 'pi':{const v=String(val??'').trim();return /^[0-9A-Fa-f]{4}$/.test(v)?'':'4 hex digits (e.g. 1234).';}default:return'';}}
  861. function setDirty(key,val){S.draft[key]=val;S.errors[key]=validate(key,val);const sv=srvVal(key);const eq=!S.errors[key]&&FIELDS[key].eq(val,sv);if(eq){S.dirty.delete(key);delete S.draft[key];}else S.dirty.add(key);if(key==='frequencyMHz'&&typeof val==='number')syncFreqPresetIdx(val);render();}
  862. function clearDirty(keys){keys.forEach(k=>{S.dirty.delete(k);delete S.draft[k];S.errors[k]='';});render();}
  863. function secDirty(sec){for(const k of S.dirty){if(FIELDS[k]?.section===sec)return true;}return false;}
  864. function secErrors(sec){return Object.entries(FIELDS).some(([k,m])=>m.section===sec&&S.errors[k]);}
  865. function secPatch(sec){const p={};for(const k of S.dirty){if(FIELDS[k]?.section===sec&&!S.errors[k])p[k]=S.draft[k];}return p;}
  866. // ── Ingest draft ───────────────────────────────────────────────────────────
  867. function ingFromSrv(){return S.server.config?.ingest||{};}
  868. function syncIngDraft(force=false){if(!S.server.config)return;if(!S.ingestDraft||force||!S.ingestDirty){S.ingestDraft=clone(ingFromSrv());S.ingestError='';}S.ingestDirty=JSON.stringify(S.ingestDraft)!==JSON.stringify(ingFromSrv());}
  869. function ingVal(path){return gp(S.ingestDraft||{},path);}
  870. function setIngField(path,val){if(!S.ingestDraft)S.ingestDraft=clone(ingFromSrv());sp(S.ingestDraft,path,val);S.ingestDirty=JSON.stringify(S.ingestDraft)!==JSON.stringify(ingFromSrv());S.ingestError='';render();}
  871. // ── API ────────────────────────────────────────────────────────────────────
  872. async function api(path,opts){const r=await fetch(path,opts);const t=await r.text();if(!r.ok)throw new Error(t.trim()||`HTTP ${r.status}`);if(!t)return{};try{return JSON.parse(t);}catch{return{ok:true};}}
  873. function setConn(ok,label){const led=$('led-conn'),lbl=$('conn-label');led.className='led '+(ok?S.pending>0?'on-amber':'on-green':'on-red');lbl.textContent=ok?S.pending>0?'busy':label||'connected':label||'offline';}
  874. async function loadConfig({silent=false}={}){try{const cfg=await api('/config');S.server.config=cfg;S.server.configOk=true;S.server.lastConfigAt=Date.now();syncIngDraft();syncCfgFromServer();syncFreqPresetIdx(cfg.fm?.frequencyMHz);setConn(true);render();if(!silent)log('Config synchronized','info');return cfg;}catch(e){S.server.configOk=false;if(!S.server.runtimeOk)setConn(false);render();if(!silent)log('Config load failed: '+e.message,'err');throw e;}}
  875. async function loadRuntime({silent=true}={}){try{const rt=await api('/runtime');S.server.runtime=rt;S.server.runtimeOk=true;S.server.lastRuntimeAt=Date.now();const synced=syncTransitions(rt.engine);notifyTransition(rt.engine,!synced);pushHistory(rt);setConn(true);render();return rt;}catch(e){S.server.runtimeOk=false;if(!S.server.configOk)setConn(false);render();if(!silent)log('Runtime load failed: '+e.message,'err');throw e;}}
  876. function syncCfgFromServer(){Object.keys(CFG).forEach(k=>{if(S.cfgDraft[k]===undefined)S.cfgDraft[k]=cfgSrvVal(k);});Object.keys(S.cfgDirty).forEach(s=>{S.cfgDirty[s]=Object.keys(CFG).filter(k=>CFG[k].sec===s).some(k=>S.cfgDraft[k]!==undefined&&!cfgEq(k,S.cfgDraft[k],cfgSrvVal(k)));});}
  877. // ── History ────────────────────────────────────────────────────────────────
  878. function pushChart(arr,v){arr.push(isFinite(v)?v:0);if(arr.length>SPARK_LIMIT)arr.splice(0,arr.length-SPARK_LIMIT);}
  879. function pushHistory(rt){const eng=rt.engine||{},drv=rt.driver||{},aud=rt.audioStream||{};pushChart(S.charts.audio,typeof aud.buffered==='number'?aud.buffered:0);pushChart(S.charts.hw,Number(aud.highWatermarkDurationSeconds)||0);pushChart(S.charts.qf,Number(eng.queue?.fillLevel??0));pushChart(S.charts.underruns,Number(eng.underruns??drv.underruns??0));pushChart(S.charts.tx,normState(eng.state)==='running'?1:S.txBusy?.55:.05);}
  880. function syncTransitions(eng){const es=Array.isArray(eng?.transitionHistory)?eng.transitionHistory:null;if(!es)return false;S.transitions=es.slice(Math.max(0,es.length-TRANS_LIMIT)).map(e=>({from:normState(e?.from),to:normState(e?.to),sev:String(e?.severity||'info').toLowerCase(),time:parseTs(e?.time)})).reverse();renderTransitions();return true;}
  881. function notifyTransition(eng,push=true){if(!eng)return;const next=normState(eng.state),prev=S.lastRTState;S.lastRTState=next;if(!prev||prev===next)return;const sev=stateSev(next);if(push){S.transitions.unshift({from:prev,to:next,sev,time:Date.now()});if(S.transitions.length>TRANS_LIMIT)S.transitions.pop();renderTransitions();}toast(`${prev.toUpperCase()} → ${next.toUpperCase()}`,sev==='err'?'err':sev==='warn'?'warn':'info');log(`Runtime ${prev.toUpperCase()} → ${next.toUpperCase()}`,sev==='err'?'err':sev==='warn'?'warn':'ok');}
  882. function renderTransitions(){const c=$('transition-history');if(!c)return;if(!S.transitions.length){c.innerHTML='<div class="transition-history-empty">No state transitions recorded yet.</div>';return;}c.innerHTML=S.transitions.map(e=>{const t=e.time?new Date(e.time):null,tl=t&&!isNaN(t)?t.toLocaleTimeString():'--';const cls=e.sev==='err'?'err':e.sev==='warn'?'warn':e.sev==='ok'?'good':'info';return`<div class="transition-history-entry ${cls}"><span class="transition-history-time">${tl}</span><span class="transition-history-desc">${e.from.toUpperCase()} → ${e.to.toUpperCase()}</span></div>`;}).join('');}
  883. function parseTs(v){if(!v)return Date.now();if(typeof v==='number')return v;const p=Date.parse(String(v));return isNaN(p)?Date.now():p;}
  884. function normState(s){return(typeof s==='string'?s.trim().toLowerCase():'')||'idle';}
  885. function stateSev(s){switch(normState(s)){case 'running':return'ok';case 'degraded':case 'muted':return'warn';case 'faulted':return'err';default:return'info';}}
  886. function stateClass(s){switch(normState(s)){case 'faulted':return'err';case 'muted':case 'degraded':case 'prebuffering':case 'arming':case 'stopping':case 'idle':return'warn';default:return'good';}}
  887. // ── Formatters ─────────────────────────────────────────────────────────────
  888. function fmt(n){if(n==null)return'--';if(n>=1e9)return(n/1e9).toFixed(2)+'G';if(n>=1e6)return(n/1e6).toFixed(2)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'k';return String(n);}
  889. function fmtTime(s){if(!s||s<=0)return'--';const h=Math.floor(s/3600),m=Math.floor(s%3600/60),sec=Math.floor(s%60);if(h>0)return`${h}h ${m}m`;if(m>0)return`${m}m ${sec}s`;return`${sec}s`;}
  890. function fmtPct(v){return typeof v==='number'?(v*100).toFixed(0)+'%':'--';}
  891. function fmtFreq(v){return typeof v==='number'?v.toFixed(1)+' MHz':'--';}
  892. function fmtDur(v){if(!isFinite(v)||v<0)return'--';return v>=1?v.toFixed(2)+'s':(v*1000).toFixed(0)+'ms';}
  893. function fmtPilot(v){return typeof v==='number'?(v*100).toFixed(1)+'%':'--';}
  894. function fmtPI(v){if(!v||!String(v).trim())return'--';return'0x'+String(v).toUpperCase().padStart(4,'0');}
  895. function fmtPTY(v){const n=Number(v);return(n>=0&&n<=31)?`${n} — ${PTY_NAMES[n]}`:'--';}
  896. function ageStr(ts){if(!ts)return'--';const d=Math.max(0,Math.floor((Date.now()-ts)/1000));if(d<2)return'just now';if(d<60)return d+'s ago';const m=Math.floor(d/60);if(m<60)return m+'m ago';return Math.floor(m/60)+'h ago';}
  897. function ageFrom(v){if(!v)return'--';if(typeof v==='number')return ageStr(v);const ts=Date.parse(String(v));return isNaN(ts)?'--':ageStr(ts);}
  898. function joinParts(ps){return ps.filter(p=>String(p||'').trim()!=='').join(' · ');}
  899. // ── DOM helpers ────────────────────────────────────────────────────────────
  900. function setText(id,text){const el=$(id);if(el){const s=text==null?'--':String(text);if(el.textContent!==s)el.textContent=s;}}
  901. function setHTML(id,h){const el=$(id);if(el&&el.innerHTML!==h)el.innerHTML=h;}
  902. function setCls(id,c){const el=$(id);if(el)el.className=c;}
  903. function setMeter(fid,tid,ratio,text,mode='good'){const f=$(fid);if(!f)return;f.style.width=Math.max(0,Math.min(100,Math.round((ratio??0)*100)))+'%';f.className='meter-fill'+(mode==='warn'?' warn':mode==='err'?' err':'');setText(tid,text);}
  904. function drawSpark(svgId,vals,mode='good',maxO=null){const svg=$(svgId);if(!svg)return;svg.setAttribute('class',`spark ${mode}`);const W=160,H=34,pts=vals.length?vals:[0,0],mx=maxO!=null?maxO:Math.max(...pts,1),step=pts.length<=1?W:W/(pts.length-1);const coords=pts.map((v,i)=>{const x=i*step,y=H-4-((v-0)/(mx||1))*(H-8);return[x,y];});const line=coords.map(([x,y],i)=>`${i===0?'M':'L'}${x.toFixed(2)},${y.toFixed(2)}`).join(' ');svg.innerHTML=`<path class="area" d="${line} L${W},${H} L0,${H} Z"></path><path class="line" d="${line}"></path>`;}
  905. function syncSlider(sid,did,key,fmt2=v=>v==null?'--':Number(v).toFixed(2)){const sl=$(sid);if(!sl)return;const n=cfgEff(key);if(document.activeElement!==sl&&n!=null)sl.value=String(Number(n));setText(did,fmt2(n));}
  906. function syncToggle(tid,lid,key){const on=!!srvVal(key),busy=!!S.toggleBusy[key];const el=$(tid);if(!el)return;el.className='toggle'+(on?' on':'')+(busy?' busy':'');el.setAttribute('aria-checked',on?'true':'false');setText(lid,busy?'WAIT':(on?'ON':'OFF'));}
  907. function syncCfgToggle(tid,lid,key){const on=!!cfgEff(key);const el=$(tid);if(!el)return;el.className='toggle'+(on?' on':'');el.setAttribute('aria-checked',on?'true':'false');setText(lid,on?'ON':'OFF');}
  908. function syncDirtyInput(id,key,xform=v=>v){const el=$(id);if(!el)return;const dirty=S.dirty.has(key),val=dirty?xform(S.draft[key]):xform(srvVal(key)),s=val==null?'':String(val);if((!dirty||document.activeElement!==el)&&el.value!==s)el.value=s;el.classList.toggle('input-dirty',dirty&&!S.errors[key]);el.classList.toggle('input-error',!!S.errors[key]);}
  909. function syncIngInput(id,path,xform=v=>v){const el=$(id);if(!el)return;const v=xform(ingVal(path)),s=v==null?'':String(v);if(document.activeElement!==el&&el.value!==s)el.value=s;}
  910. function syncIngChk(id,path){const el=$(id);if(el)el.checked=!!ingVal(path);}
  911. // ── Freq presets ───────────────────────────────────────────────────────────
  912. function syncFreqPresetIdx(v){if(typeof v!=='number')return;let ci=0,cd=Infinity;FREQ_PRESETS.forEach((f,i)=>{const d=Math.abs(f-v);if(d<cd){cd=d;ci=i;}});S.freqPresetIndex=ci;}
  913. function cyclePreset(dir){S.freqPresetIndex=(S.freqPresetIndex+dir+FREQ_PRESETS.length)%FREQ_PRESETS.length;setDirty('frequencyMHz',FREQ_PRESETS[S.freqPresetIndex]);toast(`Preset ${FREQ_PRESETS[S.freqPresetIndex].toFixed(1)} MHz`,'info');}
  914. function refreshFreqPresets(){const ef=effVal('frequencyMHz');document.querySelectorAll('[data-freq-preset]').forEach(b=>b.classList.toggle('active',typeof ef==='number'&&nearEq(Number(b.dataset.freqPreset),ef,.05)));}
  915. // ── API actions ────────────────────────────────────────────────────────────
  916. function beginReq(){S.pending++;setConn(true,'busy');render();}
  917. function endReq(){S.pending=Math.max(0,S.pending-1);setConn(S.server.configOk||S.server.runtimeOk);render();}
  918. 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();}}
  919. 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});}
  920. function resetSection(sec){clearDirty(Object.keys(FIELDS).filter(k=>FIELDS[k].section===sec));toast('Draft reset','info');}
  921. 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();}}
  922. async function txAction(action){
  923. if(S.txBusy)return;
  924. S.txBusy=true;
  925. if(action==='stop'&&S.server.runtime){
  926. S.server.runtime.engine={...(S.server.runtime.engine||{}),state:'stopping'};
  927. }
  928. render();
  929. beginReq();
  930. try{
  931. log('TX '+action+' requested','info');
  932. await api(`/tx/${action}`,{method:'POST'});
  933. toast(action==='start'?'TX started':'TX stop requested','ok');
  934. log('TX '+action+' accepted','ok');
  935. await Promise.allSettled([loadRuntime({silent:true}),loadConfig({silent:true})]);
  936. }catch(e){
  937. toast(e.message,'err');
  938. log('TX '+action+' failed: '+e.message,'err');
  939. }finally{
  940. S.txBusy=false;
  941. endReq();
  942. render();
  943. }
  944. }
  945. async function resetFault(){if(S.faultBusy)return;S.faultBusy=true;render();beginReq();try{await api('/runtime/fault/reset',{method:'POST'});toast('Fault reset','ok');log('Fault reset','ok');await loadRuntime({silent:true});}catch(e){toast(e.message,'err');log('Fault reset failed: '+e.message,'err');}finally{S.faultBusy=false;endReq();render();}}
  946. async function setToggle(key,val){if(S.toggleBusy[key])return;S.toggleBusy[key]=true;render();try{await sendPatch({[key]:val},{ok:key.replace(/Enabled$/,'')+' '+(val?'enabled':'disabled')});}finally{S.toggleBusy[key]=false;render();}}
  947. async function saveIngest(){if(S.ingestSaving)return;if(!S.ingestDirty){toast('No changes','info');return;}S.ingestSaving=true;S.ingestError='';beginReq();render();try{const res=await api('/config/ingest/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ingest:S.ingestDraft})});S.ingestDirty=false;toast(res.reloadScheduled?'Saved, reloading…':'Saved','ok');log('Ingest saved'+(res.reloadScheduled?' [reload]':''),'ok');if(res.reloadScheduled)setTimeout(()=>location.reload(),1500);}catch(e){S.ingestError=e.message;toast(e.message,'err');log('Ingest save failed: '+e.message,'err');}finally{S.ingestSaving=false;endReq();render();}}
  948. // ── Render ─────────────────────────────────────────────────────────────────
  949. function render(){try{_render();}catch(e){console.error('[render]',e);}}
  950. function _render(){
  951. const cfg=S.server.config||{},rt=S.server.runtime||{},eng=rt.engine||{},drv=rt.driver||{},aud=rt.audioStream||null;
  952. // ── Header
  953. setText('badge-backend',cfg.backend?.kind||cfg.backend||'--');
  954. setText('badge-mode',eng.state&&eng.state!=='idle'?'TX Active':'Control Plane');
  955. setText('badge-live',S.server.runtimeOk?'Connected':'Waiting');
  956. // ── Overview: freq display
  957. const applied=Number(eng.appliedFrequencyMHz),desired=Number(cfg.fm?.frequencyMHz);
  958. const dispFreq=isFinite(applied)?applied:effVal('frequencyMHz')??desired;
  959. setHTML('freq-display',`${typeof dispFreq==='number'?dispFreq.toFixed(1):'---.-'}<span class="unit">MHz</span>`);
  960. setText('freq-applied',isFinite(applied)?`Applied ${applied.toFixed(1)} MHz`:'Applied --');
  961. setText('freq-desired',isFinite(desired)?`Desired ${desired.toFixed(1)} MHz`:'Desired --');
  962. $('freq-note')?.classList.toggle('mismatch',isFinite(applied)&&isFinite(desired)&&!nearEq(applied,desired,.001));
  963. // ── Overview: quick stats
  964. setText('t-chunks',fmt(eng.chunksProduced));setText('t-samples',fmt(eng.totalSamples));
  965. setText('t-uptime',fmtTime(eng.uptimeSeconds));
  966. setText('t-rate',drv.effectiveSampleRateHz?(drv.effectiveSampleRateHz/1000).toFixed(0)+'k':'--');
  967. const ur=eng.underruns??drv.underruns;const urEl=$('t-underruns');if(urEl){urEl.textContent=ur==null?'--':String(ur);urEl.className='value'+(ur>0?' err':ur===0?' good':'');}
  968. // ── Overview: TX state
  969. const txSt=normState(eng.state);
  970. $('tx-state').textContent=S.txBusy?'WORKING':txSt.toUpperCase();
  971. $('tx-state').className='tx-state '+(S.txBusy?'working':txSt);
  972. setText('tx-hint',eng.lastError?`Last error: ${eng.lastError}`:S.txBusy?'Command in progress':'Runtime polled every 1s');
  973. const canStopStates=['running','arming','prebuffering','degraded','muted','faulted','stopping'];
  974. const startDis=S.txBusy||txSt==='running';
  975. const stopDis=S.txBusy||!canStopStates.includes(txSt);
  976. $('btn-start').disabled=startDis;$('btn-stop').disabled=stopDis;$('btn-refresh').disabled=S.pending>0;
  977. // ── Overview: meters + sparklines
  978. if(aud&&typeof aud.buffered==='number'){const r=Math.max(0,Math.min(1,aud.buffered));setMeter('meter-audio-fill','meter-audio-text',r,fmtPct(r),r<.05?'err':r<.2?'warn':'good');}else setMeter('meter-audio-fill','meter-audio-text',0,'N/A','warn');
  979. const urN=Number(eng.underruns??drv.underruns??0);
  980. setMeter('meter-stream-fill','meter-stream-text',urN<=0?1:Math.max(0,1-Math.min(urN,10)/10),urN===0?'Clean':`${urN} underrun${urN===1?'':'s'}`,urN===0?'good':urN<3?'warn':'err');
  981. const txR=txSt==='running'?1:S.txBusy?.55:.08;
  982. setMeter('meter-tx-fill','meter-tx-text',txR,txSt==='running'?'Live':S.txBusy?'Working':'Idle',txSt==='running'?'good':S.txBusy?'warn':'err');
  983. drawSpark('spark-audio',S.charts.audio,'good',1);
  984. drawSpark('spark-underruns',S.charts.underruns,urN>0?'err':'warn');
  985. drawSpark('spark-tx',S.charts.tx,txSt==='running'?'good':'warn',1);
  986. // ── Overview: sidebar
  987. setText('info-backend',cfg.backend?.kind||cfg.backend||'--');setText('info-freq',fmtFreq(cfg.fm?.frequencyMHz));
  988. setText('info-preemph',cfg.fm?.preEmphasisTauUS?`${cfg.fm.preEmphasisTauUS} µs`:'Off');
  989. setText('info-pi',fmtPI(cfg.rds?.pi));setText('info-pty',fmtPTY(cfg.rds?.pty));
  990. setText('info-runtime-age',ageStr(S.server.lastRuntimeAt));setText('info-last-alert',eng.runtimeAlert||eng.lastError||'None');
  991. setText('info-drive',cfg.fm?.outputDrive!=null?Number(cfg.fm.outputDrive).toFixed(2):'--');
  992. setText('info-limiter',cfg.fm?.limiterEnabled?(cfg.fm?.limiterCeiling!=null?`Limiter ON · clips always active · ceil ${Number(cfg.fm.limiterCeiling).toFixed(2)}`:'Limiter ON · clips always active'):'Limiter OFF · hard clips still active');
  993. setText('info-pilot',fmtPilot(cfg.fm?.pilotLevel));setText('info-rdsinj',fmtPilot(cfg.fm?.rdsInjection));
  994. setText('info-mpxgain',cfg.fm?.mpxGain!=null?Number(cfg.fm.mpxGain).toFixed(2):'--');
  995. setText('info-bs412',cfg.fm?.bs412Enabled?`ON (${cfg.fm?.bs412ThresholdDBr??0} dBr)`:'OFF');
  996. const cc=cfg.fm?.compositeClipper;setText('info-compclip',cc?.enabled?`ON (${cc.iterations??3}× ${cc.lookaheadMs?'LA ':''}${cc.softKnee>0?'soft':'hard'})`:'OFF');
  997. // ── TX Control tab
  998. // Freq
  999. syncDirtyInput('freq-slider','frequencyMHz',v=>typeof v==='number'?v.toFixed(1):'100.0');
  1000. syncDirtyInput('freq-num','frequencyMHz',v=>typeof v==='number'?v.toFixed(1):'100.0');
  1001. $('freq-apply').disabled=!secDirty('freq')||secErrors('freq');$('freq-reset').disabled=!secDirty('freq');
  1002. setText('freq-meta',secErrors('freq')?'Validation error':secDirty('freq')?'Draft pending':'Live + Saved');
  1003. const fe=$('freq-error');if(fe){fe.textContent=S.errors.frequencyMHz||'';fe.classList.toggle('show',!!S.errors.frequencyMHz);}
  1004. refreshFreqPresets();
  1005. // Audio sliders
  1006. syncSlider('drive-slider','drive-val','outputDrive',v=>v==null?'--':Number(v).toFixed(2));
  1007. syncSlider('lim-ceiling-slider','lim-ceiling-val','limiterCeiling',v=>v==null?'--':Number(v).toFixed(2));
  1008. syncSlider('gain-slider','gain-val','audioGain',v=>v==null?'--':Number(v).toFixed(2));
  1009. const peEl=$('preemph-select');if(peEl&&document.activeElement!==peEl)peEl.value=String(cfgEff('preEmphasisTauUS')??50);
  1010. setText('audio-meta',S.cfgDirty['audio']?'Draft pending':'Mixed Apply Modes');
  1011. $('audio-apply').disabled=!S.cfgDirty['audio'];$('audio-reset').disabled=!S.cfgDirty['audio'];
  1012. // Tones
  1013. syncSlider('tone-amp-slider','tone-amp-val','toneAmplitude',v=>v==null?'--':Number(v).toFixed(2));
  1014. const tl=$(('tone-l-slider')),tln=$('tone-l-num');if(tl&&document.activeElement!==tl)tl.value=String(cfgEff('toneLeftHz')??1000);if(tln&&document.activeElement!==tln)tln.value=String(cfgEff('toneLeftHz')??1000);
  1015. const tr=$('tone-r-slider'),trn=$('tone-r-num');if(tr&&document.activeElement!==tr)tr.value=String(cfgEff('toneRightHz')??1000);if(trn&&document.activeElement!==trn)trn.value=String(cfgEff('toneRightHz')??1000);
  1016. // Switches
  1017. syncToggle('tog-stereo','stereo-label','stereoEnabled');syncToggle('tog-limiter','limiter-label','limiterEnabled');
  1018. const selMode=document.getElementById('sel-stereo-mode');if(selMode){const m=srvVal('stereoMode');if(m)selMode.value=m;}
  1019. // Compliance
  1020. syncCfgToggle('tog-bs412','bs412-label','bs412Enabled');
  1021. syncSlider('bs412-threshold-slider','bs412-threshold-val','bs412ThresholdDBr',v=>v==null?'--':Number(v).toFixed(1));
  1022. syncSlider('mpxgain-slider','mpxgain-val','mpxGain',v=>v==null?'--':Number(v).toFixed(2));
  1023. setText('compliance-meta',S.cfgDirty['compliance']?'Draft pending':'Saved + Restart Required');
  1024. $('compliance-apply').disabled=!S.cfgDirty['compliance'];$('compliance-reset').disabled=!S.cfgDirty['compliance'];
  1025. // Composite Clipper
  1026. syncToggle('tog-compclip','compclip-label','compositeClipperEnabled');
  1027. syncSlider('compclip-iter-slider','compclip-iter-val','compositeClipperIterations',v=>v==null?'--':String(Math.round(Number(v))));
  1028. syncSlider('compclip-knee-slider','compclip-knee-val','compositeClipperSoftKnee',v=>v==null?'--':Number(v).toFixed(2));
  1029. syncSlider('compclip-la-slider','compclip-la-val','compositeClipperLookaheadMs',v=>v==null?'--':Number(v).toFixed(1));
  1030. setText('compclip-meta',S.cfgDirty['compclip']?'Draft pending':'SM.1268');
  1031. $('compclip-apply').disabled=!S.cfgDirty['compclip'];$('compclip-reset').disabled=!S.cfgDirty['compclip'];
  1032. // Danger
  1033. $('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';}
  1034. const rh=$('reset-hint');if(rh){const sn=normState(eng.state);rh.textContent=sn==='faulted'?'Faulted: reset moves runtime back to DEGRADED.':sn==='muted'||sn==='degraded'?'Reset Fault holds at DEGRADED until queue recovers.':'Manual fault reset drops to DEGRADED while queue recovers.';}
  1035. // ── RDS tab
  1036. syncToggle('tog-rds','rds-label','rdsEnabled');
  1037. // PI
  1038. const piEl=$('rds-pi');if(piEl&&document.activeElement!==piEl)piEl.value=String(cfgEff('pi')||cfg.rds?.pi||'');
  1039. setText('pi-display',fmtPI(cfgEff('pi')||cfg.rds?.pi));
  1040. const piErr=$('pi-error');if(piErr){const e=S.cfgErrors?.pi||'';piErr.textContent=e;piErr.classList.toggle('show',!!e);}
  1041. // PTY
  1042. const ptyEl=$('rds-pty');if(ptyEl&&document.activeElement!==ptyEl)ptyEl.value=String(cfgEff('pty')??cfg.rds?.pty??0);
  1043. const idDirty=!!S.cfgDirty['rds-id'];setText('rds-identity-meta',S.cfgErrors?.pi?'Validation error':idDirty?'Draft pending':'Saved + Restart Required');
  1044. $('rds-identity-apply').disabled=!idDirty||!!S.cfgErrors?.pi;$('rds-identity-reset').disabled=!idDirty;
  1045. // Text
  1046. syncDirtyInput('rds-ps','ps',v=>String(v??''));syncDirtyInput('rds-rt','radioText',v=>String(v??''));
  1047. const psV=String(effVal('ps')??cfg.rds?.ps??''),rtV=String(effVal('radioText')??cfg.rds?.radioText??'');
  1048. setText('ps-count',psV.length);setText('rt-count',rtV.length);
  1049. const rdsD=secDirty('rds');setText('rds-text-meta',secErrors('rds')?'Validation error':rdsD?'Draft pending':'Saved + Runtime');
  1050. $('rds-apply').disabled=!rdsD||secErrors('rds');$('rds-reset').disabled=!rdsD;
  1051. const psErr=$('ps-error');if(psErr){psErr.textContent=S.errors.ps||'';psErr.classList.toggle('show',!!S.errors.ps);}
  1052. const rtErr=$('rt-error');if(rtErr){rtErr.textContent=S.errors.radioText||'';rtErr.classList.toggle('show',!!S.errors.radioText);}
  1053. // Levels
  1054. syncSlider('pilot-slider','pilot-val','pilotLevel',v=>v==null?'--':(Number(v)*100).toFixed(1)+'%');
  1055. syncSlider('rdsinj-slider','rdsinj-val','rdsInjection',v=>v==null?'--':(Number(v)*100).toFixed(1)+'%');
  1056. const lvlDirty=!!S.cfgDirty['rds-lvl'];$('rds-levels-apply').disabled=!lvlDirty;$('rds-levels-reset').disabled=!lvlDirty;
  1057. // RDS Features sync
  1058. const rCfg=cfg.rds||{};
  1059. const syncCB=(id,key)=>{const el=$(id);if(el){const v=cfgEff(key);el.checked=v!=null?!!v:!!gp(cfg,CFG[key]?.path);}};
  1060. const tpEl=$('rds-tp');if(tpEl)tpEl.checked=!!rCfg.tp;
  1061. const taEl=$('rds-ta');if(taEl)taEl.checked=!!rCfg.ta;
  1062. syncCB('rds-ct','rdsCT');syncCB('rds-rtplus','rdsRTPlus');
  1063. 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));}
  1064. const sepEl=$('rds-rtplus-sep');if(sepEl&&document.activeElement!==sepEl){const sv=cfgEff('rdsRTPlusSep');sepEl.value=sv!=null?sv:(rCfg.rtPlusSeparator||' - ');}
  1065. const ptynEl=$('rds-ptyn');if(ptynEl&&document.activeElement!==ptynEl){const pv=cfgEff('rdsPTYN');ptynEl.value=pv!=null?pv:(rCfg.ptyn||'');}
  1066. const lpsEl=$('rds-lps');if(lpsEl&&document.activeElement!==lpsEl){const lv=cfgEff('rdsLPS');lpsEl.value=lv!=null?lv:(rCfg.lps||'');}
  1067. const afEl=$('rds-af');if(afEl&&document.activeElement!==afEl){const av=cfgEff('rdsAF');afEl.value=(av!=null?av:(rCfg.af||[])).join(', ');}
  1068. const ertOnEl=$('rds-ert-on');if(ertOnEl){const ev=cfgEff('rdsERTEnabled');ertOnEl.checked=ev!=null?!!ev:!!rCfg.ertEnabled;}
  1069. const ertEl=$('rds-ert');if(ertEl&&document.activeElement!==ertEl){const etv=cfgEff('rdsERT');ertEl.value=etv!=null?etv:(rCfg.ert||'');}
  1070. const featDirty=!!S.cfgDirty['rds-feat'];$('rds-features-apply').disabled=!featDirty;$('rds-features-reset').disabled=!featDirty;
  1071. // RDS2
  1072. const r2On=$('rds2-on');if(r2On){const r2v=cfgEff('rdsRDS2Enabled');r2On.checked=r2v!=null?!!r2v:!!rCfg.rds2Enabled;}
  1073. const r2Logo=$('rds2-logo');if(r2Logo&&document.activeElement!==r2Logo){const lv=cfgEff('rdsLogoPath');r2Logo.value=lv!=null?lv:(rCfg.stationLogoPath||'');}
  1074. const r2Dirty=!!S.cfgDirty['rds2'];if($('rds2-apply')){$('rds2-apply').disabled=!r2Dirty;$('rds2-reset').disabled=!r2Dirty;}
  1075. // Status card
  1076. const activePS=String(eng.activePS||cfg.rds?.ps||'').trim();
  1077. const activeRT=String(eng.activeRadioText||cfg.rds?.radioText||'').trim();
  1078. const savedRT=String(cfg.rds?.radioText||'').trim();
  1079. setText('rds-stat-enabled',cfg.rds?.enabled?'ON':'OFF');setText('rds-stat-pi',fmtPI(cfg.rds?.pi));
  1080. setText('rds-stat-pty',fmtPTY(cfg.rds?.pty));setText('rds-stat-ps',activePS||'--');setText('rds-stat-rt',activeRT||'--');setText('rds-stat-rt-saved',savedRT||'--');setText('rds-saved-rt',savedRT||'--');setText('rds-active-rt-inline',activeRT||'--');
  1081. setText('rds-stat-pilot',fmtPilot(cfg.fm?.pilotLevel));setText('rds-stat-inj',fmtPilot(cfg.fm?.rdsInjection));
  1082. // ── Ingest tab
  1083. const ingest=rt.ingest||{},ia=ingest.active||{},iSrc=ingest.source||{},iRt=ingest.runtime||{},hasIR=!!rt.ingest;
  1084. setText('ingest-summary-state',hasIR?joinParts([String(iRt.state||'').toUpperCase(),iSrc.state?`source ${String(iSrc.state).toUpperCase()}`:'',iRt.prebuffering?'PREBUFFERING':'',iRt.writeBlocked?'WRITE-BLOCKED':''])||'--':'--');
  1085. setText('ingest-summary-source',joinParts([ia.kind||'none',ia.transport||'',ia.codec||'',isFinite(Number(ia.sampleRateHz))?`${Number(ia.sampleRateHz)} Hz`:'',isFinite(Number(ia.channels))?`${Number(ia.channels)} ch`:''])||'--');
  1086. setText('ingest-summary-signal',hasIR?joinParts([iSrc.connected?'connected':'disconnected',isFinite(Number(iSrc.bufferedSeconds))?`${Number(iSrc.bufferedSeconds).toFixed(2)}s buffered`:'',isFinite(Number(iSrc.reconnects))?`${Number(iSrc.reconnects)} reconnects`:''])||'--':'--');
  1087. setText('ingest-summary-detail',hasIR?(iSrc.streamTitle||ia.detail||iSrc.lastError||'--'):'--');
  1088. const org=ia.origin||{};setText('ingest-summary-origin',hasIR?joinParts([org.kind||'',org.endpoint||'',org.streamName||''])||'--':'--');
  1089. setText('ingest-summary-last',hasIR?ageFrom(iRt.lastChunkAt||iSrc.lastChunkAt):'--');
  1090. syncIngInput('ing-kind','kind',v=>String(v??'none'));syncIngInput('ing-prebuffer','prebufferMs',v=>isFinite(Number(v))?Number(v):0);
  1091. syncIngChk('ing-reconnect-enabled','reconnect.enabled');syncIngInput('ing-reconnect-initial','reconnect.initialBackoffMs',v=>isFinite(Number(v))?Number(v):0);syncIngInput('ing-reconnect-max','reconnect.maxBackoffMs',v=>isFinite(Number(v))?Number(v):0);
  1092. syncIngInput('ing-icecast-url','icecast.url',v=>String(v??''));syncIngInput('ing-icecast-decoder','icecast.decoder',v=>String(v??'auto'));
  1093. syncIngChk('ing-icecast-rt-enabled','icecast.radioText.enabled');syncIngInput('ing-icecast-rt-prefix','icecast.radioText.prefix',v=>String(v??''));syncIngInput('ing-icecast-rt-maxlen','icecast.radioText.maxLen',v=>isFinite(Number(v))?Number(v):64);syncIngChk('ing-icecast-rt-only-change','icecast.radioText.onlyOnChange');
  1094. syncIngInput('ing-srt-url','srt.url',v=>String(v??''));syncIngInput('ing-srt-mode','srt.mode',v=>String(v??'listener'));syncIngInput('ing-srt-rate','srt.sampleRateHz',v=>isFinite(Number(v))?Number(v):48000);syncIngInput('ing-srt-channels','srt.channels',v=>isFinite(Number(v))?Number(v):2);
  1095. 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??''));
  1096. const kind=String(ingVal('kind')||'none').toLowerCase();
  1097. $('ing-group-icecast').style.display=kind==='icecast'?'':'none';$('ing-group-srt').style.display=kind==='srt'?'':'none';$('ing-group-aes67').style.display=kind==='aes67'?'':'none';
  1098. setText('ingest-meta',S.ingestSaving?'Saving…':S.ingestDirty?'Draft pending':'Saved + Hard Reload');
  1099. $('ingest-save-reload').disabled=!S.ingestDirty||S.ingestSaving||!S.server.configOk;$('ingest-save-reload').textContent=S.ingestSaving?'Saving...':'Save + Hard Reload';
  1100. $('ingest-reset').disabled=!S.ingestDirty||S.ingestSaving;
  1101. const iErr=$('ingest-error');if(iErr){iErr.textContent=S.ingestError||'';iErr.classList.toggle('show',!!S.ingestError);}
  1102. // ── Diagnostics tab
  1103. setText('health-http',S.server.configOk?'OK':'OFFLINE');setCls('health-http','val '+(S.server.configOk?'good':'err'));
  1104. const rSt=S.server.runtimeOk?String(eng.state||'unknown'):'WAITING';setText('health-runtime',rSt.toUpperCase());setCls('health-runtime','val '+(S.server.runtimeOk?stateClass(eng.state):'warn'));
  1105. const dur=Number(eng.runtimeStateDurationSeconds);setText('health-state-age',isFinite(dur)&&dur>0?fmtTime(dur):'--');
  1106. const ri=eng.runtimeIndicator,riMap={normal:'Normal',degraded:'Degraded',queueCritical:'Queue critical'};setText('health-indicator',riMap[ri]||ri||'--');setCls('health-indicator','val'+(ri==='queueCritical'?' err':ri==='degraded'?' warn':ri==='normal'?' good':''));
  1107. const alert=String(eng.runtimeAlert||'').trim();setText('health-alert',alert||'None');setCls('health-alert','val '+(alert?'warn':'good'));
  1108. setText('health-transitions',eng.degradedTransitions!=null?`${eng.degradedTransitions??0} / ${eng.mutedTransitions??0} / ${eng.faultedTransitions??0}`:'--');
  1109. const fc=eng.faultCount!=null?Number(eng.faultCount):null;setText('health-fault-count',fc!=null?String(fc):'--');setCls('health-fault-count','val'+(fc!=null?(fc>0?' warn':' good'):''));
  1110. const lf=eng.lastFault;if(lf){setCls('health-last-fault','val '+(String(lf.severity||'').toLowerCase()==='faulted'?'err':'warn'));const t=lf.time?new Date(lf.time):null;setText('health-last-fault',`${String(lf.severity||'Fault').toUpperCase()} ${lf.reason||''}${lf.message?' - '+lf.message:''}${t&&!isNaN(t.getTime())?' @ '+t.toLocaleTimeString():''}`);}else{setCls('health-last-fault','val good');setText('health-last-fault','None');}
  1111. if(aud&&typeof aud.buffered==='number'){const f=aud.buffered;setText('health-audio',fmtPct(f));setCls('health-audio','val '+(f<.05?'err':f<.2?'warn':'good'));}else{setText('health-audio','N/A');setCls('health-audio','val');}
  1112. setText('health-buffer-duration',fmtDur(Number(aud?.bufferedDurationSeconds)));
  1113. const hwD=Number(aud?.highWatermarkDurationSeconds),hwF=Number(aud?.highWatermark);setText('health-buffer-highwater',isFinite(hwD)?`${fmtDur(hwD)} (${isFinite(hwF)?hwF:'?'} fr)`:(isFinite(hwF)?`${hwF} fr`:'--'));
  1114. const qf=Number(eng.queue?.fillLevel),qh=String(eng.queue?.health||'').toLowerCase();setText('health-queue-fill',isFinite(qf)?fmtPct(qf)+(qh?` · ${qh[0].toUpperCase()+qh.slice(1)}`:''):(qh||'--'));setCls('health-queue-fill','val '+(qh==='critical'?'err':qh==='low'?'warn':'good'));
  1115. const uk=Number(drv?.underrunStreak),um=Number(drv?.maxUnderrunStreak);setText('health-underrun-streak',isFinite(uk)?`${uk}${isFinite(um)?' (max '+um+')':''}`:isFinite(um)?`Max ${um}`:'--');setCls('health-underrun-streak','val'+(isFinite(uk)||isFinite(um)?Math.max(isFinite(uk)?uk:0,isFinite(um)?um:0)>=6?' err':Math.max(isFinite(uk)?uk:0,isFinite(um)?um:0)>0?' warn':' good':''));
  1116. setText('health-last',ageStr(Math.max(S.server.lastConfigAt||0,S.server.lastRuntimeAt||0)));
  1117. const hwMode=hwF/(Number(aud?.capacity)||1)>=.95?'err':hwF/(Number(aud?.capacity)||1)>=.65?'warn':'good';
  1118. drawSpark('spark-high-watermark',S.charts.hw,hwMode);drawSpark('spark-queue-fill',S.charts.qf,qh==='critical'?'err':qh==='low'?'warn':'good',1);
  1119. // Audit
  1120. const audit=rt.controlAudit||{};let total=0;
  1121. ['methodNotAllowed','unsupportedMediaType','bodyTooLarge','unexpectedBody'].forEach(k=>{const v=audit[k]!=null?Number(audit[k]):null;total+=(v||0);const el=$('audit-'+k);if(el){el.textContent=v!=null?String(v):'--';el.className='audit-val'+(v!=null?(v>0?' warn':' good'):'');}});
  1122. const at=$('audit-total');if(at){at.textContent=String(total);at.className='audit-val'+(total>0?' warn':' good');}
  1123. // Fault history
  1124. const fh=$('fault-history');if(fh){const hist=Array.isArray(eng.faultHistory)?eng.faultHistory:[];if(!hist.length){fh.innerHTML='<div class="fault-history-empty">No faults recorded yet.</div>';}else fh.innerHTML=hist.slice().reverse().map(e=>{const t=e?.time?new Date(e.time):null,tl=t&&!isNaN(t)?t.toLocaleTimeString():'--',sev=String(e?.severity||'warn').toLowerCase();return`<div class="fault-history-entry ${sev}"><span class="fault-history-time">${tl}</span><span class="fault-history-desc">${String(e?.severity||'').toUpperCase()} ${e?.reason||''}${e?.message?' · '+e.message:''}</span></div>`;}).join('');}
  1125. applyMobilePanels();
  1126. }
  1127. function applyMobilePanels(){if(!mobileMq.matches||S.mobilePanelsApplied)return;S.mobilePanelsApplied=true;document.querySelectorAll('.panel[data-panel-key]').forEach(p=>{const k=p.dataset.panelKey,h=p.querySelector('.panel-head'),b=p.querySelector('.panel-body'),keep=['frequency','danger'].includes(k);h.classList.toggle('collapsed',!keep);b.classList.toggle('collapsed',!keep);});}
  1128. // ── Toast / Log ────────────────────────────────────────────────────────────
  1129. function toast(msg,type='info'){const t=$('toast');t.textContent=msg;t.className='toast '+type+' show';clearTimeout(toastTimer);toastTimer=setTimeout(()=>t.classList.remove('show'),2800);}
  1130. function log(msg,type=''){const el=$('log');const em=el.querySelector('.empty-log');if(em)em.remove();const row=document.createElement('div');row.className='entry '+type;row.textContent=`${new Date().toLocaleTimeString()} ${msg}`;el.appendChild(row);while(el.children.length>300)el.removeChild(el.firstChild);el.scrollTop=el.scrollHeight;}
  1131. // ── Bindings ───────────────────────────────────────────────────────────────
  1132. function bindAll(){
  1133. // Tabs
  1134. const tbs=Array.from(document.querySelectorAll('.tab-btn[data-tab]')),tps=Array.from(document.querySelectorAll('.tab-panel[data-tab-panel]'));
  1135. tbs.forEach(b=>b.addEventListener('click',()=>{tbs.forEach(x=>x.classList.toggle('active',x===b));tps.forEach(p=>p.classList.toggle('active',p.dataset.tabPanel===b.dataset.tab));}));
  1136. // Panels
  1137. document.querySelectorAll('[data-panel]').forEach(h=>h.addEventListener('click',()=>{h.classList.toggle('collapsed');h.nextElementSibling?.classList.toggle('collapsed');}));
  1138. // Live toggles
  1139. document.querySelectorAll('.toggle[data-toggle]').forEach(tog=>{const key=tog.dataset.toggle;const handler=()=>setToggle(key,!srvVal(key));tog.addEventListener('click',handler);tog.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();handler();}});});
  1140. // Stereo mode select (live)
  1141. $('sel-stereo-mode')?.addEventListener('change',e=>sendPatch({stereoMode:e.target.value},{ok:'Stereo mode: '+e.target.value}));
  1142. // Config-only toggles (restart-required)
  1143. document.querySelectorAll('.toggle[data-toggle-cfg]').forEach(tog=>{const key=tog.dataset.toggleCfg;const handler=()=>cfgSetDirty(key,!cfgEff(key));tog.addEventListener('click',handler);tog.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();handler();}});});
  1144. // Freq
  1145. $('freq-slider').addEventListener('input',e=>setDirty('frequencyMHz',Number(e.target.value)));
  1146. $('freq-num').addEventListener('input',e=>{const v=Number(e.target.value);if(!isNaN(v))setDirty('frequencyMHz',v);});
  1147. $('freq-apply').addEventListener('click',()=>applySection('freq'));$('freq-reset').addEventListener('click',()=>resetSection('freq'));
  1148. // Freq presets
  1149. document.querySelectorAll('[data-freq-preset]').forEach(b=>b.addEventListener('click',()=>{setDirty('frequencyMHz',Number(b.dataset.freqPreset));toast(`Freq preset ${Number(b.dataset.freqPreset).toFixed(1)} MHz`,'info');}));
  1150. // Audio sliders
  1151. function bindCfgSlider(sid,key,xf=v=>Number(v)){const el=$(sid);if(!el)return;const evt=(el.tagName||'').toLowerCase()==='select'?'change':'input';el.addEventListener(evt,e=>cfgSetDirty(key,xf(e.target.value)));}
  1152. bindCfgSlider('drive-slider','outputDrive');bindCfgSlider('lim-ceiling-slider','limiterCeiling');
  1153. bindCfgSlider('gain-slider','audioGain');$('preemph-select').addEventListener('change',e=>cfgSetDirty('preEmphasisTauUS',Number(e.target.value)));
  1154. $('audio-apply').addEventListener('click',()=>applyCfgSection('audio'));$('audio-reset').addEventListener('click',()=>{cfgClear('audio');toast('Draft reset','info');});
  1155. // Tones
  1156. bindCfgSlider('tone-l-slider','toneLeftHz');$('tone-l-num').addEventListener('input',e=>cfgSetDirty('toneLeftHz',Number(e.target.value)));
  1157. bindCfgSlider('tone-r-slider','toneRightHz');$('tone-r-num').addEventListener('input',e=>cfgSetDirty('toneRightHz',Number(e.target.value)));
  1158. bindCfgSlider('tone-amp-slider','toneAmplitude');
  1159. $('tones-apply').addEventListener('click',()=>applyCfgSection('tones'));$('tones-off').addEventListener('click',()=>{cfgSetDirty('toneAmplitude',0);toast('Tone disable queued for live apply + config save','info');applyCfgSection('tones');});
  1160. // Compliance
  1161. bindCfgSlider('bs412-threshold-slider','bs412ThresholdDBr');bindCfgSlider('mpxgain-slider','mpxGain');
  1162. $('compliance-apply').addEventListener('click',()=>applyCfgSection('compliance'));$('compliance-reset').addEventListener('click',()=>{cfgClear('compliance');toast('Draft reset','info');});
  1163. $('compclip-apply').addEventListener('click',()=>applyCfgSection('compclip'));$('compclip-reset').addEventListener('click',()=>{cfgClear('compclip');toast('Draft reset','info');});
  1164. // TX
  1165. $('btn-start').addEventListener('click',()=>txAction('start'));$('btn-stop').addEventListener('click',()=>txAction('stop'));
  1166. $('danger-stop').addEventListener('click',()=>txAction('stop'));$('btn-refresh').addEventListener('click',manualRefresh);
  1167. $('danger-reset-fault').addEventListener('click',()=>resetFault());
  1168. // RDS
  1169. $('rds-pi').addEventListener('input',e=>{const v=e.target.value.toUpperCase().replace(/[^0-9A-F]/g,'').slice(0,4);e.target.value=v;S.cfgErrors=S.cfgErrors||{};S.cfgErrors.pi=validate('pi',v);cfgSetDirty('pi',v);});
  1170. $('rds-pty').addEventListener('change',e=>cfgSetDirty('pty',Number(e.target.value)));
  1171. $('rds-identity-apply').addEventListener('click',()=>applyCfgSection('rds-id'));$('rds-identity-reset').addEventListener('click',()=>{cfgClear('rds-id');toast('Draft reset','info');});
  1172. $('rds-ps').addEventListener('input',e=>setDirty('ps',e.target.value.toUpperCase().slice(0,8)));
  1173. $('rds-rt').addEventListener('input',e=>setDirty('radioText',e.target.value.slice(0,64)));
  1174. $('rds-apply').addEventListener('click',()=>applySection('rds'));$('rds-reset').addEventListener('click',()=>resetSection('rds'));
  1175. document.querySelectorAll('[data-rds-ps]').forEach(b=>b.addEventListener('click',()=>{setDirty('ps',b.dataset.rdsPs||'');setDirty('radioText',b.dataset.rdsRt||'');toast('RDS preset loaded','info');}));
  1176. bindCfgSlider('pilot-slider','pilotLevel');bindCfgSlider('rdsinj-slider','rdsInjection');
  1177. $('rds-levels-apply').addEventListener('click',()=>applyCfgSection('rds-lvl'));$('rds-levels-reset').addEventListener('click',()=>{cfgClear('rds-lvl');toast('Draft reset','info');});
  1178. // RDS Features
  1179. $('rds-tp')?.addEventListener('change',e=>sendPatch({tp:e.target.checked},{ok:'TP '+(e.target.checked?'on':'off')}));
  1180. $('rds-ta')?.addEventListener('change',e=>sendPatch({ta:e.target.checked},{ok:'TA '+(e.target.checked?'on':'off')}));
  1181. $('rds-ms')?.addEventListener('change',e=>cfgSetDirty('rdsMS',e.target.value==='true'));
  1182. $('rds-ct')?.addEventListener('change',e=>cfgSetDirty('rdsCT',e.target.checked));
  1183. $('rds-rtplus')?.addEventListener('change',e=>cfgSetDirty('rdsRTPlus',e.target.checked));
  1184. $('rds-rtplus-sep')?.addEventListener('input',e=>cfgSetDirty('rdsRTPlusSep',e.target.value));
  1185. $('rds-ptyn')?.addEventListener('input',e=>cfgSetDirty('rdsPTYN',e.target.value.toUpperCase()));
  1186. $('rds-lps')?.addEventListener('input',e=>cfgSetDirty('rdsLPS',e.target.value));
  1187. $('rds-ert-on')?.addEventListener('change',e=>cfgSetDirty('rdsERTEnabled',e.target.checked));
  1188. $('rds-ert')?.addEventListener('input',e=>cfgSetDirty('rdsERT',e.target.value));
  1189. $('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);});
  1190. $('rds-features-apply')?.addEventListener('click',()=>applyCfgSection('rds-feat'));
  1191. $('rds-features-reset')?.addEventListener('click',()=>{cfgClear('rds-feat');toast('Draft reset','info');});
  1192. // RDS2
  1193. $('rds2-on')?.addEventListener('change',e=>cfgSetDirty('rdsRDS2Enabled',e.target.checked));
  1194. $('rds2-logo')?.addEventListener('input',e=>cfgSetDirty('rdsLogoPath',e.target.value));
  1195. $('rds2-apply')?.addEventListener('click',()=>applyCfgSection('rds2'));
  1196. $('rds2-reset')?.addEventListener('click',()=>{cfgClear('rds2');toast('Draft reset','info');});
  1197. // Ingest
  1198. 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||''));});});
  1199. $('ingest-save-reload').addEventListener('click',()=>saveIngest());
  1200. $('ingest-reset').addEventListener('click',()=>{syncIngDraft(true);toast('Draft reset','info');log('Ingest draft reset','warn');render();});
  1201. // Log
  1202. $('btn-clear-log').addEventListener('click',()=>{$('log').innerHTML='<div class="empty-log">No activity recorded yet.</div>';toast('Activity log cleared','info');});
  1203. // Keyboard
  1204. window.addEventListener('keydown',e=>{const typing=(()=>{const t=e.target;if(!t)return false;const tag=(t.tagName||'').toLowerCase();return tag==='input'||tag==='textarea'||t.isContentEditable;})();if(typing){if(e.key==='Enter'){e.preventDefault();if(S.dirty.has('frequencyMHz'))applySection('freq');else if(secDirty('rds'))applySection('rds');}return;}if(e.key==='t'&&!e.shiftKey){e.preventDefault();txAction('start');}else if(e.key==='T'||(e.key==='t'&&e.shiftKey)){e.preventDefault();txAction('stop');}else if(e.key.toLowerCase()==='r'){e.preventDefault();manualRefresh();}else if(e.key==='['){e.preventDefault();cyclePreset(-1);}else if(e.key===']'){e.preventDefault();cyclePreset(1);}else if(e.key==='Enter'){e.preventDefault();if(S.dirty.has('frequencyMHz'))applySection('freq');else if(secDirty('rds'))applySection('rds');}});
  1205. // Responsive
  1206. const respHandler=()=>{if(!mobileMq.matches)S.mobilePanelsApplied=false;else applyMobilePanels();};if(mobileMq.addEventListener)mobileMq.addEventListener('change',respHandler);else mobileMq.addListener(respHandler);
  1207. }
  1208. async function manualRefresh(){beginReq();try{await Promise.allSettled([loadConfig({silent:true}),loadRuntime({silent:true})]);toast('UI data refreshed','info');log('Manual refresh','info');}finally{endReq();}}
  1209. function startPollers(){if(S.pollersStarted)return;S.pollersStarted=true;setInterval(()=>loadRuntime({silent:true}),RUNTIME_MS);setInterval(()=>loadConfig({silent:true}),CONFIG_MS);}
  1210. async function init(){
  1211. bindAll();render();
  1212. log('fm-rds-tx control UI booting','info');
  1213. await Promise.allSettled([loadConfig({silent:false}),loadRuntime({silent:true})]);
  1214. render();startPollers();
  1215. log('Polling active: runtime 1s · config 8s','ok');
  1216. log('Keyboard shortcuts ready','info');
  1217. }
  1218. init();
  1219. </script>
  1220. </body>
  1221. </html>