Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

1256 Zeilen
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 are saved to config for the generator path. They do not hot-apply to a running TX engine; restart TX after saving to hear the change. 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-restart">restart</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-restart">restart</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-restart">restart</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">MPX peak protection</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">Live + Saved</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.</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>
  542. </div>
  543. <!-- RDS Features -->
  544. <div class="card panel" data-panel-key="rds-features">
  545. <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>
  546. <div class="panel-body">
  547. <div class="section-note">Traffic, clock, RT+ and other RDS features. Saved to config, takes effect after TX restart.</div>
  548. <div class="ctrl-row">
  549. <div class="ctrl-label-wrap"><span class="ctrl-label">Traffic Program (TP)</span><span class="ctrl-sub">Station carries traffic info</span></div>
  550. <div class="ctrl-input"><input type="checkbox" id="rds-tp"><span class="tag tag-live">live</span></div>
  551. </div>
  552. <div class="ctrl-row">
  553. <div class="ctrl-label-wrap"><span class="ctrl-label">Traffic Announcement (TA)</span><span class="ctrl-sub">Currently on air</span></div>
  554. <div class="ctrl-input"><input type="checkbox" id="rds-ta"><span class="tag tag-live">live</span></div>
  555. </div>
  556. <div class="ctrl-row">
  557. <div class="ctrl-label-wrap"><span class="ctrl-label">Music / Speech</span><span class="ctrl-sub">MS flag for receivers</span></div>
  558. <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>
  559. </div>
  560. <div class="ctrl-row">
  561. <div class="ctrl-label-wrap"><span class="ctrl-label">Clock-Time (CT)</span><span class="ctrl-sub">Group 4A, UTC, 1×/min</span></div>
  562. <div class="ctrl-input"><input type="checkbox" id="rds-ct"><span class="tag tag-restart">restart</span></div>
  563. </div>
  564. <div class="ctrl-row">
  565. <div class="ctrl-label-wrap"><span class="ctrl-label">RT+ Auto-Parse</span><span class="ctrl-sub">Artist/Title from RadioText</span></div>
  566. <div class="ctrl-input"><input type="checkbox" id="rds-rtplus"><span class="tag tag-restart">restart</span></div>
  567. </div>
  568. <div class="ctrl-row">
  569. <div class="ctrl-label-wrap"><span class="ctrl-label">RT+ Separator</span><span class="ctrl-sub">Split char(s) in RT</span></div>
  570. <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>
  571. </div>
  572. <div class="ctrl-row">
  573. <div class="ctrl-label-wrap"><span class="ctrl-label">PTYN</span><span class="ctrl-sub">Custom type name, 8 chars</span></div>
  574. <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>
  575. </div>
  576. <div class="ctrl-row">
  577. <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>
  578. <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>
  579. </div>
  580. <div class="ctrl-row">
  581. <div class="ctrl-label-wrap"><span class="ctrl-label">Alt. Frequencies</span><span class="ctrl-sub">Comma-separated MHz</span></div>
  582. <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>
  583. </div>
  584. <div class="ctrl-row">
  585. <div class="ctrl-label-wrap"><span class="ctrl-label">eRT (Enhanced RT)</span><span class="ctrl-sub">UTF-8, 128 bytes, ODA</span></div>
  586. <div class="ctrl-input"><input type="checkbox" id="rds-ert-on"><span class="tag tag-restart">restart</span></div>
  587. </div>
  588. <div class="ctrl-row">
  589. <div class="ctrl-label-wrap"><span class="ctrl-label">eRT Text</span><span class="ctrl-sub">UTF-8 multilingual text</span></div>
  590. <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>
  591. </div>
  592. <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>
  593. </div>
  594. </div>
  595. <!-- RDS2 -->
  596. <div class="card panel" data-panel-key="rds2">
  597. <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>
  598. <div class="panel-body">
  599. <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>
  600. <div class="ctrl-row">
  601. <div class="ctrl-label-wrap"><span class="ctrl-label">RDS2 Enable</span><span class="ctrl-sub">Activate streams 1-3</span></div>
  602. <div class="ctrl-input"><input type="checkbox" id="rds2-on"><span class="tag tag-restart">restart</span></div>
  603. </div>
  604. <div class="ctrl-row">
  605. <div class="ctrl-label-wrap"><span class="ctrl-label">Station Logo</span><span class="ctrl-sub">PNG/JPEG path on server</span></div>
  606. <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>
  607. </div>
  608. <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>
  609. </div>
  610. </div>
  611. </div>
  612. <div class="stack">
  613. <!-- Injection Levels -->
  614. <div class="card panel" data-panel-key="rds-levels">
  615. <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>
  616. <div class="panel-body">
  617. <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>
  618. <div class="ctrl-row">
  619. <div class="ctrl-label-wrap"><span class="ctrl-label">Pilot Level</span><span class="ctrl-sub">19 kHz, 0–20%</span></div>
  620. <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>
  621. </div>
  622. <div class="ctrl-row">
  623. <div class="ctrl-label-wrap"><span class="ctrl-label">RDS Injection</span><span class="ctrl-sub">57 kHz, 0–15%</span></div>
  624. <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>
  625. </div>
  626. <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>
  627. </div>
  628. </div>
  629. <!-- RDS Status -->
  630. <div class="card sidebar-card">
  631. <div class="sidebar-section">
  632. <div class="sidebar-title">RDS Runtime</div>
  633. <div class="kv">
  634. <div class="k">Enabled</div><div class="v" id="rds-stat-enabled">--</div>
  635. <div class="k">PI Code</div><div class="v" id="rds-stat-pi" style="font-family:var(--mono);font-weight:700">--</div>
  636. <div class="k">PTY</div><div class="v" id="rds-stat-pty">--</div>
  637. <div class="k">PS</div><div class="v" id="rds-stat-ps" style="font-family:var(--mono);font-weight:700;letter-spacing:1px">--</div>
  638. <div class="k">RadioText</div><div class="v" id="rds-stat-rt">--</div>
  639. <div class="k">Pilot</div><div class="v" id="rds-stat-pilot">--</div>
  640. <div class="k">RDS Inj.</div><div class="v" id="rds-stat-inj">--</div>
  641. </div>
  642. </div>
  643. </div>
  644. </div>
  645. </div>
  646. </section>
  647. <!-- INGEST TAB -->
  648. <section class="tab-panel" data-tab-panel="ingest">
  649. <div class="tab-columns one"><div class="stack">
  650. <div class="card sidebar-card">
  651. <div class="sidebar-section">
  652. <div class="sidebar-title">Active Ingest Summary</div>
  653. <div class="section-note">Runtime snapshot. Deep metrics in Diagnostics.</div>
  654. <div class="kv">
  655. <div class="k">State</div><div class="v" id="ingest-summary-state">--</div>
  656. <div class="k">Source</div><div class="v" id="ingest-summary-source">--</div>
  657. <div class="k">Signal</div><div class="v" id="ingest-summary-signal">--</div>
  658. <div class="k">Detail</div><div class="v" id="ingest-summary-detail">--</div>
  659. <div class="k">Origin</div><div class="v" id="ingest-summary-origin">--</div>
  660. <div class="k">Last Chunk</div><div class="v" id="ingest-summary-last">--</div>
  661. </div>
  662. </div>
  663. </div>
  664. <div class="card panel" data-panel-key="ingest">
  665. <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>
  666. <div class="panel-body">
  667. <div class="section-note">Changes are saved to the config file and take effect only after a hard reload of the service.</div>
  668. <div class="ingest-grid">
  669. <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>
  670. <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>
  671. <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>
  672. <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>
  673. <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>
  674. </div>
  675. <div class="ingest-group" id="ing-group-icecast">
  676. <div class="ingest-group-title">Icecast</div>
  677. <div class="ingest-grid">
  678. <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>
  679. <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>
  680. <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>
  681. <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>
  682. <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>
  683. <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>
  684. </div>
  685. </div>
  686. <div class="ingest-group" id="ing-group-srt">
  687. <div class="ingest-group-title">SRT</div>
  688. <div class="ingest-grid">
  689. <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>
  690. <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>
  691. <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>
  692. <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>
  693. </div>
  694. </div>
  695. <div class="ingest-group" id="ing-group-aes67">
  696. <div class="ingest-group-title">AES67</div>
  697. <div class="ingest-grid">
  698. <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>
  699. <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>
  700. <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>
  701. <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>
  702. <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>
  703. <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>
  704. <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>
  705. <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>
  706. <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>
  707. </div>
  708. </div>
  709. <div class="field-error" id="ingest-error"></div>
  710. <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>
  711. <div class="section-note reset-hint">Ingest changes are not hot-applied. Saving writes config and schedules a hard service reload.</div>
  712. </div>
  713. </div>
  714. </div></div>
  715. </section>
  716. <!-- DIAGNOSTICS TAB -->
  717. <section class="tab-panel" data-tab-panel="diagnostics">
  718. <div class="tab-columns two">
  719. <div class="stack">
  720. <div class="card sidebar-card">
  721. <div class="sidebar-section">
  722. <div class="sidebar-title">Health</div>
  723. <div class="health-line"><div class="name">HTTP</div><div class="val" id="health-http">--</div></div>
  724. <div class="health-line"><div class="name">Runtime State</div><div class="val" id="health-runtime">--</div></div>
  725. <div class="health-line"><div class="name">State Age</div><div class="val" id="health-state-age">--</div></div>
  726. <div class="health-line"><div class="name">Runtime Signal</div><div class="val" id="health-indicator">--</div></div>
  727. <div class="health-line"><div class="name">Runtime Alert</div><div class="val" id="health-alert">--</div></div>
  728. <div class="health-line"><div class="name">Transitions D/M/F</div><div class="val" id="health-transitions">--</div></div>
  729. <div class="health-line"><div class="name">Fault Count</div><div class="val" id="health-fault-count">--</div></div>
  730. <div class="health-line"><div class="name">Last Fault</div><div class="val" id="health-last-fault">--</div></div>
  731. <div class="health-line"><div class="name">Audio Buffer</div><div class="val" id="health-audio">--</div></div>
  732. <div class="health-line"><div class="name">Buffer Duration</div><div class="val" id="health-buffer-duration">--</div></div>
  733. <div class="health-line"><div class="name">High Watermark</div><div class="val" id="health-buffer-highwater">--</div></div>
  734. <div class="health-line"><div class="name">Queue Fill</div><div class="val" id="health-queue-fill">--</div></div>
  735. <div class="health-line"><div class="name">Underrun Streak</div><div class="val" id="health-underrun-streak">--</div></div>
  736. <div class="health-line"><div class="name">Last Update</div><div class="val" id="health-last">--</div></div>
  737. <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>
  738. <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>
  739. </div>
  740. </div>
  741. <div class="card sidebar-card">
  742. <div class="sidebar-section">
  743. <div class="sidebar-title">Control Audit</div>
  744. <div class="section-note">4xx reject counts from the control API.</div>
  745. <div class="audit-row"><span class="audit-name">Total rejects</span><span class="audit-val" id="audit-total">--</span></div>
  746. <div class="audit-row"><span class="audit-name">405 Method Not Allowed</span><span class="audit-val" id="audit-methodNotAllowed">--</span></div>
  747. <div class="audit-row"><span class="audit-name">415 Unsupported Media</span><span class="audit-val" id="audit-unsupportedMediaType">--</span></div>
  748. <div class="audit-row"><span class="audit-name">413 Body Too Large</span><span class="audit-val" id="audit-bodyTooLarge">--</span></div>
  749. <div class="audit-row"><span class="audit-name">400 Unexpected Body</span><span class="audit-val" id="audit-unexpectedBody">--</span></div>
  750. </div>
  751. </div>
  752. </div>
  753. <div class="stack">
  754. <div class="card panel" data-panel-key="transition-history">
  755. <div class="panel-head" data-panel><h2>Transition History</h2><div class="meta">state shifts</div><span class="chevron">▼</span></div>
  756. <div class="panel-body"><div class="transition-history" id="transition-history"><div class="transition-history-empty">No state transitions recorded yet.</div></div></div>
  757. </div>
  758. <div class="card panel" data-panel-key="fault-history">
  759. <div class="panel-head" data-panel><h2>Fault History</h2><div class="meta">recent faults</div><span class="chevron">▼</span></div>
  760. <div class="panel-body"><div class="fault-history" id="fault-history"><div class="fault-history-empty">No faults recorded yet.</div></div></div>
  761. </div>
  762. </div>
  763. </div>
  764. </section>
  765. <!-- ACTIVITY TAB -->
  766. <section class="tab-panel" data-tab-panel="activity">
  767. <div class="tab-columns one"><div class="stack">
  768. <div class="card panel" data-panel-key="log">
  769. <div class="panel-head" data-panel><h2>Activity Log</h2><div class="meta" id="log-meta">recent events</div><span class="chevron">▼</span></div>
  770. <div class="panel-body">
  771. <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>
  772. <div class="log" id="log"><div class="empty-log">No activity recorded yet.</div></div>
  773. </div>
  774. </div>
  775. </div></div>
  776. </section>
  777. </div><!-- /tab-panels -->
  778. </div><!-- /app -->
  779. <div class="toast" id="toast"></div>
  780. <script>
  781. 'use strict';
  782. const $=id=>document.getElementById(id);
  783. const RUNTIME_MS=1000,CONFIG_MS=8000,SPARK_LIMIT=40,TRANS_LIMIT=6;
  784. const FREQ_PRESETS=[87.6,94.5,99.5,100.0,107.9];
  785. 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'];
  786. const mobileMq=window.matchMedia('(max-width:640px)');
  787. let toastTimer=null;
  788. const S={
  789. server:{config:null,runtime:null,configOk:false,runtimeOk:false,lastConfigAt:0,lastRuntimeAt:0},
  790. lastRTState:'',draft:{},errors:{},dirty:new Set(),
  791. pending:0,txBusy:false,faultBusy:false,toggleBusy:{},
  792. cfgDraft:{},cfgDirty:{},cfgErrors:{},
  793. ingestDraft:null,ingestDirty:false,ingestSaving:false,ingestError:'',
  794. pollersStarted:false,mobilePanelsApplied:false,freqPresetIndex:0,
  795. charts:{audio:[],underruns:[],tx:[],hw:[],qf:[]},
  796. transitions:[],
  797. };
  798. // ── Field definitions ──────────────────────────────────────────────────────
  799. const FIELDS={
  800. frequencyMHz:{section:'freq',eq:(a,b)=>nearEq(a,b,1e-4)},
  801. ps:{section:'rds',eq:(a,b)=>String(a??'')===String(b??'')},
  802. radioText:{section:'rds',eq:(a,b)=>String(a??'')===String(b??'')},
  803. };
  804. // CFG_FIELDS: fields that go through /config PATCH but aren't in the main draft system
  805. const CFG={
  806. outputDrive: {sec:'audio', live:true, path:'fm.outputDrive', min:0, max:10, step:.05},
  807. limiterCeiling:{sec:'audio', live:true, path:'fm.limiterCeiling', min:.5, max:2, step:.05},
  808. preEmphasisTauUS:{sec:'audio', live:false,path:'fm.preEmphasisTauUS'},
  809. audioGain: {sec:'audio', live:false,path:'audio.gain', min:0, max:4, step:.05},
  810. pilotLevel: {sec:'rds-lvl', live:true, path:'fm.pilotLevel', min:0, max:.2, step:.001},
  811. rdsInjection: {sec:'rds-lvl', live:true, path:'fm.rdsInjection', min:0, max:.15, step:.001},
  812. pi: {sec:'rds-id', live:false,path:'rds.pi'},
  813. pty: {sec:'rds-id', live:false,path:'rds.pty'},
  814. rdsTP: {sec:'rds-feat', live:false,path:'rds.tp'},
  815. rdsTA: {sec:'rds-feat', live:false,path:'rds.ta'},
  816. rdsMS: {sec:'rds-feat', live:false,path:'rds.ms'},
  817. rdsCT: {sec:'rds-feat', live:false,path:'rds.ctEnabled'},
  818. rdsRTPlus: {sec:'rds-feat', live:false,path:'rds.rtPlusEnabled'},
  819. rdsRTPlusSep: {sec:'rds-feat', live:false,path:'rds.rtPlusSeparator'},
  820. rdsPTYN: {sec:'rds-feat', live:false,path:'rds.ptyn'},
  821. rdsLPS: {sec:'rds-feat', live:false,path:'rds.lps'},
  822. rdsERT: {sec:'rds-feat', live:false,path:'rds.ert'},
  823. rdsERTEnabled: {sec:'rds-feat', live:false,path:'rds.ertEnabled'},
  824. rdsRDS2Enabled:{sec:'rds2', live:false,path:'rds.rds2Enabled'},
  825. rdsLogoPath: {sec:'rds2', live:false,path:'rds.stationLogoPath'},
  826. rdsAF: {sec:'rds-feat', live:false,path:'rds.af'},
  827. bs412Enabled: {sec:'compliance',live:false,path:'fm.bs412Enabled'},
  828. bs412ThresholdDBr:{sec:'compliance',live:false,path:'fm.bs412ThresholdDBr',min:-6,max:6,step:.5},
  829. mpxGain: {sec:'compliance',live:false,path:'fm.mpxGain', min:.1, max:5, step:.05},
  830. toneLeftHz: {sec:'tones', live:true, path:'audio.toneLeftHz', min:0, max:20000,step:10},
  831. toneRightHz: {sec:'tones', live:true, path:'audio.toneRightHz', min:0, max:20000,step:10},
  832. toneAmplitude: {sec:'tones', live:true, path:'audio.toneAmplitude', min:0, max:1, step:.01},
  833. compositeClipperEnabled: {sec:'compclip',live:true, path:'fm.compositeClipper.enabled'},
  834. compositeClipperIterations: {sec:'compclip',live:false,path:'fm.compositeClipper.iterations',min:1,max:5,step:1},
  835. compositeClipperSoftKnee: {sec:'compclip',live:false,path:'fm.compositeClipper.softKnee',min:0,max:.5,step:.01},
  836. compositeClipperLookaheadMs:{sec:'compclip',live:false,path:'fm.compositeClipper.lookaheadMs',min:0,max:3,step:.1},
  837. };
  838. 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'};
  839. // ── Helpers ────────────────────────────────────────────────────────────────
  840. 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;}
  841. function clone(o){return JSON.parse(JSON.stringify(o??{}));}
  842. 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;}
  843. 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];}}
  844. 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;}
  845. function cfgSrvVal(key){const cfg=S.server.config||{};const f=CFG[key];return f?gp(cfg,f.path):undefined;}
  846. function effVal(key){return S.dirty.has(key)?S.draft[key]:srvVal(key);}
  847. function cfgEff(key){return S.cfgDraft[key]!==undefined?S.cfgDraft[key]:cfgSrvVal(key);}
  848. 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);}
  849. function cfgSetDirty(key,val){
  850. const f=CFG[key];if(!f)return;
  851. S.cfgDraft[key]=val;
  852. 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)));
  853. render();
  854. }
  855. 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();}
  856. 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;}
  857. function cfgHasRestart(sec){return Object.keys(CFG).filter(k=>CFG[k].sec===sec&&!CFG[k].live).some(k=>S.cfgDraft[k]!==undefined);}
  858. 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'';}}
  859. 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();}
  860. function clearDirty(keys){keys.forEach(k=>{S.dirty.delete(k);delete S.draft[k];S.errors[k]='';});render();}
  861. function secDirty(sec){for(const k of S.dirty){if(FIELDS[k]?.section===sec)return true;}return false;}
  862. function secErrors(sec){return Object.entries(FIELDS).some(([k,m])=>m.section===sec&&S.errors[k]);}
  863. 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;}
  864. // ── Ingest draft ───────────────────────────────────────────────────────────
  865. function ingFromSrv(){return S.server.config?.ingest||{};}
  866. 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());}
  867. function ingVal(path){return gp(S.ingestDraft||{},path);}
  868. 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();}
  869. // ── API ────────────────────────────────────────────────────────────────────
  870. 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};}}
  871. 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';}
  872. 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;}}
  873. 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;}}
  874. 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)));});}
  875. // ── History ────────────────────────────────────────────────────────────────
  876. function pushChart(arr,v){arr.push(isFinite(v)?v:0);if(arr.length>SPARK_LIMIT)arr.splice(0,arr.length-SPARK_LIMIT);}
  877. 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);}
  878. 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;}
  879. 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');}
  880. 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('');}
  881. 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;}
  882. function normState(s){return(typeof s==='string'?s.trim().toLowerCase():'')||'idle';}
  883. function stateSev(s){switch(normState(s)){case 'running':return'ok';case 'degraded':case 'muted':return'warn';case 'faulted':return'err';default:return'info';}}
  884. 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';}}
  885. // ── Formatters ─────────────────────────────────────────────────────────────
  886. 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);}
  887. 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`;}
  888. function fmtPct(v){return typeof v==='number'?(v*100).toFixed(0)+'%':'--';}
  889. function fmtFreq(v){return typeof v==='number'?v.toFixed(1)+' MHz':'--';}
  890. function fmtDur(v){if(!isFinite(v)||v<0)return'--';return v>=1?v.toFixed(2)+'s':(v*1000).toFixed(0)+'ms';}
  891. function fmtPilot(v){return typeof v==='number'?(v*100).toFixed(1)+'%':'--';}
  892. function fmtPI(v){if(!v||!String(v).trim())return'--';return'0x'+String(v).toUpperCase().padStart(4,'0');}
  893. function fmtPTY(v){const n=Number(v);return(n>=0&&n<=31)?`${n} — ${PTY_NAMES[n]}`:'--';}
  894. 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';}
  895. function ageFrom(v){if(!v)return'--';if(typeof v==='number')return ageStr(v);const ts=Date.parse(String(v));return isNaN(ts)?'--':ageStr(ts);}
  896. function joinParts(ps){return ps.filter(p=>String(p||'').trim()!=='').join(' · ');}
  897. // ── DOM helpers ────────────────────────────────────────────────────────────
  898. function setText(id,text){const el=$(id);if(el){const s=text==null?'--':String(text);if(el.textContent!==s)el.textContent=s;}}
  899. function setHTML(id,h){const el=$(id);if(el&&el.innerHTML!==h)el.innerHTML=h;}
  900. function setCls(id,c){const el=$(id);if(el)el.className=c;}
  901. 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);}
  902. 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>`;}
  903. 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));}
  904. 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'));}
  905. 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');}
  906. 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]);}
  907. 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;}
  908. function syncIngChk(id,path){const el=$(id);if(el)el.checked=!!ingVal(path);}
  909. // ── Freq presets ───────────────────────────────────────────────────────────
  910. 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;}
  911. 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');}
  912. 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)));}
  913. // ── API actions ────────────────────────────────────────────────────────────
  914. function beginReq(){S.pending++;setConn(true,'busy');render();}
  915. function endReq(){S.pending=Math.max(0,S.pending-1);setConn(S.server.configOk||S.server.runtimeOk);render();}
  916. 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();}}
  917. 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});}
  918. function resetSection(sec){clearDirty(Object.keys(FIELDS).filter(k=>FIELDS[k].section===sec));toast('Draft reset','info');}
  919. 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();}}
  920. async function txAction(action){
  921. if(S.txBusy)return;
  922. S.txBusy=true;
  923. if(action==='stop'&&S.server.runtime){
  924. S.server.runtime.engine={...(S.server.runtime.engine||{}),state:'stopping'};
  925. }
  926. render();
  927. beginReq();
  928. try{
  929. log('TX '+action+' requested','info');
  930. await api(`/tx/${action}`,{method:'POST'});
  931. toast(action==='start'?'TX started':'TX stop requested','ok');
  932. log('TX '+action+' accepted','ok');
  933. await Promise.allSettled([loadRuntime({silent:true}),loadConfig({silent:true})]);
  934. }catch(e){
  935. toast(e.message,'err');
  936. log('TX '+action+' failed: '+e.message,'err');
  937. }finally{
  938. S.txBusy=false;
  939. endReq();
  940. render();
  941. }
  942. }
  943. 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();}}
  944. 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();}}
  945. 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();}}
  946. // ── Render ─────────────────────────────────────────────────────────────────
  947. function render(){try{_render();}catch(e){console.error('[render]',e);}}
  948. function _render(){
  949. const cfg=S.server.config||{},rt=S.server.runtime||{},eng=rt.engine||{},drv=rt.driver||{},aud=rt.audioStream||null;
  950. // ── Header
  951. setText('badge-backend',cfg.backend?.kind||cfg.backend||'--');
  952. setText('badge-mode',eng.state&&eng.state!=='idle'?'TX Active':'Control Plane');
  953. setText('badge-live',S.server.runtimeOk?'Connected':'Waiting');
  954. // ── Overview: freq display
  955. const applied=Number(eng.appliedFrequencyMHz),desired=Number(cfg.fm?.frequencyMHz);
  956. const dispFreq=isFinite(applied)?applied:effVal('frequencyMHz')??desired;
  957. setHTML('freq-display',`${typeof dispFreq==='number'?dispFreq.toFixed(1):'---.-'}<span class="unit">MHz</span>`);
  958. setText('freq-applied',isFinite(applied)?`Applied ${applied.toFixed(1)} MHz`:'Applied --');
  959. setText('freq-desired',isFinite(desired)?`Desired ${desired.toFixed(1)} MHz`:'Desired --');
  960. $('freq-note')?.classList.toggle('mismatch',isFinite(applied)&&isFinite(desired)&&!nearEq(applied,desired,.001));
  961. // ── Overview: quick stats
  962. setText('t-chunks',fmt(eng.chunksProduced));setText('t-samples',fmt(eng.totalSamples));
  963. setText('t-uptime',fmtTime(eng.uptimeSeconds));
  964. setText('t-rate',drv.effectiveSampleRateHz?(drv.effectiveSampleRateHz/1000).toFixed(0)+'k':'--');
  965. 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':'');}
  966. // ── Overview: TX state
  967. const txSt=normState(eng.state);
  968. $('tx-state').textContent=S.txBusy?'WORKING':txSt.toUpperCase();
  969. $('tx-state').className='tx-state '+(S.txBusy?'working':txSt);
  970. setText('tx-hint',eng.lastError?`Last error: ${eng.lastError}`:S.txBusy?'Command in progress':'Runtime polled every 1s');
  971. const canStopStates=['running','arming','prebuffering','degraded','muted','faulted','stopping'];
  972. const startDis=S.txBusy||txSt==='running';
  973. const stopDis=S.txBusy||!canStopStates.includes(txSt);
  974. $('btn-start').disabled=startDis;$('btn-stop').disabled=stopDis;$('btn-refresh').disabled=S.pending>0;
  975. // ── Overview: meters + sparklines
  976. 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');
  977. const urN=Number(eng.underruns??drv.underruns??0);
  978. 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');
  979. const txR=txSt==='running'?1:S.txBusy?.55:.08;
  980. setMeter('meter-tx-fill','meter-tx-text',txR,txSt==='running'?'Live':S.txBusy?'Working':'Idle',txSt==='running'?'good':S.txBusy?'warn':'err');
  981. drawSpark('spark-audio',S.charts.audio,'good',1);
  982. drawSpark('spark-underruns',S.charts.underruns,urN>0?'err':'warn');
  983. drawSpark('spark-tx',S.charts.tx,txSt==='running'?'good':'warn',1);
  984. // ── Overview: sidebar
  985. setText('info-backend',cfg.backend?.kind||cfg.backend||'--');setText('info-freq',fmtFreq(cfg.fm?.frequencyMHz));
  986. setText('info-preemph',cfg.fm?.preEmphasisTauUS?`${cfg.fm.preEmphasisTauUS} µs`:'Off');
  987. setText('info-pi',fmtPI(cfg.rds?.pi));setText('info-pty',fmtPTY(cfg.rds?.pty));
  988. setText('info-runtime-age',ageStr(S.server.lastRuntimeAt));setText('info-last-alert',eng.runtimeAlert||eng.lastError||'None');
  989. setText('info-drive',cfg.fm?.outputDrive!=null?Number(cfg.fm.outputDrive).toFixed(2):'--');
  990. setText('info-limiter',cfg.fm?.limiterEnabled?(cfg.fm?.limiterCeiling!=null?`ON (ceil ${Number(cfg.fm.limiterCeiling).toFixed(2)})`:'ON'):'OFF');
  991. setText('info-pilot',fmtPilot(cfg.fm?.pilotLevel));setText('info-rdsinj',fmtPilot(cfg.fm?.rdsInjection));
  992. setText('info-mpxgain',cfg.fm?.mpxGain!=null?Number(cfg.fm.mpxGain).toFixed(2):'--');
  993. setText('info-bs412',cfg.fm?.bs412Enabled?`ON (${cfg.fm?.bs412ThresholdDBr??0} dBr)`:'OFF');
  994. const cc=cfg.fm?.compositeClipper;setText('info-compclip',cc?.enabled?`ON (${cc.iterations??3}× ${cc.lookaheadMs?'LA ':''}${cc.softKnee>0?'soft':'hard'})`:'OFF');
  995. // ── TX Control tab
  996. // Freq
  997. syncDirtyInput('freq-slider','frequencyMHz',v=>typeof v==='number'?v.toFixed(1):'100.0');
  998. syncDirtyInput('freq-num','frequencyMHz',v=>typeof v==='number'?v.toFixed(1):'100.0');
  999. $('freq-apply').disabled=!secDirty('freq')||secErrors('freq');$('freq-reset').disabled=!secDirty('freq');
  1000. setText('freq-meta',secErrors('freq')?'Validation error':secDirty('freq')?'Draft pending':'Live + Saved');
  1001. const fe=$('freq-error');if(fe){fe.textContent=S.errors.frequencyMHz||'';fe.classList.toggle('show',!!S.errors.frequencyMHz);}
  1002. refreshFreqPresets();
  1003. // Audio sliders
  1004. syncSlider('drive-slider','drive-val','outputDrive',v=>v==null?'--':Number(v).toFixed(2));
  1005. syncSlider('lim-ceiling-slider','lim-ceiling-val','limiterCeiling',v=>v==null?'--':Number(v).toFixed(2));
  1006. syncSlider('gain-slider','gain-val','audioGain',v=>v==null?'--':Number(v).toFixed(2));
  1007. const peEl=$('preemph-select');if(peEl&&document.activeElement!==peEl)peEl.value=String(cfgEff('preEmphasisTauUS')??50);
  1008. setText('audio-meta',S.cfgDirty['audio']?'Draft pending':'Mixed Apply Modes');
  1009. $('audio-apply').disabled=!S.cfgDirty['audio'];$('audio-reset').disabled=!S.cfgDirty['audio'];
  1010. // Tones
  1011. syncSlider('tone-amp-slider','tone-amp-val','toneAmplitude',v=>v==null?'--':Number(v).toFixed(2));
  1012. 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);
  1013. 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);
  1014. // Switches
  1015. syncToggle('tog-stereo','stereo-label','stereoEnabled');syncToggle('tog-limiter','limiter-label','limiterEnabled');
  1016. const selMode=document.getElementById('sel-stereo-mode');if(selMode){const m=srvVal('stereoMode');if(m)selMode.value=m;}
  1017. // Compliance
  1018. syncCfgToggle('tog-bs412','bs412-label','bs412Enabled');
  1019. syncSlider('bs412-threshold-slider','bs412-threshold-val','bs412ThresholdDBr',v=>v==null?'--':Number(v).toFixed(1));
  1020. syncSlider('mpxgain-slider','mpxgain-val','mpxGain',v=>v==null?'--':Number(v).toFixed(2));
  1021. setText('compliance-meta',S.cfgDirty['compliance']?'Draft pending':'Saved + Restart Required');
  1022. $('compliance-apply').disabled=!S.cfgDirty['compliance'];$('compliance-reset').disabled=!S.cfgDirty['compliance'];
  1023. // Composite Clipper
  1024. syncToggle('tog-compclip','compclip-label','compositeClipperEnabled');
  1025. syncSlider('compclip-iter-slider','compclip-iter-val','compositeClipperIterations',v=>v==null?'--':String(Math.round(Number(v))));
  1026. syncSlider('compclip-knee-slider','compclip-knee-val','compositeClipperSoftKnee',v=>v==null?'--':Number(v).toFixed(2));
  1027. syncSlider('compclip-la-slider','compclip-la-val','compositeClipperLookaheadMs',v=>v==null?'--':Number(v).toFixed(1));
  1028. setText('compclip-meta',S.cfgDirty['compclip']?'Draft pending':'SM.1268');
  1029. $('compclip-apply').disabled=!S.cfgDirty['compclip'];$('compclip-reset').disabled=!S.cfgDirty['compclip'];
  1030. // Danger
  1031. $('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';}
  1032. 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.';}
  1033. // ── RDS tab
  1034. syncToggle('tog-rds','rds-label','rdsEnabled');
  1035. // PI
  1036. const piEl=$('rds-pi');if(piEl&&document.activeElement!==piEl)piEl.value=String(cfgEff('pi')||cfg.rds?.pi||'');
  1037. setText('pi-display',fmtPI(cfgEff('pi')||cfg.rds?.pi));
  1038. const piErr=$('pi-error');if(piErr){const e=S.cfgErrors?.pi||'';piErr.textContent=e;piErr.classList.toggle('show',!!e);}
  1039. // PTY
  1040. const ptyEl=$('rds-pty');if(ptyEl&&document.activeElement!==ptyEl)ptyEl.value=String(cfgEff('pty')??cfg.rds?.pty??0);
  1041. const idDirty=!!S.cfgDirty['rds-id'];setText('rds-identity-meta',S.cfgErrors?.pi?'Validation error':idDirty?'Draft pending':'Saved + Restart Required');
  1042. $('rds-identity-apply').disabled=!idDirty||!!S.cfgErrors?.pi;$('rds-identity-reset').disabled=!idDirty;
  1043. // Text
  1044. syncDirtyInput('rds-ps','ps',v=>String(v??''));syncDirtyInput('rds-rt','radioText',v=>String(v??''));
  1045. const psV=String(effVal('ps')??cfg.rds?.ps??''),rtV=String(effVal('radioText')??cfg.rds?.radioText??'');
  1046. setText('ps-count',psV.length);setText('rt-count',rtV.length);
  1047. const rdsD=secDirty('rds');setText('rds-text-meta',secErrors('rds')?'Validation error':rdsD?'Draft pending':'Live + Saved');
  1048. $('rds-apply').disabled=!rdsD||secErrors('rds');$('rds-reset').disabled=!rdsD;
  1049. const psErr=$('ps-error');if(psErr){psErr.textContent=S.errors.ps||'';psErr.classList.toggle('show',!!S.errors.ps);}
  1050. const rtErr=$('rt-error');if(rtErr){rtErr.textContent=S.errors.radioText||'';rtErr.classList.toggle('show',!!S.errors.radioText);}
  1051. // Levels
  1052. syncSlider('pilot-slider','pilot-val','pilotLevel',v=>v==null?'--':(Number(v)*100).toFixed(1)+'%');
  1053. syncSlider('rdsinj-slider','rdsinj-val','rdsInjection',v=>v==null?'--':(Number(v)*100).toFixed(1)+'%');
  1054. const lvlDirty=!!S.cfgDirty['rds-lvl'];$('rds-levels-apply').disabled=!lvlDirty;$('rds-levels-reset').disabled=!lvlDirty;
  1055. // RDS Features sync
  1056. const rCfg=cfg.rds||{};
  1057. const syncCB=(id,key)=>{const el=$(id);if(el){const v=cfgEff(key);el.checked=v!=null?!!v:!!gp(cfg,CFG[key]?.path);}};
  1058. const tpEl=$('rds-tp');if(tpEl)tpEl.checked=!!rCfg.tp;
  1059. const taEl=$('rds-ta');if(taEl)taEl.checked=!!rCfg.ta;
  1060. syncCB('rds-ct','rdsCT');syncCB('rds-rtplus','rdsRTPlus');
  1061. 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));}
  1062. const sepEl=$('rds-rtplus-sep');if(sepEl&&document.activeElement!==sepEl){const sv=cfgEff('rdsRTPlusSep');sepEl.value=sv!=null?sv:(rCfg.rtPlusSeparator||' - ');}
  1063. const ptynEl=$('rds-ptyn');if(ptynEl&&document.activeElement!==ptynEl){const pv=cfgEff('rdsPTYN');ptynEl.value=pv!=null?pv:(rCfg.ptyn||'');}
  1064. const lpsEl=$('rds-lps');if(lpsEl&&document.activeElement!==lpsEl){const lv=cfgEff('rdsLPS');lpsEl.value=lv!=null?lv:(rCfg.lps||'');}
  1065. const afEl=$('rds-af');if(afEl&&document.activeElement!==afEl){const av=cfgEff('rdsAF');afEl.value=(av!=null?av:(rCfg.af||[])).join(', ');}
  1066. const ertOnEl=$('rds-ert-on');if(ertOnEl){const ev=cfgEff('rdsERTEnabled');ertOnEl.checked=ev!=null?!!ev:!!rCfg.ertEnabled;}
  1067. const ertEl=$('rds-ert');if(ertEl&&document.activeElement!==ertEl){const etv=cfgEff('rdsERT');ertEl.value=etv!=null?etv:(rCfg.ert||'');}
  1068. const featDirty=!!S.cfgDirty['rds-feat'];$('rds-features-apply').disabled=!featDirty;$('rds-features-reset').disabled=!featDirty;
  1069. // RDS2
  1070. const r2On=$('rds2-on');if(r2On){const r2v=cfgEff('rdsRDS2Enabled');r2On.checked=r2v!=null?!!r2v:!!rCfg.rds2Enabled;}
  1071. const r2Logo=$('rds2-logo');if(r2Logo&&document.activeElement!==r2Logo){const lv=cfgEff('rdsLogoPath');r2Logo.value=lv!=null?lv:(rCfg.stationLogoPath||'');}
  1072. const r2Dirty=!!S.cfgDirty['rds2'];if($('rds2-apply')){$('rds2-apply').disabled=!r2Dirty;$('rds2-reset').disabled=!r2Dirty;}
  1073. // Status card
  1074. const activePS=String(eng.activePS||cfg.rds?.ps||'').trim();
  1075. const activeRT=String(eng.activeRadioText||cfg.rds?.radioText||'').trim();
  1076. setText('rds-stat-enabled',cfg.rds?.enabled?'ON':'OFF');setText('rds-stat-pi',fmtPI(cfg.rds?.pi));
  1077. setText('rds-stat-pty',fmtPTY(cfg.rds?.pty));setText('rds-stat-ps',activePS||'--');setText('rds-stat-rt',activeRT||'--');
  1078. setText('rds-stat-pilot',fmtPilot(cfg.fm?.pilotLevel));setText('rds-stat-inj',fmtPilot(cfg.fm?.rdsInjection));
  1079. // ── Ingest tab
  1080. const ingest=rt.ingest||{},ia=ingest.active||{},iSrc=ingest.source||{},iRt=ingest.runtime||{},hasIR=!!rt.ingest;
  1081. setText('ingest-summary-state',hasIR?joinParts([String(iRt.state||'').toUpperCase(),iSrc.state?`source ${String(iSrc.state).toUpperCase()}`:'',iRt.prebuffering?'PREBUFFERING':'',iRt.writeBlocked?'WRITE-BLOCKED':''])||'--':'--');
  1082. 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`:''])||'--');
  1083. 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`:''])||'--':'--');
  1084. setText('ingest-summary-detail',hasIR?(iSrc.streamTitle||ia.detail||iSrc.lastError||'--'):'--');
  1085. const org=ia.origin||{};setText('ingest-summary-origin',hasIR?joinParts([org.kind||'',org.endpoint||'',org.streamName||''])||'--':'--');
  1086. setText('ingest-summary-last',hasIR?ageFrom(iRt.lastChunkAt||iSrc.lastChunkAt):'--');
  1087. syncIngInput('ing-kind','kind',v=>String(v??'none'));syncIngInput('ing-prebuffer','prebufferMs',v=>isFinite(Number(v))?Number(v):0);
  1088. 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);
  1089. syncIngInput('ing-icecast-url','icecast.url',v=>String(v??''));syncIngInput('ing-icecast-decoder','icecast.decoder',v=>String(v??'auto'));
  1090. 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');
  1091. 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);
  1092. 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??''));
  1093. const kind=String(ingVal('kind')||'none').toLowerCase();
  1094. $('ing-group-icecast').style.display=kind==='icecast'?'':'none';$('ing-group-srt').style.display=kind==='srt'?'':'none';$('ing-group-aes67').style.display=kind==='aes67'?'':'none';
  1095. setText('ingest-meta',S.ingestSaving?'Saving…':S.ingestDirty?'Draft pending':'Saved + Hard Reload');
  1096. $('ingest-save-reload').disabled=!S.ingestDirty||S.ingestSaving||!S.server.configOk;$('ingest-save-reload').textContent=S.ingestSaving?'Saving...':'Save + Hard Reload';
  1097. $('ingest-reset').disabled=!S.ingestDirty||S.ingestSaving;
  1098. const iErr=$('ingest-error');if(iErr){iErr.textContent=S.ingestError||'';iErr.classList.toggle('show',!!S.ingestError);}
  1099. // ── Diagnostics tab
  1100. setText('health-http',S.server.configOk?'OK':'OFFLINE');setCls('health-http','val '+(S.server.configOk?'good':'err'));
  1101. 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'));
  1102. const dur=Number(eng.runtimeStateDurationSeconds);setText('health-state-age',isFinite(dur)&&dur>0?fmtTime(dur):'--');
  1103. 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':''));
  1104. const alert=String(eng.runtimeAlert||'').trim();setText('health-alert',alert||'None');setCls('health-alert','val '+(alert?'warn':'good'));
  1105. setText('health-transitions',eng.degradedTransitions!=null?`${eng.degradedTransitions??0} / ${eng.mutedTransitions??0} / ${eng.faultedTransitions??0}`:'--');
  1106. 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'):''));
  1107. 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');}
  1108. 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');}
  1109. setText('health-buffer-duration',fmtDur(Number(aud?.bufferedDurationSeconds)));
  1110. 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`:'--'));
  1111. 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'));
  1112. 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':''));
  1113. setText('health-last',ageStr(Math.max(S.server.lastConfigAt||0,S.server.lastRuntimeAt||0)));
  1114. const hwMode=hwF/(Number(aud?.capacity)||1)>=.95?'err':hwF/(Number(aud?.capacity)||1)>=.65?'warn':'good';
  1115. drawSpark('spark-high-watermark',S.charts.hw,hwMode);drawSpark('spark-queue-fill',S.charts.qf,qh==='critical'?'err':qh==='low'?'warn':'good',1);
  1116. // Audit
  1117. const audit=rt.controlAudit||{};let total=0;
  1118. ['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'):'');}});
  1119. const at=$('audit-total');if(at){at.textContent=String(total);at.className='audit-val'+(total>0?' warn':' good');}
  1120. // Fault history
  1121. 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('');}
  1122. applyMobilePanels();
  1123. }
  1124. 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);});}
  1125. // ── Toast / Log ────────────────────────────────────────────────────────────
  1126. 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);}
  1127. 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;}
  1128. // ── Bindings ───────────────────────────────────────────────────────────────
  1129. function bindAll(){
  1130. // Tabs
  1131. const tbs=Array.from(document.querySelectorAll('.tab-btn[data-tab]')),tps=Array.from(document.querySelectorAll('.tab-panel[data-tab-panel]'));
  1132. 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));}));
  1133. // Panels
  1134. document.querySelectorAll('[data-panel]').forEach(h=>h.addEventListener('click',()=>{h.classList.toggle('collapsed');h.nextElementSibling?.classList.toggle('collapsed');}));
  1135. // Live toggles
  1136. 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();}});});
  1137. // Stereo mode select (live)
  1138. $('sel-stereo-mode')?.addEventListener('change',e=>sendPatch({stereoMode:e.target.value},{ok:'Stereo mode: '+e.target.value}));
  1139. // Config-only toggles (restart-required)
  1140. 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();}});});
  1141. // Freq
  1142. $('freq-slider').addEventListener('input',e=>setDirty('frequencyMHz',Number(e.target.value)));
  1143. $('freq-num').addEventListener('input',e=>{const v=Number(e.target.value);if(!isNaN(v))setDirty('frequencyMHz',v);});
  1144. $('freq-apply').addEventListener('click',()=>applySection('freq'));$('freq-reset').addEventListener('click',()=>resetSection('freq'));
  1145. // Freq presets
  1146. 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');}));
  1147. // Audio sliders
  1148. 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)));}
  1149. bindCfgSlider('drive-slider','outputDrive');bindCfgSlider('lim-ceiling-slider','limiterCeiling');
  1150. bindCfgSlider('gain-slider','audioGain');$('preemph-select').addEventListener('change',e=>cfgSetDirty('preEmphasisTauUS',Number(e.target.value)));
  1151. $('audio-apply').addEventListener('click',()=>applyCfgSection('audio'));$('audio-reset').addEventListener('click',()=>{cfgClear('audio');toast('Draft reset','info');});
  1152. // Tones
  1153. bindCfgSlider('tone-l-slider','toneLeftHz');$('tone-l-num').addEventListener('input',e=>cfgSetDirty('toneLeftHz',Number(e.target.value)));
  1154. bindCfgSlider('tone-r-slider','toneRightHz');$('tone-r-num').addEventListener('input',e=>cfgSetDirty('toneRightHz',Number(e.target.value)));
  1155. bindCfgSlider('tone-amp-slider','toneAmplitude');
  1156. $('tones-apply').addEventListener('click',()=>applyCfgSection('tones'));$('tones-off').addEventListener('click',()=>{cfgSetDirty('toneAmplitude',0);toast('Tone disable saved after apply / TX restart','info');applyCfgSection('tones');});
  1157. // Compliance
  1158. bindCfgSlider('bs412-threshold-slider','bs412ThresholdDBr');bindCfgSlider('mpxgain-slider','mpxGain');
  1159. $('compliance-apply').addEventListener('click',()=>applyCfgSection('compliance'));$('compliance-reset').addEventListener('click',()=>{cfgClear('compliance');toast('Draft reset','info');});
  1160. $('compclip-apply').addEventListener('click',()=>applyCfgSection('compclip'));$('compclip-reset').addEventListener('click',()=>{cfgClear('compclip');toast('Draft reset','info');});
  1161. // TX
  1162. $('btn-start').addEventListener('click',()=>txAction('start'));$('btn-stop').addEventListener('click',()=>txAction('stop'));
  1163. $('danger-stop').addEventListener('click',()=>txAction('stop'));$('btn-refresh').addEventListener('click',manualRefresh);
  1164. $('danger-reset-fault').addEventListener('click',()=>resetFault());
  1165. // RDS
  1166. $('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);});
  1167. $('rds-pty').addEventListener('change',e=>cfgSetDirty('pty',Number(e.target.value)));
  1168. $('rds-identity-apply').addEventListener('click',()=>applyCfgSection('rds-id'));$('rds-identity-reset').addEventListener('click',()=>{cfgClear('rds-id');toast('Draft reset','info');});
  1169. $('rds-ps').addEventListener('input',e=>setDirty('ps',e.target.value.toUpperCase().slice(0,8)));
  1170. $('rds-rt').addEventListener('input',e=>setDirty('radioText',e.target.value.slice(0,64)));
  1171. $('rds-apply').addEventListener('click',()=>applySection('rds'));$('rds-reset').addEventListener('click',()=>resetSection('rds'));
  1172. 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');}));
  1173. bindCfgSlider('pilot-slider','pilotLevel');bindCfgSlider('rdsinj-slider','rdsInjection');
  1174. $('rds-levels-apply').addEventListener('click',()=>applyCfgSection('rds-lvl'));$('rds-levels-reset').addEventListener('click',()=>{cfgClear('rds-lvl');toast('Draft reset','info');});
  1175. // RDS Features
  1176. $('rds-tp')?.addEventListener('change',e=>sendPatch({tp:e.target.checked},{ok:'TP '+(e.target.checked?'on':'off')}));
  1177. $('rds-ta')?.addEventListener('change',e=>sendPatch({ta:e.target.checked},{ok:'TA '+(e.target.checked?'on':'off')}));
  1178. $('rds-ms')?.addEventListener('change',e=>cfgSetDirty('rdsMS',e.target.value==='true'));
  1179. $('rds-ct')?.addEventListener('change',e=>cfgSetDirty('rdsCT',e.target.checked));
  1180. $('rds-rtplus')?.addEventListener('change',e=>cfgSetDirty('rdsRTPlus',e.target.checked));
  1181. $('rds-rtplus-sep')?.addEventListener('input',e=>cfgSetDirty('rdsRTPlusSep',e.target.value));
  1182. $('rds-ptyn')?.addEventListener('input',e=>cfgSetDirty('rdsPTYN',e.target.value.toUpperCase()));
  1183. $('rds-lps')?.addEventListener('input',e=>cfgSetDirty('rdsLPS',e.target.value));
  1184. $('rds-ert-on')?.addEventListener('change',e=>cfgSetDirty('rdsERTEnabled',e.target.checked));
  1185. $('rds-ert')?.addEventListener('input',e=>cfgSetDirty('rdsERT',e.target.value));
  1186. $('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);});
  1187. $('rds-features-apply')?.addEventListener('click',()=>applyCfgSection('rds-feat'));
  1188. $('rds-features-reset')?.addEventListener('click',()=>{cfgClear('rds-feat');toast('Draft reset','info');});
  1189. // RDS2
  1190. $('rds2-on')?.addEventListener('change',e=>cfgSetDirty('rdsRDS2Enabled',e.target.checked));
  1191. $('rds2-logo')?.addEventListener('input',e=>cfgSetDirty('rdsLogoPath',e.target.value));
  1192. $('rds2-apply')?.addEventListener('click',()=>applyCfgSection('rds2'));
  1193. $('rds2-reset')?.addEventListener('click',()=>{cfgClear('rds2');toast('Draft reset','info');});
  1194. // Ingest
  1195. 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||''));});});
  1196. $('ingest-save-reload').addEventListener('click',()=>saveIngest());
  1197. $('ingest-reset').addEventListener('click',()=>{syncIngDraft(true);toast('Draft reset','info');log('Ingest draft reset','warn');render();});
  1198. // Log
  1199. $('btn-clear-log').addEventListener('click',()=>{$('log').innerHTML='<div class="empty-log">No activity recorded yet.</div>';toast('Activity log cleared','info');});
  1200. // Keyboard
  1201. 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');}});
  1202. // Responsive
  1203. const respHandler=()=>{if(!mobileMq.matches)S.mobilePanelsApplied=false;else applyMobilePanels();};if(mobileMq.addEventListener)mobileMq.addEventListener('change',respHandler);else mobileMq.addListener(respHandler);
  1204. }
  1205. 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();}}
  1206. function startPollers(){if(S.pollersStarted)return;S.pollersStarted=true;setInterval(()=>loadRuntime({silent:true}),RUNTIME_MS);setInterval(()=>loadConfig({silent:true}),CONFIG_MS);}
  1207. async function init(){
  1208. bindAll();render();
  1209. log('fm-rds-tx control UI booting','info');
  1210. await Promise.allSettled([loadConfig({silent:false}),loadRuntime({silent:true})]);
  1211. render();startPollers();
  1212. log('Polling active: runtime 1s · config 8s','ok');
  1213. log('Keyboard shortcuts ready','info');
  1214. }
  1215. init();
  1216. </script>
  1217. </body>
  1218. </html>