Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

1131 строка
108KB

  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">Limiter</div><div class="sub">MPX peak protection</div></div>
  403. <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>
  404. </div>
  405. </div>
  406. </div>
  407. <!-- MPX Compliance -->
  408. <div class="card panel" data-panel-key="compliance">
  409. <div class="panel-head" data-panel><h2>MPX Compliance</h2><div class="meta" id="compliance-meta">BS.412</div><span class="chevron">▼</span></div>
  410. <div class="panel-body">
  411. <div class="section-note">ITU-R BS.412 limits total MPX power. Mandatory for licensed FM in EU/CH. Changes require TX restart.</div>
  412. <div class="toggle-row">
  413. <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>
  414. <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>
  415. </div>
  416. <div class="ctrl-row">
  417. <div class="ctrl-label-wrap"><span class="ctrl-label">Threshold</span><span class="ctrl-sub">dBr — 0 = standard</span></div>
  418. <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>
  419. </div>
  420. <div class="ctrl-row">
  421. <div class="ctrl-label-wrap"><span class="ctrl-label">MPX Gain</span><span class="ctrl-sub">Hardware calibration</span></div>
  422. <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>
  423. </div>
  424. <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>
  425. <div class="section-note reset-hint">Compliance changes are persisted to config and require TX restart before they affect the modulation chain.</div>
  426. </div>
  427. </div>
  428. <!-- Composite Clipper -->
  429. <div class="card panel" data-panel-key="compclip">
  430. <div class="panel-head" data-panel><h2>Composite Clipper</h2><div class="meta" id="compclip-meta">SM.1268</div><span class="chevron">▼</span></div>
  431. <div class="panel-body">
  432. <div class="section-note">ITU-R SM.1268 iterative composite clipper. Enable/disable is live. Iterations, knee, and look-ahead require TX restart.</div>
  433. <div class="toggle-row">
  434. <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>
  435. <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>
  436. </div>
  437. <div class="ctrl-row">
  438. <div class="ctrl-label-wrap"><span class="ctrl-label">Iterations</span><span class="ctrl-sub">clip-filter passes (1-5)</span></div>
  439. <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>
  440. </div>
  441. <div class="ctrl-row">
  442. <div class="ctrl-label-wrap"><span class="ctrl-label">Soft Knee</span><span class="ctrl-sub">0 = hard clip, 0.3 = gentle</span></div>
  443. <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>
  444. </div>
  445. <div class="ctrl-row">
  446. <div class="ctrl-label-wrap"><span class="ctrl-label">Look-ahead</span><span class="ctrl-sub">0 = off, 1.0 ms = typical</span></div>
  447. <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>
  448. </div>
  449. <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>
  450. <div class="section-note reset-hint">Structural clipper changes (iterations, knee, look-ahead) are persisted to config and require TX restart.</div>
  451. </div>
  452. </div>
  453. <!-- Danger -->
  454. <div class="card panel" data-panel-key="danger">
  455. <div class="panel-head" data-panel><h2>Danger Zone</h2><div class="meta">emergency</div><span class="chevron">▼</span></div>
  456. <div class="panel-body">
  457. <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>
  458. <div class="section-note reset-hint" id="reset-hint">Reset Fault moves the runtime back to DEGRADED while the queue settles.</div>
  459. </div>
  460. </div>
  461. <!-- Shortcuts -->
  462. <div class="card panel" data-panel-key="shortcuts">
  463. <div class="panel-head" data-panel><h2>Shortcuts</h2><div class="meta">keyboard</div><span class="chevron">▼</span></div>
  464. <div class="panel-body">
  465. <div class="shortcuts-grid">
  466. <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>
  467. <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>
  468. </div>
  469. </div>
  470. </div>
  471. </div>
  472. </div>
  473. </section>
  474. <!-- RDS TAB -->
  475. <section class="tab-panel" data-tab-panel="rds">
  476. <div class="tab-columns two">
  477. <div class="stack">
  478. <!-- Station Identity -->
  479. <div class="card panel" data-panel-key="rds-identity">
  480. <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>
  481. <div class="panel-body">
  482. <div class="section-note">RDS enable applies live. PI and PTY are saved to config and take effect after the next TX restart.</div>
  483. <div class="ctrl-row">
  484. <div class="ctrl-label-wrap"><span class="ctrl-label">Enable RDS</span><span class="ctrl-sub">57 kHz subcarrier</span></div>
  485. <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>
  486. </div>
  487. <div class="ctrl-row">
  488. <div class="ctrl-label-wrap"><span class="ctrl-label">PI Code</span><span class="ctrl-sub">Programme Identifier (hex)</span></div>
  489. <div class="ctrl-input" style="flex-direction:column;align-items:flex-start;gap:6px">
  490. <div style="display:flex;align-items:center;gap:10px;width:100%">
  491. <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">
  492. <div class="pi-display" id="pi-display">0x----</div>
  493. <span class="tag tag-restart">restart</span>
  494. </div>
  495. <div class="field-error" id="pi-error"></div>
  496. </div>
  497. </div>
  498. <div class="ctrl-row">
  499. <div class="ctrl-label-wrap"><span class="ctrl-label">Programme Type</span><span class="ctrl-sub">PTY 0–31</span></div>
  500. <div class="ctrl-input">
  501. <select id="rds-pty" style="min-width:200px">
  502. <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>
  503. </select>
  504. <span class="tag tag-restart">restart</span>
  505. </div>
  506. </div>
  507. <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>
  508. <div class="section-note reset-hint">Identity settings persist immediately in config, but PI / PTY changes appear on-air only after TX restart.</div>
  509. </div>
  510. </div>
  511. <!-- On-Air Text -->
  512. <div class="card panel" data-panel-key="rds-text">
  513. <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>
  514. <div class="panel-body">
  515. <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>
  516. <div class="preset-row">
  517. <button class="preset-btn rds" type="button" data-rds-ps="FMRTX" data-rds-rt="fm-rds-tx live">Station ID</button>
  518. <button class="preset-btn rds" type="button" data-rds-ps="ONAIR" data-rds-rt="Now broadcasting">On Air</button>
  519. <button class="preset-btn rds" type="button" data-rds-ps="LIVE" data-rds-rt="Live set in progress">Live Set</button>
  520. <button class="preset-btn rds" type="button" data-rds-ps="TEST" data-rds-rt="RDS test transmission">Test</button>
  521. </div>
  522. <div class="rds-grid">
  523. <div class="rds-field">
  524. <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>
  525. <input type="text" class="rds-input" id="rds-ps" maxlength="8" placeholder="STATION" spellcheck="false">
  526. <div class="rds-charcount"><span id="ps-count">0</span>/8</div>
  527. <div class="field-error" id="ps-error"></div>
  528. </div>
  529. <div class="rds-field">
  530. <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>
  531. <input type="text" class="rds-input rt" id="rds-rt" maxlength="64" placeholder="Now playing..." spellcheck="false">
  532. <div class="rds-charcount"><span id="rt-count">0</span>/64</div>
  533. <div class="field-error" id="rt-error"></div>
  534. </div>
  535. </div>
  536. <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>
  537. </div>
  538. </div>
  539. </div>
  540. <div class="stack">
  541. <!-- Injection Levels -->
  542. <div class="card panel" data-panel-key="rds-levels">
  543. <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>
  544. <div class="panel-body">
  545. <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>
  546. <div class="ctrl-row">
  547. <div class="ctrl-label-wrap"><span class="ctrl-label">Pilot Level</span><span class="ctrl-sub">19 kHz, 0–20%</span></div>
  548. <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>
  549. </div>
  550. <div class="ctrl-row">
  551. <div class="ctrl-label-wrap"><span class="ctrl-label">RDS Injection</span><span class="ctrl-sub">57 kHz, 0–15%</span></div>
  552. <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>
  553. </div>
  554. <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>
  555. </div>
  556. </div>
  557. <!-- RDS Status -->
  558. <div class="card sidebar-card">
  559. <div class="sidebar-section">
  560. <div class="sidebar-title">RDS Runtime</div>
  561. <div class="kv">
  562. <div class="k">Enabled</div><div class="v" id="rds-stat-enabled">--</div>
  563. <div class="k">PI Code</div><div class="v" id="rds-stat-pi" style="font-family:var(--mono);font-weight:700">--</div>
  564. <div class="k">PTY</div><div class="v" id="rds-stat-pty">--</div>
  565. <div class="k">PS</div><div class="v" id="rds-stat-ps" style="font-family:var(--mono);font-weight:700;letter-spacing:1px">--</div>
  566. <div class="k">RadioText</div><div class="v" id="rds-stat-rt">--</div>
  567. <div class="k">Pilot</div><div class="v" id="rds-stat-pilot">--</div>
  568. <div class="k">RDS Inj.</div><div class="v" id="rds-stat-inj">--</div>
  569. </div>
  570. </div>
  571. </div>
  572. </div>
  573. </div>
  574. </section>
  575. <!-- INGEST TAB -->
  576. <section class="tab-panel" data-tab-panel="ingest">
  577. <div class="tab-columns one"><div class="stack">
  578. <div class="card sidebar-card">
  579. <div class="sidebar-section">
  580. <div class="sidebar-title">Active Ingest Summary</div>
  581. <div class="section-note">Runtime snapshot. Deep metrics in Diagnostics.</div>
  582. <div class="kv">
  583. <div class="k">State</div><div class="v" id="ingest-summary-state">--</div>
  584. <div class="k">Source</div><div class="v" id="ingest-summary-source">--</div>
  585. <div class="k">Signal</div><div class="v" id="ingest-summary-signal">--</div>
  586. <div class="k">Detail</div><div class="v" id="ingest-summary-detail">--</div>
  587. <div class="k">Origin</div><div class="v" id="ingest-summary-origin">--</div>
  588. <div class="k">Last Chunk</div><div class="v" id="ingest-summary-last">--</div>
  589. </div>
  590. </div>
  591. </div>
  592. <div class="card panel" data-panel-key="ingest">
  593. <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>
  594. <div class="panel-body">
  595. <div class="section-note">Changes are saved to the config file and take effect only after a hard reload of the service.</div>
  596. <div class="ingest-grid">
  597. <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>
  598. <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>
  599. <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>
  600. <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>
  601. <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>
  602. </div>
  603. <div class="ingest-group" id="ing-group-icecast">
  604. <div class="ingest-group-title">Icecast</div>
  605. <div class="ingest-grid">
  606. <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>
  607. <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>
  608. <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>
  609. <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>
  610. <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>
  611. <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>
  612. </div>
  613. </div>
  614. <div class="ingest-group" id="ing-group-srt">
  615. <div class="ingest-group-title">SRT</div>
  616. <div class="ingest-grid">
  617. <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>
  618. <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>
  619. <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>
  620. <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>
  621. </div>
  622. </div>
  623. <div class="ingest-group" id="ing-group-aes67">
  624. <div class="ingest-group-title">AES67</div>
  625. <div class="ingest-grid">
  626. <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>
  627. <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>
  628. <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>
  629. <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>
  630. <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>
  631. <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>
  632. <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>
  633. <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>
  634. <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>
  635. </div>
  636. </div>
  637. <div class="field-error" id="ingest-error"></div>
  638. <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>
  639. <div class="section-note reset-hint">Ingest changes are not hot-applied. Saving writes config and schedules a hard service reload.</div>
  640. </div>
  641. </div>
  642. </div></div>
  643. </section>
  644. <!-- DIAGNOSTICS TAB -->
  645. <section class="tab-panel" data-tab-panel="diagnostics">
  646. <div class="tab-columns two">
  647. <div class="stack">
  648. <div class="card sidebar-card">
  649. <div class="sidebar-section">
  650. <div class="sidebar-title">Health</div>
  651. <div class="health-line"><div class="name">HTTP</div><div class="val" id="health-http">--</div></div>
  652. <div class="health-line"><div class="name">Runtime State</div><div class="val" id="health-runtime">--</div></div>
  653. <div class="health-line"><div class="name">State Age</div><div class="val" id="health-state-age">--</div></div>
  654. <div class="health-line"><div class="name">Runtime Signal</div><div class="val" id="health-indicator">--</div></div>
  655. <div class="health-line"><div class="name">Runtime Alert</div><div class="val" id="health-alert">--</div></div>
  656. <div class="health-line"><div class="name">Transitions D/M/F</div><div class="val" id="health-transitions">--</div></div>
  657. <div class="health-line"><div class="name">Fault Count</div><div class="val" id="health-fault-count">--</div></div>
  658. <div class="health-line"><div class="name">Last Fault</div><div class="val" id="health-last-fault">--</div></div>
  659. <div class="health-line"><div class="name">Audio Buffer</div><div class="val" id="health-audio">--</div></div>
  660. <div class="health-line"><div class="name">Buffer Duration</div><div class="val" id="health-buffer-duration">--</div></div>
  661. <div class="health-line"><div class="name">High Watermark</div><div class="val" id="health-buffer-highwater">--</div></div>
  662. <div class="health-line"><div class="name">Queue Fill</div><div class="val" id="health-queue-fill">--</div></div>
  663. <div class="health-line"><div class="name">Underrun Streak</div><div class="val" id="health-underrun-streak">--</div></div>
  664. <div class="health-line"><div class="name">Last Update</div><div class="val" id="health-last">--</div></div>
  665. <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>
  666. <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>
  667. </div>
  668. </div>
  669. <div class="card sidebar-card">
  670. <div class="sidebar-section">
  671. <div class="sidebar-title">Control Audit</div>
  672. <div class="section-note">4xx reject counts from the control API.</div>
  673. <div class="audit-row"><span class="audit-name">Total rejects</span><span class="audit-val" id="audit-total">--</span></div>
  674. <div class="audit-row"><span class="audit-name">405 Method Not Allowed</span><span class="audit-val" id="audit-methodNotAllowed">--</span></div>
  675. <div class="audit-row"><span class="audit-name">415 Unsupported Media</span><span class="audit-val" id="audit-unsupportedMediaType">--</span></div>
  676. <div class="audit-row"><span class="audit-name">413 Body Too Large</span><span class="audit-val" id="audit-bodyTooLarge">--</span></div>
  677. <div class="audit-row"><span class="audit-name">400 Unexpected Body</span><span class="audit-val" id="audit-unexpectedBody">--</span></div>
  678. </div>
  679. </div>
  680. </div>
  681. <div class="stack">
  682. <div class="card panel" data-panel-key="transition-history">
  683. <div class="panel-head" data-panel><h2>Transition History</h2><div class="meta">state shifts</div><span class="chevron">▼</span></div>
  684. <div class="panel-body"><div class="transition-history" id="transition-history"><div class="transition-history-empty">No state transitions recorded yet.</div></div></div>
  685. </div>
  686. <div class="card panel" data-panel-key="fault-history">
  687. <div class="panel-head" data-panel><h2>Fault History</h2><div class="meta">recent faults</div><span class="chevron">▼</span></div>
  688. <div class="panel-body"><div class="fault-history" id="fault-history"><div class="fault-history-empty">No faults recorded yet.</div></div></div>
  689. </div>
  690. </div>
  691. </div>
  692. </section>
  693. <!-- ACTIVITY TAB -->
  694. <section class="tab-panel" data-tab-panel="activity">
  695. <div class="tab-columns one"><div class="stack">
  696. <div class="card panel" data-panel-key="log">
  697. <div class="panel-head" data-panel><h2>Activity Log</h2><div class="meta" id="log-meta">recent events</div><span class="chevron">▼</span></div>
  698. <div class="panel-body">
  699. <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>
  700. <div class="log" id="log"><div class="empty-log">No activity recorded yet.</div></div>
  701. </div>
  702. </div>
  703. </div></div>
  704. </section>
  705. </div><!-- /tab-panels -->
  706. </div><!-- /app -->
  707. <div class="toast" id="toast"></div>
  708. <script>
  709. 'use strict';
  710. const $=id=>document.getElementById(id);
  711. const RUNTIME_MS=1000,CONFIG_MS=8000,SPARK_LIMIT=40,TRANS_LIMIT=6;
  712. const FREQ_PRESETS=[87.6,94.5,99.5,100.0,107.9];
  713. 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'];
  714. const mobileMq=window.matchMedia('(max-width:640px)');
  715. let toastTimer=null;
  716. const S={
  717. server:{config:null,runtime:null,configOk:false,runtimeOk:false,lastConfigAt:0,lastRuntimeAt:0},
  718. lastRTState:'',draft:{},errors:{},dirty:new Set(),
  719. pending:0,txBusy:false,faultBusy:false,toggleBusy:{},
  720. cfgDraft:{},cfgDirty:{},cfgErrors:{},
  721. ingestDraft:null,ingestDirty:false,ingestSaving:false,ingestError:'',
  722. pollersStarted:false,mobilePanelsApplied:false,freqPresetIndex:0,
  723. charts:{audio:[],underruns:[],tx:[],hw:[],qf:[]},
  724. transitions:[],
  725. };
  726. // ── Field definitions ──────────────────────────────────────────────────────
  727. const FIELDS={
  728. frequencyMHz:{section:'freq',eq:(a,b)=>nearEq(a,b,1e-4)},
  729. ps:{section:'rds',eq:(a,b)=>String(a??'')===String(b??'')},
  730. radioText:{section:'rds',eq:(a,b)=>String(a??'')===String(b??'')},
  731. };
  732. // CFG_FIELDS: fields that go through /config PATCH but aren't in the main draft system
  733. const CFG={
  734. outputDrive: {sec:'audio', live:true, path:'fm.outputDrive', min:0, max:10, step:.05},
  735. limiterCeiling:{sec:'audio', live:true, path:'fm.limiterCeiling', min:.5, max:2, step:.05},
  736. preEmphasisTauUS:{sec:'audio', live:false,path:'fm.preEmphasisTauUS'},
  737. audioGain: {sec:'audio', live:false,path:'audio.gain', min:0, max:4, step:.05},
  738. pilotLevel: {sec:'rds-lvl', live:true, path:'fm.pilotLevel', min:0, max:.2, step:.001},
  739. rdsInjection: {sec:'rds-lvl', live:true, path:'fm.rdsInjection', min:0, max:.15, step:.001},
  740. pi: {sec:'rds-id', live:false,path:'rds.pi'},
  741. pty: {sec:'rds-id', live:false,path:'rds.pty'},
  742. bs412Enabled: {sec:'compliance',live:false,path:'fm.bs412Enabled'},
  743. bs412ThresholdDBr:{sec:'compliance',live:false,path:'fm.bs412ThresholdDBr',min:-6,max:6,step:.5},
  744. mpxGain: {sec:'compliance',live:false,path:'fm.mpxGain', min:.1, max:5, step:.05},
  745. toneLeftHz: {sec:'tones', live:true, path:'audio.toneLeftHz', min:0, max:20000,step:10},
  746. toneRightHz: {sec:'tones', live:true, path:'audio.toneRightHz', min:0, max:20000,step:10},
  747. toneAmplitude: {sec:'tones', live:true, path:'audio.toneAmplitude', min:0, max:1, step:.01},
  748. compositeClipperEnabled: {sec:'compclip',live:true, path:'fm.compositeClipper.enabled'},
  749. compositeClipperIterations: {sec:'compclip',live:false,path:'fm.compositeClipper.iterations',min:1,max:5,step:1},
  750. compositeClipperSoftKnee: {sec:'compclip',live:false,path:'fm.compositeClipper.softKnee',min:0,max:.5,step:.01},
  751. compositeClipperLookaheadMs:{sec:'compclip',live:false,path:'fm.compositeClipper.lookaheadMs',min:0,max:3,step:.1},
  752. };
  753. const CFG_PATCH_KEY={outputDrive:'outputDrive',limiterCeiling:'limiterCeiling',preEmphasisTauUS:'preEmphasisTauUS',audioGain:'audioGain',pilotLevel:'pilotLevel',rdsInjection:'rdsInjection',pi:'pi',pty:'pty',bs412Enabled:'bs412Enabled',bs412ThresholdDBr:'bs412ThresholdDBr',mpxGain:'mpxGain',toneLeftHz:'toneLeftHz',toneRightHz:'toneRightHz',toneAmplitude:'toneAmplitude',compositeClipperEnabled:'compositeClipperEnabled',compositeClipperIterations:'compositeClipperIterations',compositeClipperSoftKnee:'compositeClipperSoftKnee',compositeClipperLookaheadMs:'compositeClipperLookaheadMs'};
  754. // ── Helpers ────────────────────────────────────────────────────────────────
  755. 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;}
  756. function clone(o){return JSON.parse(JSON.stringify(o??{}));}
  757. 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;}
  758. 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];}}
  759. 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 'rdsEnabled':return cfg.rds?.enabled;case 'limiterEnabled':return cfg.fm?.limiterEnabled;case 'compositeClipperEnabled':return cfg.fm?.compositeClipper?.enabled;}return undefined;}
  760. function cfgSrvVal(key){const cfg=S.server.config||{};const f=CFG[key];return f?gp(cfg,f.path):undefined;}
  761. function effVal(key){return S.dirty.has(key)?S.draft[key]:srvVal(key);}
  762. function cfgEff(key){return S.cfgDraft[key]!==undefined?S.cfgDraft[key]:cfgSrvVal(key);}
  763. 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);}
  764. function cfgSetDirty(key,val){
  765. const f=CFG[key];if(!f)return;
  766. S.cfgDraft[key]=val;
  767. 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)));
  768. render();
  769. }
  770. 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();}
  771. 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;}
  772. function cfgHasRestart(sec){return Object.keys(CFG).filter(k=>CFG[k].sec===sec&&!CFG[k].live).some(k=>S.cfgDraft[k]!==undefined);}
  773. 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'';}}
  774. 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();}
  775. function clearDirty(keys){keys.forEach(k=>{S.dirty.delete(k);delete S.draft[k];S.errors[k]='';});render();}
  776. function secDirty(sec){for(const k of S.dirty){if(FIELDS[k]?.section===sec)return true;}return false;}
  777. function secErrors(sec){return Object.entries(FIELDS).some(([k,m])=>m.section===sec&&S.errors[k]);}
  778. 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;}
  779. // ── Ingest draft ───────────────────────────────────────────────────────────
  780. function ingFromSrv(){return S.server.config?.ingest||{};}
  781. 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());}
  782. function ingVal(path){return gp(S.ingestDraft||{},path);}
  783. 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();}
  784. // ── API ────────────────────────────────────────────────────────────────────
  785. 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};}}
  786. 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';}
  787. 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;}}
  788. 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;}}
  789. 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)));});}
  790. // ── History ────────────────────────────────────────────────────────────────
  791. function pushChart(arr,v){arr.push(isFinite(v)?v:0);if(arr.length>SPARK_LIMIT)arr.splice(0,arr.length-SPARK_LIMIT);}
  792. 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);}
  793. 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;}
  794. 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');}
  795. 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('');}
  796. 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;}
  797. function normState(s){return(typeof s==='string'?s.trim().toLowerCase():'')||'idle';}
  798. function stateSev(s){switch(normState(s)){case 'running':return'ok';case 'degraded':case 'muted':return'warn';case 'faulted':return'err';default:return'info';}}
  799. 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';}}
  800. // ── Formatters ─────────────────────────────────────────────────────────────
  801. 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);}
  802. 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`;}
  803. function fmtPct(v){return typeof v==='number'?(v*100).toFixed(0)+'%':'--';}
  804. function fmtFreq(v){return typeof v==='number'?v.toFixed(1)+' MHz':'--';}
  805. function fmtDur(v){if(!isFinite(v)||v<0)return'--';return v>=1?v.toFixed(2)+'s':(v*1000).toFixed(0)+'ms';}
  806. function fmtPilot(v){return typeof v==='number'?(v*100).toFixed(1)+'%':'--';}
  807. function fmtPI(v){if(!v||!String(v).trim())return'--';return'0x'+String(v).toUpperCase().padStart(4,'0');}
  808. function fmtPTY(v){const n=Number(v);return(n>=0&&n<=31)?`${n} — ${PTY_NAMES[n]}`:'--';}
  809. 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';}
  810. function ageFrom(v){if(!v)return'--';if(typeof v==='number')return ageStr(v);const ts=Date.parse(String(v));return isNaN(ts)?'--':ageStr(ts);}
  811. function joinParts(ps){return ps.filter(p=>String(p||'').trim()!=='').join(' · ');}
  812. // ── DOM helpers ────────────────────────────────────────────────────────────
  813. function setText(id,text){const el=$(id);if(el){const s=text==null?'--':String(text);if(el.textContent!==s)el.textContent=s;}}
  814. function setHTML(id,h){const el=$(id);if(el&&el.innerHTML!==h)el.innerHTML=h;}
  815. function setCls(id,c){const el=$(id);if(el)el.className=c;}
  816. 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);}
  817. 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>`;}
  818. 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));}
  819. 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'));}
  820. 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');}
  821. 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]);}
  822. 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;}
  823. function syncIngChk(id,path){const el=$(id);if(el)el.checked=!!ingVal(path);}
  824. // ── Freq presets ───────────────────────────────────────────────────────────
  825. 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;}
  826. 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');}
  827. 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)));}
  828. // ── API actions ────────────────────────────────────────────────────────────
  829. function beginReq(){S.pending++;setConn(true,'busy');render();}
  830. function endReq(){S.pending=Math.max(0,S.pending-1);setConn(S.server.configOk||S.server.runtimeOk);render();}
  831. 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();}}
  832. 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});}
  833. function resetSection(sec){clearDirty(Object.keys(FIELDS).filter(k=>FIELDS[k].section===sec));toast('Draft reset','info');}
  834. 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();}}
  835. async function txAction(action){
  836. if(S.txBusy)return;
  837. S.txBusy=true;
  838. if(action==='stop'&&S.server.runtime){
  839. S.server.runtime.engine={...(S.server.runtime.engine||{}),state:'stopping'};
  840. }
  841. render();
  842. beginReq();
  843. try{
  844. log('TX '+action+' requested','info');
  845. await api(`/tx/${action}`,{method:'POST'});
  846. toast(action==='start'?'TX started':'TX stop requested','ok');
  847. log('TX '+action+' accepted','ok');
  848. await Promise.allSettled([loadRuntime({silent:true}),loadConfig({silent:true})]);
  849. }catch(e){
  850. toast(e.message,'err');
  851. log('TX '+action+' failed: '+e.message,'err');
  852. }finally{
  853. S.txBusy=false;
  854. endReq();
  855. render();
  856. }
  857. }
  858. 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();}}
  859. 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();}}
  860. 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();}}
  861. // ── Render ─────────────────────────────────────────────────────────────────
  862. function render(){try{_render();}catch(e){console.error('[render]',e);}}
  863. function _render(){
  864. const cfg=S.server.config||{},rt=S.server.runtime||{},eng=rt.engine||{},drv=rt.driver||{},aud=rt.audioStream||null;
  865. // ── Header
  866. setText('badge-backend',cfg.backend?.kind||cfg.backend||'--');
  867. setText('badge-mode',eng.state&&eng.state!=='idle'?'TX Active':'Control Plane');
  868. setText('badge-live',S.server.runtimeOk?'Connected':'Waiting');
  869. // ── Overview: freq display
  870. const applied=Number(eng.appliedFrequencyMHz),desired=Number(cfg.fm?.frequencyMHz);
  871. const dispFreq=isFinite(applied)?applied:effVal('frequencyMHz')??desired;
  872. setHTML('freq-display',`${typeof dispFreq==='number'?dispFreq.toFixed(1):'---.-'}<span class="unit">MHz</span>`);
  873. setText('freq-applied',isFinite(applied)?`Applied ${applied.toFixed(1)} MHz`:'Applied --');
  874. setText('freq-desired',isFinite(desired)?`Desired ${desired.toFixed(1)} MHz`:'Desired --');
  875. $('freq-note')?.classList.toggle('mismatch',isFinite(applied)&&isFinite(desired)&&!nearEq(applied,desired,.001));
  876. // ── Overview: quick stats
  877. setText('t-chunks',fmt(eng.chunksProduced));setText('t-samples',fmt(eng.totalSamples));
  878. setText('t-uptime',fmtTime(eng.uptimeSeconds));
  879. setText('t-rate',drv.effectiveSampleRateHz?(drv.effectiveSampleRateHz/1000).toFixed(0)+'k':'--');
  880. 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':'');}
  881. // ── Overview: TX state
  882. const txSt=normState(eng.state);
  883. $('tx-state').textContent=S.txBusy?'WORKING':txSt.toUpperCase();
  884. $('tx-state').className='tx-state '+(S.txBusy?'working':txSt);
  885. setText('tx-hint',eng.lastError?`Last error: ${eng.lastError}`:S.txBusy?'Command in progress':'Runtime polled every 1s');
  886. const canStopStates=['running','arming','prebuffering','degraded','muted','faulted','stopping'];
  887. const startDis=S.txBusy||txSt==='running';
  888. const stopDis=S.txBusy||!canStopStates.includes(txSt);
  889. $('btn-start').disabled=startDis;$('btn-stop').disabled=stopDis;$('btn-refresh').disabled=S.pending>0;
  890. // ── Overview: meters + sparklines
  891. 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');
  892. const urN=Number(eng.underruns??drv.underruns??0);
  893. 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');
  894. const txR=txSt==='running'?1:S.txBusy?.55:.08;
  895. setMeter('meter-tx-fill','meter-tx-text',txR,txSt==='running'?'Live':S.txBusy?'Working':'Idle',txSt==='running'?'good':S.txBusy?'warn':'err');
  896. drawSpark('spark-audio',S.charts.audio,'good',1);
  897. drawSpark('spark-underruns',S.charts.underruns,urN>0?'err':'warn');
  898. drawSpark('spark-tx',S.charts.tx,txSt==='running'?'good':'warn',1);
  899. // ── Overview: sidebar
  900. setText('info-backend',cfg.backend?.kind||cfg.backend||'--');setText('info-freq',fmtFreq(cfg.fm?.frequencyMHz));
  901. setText('info-preemph',cfg.fm?.preEmphasisTauUS?`${cfg.fm.preEmphasisTauUS} µs`:'Off');
  902. setText('info-pi',fmtPI(cfg.rds?.pi));setText('info-pty',fmtPTY(cfg.rds?.pty));
  903. setText('info-runtime-age',ageStr(S.server.lastRuntimeAt));setText('info-last-alert',eng.runtimeAlert||eng.lastError||'None');
  904. setText('info-drive',cfg.fm?.outputDrive!=null?Number(cfg.fm.outputDrive).toFixed(2):'--');
  905. setText('info-limiter',cfg.fm?.limiterEnabled?(cfg.fm?.limiterCeiling!=null?`ON (ceil ${Number(cfg.fm.limiterCeiling).toFixed(2)})`:'ON'):'OFF');
  906. setText('info-pilot',fmtPilot(cfg.fm?.pilotLevel));setText('info-rdsinj',fmtPilot(cfg.fm?.rdsInjection));
  907. setText('info-mpxgain',cfg.fm?.mpxGain!=null?Number(cfg.fm.mpxGain).toFixed(2):'--');
  908. setText('info-bs412',cfg.fm?.bs412Enabled?`ON (${cfg.fm?.bs412ThresholdDBr??0} dBr)`:'OFF');
  909. const cc=cfg.fm?.compositeClipper;setText('info-compclip',cc?.enabled?`ON (${cc.iterations??3}× ${cc.lookaheadMs?'LA ':''}${cc.softKnee>0?'soft':'hard'})`:'OFF');
  910. // ── TX Control tab
  911. // Freq
  912. syncDirtyInput('freq-slider','frequencyMHz',v=>typeof v==='number'?v.toFixed(1):'100.0');
  913. syncDirtyInput('freq-num','frequencyMHz',v=>typeof v==='number'?v.toFixed(1):'100.0');
  914. $('freq-apply').disabled=!secDirty('freq')||secErrors('freq');$('freq-reset').disabled=!secDirty('freq');
  915. setText('freq-meta',secErrors('freq')?'Validation error':secDirty('freq')?'Draft pending':'Live + Saved');
  916. const fe=$('freq-error');if(fe){fe.textContent=S.errors.frequencyMHz||'';fe.classList.toggle('show',!!S.errors.frequencyMHz);}
  917. refreshFreqPresets();
  918. // Audio sliders
  919. syncSlider('drive-slider','drive-val','outputDrive',v=>v==null?'--':Number(v).toFixed(2));
  920. syncSlider('lim-ceiling-slider','lim-ceiling-val','limiterCeiling',v=>v==null?'--':Number(v).toFixed(2));
  921. syncSlider('gain-slider','gain-val','audioGain',v=>v==null?'--':Number(v).toFixed(2));
  922. const peEl=$('preemph-select');if(peEl&&document.activeElement!==peEl)peEl.value=String(cfgEff('preEmphasisTauUS')??50);
  923. setText('audio-meta',S.cfgDirty['audio']?'Draft pending':'Mixed Apply Modes');
  924. $('audio-apply').disabled=!S.cfgDirty['audio'];$('audio-reset').disabled=!S.cfgDirty['audio'];
  925. // Tones
  926. syncSlider('tone-amp-slider','tone-amp-val','toneAmplitude',v=>v==null?'--':Number(v).toFixed(2));
  927. 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);
  928. 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);
  929. // Switches
  930. syncToggle('tog-stereo','stereo-label','stereoEnabled');syncToggle('tog-limiter','limiter-label','limiterEnabled');
  931. // Compliance
  932. syncCfgToggle('tog-bs412','bs412-label','bs412Enabled');
  933. syncSlider('bs412-threshold-slider','bs412-threshold-val','bs412ThresholdDBr',v=>v==null?'--':Number(v).toFixed(1));
  934. syncSlider('mpxgain-slider','mpxgain-val','mpxGain',v=>v==null?'--':Number(v).toFixed(2));
  935. setText('compliance-meta',S.cfgDirty['compliance']?'Draft pending':'Saved + Restart Required');
  936. $('compliance-apply').disabled=!S.cfgDirty['compliance'];$('compliance-reset').disabled=!S.cfgDirty['compliance'];
  937. // Composite Clipper
  938. syncToggle('tog-compclip','compclip-label','compositeClipperEnabled');
  939. syncSlider('compclip-iter-slider','compclip-iter-val','compositeClipperIterations',v=>v==null?'--':String(Math.round(Number(v))));
  940. syncSlider('compclip-knee-slider','compclip-knee-val','compositeClipperSoftKnee',v=>v==null?'--':Number(v).toFixed(2));
  941. syncSlider('compclip-la-slider','compclip-la-val','compositeClipperLookaheadMs',v=>v==null?'--':Number(v).toFixed(1));
  942. setText('compclip-meta',S.cfgDirty['compclip']?'Draft pending':'SM.1268');
  943. $('compclip-apply').disabled=!S.cfgDirty['compclip'];$('compclip-reset').disabled=!S.cfgDirty['compclip'];
  944. // Danger
  945. $('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';}
  946. 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.';}
  947. // ── RDS tab
  948. syncToggle('tog-rds','rds-label','rdsEnabled');
  949. // PI
  950. const piEl=$('rds-pi');if(piEl&&document.activeElement!==piEl)piEl.value=String(cfgEff('pi')||cfg.rds?.pi||'');
  951. setText('pi-display',fmtPI(cfgEff('pi')||cfg.rds?.pi));
  952. const piErr=$('pi-error');if(piErr){const e=S.cfgErrors?.pi||'';piErr.textContent=e;piErr.classList.toggle('show',!!e);}
  953. // PTY
  954. const ptyEl=$('rds-pty');if(ptyEl&&document.activeElement!==ptyEl)ptyEl.value=String(cfgEff('pty')??cfg.rds?.pty??0);
  955. const idDirty=!!S.cfgDirty['rds-id'];setText('rds-identity-meta',S.cfgErrors?.pi?'Validation error':idDirty?'Draft pending':'Saved + Restart Required');
  956. $('rds-identity-apply').disabled=!idDirty||!!S.cfgErrors?.pi;$('rds-identity-reset').disabled=!idDirty;
  957. // Text
  958. syncDirtyInput('rds-ps','ps',v=>String(v??''));syncDirtyInput('rds-rt','radioText',v=>String(v??''));
  959. const psV=String(effVal('ps')??cfg.rds?.ps??''),rtV=String(effVal('radioText')??cfg.rds?.radioText??'');
  960. setText('ps-count',psV.length);setText('rt-count',rtV.length);
  961. const rdsD=secDirty('rds');setText('rds-text-meta',secErrors('rds')?'Validation error':rdsD?'Draft pending':'Live + Saved');
  962. $('rds-apply').disabled=!rdsD||secErrors('rds');$('rds-reset').disabled=!rdsD;
  963. const psErr=$('ps-error');if(psErr){psErr.textContent=S.errors.ps||'';psErr.classList.toggle('show',!!S.errors.ps);}
  964. const rtErr=$('rt-error');if(rtErr){rtErr.textContent=S.errors.radioText||'';rtErr.classList.toggle('show',!!S.errors.radioText);}
  965. // Levels
  966. syncSlider('pilot-slider','pilot-val','pilotLevel',v=>v==null?'--':(Number(v)*100).toFixed(1)+'%');
  967. syncSlider('rdsinj-slider','rdsinj-val','rdsInjection',v=>v==null?'--':(Number(v)*100).toFixed(1)+'%');
  968. const lvlDirty=!!S.cfgDirty['rds-lvl'];$('rds-levels-apply').disabled=!lvlDirty;$('rds-levels-reset').disabled=!lvlDirty;
  969. // Status card
  970. const activePS=String(eng.activePS||cfg.rds?.ps||'').trim();
  971. const activeRT=String(eng.activeRadioText||cfg.rds?.radioText||'').trim();
  972. setText('rds-stat-enabled',cfg.rds?.enabled?'ON':'OFF');setText('rds-stat-pi',fmtPI(cfg.rds?.pi));
  973. setText('rds-stat-pty',fmtPTY(cfg.rds?.pty));setText('rds-stat-ps',activePS||'--');setText('rds-stat-rt',activeRT||'--');
  974. setText('rds-stat-pilot',fmtPilot(cfg.fm?.pilotLevel));setText('rds-stat-inj',fmtPilot(cfg.fm?.rdsInjection));
  975. // ── Ingest tab
  976. const ingest=rt.ingest||{},ia=ingest.active||{},iSrc=ingest.source||{},iRt=ingest.runtime||{},hasIR=!!rt.ingest;
  977. setText('ingest-summary-state',hasIR?joinParts([String(iRt.state||'').toUpperCase(),iSrc.state?`source ${String(iSrc.state).toUpperCase()}`:'',iRt.prebuffering?'PREBUFFERING':'',iRt.writeBlocked?'WRITE-BLOCKED':''])||'--':'--');
  978. 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`:''])||'--');
  979. 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`:''])||'--':'--');
  980. setText('ingest-summary-detail',hasIR?(iSrc.streamTitle||ia.detail||iSrc.lastError||'--'):'--');
  981. const org=ia.origin||{};setText('ingest-summary-origin',hasIR?joinParts([org.kind||'',org.endpoint||'',org.streamName||''])||'--':'--');
  982. setText('ingest-summary-last',hasIR?ageFrom(iRt.lastChunkAt||iSrc.lastChunkAt):'--');
  983. syncIngInput('ing-kind','kind',v=>String(v??'none'));syncIngInput('ing-prebuffer','prebufferMs',v=>isFinite(Number(v))?Number(v):0);
  984. 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);
  985. syncIngInput('ing-icecast-url','icecast.url',v=>String(v??''));syncIngInput('ing-icecast-decoder','icecast.decoder',v=>String(v??'auto'));
  986. 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');
  987. 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);
  988. 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??''));
  989. const kind=String(ingVal('kind')||'none').toLowerCase();
  990. $('ing-group-icecast').style.display=kind==='icecast'?'':'none';$('ing-group-srt').style.display=kind==='srt'?'':'none';$('ing-group-aes67').style.display=kind==='aes67'?'':'none';
  991. setText('ingest-meta',S.ingestSaving?'Saving…':S.ingestDirty?'Draft pending':'Saved + Hard Reload');
  992. $('ingest-save-reload').disabled=!S.ingestDirty||S.ingestSaving||!S.server.configOk;$('ingest-save-reload').textContent=S.ingestSaving?'Saving...':'Save + Hard Reload';
  993. $('ingest-reset').disabled=!S.ingestDirty||S.ingestSaving;
  994. const iErr=$('ingest-error');if(iErr){iErr.textContent=S.ingestError||'';iErr.classList.toggle('show',!!S.ingestError);}
  995. // ── Diagnostics tab
  996. setText('health-http',S.server.configOk?'OK':'OFFLINE');setCls('health-http','val '+(S.server.configOk?'good':'err'));
  997. 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'));
  998. const dur=Number(eng.runtimeStateDurationSeconds);setText('health-state-age',isFinite(dur)&&dur>0?fmtTime(dur):'--');
  999. 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':''));
  1000. const alert=String(eng.runtimeAlert||'').trim();setText('health-alert',alert||'None');setCls('health-alert','val '+(alert?'warn':'good'));
  1001. setText('health-transitions',eng.degradedTransitions!=null?`${eng.degradedTransitions??0} / ${eng.mutedTransitions??0} / ${eng.faultedTransitions??0}`:'--');
  1002. 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'):''));
  1003. 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');}
  1004. 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');}
  1005. setText('health-buffer-duration',fmtDur(Number(aud?.bufferedDurationSeconds)));
  1006. 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`:'--'));
  1007. 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'));
  1008. 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':''));
  1009. setText('health-last',ageStr(Math.max(S.server.lastConfigAt||0,S.server.lastRuntimeAt||0)));
  1010. const hwMode=hwF/(Number(aud?.capacity)||1)>=.95?'err':hwF/(Number(aud?.capacity)||1)>=.65?'warn':'good';
  1011. drawSpark('spark-high-watermark',S.charts.hw,hwMode);drawSpark('spark-queue-fill',S.charts.qf,qh==='critical'?'err':qh==='low'?'warn':'good',1);
  1012. // Audit
  1013. const audit=rt.controlAudit||{};let total=0;
  1014. ['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'):'');}});
  1015. const at=$('audit-total');if(at){at.textContent=String(total);at.className='audit-val'+(total>0?' warn':' good');}
  1016. // Fault history
  1017. 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('');}
  1018. applyMobilePanels();
  1019. }
  1020. 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);});}
  1021. // ── Toast / Log ────────────────────────────────────────────────────────────
  1022. 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);}
  1023. 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;}
  1024. // ── Bindings ───────────────────────────────────────────────────────────────
  1025. function bindAll(){
  1026. // Tabs
  1027. const tbs=Array.from(document.querySelectorAll('.tab-btn[data-tab]')),tps=Array.from(document.querySelectorAll('.tab-panel[data-tab-panel]'));
  1028. 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));}));
  1029. // Panels
  1030. document.querySelectorAll('[data-panel]').forEach(h=>h.addEventListener('click',()=>{h.classList.toggle('collapsed');h.nextElementSibling?.classList.toggle('collapsed');}));
  1031. // Live toggles
  1032. 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();}});});
  1033. // Config-only toggles (restart-required)
  1034. 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();}});});
  1035. // Freq
  1036. $('freq-slider').addEventListener('input',e=>setDirty('frequencyMHz',Number(e.target.value)));
  1037. $('freq-num').addEventListener('input',e=>{const v=Number(e.target.value);if(!isNaN(v))setDirty('frequencyMHz',v);});
  1038. $('freq-apply').addEventListener('click',()=>applySection('freq'));$('freq-reset').addEventListener('click',()=>resetSection('freq'));
  1039. // Freq presets
  1040. 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');}));
  1041. // Audio sliders
  1042. 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)));}
  1043. bindCfgSlider('drive-slider','outputDrive');bindCfgSlider('lim-ceiling-slider','limiterCeiling');
  1044. bindCfgSlider('gain-slider','audioGain');$('preemph-select').addEventListener('change',e=>cfgSetDirty('preEmphasisTauUS',Number(e.target.value)));
  1045. $('audio-apply').addEventListener('click',()=>applyCfgSection('audio'));$('audio-reset').addEventListener('click',()=>{cfgClear('audio');toast('Draft reset','info');});
  1046. // Tones
  1047. bindCfgSlider('tone-l-slider','toneLeftHz');$('tone-l-num').addEventListener('input',e=>cfgSetDirty('toneLeftHz',Number(e.target.value)));
  1048. bindCfgSlider('tone-r-slider','toneRightHz');$('tone-r-num').addEventListener('input',e=>cfgSetDirty('toneRightHz',Number(e.target.value)));
  1049. bindCfgSlider('tone-amp-slider','toneAmplitude');
  1050. $('tones-apply').addEventListener('click',()=>applyCfgSection('tones'));$('tones-off').addEventListener('click',()=>{cfgSetDirty('toneAmplitude',0);toast('Tone disable saved after apply / TX restart','info');applyCfgSection('tones');});
  1051. // Compliance
  1052. bindCfgSlider('bs412-threshold-slider','bs412ThresholdDBr');bindCfgSlider('mpxgain-slider','mpxGain');
  1053. $('compliance-apply').addEventListener('click',()=>applyCfgSection('compliance'));$('compliance-reset').addEventListener('click',()=>{cfgClear('compliance');toast('Draft reset','info');});
  1054. $('compclip-apply').addEventListener('click',()=>applyCfgSection('compclip'));$('compclip-reset').addEventListener('click',()=>{cfgClear('compclip');toast('Draft reset','info');});
  1055. // TX
  1056. $('btn-start').addEventListener('click',()=>txAction('start'));$('btn-stop').addEventListener('click',()=>txAction('stop'));
  1057. $('danger-stop').addEventListener('click',()=>txAction('stop'));$('btn-refresh').addEventListener('click',manualRefresh);
  1058. $('danger-reset-fault').addEventListener('click',()=>resetFault());
  1059. // RDS
  1060. $('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);});
  1061. $('rds-pty').addEventListener('change',e=>cfgSetDirty('pty',Number(e.target.value)));
  1062. $('rds-identity-apply').addEventListener('click',()=>applyCfgSection('rds-id'));$('rds-identity-reset').addEventListener('click',()=>{cfgClear('rds-id');toast('Draft reset','info');});
  1063. $('rds-ps').addEventListener('input',e=>setDirty('ps',e.target.value.toUpperCase().slice(0,8)));
  1064. $('rds-rt').addEventListener('input',e=>setDirty('radioText',e.target.value.slice(0,64)));
  1065. $('rds-apply').addEventListener('click',()=>applySection('rds'));$('rds-reset').addEventListener('click',()=>resetSection('rds'));
  1066. 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');}));
  1067. bindCfgSlider('pilot-slider','pilotLevel');bindCfgSlider('rdsinj-slider','rdsInjection');
  1068. $('rds-levels-apply').addEventListener('click',()=>applyCfgSection('rds-lvl'));$('rds-levels-reset').addEventListener('click',()=>{cfgClear('rds-lvl');toast('Draft reset','info');});
  1069. // Ingest
  1070. 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||''));});});
  1071. $('ingest-save-reload').addEventListener('click',()=>saveIngest());
  1072. $('ingest-reset').addEventListener('click',()=>{syncIngDraft(true);toast('Draft reset','info');log('Ingest draft reset','warn');render();});
  1073. // Log
  1074. $('btn-clear-log').addEventListener('click',()=>{$('log').innerHTML='<div class="empty-log">No activity recorded yet.</div>';toast('Activity log cleared','info');});
  1075. // Keyboard
  1076. 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');}});
  1077. // Responsive
  1078. const respHandler=()=>{if(!mobileMq.matches)S.mobilePanelsApplied=false;else applyMobilePanels();};if(mobileMq.addEventListener)mobileMq.addEventListener('change',respHandler);else mobileMq.addListener(respHandler);
  1079. }
  1080. 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();}}
  1081. function startPollers(){if(S.pollersStarted)return;S.pollersStarted=true;setInterval(()=>loadRuntime({silent:true}),RUNTIME_MS);setInterval(()=>loadConfig({silent:true}),CONFIG_MS);}
  1082. async function init(){
  1083. bindAll();render();
  1084. log('fm-rds-tx control UI booting','info');
  1085. await Promise.allSettled([loadConfig({silent:false}),loadRuntime({silent:true})]);
  1086. render();startPollers();
  1087. log('Polling active: runtime 1s · config 8s','ok');
  1088. log('Keyboard shortcuts ready','info');
  1089. }
  1090. init();
  1091. </script>
  1092. </body>
  1093. </html>