Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

1449 linhas
159KB

  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>ferrite.fm</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:1560px;margin:0 auto;padding:24px}
  24. /* Header */
  25. .header{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:2px 0 18px;border-bottom:1px solid var(--border);margin-bottom:20px}
  26. .header-main{display:flex;align-items:center;min-height:64px}
  27. .brand-lockup{display:flex;align-items:center;gap:0}
  28. .brand-logo{height:62px;width:auto;display:block;object-fit:contain}
  29. .brand-title{font-size:28px;font-weight:800;letter-spacing:-.03em}
  30. .header-note{font-size:13px;color:var(--text-muted)}
  31. .header-sub{display:flex;flex-wrap:wrap;gap:8px}
  32. .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}
  33. .badge strong{white-space:nowrap}
  34. .badge .tag{margin-left:2px}
  35. .badge strong{color:var(--text);font-weight:700}
  36. .header-status{display:flex;align-items:center;gap:8px;padding-top:0;padding-right:2px}
  37. .header-status .danger-btn{min-height:36px;padding:0 14px;border-radius:10px;background:rgba(176,48,48,.06);border-color:rgba(176,48,48,.22);font-size:11px;letter-spacing:.08em}
  38. .header-status .danger-btn:hover:not(:disabled){background:rgba(176,48,48,.1)}
  39. .header-status .led{margin-left:4px}
  40. /* LED */
  41. .led{width:10px;height:10px;border-radius:50%;background:#333;transition:all .25s;flex-shrink:0}
  42. .led.on-green{background:var(--green);box-shadow:0 0 0 3px rgba(13,148,74,.16)}
  43. .led.on-red{background:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.14)}
  44. .led.on-amber{background:var(--amber);box-shadow:0 0 0 3px rgba(183,121,31,.14)}
  45. .led.on-blue{background:var(--accent);box-shadow:0 0 0 3px rgba(31,77,157,.14)}
  46. .status-text{font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.08em}
  47. .flow-banner{display:none;align-items:center;gap:10px;margin:0 0 14px;padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface);box-shadow:var(--shadow)}
  48. .flow-banner.show{display:flex}
  49. .flow-banner.good{border-color:rgba(13,148,74,.25);background:var(--green-soft)}
  50. .flow-banner.warn{border-color:rgba(183,121,31,.3);background:var(--amber-soft)}
  51. .flow-banner.err{border-color:rgba(176,48,48,.32);background:var(--red-soft)}
  52. .flow-banner-title{font-size:11px;font-weight:800;letter-spacing:.08em;text-transform:uppercase}
  53. .flow-banner-text{font-size:12px;color:var(--text-dim)}
  54. .flow-board{padding:18px 20px;display:flex;flex-direction:column;gap:18px}
  55. .flow-master{display:grid;grid-template-columns:minmax(260px,1.2fr) minmax(0,2.2fr);gap:12px;align-items:stretch}
  56. .flow-master-hero{padding:16px;border:1px solid var(--border-strong);border-radius:12px;background:linear-gradient(180deg,#fff 0%,#f5f8fc 100%);box-shadow:0 14px 28px rgba(15,23,42,.09)}
  57. .flow-master-label{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:8px}
  58. .flow-master-state{font-size:26px;font-weight:800;letter-spacing:-.03em;line-height:1.05}
  59. .flow-master-state.good{color:var(--green)}
  60. .flow-master-state.warn{color:var(--amber)}
  61. .flow-master-state.err{color:var(--red)}
  62. .flow-master-state.idle{color:var(--text-dim)}
  63. .flow-master-sub{margin-top:8px;font-size:12px;color:var(--text-dim)}
  64. .flow-topbar{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}
  65. .flow-summary{padding:12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface2)}
  66. .flow-summary-label{font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:6px}
  67. .flow-summary-value{font-size:16px;font-weight:700}
  68. .flow-summary-value.compact{font-size:14px;line-height:1.3}
  69. .flow-chain{display:grid;grid-template-columns:repeat(8,minmax(140px,1fr));gap:18px;align-items:stretch}
  70. .flow-node{position:relative;display:flex;flex-direction:column;gap:7px;min-height:112px;padding:12px 12px 14px;border:1px solid var(--border);border-radius:12px;background:linear-gradient(180deg,#ffffff 0%, #f7f9fc 100%);box-shadow:0 10px 26px rgba(15,23,42,.08);cursor:pointer;transition:border-color .16s,transform .16s,box-shadow .16s}
  71. .flow-node::after{content:'';position:absolute;top:50%;right:-17px;width:18px;height:2px;background:linear-gradient(90deg,var(--border-strong),rgba(188,197,206,.2));transform:translateY(-50%)}
  72. .flow-node:last-child::after{display:none}
  73. .flow-node:hover{transform:translateY(-1px);border-color:var(--border-strong);box-shadow:0 14px 30px rgba(15,23,42,.11)}
  74. .flow-node.selected{outline:2px solid rgba(31,77,157,.22);outline-offset:0;box-shadow:0 0 0 4px rgba(31,77,157,.06),0 14px 30px rgba(15,23,42,.12)}
  75. .flow-node.terminal{min-height:124px;border-width:2px;box-shadow:0 14px 32px rgba(15,23,42,.12)}
  76. .flow-node.terminal::after{display:none}
  77. .flow-node.good{border-color:rgba(13,148,74,.3);background:linear-gradient(180deg,#fff 0%, rgba(13,148,74,.045) 100%)}
  78. .flow-node.warn{border-color:rgba(183,121,31,.32);background:linear-gradient(180deg,#fff 0%, rgba(183,121,31,.06) 100%)}
  79. .flow-node.err{border-color:rgba(176,48,48,.34);background:linear-gradient(180deg,#fff 0%, rgba(176,48,48,.07) 100%)}
  80. .flow-node.idle{border-color:var(--border);background:linear-gradient(180deg,#fff 0%, #f8fafc 100%)}
  81. .flow-node-head{display:flex;align-items:center;justify-content:space-between;gap:10px}
  82. .flow-node-icon{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:8px;border:1px solid var(--border);background:var(--surface2);font-size:16px;line-height:1}
  83. .flow-node-state{width:10px;height:10px;border-radius:50%;flex-shrink:0}
  84. .flow-node.good .flow-node-state{background:var(--green);box-shadow:0 0 0 3px rgba(13,148,74,.14)}
  85. .flow-node.warn .flow-node-state{background:var(--amber);box-shadow:0 0 0 3px rgba(183,121,31,.14)}
  86. .flow-node.err .flow-node-state{background:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.14)}
  87. .flow-node.idle .flow-node-state{background:#9aa5b1;box-shadow:0 0 0 3px rgba(154,165,177,.15)}
  88. .flow-node-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}
  89. .flow-node-sub{font-size:13px;color:var(--text);min-height:0;font-weight:700;line-height:1.25}
  90. .flow-node-detail{font-size:10px;color:var(--text-muted);line-height:1.3;text-transform:uppercase;letter-spacing:.05em}
  91. .flow-bottom{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}
  92. .flow-tooltip{position:fixed;z-index:1500;display:none;max-width:280px;padding:12px;border:1px solid var(--border);border-radius:10px;background:rgba(255,255,255,.98);box-shadow:0 14px 34px rgba(15,23,42,.16);pointer-events:none}
  93. .flow-tooltip.show{display:block}
  94. .flow-tooltip-title{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.08em;margin-bottom:6px}
  95. .flow-tooltip-status{font-size:11px;font-weight:700;margin-bottom:6px}
  96. .flow-tooltip-lines{display:flex;flex-direction:column;gap:4px;font-size:11px;color:var(--text-dim)}
  97. .flow-popover{position:fixed;z-index:1600;display:none;width:min(360px,calc(100vw - 24px));padding:14px;border:1px solid var(--border-strong);border-radius:12px;background:linear-gradient(180deg,rgba(255,255,255,.995) 0%, rgba(245,248,252,.995) 100%);box-shadow:0 20px 42px rgba(15,23,42,.2)}
  98. .flow-popover.show{display:block}
  99. .flow-popover-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:10px;padding-bottom:10px;border-bottom:1px solid rgba(188,197,206,.45)}
  100. .flow-popover-title{font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.08em}
  101. .flow-popover-status{font-size:11px;color:var(--text-dim);margin-top:2px;text-transform:uppercase;letter-spacing:.08em}
  102. .flow-popover-close{min-height:32px;padding:0 10px;border-radius:8px;border:1px solid var(--border);background:var(--surface2);cursor:pointer;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.08em}
  103. .flow-popover-lines{display:flex;flex-direction:column;gap:5px;margin-bottom:12px;font-size:11px;color:var(--text-dim)}
  104. .flow-popover-lines div{display:flex;justify-content:space-between;gap:12px;padding:2px 0;border-bottom:1px dashed rgba(188,197,206,.35)}
  105. .flow-popover-lines div:last-child{border-bottom:none}
  106. .flow-popover-fields{display:flex;flex-direction:column;gap:10px}
  107. .flow-popover-row{display:flex;flex-direction:column;gap:5px}
  108. .flow-popover-row label{font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.08em}
  109. .flow-popover-row input,.flow-popover-row select{width:100%}
  110. .flow-popover-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}
  111. /* Tabs */
  112. .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}
  113. .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}
  114. .tab-btn.active{border-bottom-color:var(--accent);color:var(--accent)}
  115. .tab-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
  116. .tab-panels{display:flex;flex-direction:column;gap:14px}
  117. .tab-panel{display:none}.tab-panel.active{display:block}
  118. .tab-columns{display:grid;gap:16px}
  119. .tab-columns.two{grid-template-columns:minmax(0,1.35fr) minmax(0,.85fr)}
  120. .tab-columns.one{grid-template-columns:1fr}
  121. .stack{display:flex;flex-direction:column;gap:12px}
  122. /* Cards */
  123. .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow)}
  124. .hero{padding:16px}
  125. /* TX Bar */
  126. .tx-bar{position:relative;z-index:1;display:grid;grid-template-columns:minmax(180px,250px) 1fr auto;gap:14px;align-items:center}
  127. .freq-display-label{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim)}
  128. .freq-display{font-size:40px;color:var(--text);letter-spacing:-.04em;line-height:1;font-weight:800}
  129. .freq-display .unit{font-family:var(--mono);font-size:14px;color:var(--text-dim);margin-left:5px}
  130. .freq-note{display:flex;gap:12px;margin-top:6px;font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.08em}
  131. .freq-note-item{display:inline-flex;align-items:center;gap:4px}
  132. .freq-note.mismatch .freq-note-item{color:var(--amber)}
  133. .tx-actions{display:flex;flex-wrap:wrap;gap:10px}
  134. .tx-state-wrap{display:flex;flex-direction:column;align-items:flex-end;gap:6px}
  135. .tx-state{font-size:11px;text-transform:uppercase;letter-spacing:2px;color:var(--text-dim)}
  136. .tx-state.running{color:var(--green)}.tx-state.working,.tx-state.starting,.tx-state.stopping{color:var(--amber)}
  137. .tx-state.faulted,.tx-state.error{color:var(--red)}
  138. .status-hint{font-size:10px;color:var(--text-muted);text-align:right}
  139. /* Quick grid */
  140. .quick-grid{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px;margin-top:16px}
  141. .quick-item{padding:12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface2)}
  142. .quick-item .label{font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:6px}
  143. .quick-item .value{font-size:18px;font-weight:700}
  144. .quick-item .value.warn{color:var(--amber)}.quick-item .value.err{color:var(--accent)}.quick-item .value.good{color:var(--green)}
  145. /* Signal grid */
  146. .signal-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:12px}
  147. .signal-card{padding:12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface2)}
  148. .signal-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px}
  149. .signal-title{font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.08em}
  150. .signal-value{font-size:11px;font-weight:700}
  151. /* Meters */
  152. .meter{width:100%;height:10px;border-radius:999px;background:#e7ebf0;border:1px solid var(--border);overflow:hidden}
  153. .meter-fill{height:100%;width:0%;transition:width .25s,background-color .25s;background:linear-gradient(90deg,var(--green),#3dbd75)}
  154. .meter-fill.warn{background:linear-gradient(90deg,var(--amber),#d9a14a)}.meter-fill.err{background:linear-gradient(90deg,var(--red),#d85c5c)}
  155. /* Sparklines */
  156. .spark{width:100%;height:34px;margin-top:10px;border-radius:6px;background:#f7f9fb;border:1px solid #e3e8ee}
  157. .spark path.line{fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
  158. .spark path.area{opacity:.14}
  159. .spark.good path.line{stroke:var(--green)}.spark.good path.area{fill:var(--green)}
  160. .spark.warn path.line{stroke:var(--amber)}.spark.warn path.area{fill:var(--amber)}
  161. .spark.err path.line{stroke:var(--accent)}.spark.err path.area{fill:var(--accent)}
  162. /* Hi-fi meters */
  163. .hifi-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:12px}
  164. .hifi-card{padding:12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface2);min-height:96px}
  165. .hifi-title{font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.08em;margin-bottom:10px}
  166. .hifi-row{display:grid;grid-template-columns:26px 1fr 64px;gap:10px;align-items:center;margin-bottom:8px;min-height:20px}
  167. .hifi-row:last-child{margin-bottom:0}
  168. .hifi-label{font-size:11px;font-weight:700;color:var(--text)}
  169. .hifi-value{font-size:11px;font-weight:700;color:var(--text-muted);text-align:right;font-variant-numeric:tabular-nums;font-feature-settings:"tnum" 1;white-space:nowrap}
  170. .hifi-meter{position:relative;height:12px;border-radius:999px;background:#e7ebf0;border:1px solid var(--border);overflow:hidden}
  171. .hifi-fill{position:absolute;left:0;top:0;bottom:0;width:0%;background:linear-gradient(90deg,#35b26f 0%, #d8b14a 72%, #d95f5f 100%)}
  172. .hifi-peak{position:absolute;top:-1px;bottom:-1px;width:2px;background:#111827;opacity:.85;transform:translateX(-1px)}
  173. /* Panels */
  174. .panel{overflow:hidden}
  175. .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}
  176. .panel-head:hover{background:#eef2f6}
  177. .panel-head h2{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1.6px;color:var(--text-dim)}
  178. .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}
  179. .panel-head .chevron{color:var(--text-dim);transition:transform .2s;font-size:10px}
  180. .panel-head.collapsed .chevron{transform:rotate(-90deg)}
  181. .panel-body{padding:14px}.panel-body.collapsed{display:none}
  182. /* Controls */
  183. .section-note{font-size:11px;color:var(--text-muted);margin-bottom:12px}
  184. .ctrl-row{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border)}
  185. .ctrl-row:last-child{border-bottom:none}
  186. .ctrl-label-wrap{min-width:140px;display:flex;flex-direction:column;gap:2px}
  187. .ctrl-label{font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.8px}
  188. .ctrl-sub{font-size:10px;color:var(--text-muted)}
  189. .ctrl-input{flex:1;display:flex;align-items:center;gap:10px}
  190. /* Tags */
  191. .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}
  192. .tag-live{background:rgba(13,148,74,.12);color:var(--green)}
  193. .tag-restart{background:rgba(183,121,31,.12);color:var(--amber)}
  194. .tag-reload{background:rgba(17,23,36,.08);color:var(--text-dim)}
  195. /* Buttons */
  196. .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}
  197. .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)}
  198. .tx-btn:disabled,.ghost-btn:disabled,.apply-btn:disabled,.preset-btn:disabled,.danger-btn:disabled{opacity:.45;cursor:not-allowed;transform:none}
  199. .tx-btn.start{background:var(--green);color:#fff;border-color:transparent}
  200. .tx-btn.start:hover:not(:disabled){background:#0b7f40}
  201. .tx-btn.stop{background:#fff;color:var(--red);border-color:rgba(176,48,48,.35)}
  202. .tx-btn.stop:hover:not(:disabled){background:var(--red-soft)}
  203. .ghost-btn{color:var(--text-dim)}
  204. .danger-btn{border-color:rgba(176,48,48,.35);color:var(--red);background:var(--red-soft)}
  205. .danger-btn:hover:not(:disabled){background:rgba(176,48,48,.16)}
  206. .apply-btn{background:var(--accent);border-color:transparent;color:#fff}
  207. .apply-btn.secondary{background:var(--surface2);color:var(--text-dim);border-color:var(--border)}
  208. .apply-btn:hover:not(:disabled){opacity:.88}
  209. /* Inputs */
  210. 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}
  211. 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}
  212. input[type="range"]::-webkit-slider-thumb:hover{background:var(--accent);transform:scale(1.06)}
  213. 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}
  214. input[type="number"]{width:92px;text-align:right}input[type="text"]{width:100%}
  215. select{min-width:120px}
  216. input:focus,select:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(31,77,157,.12)}
  217. input.input-dirty{border-color:var(--amber);box-shadow:0 0 0 3px rgba(183,121,31,.08)}
  218. input.input-error{border-color:var(--red);box-shadow:0 0 0 3px rgba(176,48,48,.14);background:rgba(176,48,48,.04)}
  219. .val-display{min-width:64px;text-align:right;font-size:12px;font-weight:700}
  220. .unit-label{font-size:11px;color:var(--text-dim);min-width:44px}
  221. .field-error{display:none;margin-top:8px;font-size:11px;color:var(--accent)}
  222. .field-error.show{display:block}
  223. /* Toggles */
  224. .toggle-row{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 0;border-bottom:1px solid var(--border)}
  225. .toggle-row:last-child{border-bottom:none}
  226. .toggle-copy{display:flex;flex-direction:column;gap:3px}
  227. .toggle-copy .title{font-size:12px;font-weight:700}
  228. .toggle-copy .sub{font-size:10px;color:var(--text-muted)}
  229. .toggle-ctl{display:flex;align-items:center;gap:10px}
  230. .toggle{position:relative;width:42px;height:24px;background:var(--border);border-radius:999px;cursor:pointer;transition:all .2s;flex-shrink:0}
  231. .toggle::after{content:'';position:absolute;top:3px;left:3px;width:18px;height:18px;background:var(--text);border-radius:50%;transition:transform .2s}
  232. .toggle.on{background:var(--green)}.toggle.on::after{transform:translateX(18px)}
  233. .toggle.busy{opacity:.55;pointer-events:none}
  234. .toggle-state{min-width:36px;text-align:right;font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px}
  235. /* RDS */
  236. .rds-grid{display:grid;gap:12px}
  237. .rds-field{display:flex;flex-direction:column;gap:6px}
  238. .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}
  239. .rds-input.rt{color:var(--text);text-transform:none;letter-spacing:.5px;font-size:12px;font-weight:500}
  240. .rds-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(31,77,157,.12)}
  241. .rds-charcount{font-size:10px;color:var(--text-dim);text-align:right}
  242. .pi-display{font-family:var(--mono);font-size:22px;font-weight:700;color:var(--accent);letter-spacing:4px;padding:8px 0}
  243. /* Presets */
  244. .preset-row{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px}
  245. .preset-btn{min-height:34px;padding:0 12px;font-size:11px;letter-spacing:.08em;color:var(--text-dim)}
  246. .preset-btn.active{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
  247. .preset-btn.rds{text-transform:none;font-weight:600}
  248. .actions-row{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px}
  249. /* Sidebar/KV */
  250. .sidebar-card{padding:14px}
  251. .sidebar-section+.sidebar-section{margin-top:14px;padding-top:14px;border-top:1px solid var(--border)}
  252. .sidebar-title{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-dim);margin-bottom:10px}
  253. .kv{display:grid;grid-template-columns:auto 1fr;gap:8px 12px;align-items:start}
  254. .kv .k{font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.08em}
  255. .kv .v{font-size:12px;word-break:break-word}
  256. /* Health */
  257. .health-line{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 0;border-bottom:1px solid var(--border)}
  258. .health-line:last-child{border-bottom:none}
  259. .health-line .name{font-size:11px;color:var(--text-dim)}
  260. .health-line .val{font-size:11px;text-align:right}
  261. .health-line .val.good{color:var(--green)}.health-line .val.warn{color:var(--amber)}.health-line .val.err{color:var(--accent)}
  262. .health-trend{margin-top:10px}
  263. .health-trend-label{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted);margin-bottom:6px}
  264. /* History */
  265. .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}
  266. .fault-history-entry,.transition-history-entry{display:flex;justify-content:space-between;gap:10px;padding:4px 0;border-bottom:1px solid var(--border)}
  267. .fault-history-entry:last-child,.transition-history-entry:last-child{border-bottom:none}
  268. .fault-history-entry .fault-history-time,.transition-history-entry .transition-history-time{color:var(--text-dim)}
  269. .fault-history-entry.ok,.transition-history-entry.good{color:var(--green)}
  270. .fault-history-entry.warn,.transition-history-entry.warn{color:var(--amber)}
  271. .fault-history-entry.err,.transition-history-entry.err{color:var(--accent)}
  272. .fault-history-desc,.transition-history-desc{font-size:10px;flex:1;text-transform:uppercase;letter-spacing:.5px}
  273. .fault-history-empty,.transition-history-empty{padding:6px 0;color:var(--text-muted);font-size:11px}
  274. /* Audit */
  275. .audit-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:6px 0;border-bottom:1px solid var(--border)}
  276. .audit-row:last-child{border-bottom:none}
  277. .audit-name{font-size:11px;color:var(--text-dim)}
  278. .audit-val{font-size:11px;font-weight:700}
  279. /* Ingest */
  280. .ingest-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}
  281. .ingest-grid .ctrl-row{align-items:flex-start;padding:0;border-bottom:none}
  282. .ingest-group{margin-top:12px;padding:10px;border:1px solid var(--border);border-radius:6px;background:var(--surface2)}
  283. .ingest-group-title{margin-bottom:8px;font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.08em}
  284. /* Log */
  285. .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}
  286. .log .entry{padding:3px 0}
  287. .log .entry.err{color:var(--accent)}.log .entry.ok{color:var(--green)}.log .entry.warn{color:var(--amber)}
  288. .empty-log{color:var(--text-muted)}
  289. /* Shortcuts */
  290. .shortcuts-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px 12px}
  291. .shortcut-line{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:7px 0;border-bottom:1px solid var(--border)}
  292. .shortcut-line:last-child{border-bottom:none}
  293. .shortcut-line .name{font-size:11px;color:var(--text-dim)}
  294. .shortcut-line .keys{display:inline-flex;gap:6px}
  295. .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}
  296. .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}
  297. /* Toast */
  298. .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)}
  299. .toast.show{transform:translateY(0);opacity:1}
  300. .toast.ok{background:var(--green);color:var(--bg)}.toast.err{background:var(--accent);color:#fff}
  301. .toast.warn{background:var(--amber);color:#141414}.toast.info{background:var(--text-dim);color:#fff}
  302. /* Responsive */
  303. @media(max-width:980px){.app{max-width:100%;padding:14px}.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}.flow-master{grid-template-columns:1fr}.flow-topbar,.flow-bottom{grid-template-columns:repeat(2,minmax(0,1fr))}.flow-chain{grid-template-columns:repeat(4,minmax(140px,1fr))}}
  304. @media(max-width:640px){.app{padding:12px}.header{flex-direction:column;align-items:flex-start;gap:10px}.brand-logo{height:54px}.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}.flow-master,.flow-topbar,.flow-bottom,.flow-chain{grid-template-columns:1fr}}
  305. </style>
  306. </head>
  307. <body>
  308. <div class="app">
  309. <div class="header">
  310. <div class="header-main">
  311. <div class="brand-lockup">
  312. <img class="brand-logo" src="/logo" alt="ferrite.fm logo">
  313. </div>
  314. </div>
  315. <div class="header-status">
  316. <button class="danger-btn" id="header-danger-stop" type="button">Emergency Stop TX</button>
  317. <button class="danger-btn" id="header-danger-reset-fault" type="button">Reset Fault</button>
  318. <div class="led" id="led-conn"></div>
  319. <div class="status-text" id="conn-label">connecting</div>
  320. </div>
  321. </div>
  322. <div class="flow-banner" id="flow-banner">
  323. <div class="flow-banner-title" id="flow-banner-title">Status</div>
  324. <div class="flow-banner-text" id="flow-banner-text">No active issues.</div>
  325. </div>
  326. <div class="tab-bar">
  327. <button class="tab-btn" data-tab="flow" type="button">Flow</button>
  328. <button class="tab-btn active" data-tab="overview" type="button">Overview</button>
  329. <button class="tab-btn" data-tab="tx" type="button">TX Control</button>
  330. <button class="tab-btn" data-tab="rds" type="button">RDS</button>
  331. <button class="tab-btn" data-tab="ingest" type="button">Ingest</button>
  332. <button class="tab-btn" data-tab="diagnostics" type="button">Diagnostics</button>
  333. <button class="tab-btn" data-tab="activity" type="button">Activity</button>
  334. </div>
  335. <div class="tab-panels">
  336. <!-- FLOW TAB -->
  337. <section class="tab-panel" data-tab-panel="flow">
  338. <div class="card flow-board">
  339. <div class="flow-master">
  340. <div class="flow-master-hero">
  341. <div class="flow-master-label">On-Air Master Status</div>
  342. <div class="flow-master-state idle" id="flow-master-state">--</div>
  343. <div class="flow-master-sub" id="flow-master-sub">Waiting for runtime telemetry.</div>
  344. </div>
  345. <div class="flow-topbar">
  346. <div class="flow-summary"><div class="flow-summary-label">Applied Frequency</div><div class="flow-summary-value" id="flow-top-applied">--</div></div>
  347. <div class="flow-summary"><div class="flow-summary-label">Target Frequency</div><div class="flow-summary-value" id="flow-top-target">--</div></div>
  348. <div class="flow-summary"><div class="flow-summary-label">Program Source</div><div class="flow-summary-value compact" id="flow-top-source">--</div></div>
  349. <div class="flow-summary"><div class="flow-summary-label">Active Alert</div><div class="flow-summary-value compact" id="flow-top-alert">--</div></div>
  350. </div>
  351. </div>
  352. <div class="flow-chain" id="flow-chain"></div>
  353. <div class="flow-bottom">
  354. <div class="flow-summary"><div class="flow-summary-label">Queue Health</div><div class="flow-summary-value" id="flow-bottom-queue">--</div></div>
  355. <div class="flow-summary"><div class="flow-summary-label">Ingest Runtime</div><div class="flow-summary-value" id="flow-bottom-ingest">--</div></div>
  356. <div class="flow-summary"><div class="flow-summary-label">Runtime Age</div><div class="flow-summary-value" id="flow-bottom-age">--</div></div>
  357. <div class="flow-summary"><div class="flow-summary-label">Last Update</div><div class="flow-summary-value" id="flow-bottom-update">--</div></div>
  358. </div>
  359. </div>
  360. </section>
  361. <div class="flow-tooltip" id="flow-tooltip"></div>
  362. <div class="flow-popover" id="flow-popover"></div>
  363. <!-- OVERVIEW TAB -->
  364. <section class="tab-panel active" data-tab-panel="overview">
  365. <div class="tab-columns two">
  366. <div class="stack">
  367. <div class="card hero" id="hero-card">
  368. <div class="tx-bar">
  369. <div>
  370. <div class="freq-display-label">Carrier</div>
  371. <div class="freq-display" id="freq-display">---.-<span class="unit">MHz</span></div>
  372. <div class="freq-note" id="freq-note">
  373. <span class="freq-note-item" id="freq-applied">Applied: --</span>
  374. <span class="freq-note-item" id="freq-desired">Desired: --</span>
  375. </div>
  376. </div>
  377. <div class="tx-actions">
  378. <button class="tx-btn start" id="btn-start" type="button">TX ON</button>
  379. <button class="tx-btn stop" id="btn-stop" type="button">TX OFF</button>
  380. <button class="ghost-btn" id="btn-refresh" type="button">Refresh</button>
  381. </div>
  382. <div class="tx-state-wrap">
  383. <div class="tx-state idle" id="tx-state">IDLE</div>
  384. <div class="status-hint" id="tx-hint">Waiting for runtime telemetry</div>
  385. </div>
  386. </div>
  387. <div class="quick-grid">
  388. <div class="quick-item"><div class="label">Chunks</div><div class="value" id="t-chunks">--</div></div>
  389. <div class="quick-item"><div class="label">Samples</div><div class="value" id="t-samples">--</div></div>
  390. <div class="quick-item"><div class="label">Underruns</div><div class="value" id="t-underruns">--</div></div>
  391. <div class="quick-item"><div class="label">Uptime</div><div class="value" id="t-uptime">--</div></div>
  392. <div class="quick-item"><div class="label">Rate</div><div class="value" id="t-rate">--</div></div>
  393. </div>
  394. <div class="signal-grid">
  395. <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>
  396. <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>
  397. <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>
  398. </div>
  399. <div class="hifi-grid">
  400. <div class="hifi-card">
  401. <div class="hifi-title">Audio RMS</div>
  402. <div class="hifi-row"><div class="hifi-label">L</div><div class="hifi-meter"><div class="hifi-fill" id="audio-l-rms-fill"></div></div><div class="hifi-value" id="audio-l-rms-text">--</div></div>
  403. <div class="hifi-row"><div class="hifi-label">R</div><div class="hifi-meter"><div class="hifi-fill" id="audio-r-rms-fill"></div></div><div class="hifi-value" id="audio-r-rms-text">--</div></div>
  404. </div>
  405. <div class="hifi-card">
  406. <div class="hifi-title">Audio Peak</div>
  407. <div class="hifi-row"><div class="hifi-label">L</div><div class="hifi-meter"><div class="hifi-fill" id="audio-l-peak-fill"></div><div class="hifi-peak" id="audio-l-peak-marker"></div></div><div class="hifi-value" id="audio-l-peak-text">--</div></div>
  408. <div class="hifi-row"><div class="hifi-label">R</div><div class="hifi-meter"><div class="hifi-fill" id="audio-r-peak-fill"></div><div class="hifi-peak" id="audio-r-peak-marker"></div></div><div class="hifi-value" id="audio-r-peak-text">--</div></div>
  409. </div>
  410. <div class="hifi-card">
  411. <div class="hifi-title">MPX Peak</div>
  412. <div class="hifi-row"><div class="hifi-label">MPX</div><div class="hifi-meter"><div class="hifi-fill" id="mpx-peak-fill"></div><div class="hifi-peak" id="mpx-peak-marker"></div></div><div class="hifi-value" id="mpx-peak-text">--</div></div>
  413. </div>
  414. </div>
  415. </div>
  416. </div>
  417. <div class="stack">
  418. <div class="card sidebar-card">
  419. <div class="sidebar-section">
  420. <div class="sidebar-title">Runtime Snapshot</div>
  421. <div class="kv">
  422. <div class="k">Backend</div><div class="v" id="info-backend">--</div>
  423. <div class="k">Frequency</div><div class="v" id="info-freq">--</div>
  424. <div class="k">Pre-emphasis</div><div class="v" id="info-preemph">--</div>
  425. <div class="k">RDS PI</div><div class="v" id="info-pi" style="font-family:var(--mono);font-weight:700">--</div>
  426. <div class="k">RDS PTY</div><div class="v" id="info-pty">--</div>
  427. <div class="k">State Age</div><div class="v" id="info-runtime-age">--</div>
  428. <div class="k">Last Alert</div><div class="v" id="info-last-alert">--</div>
  429. </div>
  430. </div>
  431. <div class="sidebar-section">
  432. <div class="sidebar-title">Signal Chain</div>
  433. <div class="kv">
  434. <div class="k">Output Drive</div><div class="v" id="info-drive">--</div>
  435. <div class="k">Limiter</div><div class="v" id="info-limiter">--</div>
  436. <div class="k">Pilot Level</div><div class="v" id="info-pilot">--</div>
  437. <div class="k">RDS Inj.</div><div class="v" id="info-rdsinj">--</div>
  438. <div class="k">MPX Gain</div><div class="v" id="info-mpxgain">--</div>
  439. <div class="k">BS.412</div><div class="v" id="info-bs412">--</div>
  440. <div class="k">Comp. Clip</div><div class="v" id="info-compclip">--</div>
  441. </div>
  442. </div>
  443. </div>
  444. </div>
  445. </div>
  446. </section>
  447. <!-- TX CONTROL TAB -->
  448. <section class="tab-panel" data-tab-panel="tx">
  449. <div class="tab-columns two">
  450. <div class="stack">
  451. <!-- Frequency -->
  452. <div class="card panel" data-panel-key="frequency">
  453. <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">Applies immediately</div><span class="chevron">▼</span></div>
  454. <div class="panel-body">
  455. <div class="section-note">Takes effect without restarting. When TX is running, the change applies at the next chunk boundary (~50 ms) and is written into config.</div>
  456. <div class="preset-row" id="freq-presets">
  457. <button class="preset-btn" type="button" data-freq-preset="87.6">87.6</button>
  458. <button class="preset-btn" type="button" data-freq-preset="94.5">94.5</button>
  459. <button class="preset-btn" type="button" data-freq-preset="99.5">99.5</button>
  460. <button class="preset-btn" type="button" data-freq-preset="100.0">100.0</button>
  461. <button class="preset-btn" type="button" data-freq-preset="107.9">107.9</button>
  462. </div>
  463. <div class="ctrl-row">
  464. <div class="ctrl-label-wrap"><span class="ctrl-label">TX Freq</span><span class="ctrl-sub">65–110 MHz</span></div>
  465. <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></div>
  466. </div>
  467. <div class="field-error" id="freq-error"></div>
  468. <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>
  469. </div>
  470. </div>
  471. <!-- Audio & Drive -->
  472. <div class="card panel" data-panel-key="audio">
  473. <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">Immediate + restart-only</div><span class="chevron">▼</span></div>
  474. <div class="panel-body">
  475. <div class="section-note">Output Drive and Limiter Ceiling take effect immediately. Pre-emphasis and Input Gain require TX restart before they affect the DSP path.</div>
  476. <div class="ctrl-row">
  477. <div class="ctrl-label-wrap"><span class="ctrl-label">Output Drive</span><span class="ctrl-sub">0 – 10</span></div>
  478. <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>
  479. </div>
  480. <div class="ctrl-row">
  481. <div class="ctrl-label-wrap"><span class="ctrl-label">Limiter Ceiling</span><span class="ctrl-sub">0.5 – 2.0</span></div>
  482. <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>
  483. </div>
  484. <div class="ctrl-row">
  485. <div class="ctrl-label-wrap"><span class="ctrl-label">Pre-emphasis</span><span class="ctrl-sub">Region standard</span></div>
  486. <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>
  487. </div>
  488. <div class="ctrl-row">
  489. <div class="ctrl-label-wrap"><span class="ctrl-label">Input Gain</span><span class="ctrl-sub">0 – 4</span></div>
  490. <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>
  491. </div>
  492. <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>
  493. <div class="section-note reset-hint">Live fields update immediately. Restart-tagged fields become effective after TX restart.</div>
  494. </div>
  495. </div>
  496. <!-- Test Tones -->
  497. <div class="card panel" data-panel-key="tones">
  498. <div class="panel-head" data-panel><h2>Test Tones</h2><div class="meta">Applies immediately</div><span class="chevron">▼</span></div>
  499. <div class="panel-body">
  500. <div class="section-note">Tone settings take effect immediately in the running generator path. No TX restart is required. Set amplitude to 0 to disable.</div>
  501. <div class="ctrl-row">
  502. <div class="ctrl-label-wrap"><span class="ctrl-label">Left (Hz)</span></div>
  503. <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></div>
  504. </div>
  505. <div class="ctrl-row">
  506. <div class="ctrl-label-wrap"><span class="ctrl-label">Right (Hz)</span></div>
  507. <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></div>
  508. </div>
  509. <div class="ctrl-row">
  510. <div class="ctrl-label-wrap"><span class="ctrl-label">Amplitude</span><span class="ctrl-sub">0 – 1.0</span></div>
  511. <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></div>
  512. </div>
  513. <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>
  514. </div>
  515. </div>
  516. </div>
  517. <div class="stack">
  518. <!-- Switches -->
  519. <div class="card panel" data-panel-key="switches">
  520. <div class="panel-head" data-panel><div class="led on-green" style="width:6px;height:6px"></div><h2>Switches</h2><div class="meta">Apply immediately</div><span class="chevron">▼</span></div>
  521. <div class="panel-body">
  522. <div class="toggle-row">
  523. <div class="toggle-copy"><div class="title">Stereo</div><div class="sub">19 kHz pilot + 38 kHz DSB-SC</div></div>
  524. <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>
  525. </div>
  526. <div class="toggle-row">
  527. <div class="toggle-copy"><div class="title">Stereo Mode</div><div class="sub">Subcarrier modulation</div></div>
  528. <div class="toggle-ctl"><select id="sel-stereo-mode" style="font-size:13px;padding:4px 8px;border-radius:6px;border:1px solid var(--border);background:var(--bg-card);color:var(--fg)"><option value="DSB">DSB-SC (Standard)</option><option value="SSB">SSB-SC LSB</option><option value="VSB">VSB (Vestigial)</option></select></div>
  529. </div>
  530. <div class="toggle-row">
  531. <div class="toggle-copy"><div class="title">Limiter</div><div class="sub">Stereo limiter stage only; hard clips remain active</div></div>
  532. <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>
  533. </div>
  534. </div>
  535. </div>
  536. <!-- MPX Compliance -->
  537. <div class="card panel" data-panel-key="compliance">
  538. <div class="panel-head" data-panel><h2>MPX Compliance</h2><div class="meta" id="compliance-meta">Restart required</div><span class="chevron">▼</span></div>
  539. <div class="panel-body">
  540. <div class="section-note">ITU-R BS.412 limits total MPX power. Mandatory for licensed FM in EU/CH. Changes in this panel require TX restart.</div>
  541. <div class="toggle-row">
  542. <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>
  543. <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>
  544. </div>
  545. <div class="ctrl-row">
  546. <div class="ctrl-label-wrap"><span class="ctrl-label">Threshold</span><span class="ctrl-sub">dBr — 0 = standard</span></div>
  547. <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>
  548. </div>
  549. <div class="ctrl-row">
  550. <div class="ctrl-label-wrap"><span class="ctrl-label">MPX Gain</span><span class="ctrl-sub">Hardware calibration</span></div>
  551. <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>
  552. </div>
  553. <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>
  554. <div class="section-note reset-hint">Compliance changes are persisted to config and require TX restart before they affect the modulation chain.</div>
  555. </div>
  556. </div>
  557. <!-- Composite Clipper -->
  558. <div class="card panel" data-panel-key="compclip">
  559. <div class="panel-head" data-panel><h2>Composite Clipper</h2><div class="meta" id="compclip-meta">Immediate + restart-only</div><span class="chevron">▼</span></div>
  560. <div class="panel-body">
  561. <div class="section-note">ITU-R SM.1268 iterative composite clipper. Enable/disable takes effect immediately; iterations, knee and look-ahead require TX restart.</div>
  562. <div class="toggle-row">
  563. <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>
  564. <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>
  565. </div>
  566. <div class="ctrl-row">
  567. <div class="ctrl-label-wrap"><span class="ctrl-label">Iterations</span><span class="ctrl-sub">clip-filter passes (1-5)</span></div>
  568. <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>
  569. </div>
  570. <div class="ctrl-row">
  571. <div class="ctrl-label-wrap"><span class="ctrl-label">Soft Knee</span><span class="ctrl-sub">0 = hard clip, 0.3 = gentle</span></div>
  572. <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>
  573. </div>
  574. <div class="ctrl-row">
  575. <div class="ctrl-label-wrap"><span class="ctrl-label">Look-ahead</span><span class="ctrl-sub">0 = off, 1.0 ms = typical</span></div>
  576. <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>
  577. </div>
  578. <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>
  579. <div class="section-note reset-hint">Structural clipper changes (iterations, knee, look-ahead) are persisted to config and require TX restart.</div>
  580. </div>
  581. </div>
  582. </div>
  583. </div>
  584. </section>
  585. <!-- RDS TAB -->
  586. <section class="tab-panel" data-tab-panel="rds">
  587. <div class="tab-columns two">
  588. <div class="stack">
  589. <!-- Station Identity -->
  590. <div class="card panel" data-panel-key="rds-identity">
  591. <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">Mixed runtime impact</div><span class="chevron">▼</span></div>
  592. <div class="panel-body">
  593. <div class="section-note">RDS enable takes effect immediately. PI and PTY require TX restart before they change on-air.</div>
  594. <div class="ctrl-row">
  595. <div class="ctrl-label-wrap"><span class="ctrl-label">Enable RDS</span><span class="ctrl-sub">57 kHz subcarrier</span></div>
  596. <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></div>
  597. </div>
  598. <div class="ctrl-row">
  599. <div class="ctrl-label-wrap"><span class="ctrl-label">PI Code</span><span class="ctrl-sub">Programme Identifier (hex)</span></div>
  600. <div class="ctrl-input" style="flex-direction:column;align-items:flex-start;gap:6px">
  601. <div style="display:flex;align-items:center;gap:10px;width:100%">
  602. <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">
  603. <div class="pi-display" id="pi-display">0x----</div>
  604. <span class="tag tag-restart">restart</span>
  605. </div>
  606. <div class="field-error" id="pi-error"></div>
  607. </div>
  608. </div>
  609. <div class="ctrl-row">
  610. <div class="ctrl-label-wrap"><span class="ctrl-label">Programme Type</span><span class="ctrl-sub">PTY 0–31</span></div>
  611. <div class="ctrl-input">
  612. <select id="rds-pty" style="min-width:200px">
  613. <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>
  614. </select>
  615. <span class="tag tag-restart">restart</span>
  616. </div>
  617. </div>
  618. <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>
  619. <div class="section-note reset-hint">Identity settings persist immediately in config, but PI / PTY changes appear on-air only after TX restart.</div>
  620. </div>
  621. </div>
  622. <!-- On-Air Text -->
  623. <div class="card panel" data-panel-key="rds-text">
  624. <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">Applies on-air</div><span class="chevron">▼</span></div>
  625. <div class="panel-body">
  626. <div class="section-note">PS and RadioText apply at the next RDS group boundary (~88 ms). Edits stay local until you apply. With StreamTitle relay enabled, the active on-air RadioText can temporarily differ from the editor value.</div>
  627. <div class="preset-row">
  628. <button class="preset-btn rds" type="button" data-rds-ps="FMRTX" data-rds-rt="fm-rds-tx live">Station ID</button>
  629. <button class="preset-btn rds" type="button" data-rds-ps="ONAIR" data-rds-rt="Now broadcasting">On Air</button>
  630. <button class="preset-btn rds" type="button" data-rds-ps="LIVE" data-rds-rt="Live set in progress">Live Set</button>
  631. <button class="preset-btn rds" type="button" data-rds-ps="TEST" data-rds-rt="RDS test transmission">Test</button>
  632. </div>
  633. <div class="rds-grid">
  634. <div class="rds-field">
  635. <div style="display:flex;align-items:center;justify-content:space-between"><span class="ctrl-label">Program Service (PS)</span></div>
  636. <input type="text" class="rds-input" id="rds-ps" maxlength="8" placeholder="STATION" spellcheck="false">
  637. <div class="rds-charcount"><span id="ps-count">0</span>/8</div>
  638. <div class="field-error" id="ps-error"></div>
  639. </div>
  640. <div class="rds-field">
  641. <div style="display:flex;align-items:center;justify-content:space-between"><span class="ctrl-label">RadioText (RT)</span></div>
  642. <input type="text" class="rds-input rt" id="rds-rt" maxlength="64" placeholder="Now playing..." spellcheck="false">
  643. <div class="rds-charcount"><span id="rt-count">0</span>/64</div>
  644. <div class="field-error" id="rt-error"></div>
  645. </div>
  646. </div>
  647. <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>
  648. <div class="section-note">Saved config: <span id="rds-saved-rt">--</span><br>Active on-air text: <span id="rds-active-rt-inline">--</span></div>
  649. </div>
  650. </div>
  651. <!-- RDS Features -->
  652. <div class="card panel" data-panel-key="rds-features">
  653. <div class="panel-head" data-panel><div class="led on-amber" style="width:6px;height:6px"></div><h2>RDS Features</h2><div class="meta">Immediate + restart-only</div><span class="chevron">▼</span></div>
  654. <div class="panel-body">
  655. <div class="section-note">Traffic, clock, RT+ and other RDS features. TP/TA take effect immediately; the remaining fields in this panel require TX restart.</div>
  656. <div class="ctrl-row">
  657. <div class="ctrl-label-wrap"><span class="ctrl-label">Traffic Program (TP)</span><span class="ctrl-sub">Station carries traffic info</span></div>
  658. <div class="ctrl-input"><input type="checkbox" id="rds-tp"><span class="tag tag-live">live</span></div>
  659. </div>
  660. <div class="ctrl-row">
  661. <div class="ctrl-label-wrap"><span class="ctrl-label">Traffic Announcement (TA)</span><span class="ctrl-sub">Currently on air</span></div>
  662. <div class="ctrl-input"><input type="checkbox" id="rds-ta"><span class="tag tag-live">live</span></div>
  663. </div>
  664. <div class="ctrl-row">
  665. <div class="ctrl-label-wrap"><span class="ctrl-label">Music / Speech</span><span class="ctrl-sub">MS flag for receivers</span></div>
  666. <div class="ctrl-input"><select id="rds-ms"><option value="true">Music</option><option value="false">Speech</option></select><span class="tag tag-restart">restart</span></div>
  667. </div>
  668. <div class="ctrl-row">
  669. <div class="ctrl-label-wrap"><span class="ctrl-label">Clock-Time (CT)</span><span class="ctrl-sub">Group 4A, UTC, 1×/min</span></div>
  670. <div class="ctrl-input"><input type="checkbox" id="rds-ct"><span class="tag tag-restart">restart</span></div>
  671. </div>
  672. <div class="ctrl-row">
  673. <div class="ctrl-label-wrap"><span class="ctrl-label">RT+ Auto-Parse</span><span class="ctrl-sub">Artist/Title from RadioText</span></div>
  674. <div class="ctrl-input"><input type="checkbox" id="rds-rtplus"><span class="tag tag-restart">restart</span></div>
  675. </div>
  676. <div class="ctrl-row">
  677. <div class="ctrl-label-wrap"><span class="ctrl-label">RT+ Separator</span><span class="ctrl-sub">Split char(s) in RT</span></div>
  678. <div class="ctrl-input"><input type="text" id="rds-rtplus-sep" maxlength="5" value=" - " style="width:60px;font-family:var(--mono);text-align:center"><span class="tag tag-restart">restart</span></div>
  679. </div>
  680. <div class="ctrl-row">
  681. <div class="ctrl-label-wrap"><span class="ctrl-label">PTYN</span><span class="ctrl-sub">Custom type name, 8 chars</span></div>
  682. <div class="ctrl-input"><input type="text" id="rds-ptyn" maxlength="8" placeholder="" style="width:100px;font-family:var(--mono);text-transform:uppercase;letter-spacing:1px"><span class="tag tag-restart">restart</span></div>
  683. </div>
  684. <div class="ctrl-row">
  685. <div class="ctrl-label-wrap"><span class="ctrl-label">Long PS (LPS)</span><span class="ctrl-sub">UTF-8 station name, 32 bytes</span></div>
  686. <div class="ctrl-input"><input type="text" id="rds-lps" maxlength="32" placeholder="My Radio Station" style="width:200px;font-family:var(--mono);font-size:12px"><span class="tag tag-restart">restart</span></div>
  687. </div>
  688. <div class="ctrl-row">
  689. <div class="ctrl-label-wrap"><span class="ctrl-label">Alt. Frequencies</span><span class="ctrl-sub">Comma-separated MHz</span></div>
  690. <div class="ctrl-input" style="flex-direction:column;align-items:flex-start;gap:6px;width:100%"><div style="display:flex;align-items:center;gap:10px;width:100%"><input type="text" id="rds-af" placeholder="93.3, 95.7" style="width:180px;font-family:var(--mono);font-size:12px"><span class="tag tag-restart">restart</span></div><div class="field-error" id="rds-af-error"></div></div>
  691. </div>
  692. <div class="ctrl-row">
  693. <div class="ctrl-label-wrap"><span class="ctrl-label">eRT (Enhanced RT)</span><span class="ctrl-sub">UTF-8, 128 bytes, ODA</span></div>
  694. <div class="ctrl-input"><input type="checkbox" id="rds-ert-on"><span class="tag tag-restart">restart</span></div>
  695. </div>
  696. <div class="ctrl-row">
  697. <div class="ctrl-label-wrap"><span class="ctrl-label">eRT Text</span><span class="ctrl-sub">UTF-8 multilingual text</span></div>
  698. <div class="ctrl-input"><input type="text" id="rds-ert" maxlength="128" placeholder="Ràdio en català..." style="width:280px;font-family:var(--mono);font-size:12px"><span class="tag tag-restart">restart</span></div>
  699. </div>
  700. <div class="actions-row"><button class="apply-btn" id="rds-features-apply" type="button">Save Features</button><button class="apply-btn secondary" id="rds-features-reset" type="button">Reset Draft</button></div>
  701. </div>
  702. </div>
  703. <!-- RDS2 -->
  704. <div class="card panel" data-panel-key="rds2">
  705. <div class="panel-head" data-panel><div class="led on-amber" style="width:6px;height:6px"></div><h2>RDS2 (Streams 1-3)</h2><div class="meta">Restart required</div><span class="chevron">▼</span></div>
  706. <div class="panel-body">
  707. <div class="section-note">Three additional BPSK subcarriers at 66.5, 71.25, 76 kHz (IEC 62106-1:2018). Station logo via RFT file transfer. Requires RDS2-capable receivers.</div>
  708. <div class="ctrl-row">
  709. <div class="ctrl-label-wrap"><span class="ctrl-label">RDS2 Enable</span><span class="ctrl-sub">Activate streams 1-3</span></div>
  710. <div class="ctrl-input"><input type="checkbox" id="rds2-on"><span class="tag tag-restart">restart</span></div>
  711. </div>
  712. <div class="ctrl-row">
  713. <div class="ctrl-label-wrap"><span class="ctrl-label">Station Logo</span><span class="ctrl-sub">PNG/JPEG path on server</span></div>
  714. <div class="ctrl-input"><input type="text" id="rds2-logo" placeholder="/path/to/logo.png" style="width:250px;font-family:var(--mono);font-size:12px"><span class="tag tag-restart">restart</span></div>
  715. </div>
  716. <div class="actions-row"><button class="apply-btn" id="rds2-apply" type="button">Save RDS2</button><button class="apply-btn secondary" id="rds2-reset" type="button">Reset Draft</button></div>
  717. </div>
  718. </div>
  719. </div>
  720. <div class="stack">
  721. <!-- Injection Levels -->
  722. <div class="card panel" data-panel-key="rds-levels">
  723. <div class="panel-head" data-panel><div class="led on-green" style="width:6px;height:6px"></div><h2>Injection Levels</h2><div class="meta">Apply immediately</div><span class="chevron">▼</span></div>
  724. <div class="panel-body">
  725. <div class="section-note">Fixed percentages of ±75 kHz deviation. ITU standard: pilot 9%, RDS 4%. When TX is running, changes apply immediately.</div>
  726. <div class="ctrl-row">
  727. <div class="ctrl-label-wrap"><span class="ctrl-label">Pilot Level</span><span class="ctrl-sub">19 kHz, 0–20%</span></div>
  728. <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></div>
  729. </div>
  730. <div class="ctrl-row">
  731. <div class="ctrl-label-wrap"><span class="ctrl-label">RDS Injection</span><span class="ctrl-sub">57 kHz, 0–15%</span></div>
  732. <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></div>
  733. </div>
  734. <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>
  735. </div>
  736. </div>
  737. <!-- RDS Status -->
  738. <div class="card sidebar-card">
  739. <div class="sidebar-section">
  740. <div class="sidebar-title">RDS Runtime</div>
  741. <div class="kv">
  742. <div class="k">Enabled</div><div class="v" id="rds-stat-enabled">--</div>
  743. <div class="k">PI Code</div><div class="v" id="rds-stat-pi" style="font-family:var(--mono);font-weight:700">--</div>
  744. <div class="k">PTY</div><div class="v" id="rds-stat-pty">--</div>
  745. <div class="k">PS</div><div class="v" id="rds-stat-ps" style="font-family:var(--mono);font-weight:700;letter-spacing:1px">--</div>
  746. <div class="k">Active RadioText</div><div class="v" id="rds-stat-rt">--</div>
  747. <div class="k">Saved RadioText</div><div class="v" id="rds-stat-rt-saved">--</div>
  748. <div class="k">Pilot</div><div class="v" id="rds-stat-pilot">--</div>
  749. <div class="k">RDS Inj.</div><div class="v" id="rds-stat-inj">--</div>
  750. </div>
  751. </div>
  752. </div>
  753. </div>
  754. </div>
  755. </section>
  756. <!-- INGEST TAB -->
  757. <section class="tab-panel" data-tab-panel="ingest">
  758. <div class="tab-columns one"><div class="stack">
  759. <div class="card sidebar-card">
  760. <div class="sidebar-section">
  761. <div class="sidebar-title">Active Ingest Summary</div>
  762. <div class="section-note">Runtime snapshot. Deep metrics in Diagnostics.</div>
  763. <div class="kv">
  764. <div class="k">State</div><div class="v" id="ingest-summary-state">--</div>
  765. <div class="k">Source</div><div class="v" id="ingest-summary-source">--</div>
  766. <div class="k">Signal</div><div class="v" id="ingest-summary-signal">--</div>
  767. <div class="k">Detail</div><div class="v" id="ingest-summary-detail">--</div>
  768. <div class="k">Origin</div><div class="v" id="ingest-summary-origin">--</div>
  769. <div class="k">Last Chunk</div><div class="v" id="ingest-summary-last">--</div>
  770. </div>
  771. </div>
  772. </div>
  773. <div class="card panel" data-panel-key="ingest">
  774. <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">Hard reload required</div><span class="chevron">▼</span></div>
  775. <div class="panel-body">
  776. <div class="section-note">Changes in this panel take effect only after a hard reload of the service.</div>
  777. <div class="ingest-grid">
  778. <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>
  779. <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>
  780. <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>
  781. <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>
  782. <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>
  783. </div>
  784. <div class="ingest-group" id="ing-group-icecast">
  785. <div class="ingest-group-title">Icecast</div>
  786. <div class="ingest-grid">
  787. <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>
  788. <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>
  789. <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>
  790. <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>
  791. <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>
  792. <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>
  793. </div>
  794. </div>
  795. <div class="ingest-group" id="ing-group-srt">
  796. <div class="ingest-group-title">SRT</div>
  797. <div class="ingest-grid">
  798. <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>
  799. <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>
  800. <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>
  801. <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>
  802. </div>
  803. </div>
  804. <div class="ingest-group" id="ing-group-aes67">
  805. <div class="ingest-group-title">AES67</div>
  806. <div class="ingest-grid">
  807. <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>
  808. <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>
  809. <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>
  810. <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>
  811. <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>
  812. <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>
  813. <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>
  814. <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>
  815. <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>
  816. </div>
  817. </div>
  818. <div class="field-error" id="ingest-error"></div>
  819. <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>
  820. <div class="section-note reset-hint">Ingest changes are not hot-applied. Saving writes config and schedules a hard service reload.</div>
  821. </div>
  822. </div>
  823. </div></div>
  824. </section>
  825. <!-- DIAGNOSTICS TAB -->
  826. <section class="tab-panel" data-tab-panel="diagnostics">
  827. <div class="tab-columns two">
  828. <div class="stack">
  829. <div class="card sidebar-card">
  830. <div class="sidebar-section">
  831. <div class="sidebar-title">Health</div>
  832. <div class="health-line"><div class="name">HTTP</div><div class="val" id="health-http">--</div></div>
  833. <div class="health-line"><div class="name">Runtime State</div><div class="val" id="health-runtime">--</div></div>
  834. <div class="health-line"><div class="name">State Age</div><div class="val" id="health-state-age">--</div></div>
  835. <div class="health-line"><div class="name">Runtime Signal</div><div class="val" id="health-indicator">--</div></div>
  836. <div class="health-line"><div class="name">Runtime Alert</div><div class="val" id="health-alert">--</div></div>
  837. <div class="health-line"><div class="name">Transitions D/M/F</div><div class="val" id="health-transitions">--</div></div>
  838. <div class="health-line"><div class="name">Fault Count</div><div class="val" id="health-fault-count">--</div></div>
  839. <div class="health-line"><div class="name">Last Fault</div><div class="val" id="health-last-fault">--</div></div>
  840. <div class="health-line"><div class="name">Audio Buffer</div><div class="val" id="health-audio">--</div></div>
  841. <div class="health-line"><div class="name">Buffer Duration</div><div class="val" id="health-buffer-duration">--</div></div>
  842. <div class="health-line"><div class="name">High Watermark</div><div class="val" id="health-buffer-highwater">--</div></div>
  843. <div class="health-line"><div class="name">Queue Fill</div><div class="val" id="health-queue-fill">--</div></div>
  844. <div class="health-line"><div class="name">Underrun Streak</div><div class="val" id="health-underrun-streak">--</div></div>
  845. <div class="health-line"><div class="name">Last Update</div><div class="val" id="health-last">--</div></div>
  846. <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>
  847. <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>
  848. </div>
  849. </div>
  850. <div class="card sidebar-card">
  851. <div class="sidebar-section">
  852. <div class="sidebar-title">Control Audit</div>
  853. <div class="section-note">4xx reject counts from the control API.</div>
  854. <div class="audit-row"><span class="audit-name">Total rejects</span><span class="audit-val" id="audit-total">--</span></div>
  855. <div class="audit-row"><span class="audit-name">405 Method Not Allowed</span><span class="audit-val" id="audit-methodNotAllowed">--</span></div>
  856. <div class="audit-row"><span class="audit-name">415 Unsupported Media</span><span class="audit-val" id="audit-unsupportedMediaType">--</span></div>
  857. <div class="audit-row"><span class="audit-name">413 Body Too Large</span><span class="audit-val" id="audit-bodyTooLarge">--</span></div>
  858. <div class="audit-row"><span class="audit-name">400 Unexpected Body</span><span class="audit-val" id="audit-unexpectedBody">--</span></div>
  859. </div>
  860. </div>
  861. </div>
  862. <div class="stack">
  863. <div class="card panel" data-panel-key="transition-history">
  864. <div class="panel-head" data-panel><h2>Transition History</h2><div class="meta">state shifts</div><span class="chevron">▼</span></div>
  865. <div class="panel-body"><div class="transition-history" id="transition-history"><div class="transition-history-empty">No state transitions recorded yet.</div></div></div>
  866. </div>
  867. <div class="card panel" data-panel-key="fault-history">
  868. <div class="panel-head" data-panel><h2>Fault History</h2><div class="meta">recent faults</div><span class="chevron">▼</span></div>
  869. <div class="panel-body"><div class="fault-history" id="fault-history"><div class="fault-history-empty">No faults recorded yet.</div></div></div>
  870. </div>
  871. </div>
  872. </div>
  873. </section>
  874. <!-- ACTIVITY TAB -->
  875. <section class="tab-panel" data-tab-panel="activity">
  876. <div class="tab-columns one"><div class="stack">
  877. <div class="card panel" data-panel-key="log">
  878. <div class="panel-head" data-panel><h2>Activity Log</h2><div class="meta" id="log-meta">recent events</div><span class="chevron">▼</span></div>
  879. <div class="panel-body">
  880. <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>
  881. <div class="log" id="log"><div class="empty-log">No activity recorded yet.</div></div>
  882. </div>
  883. </div>
  884. </div></div>
  885. </section>
  886. </div><!-- /tab-panels -->
  887. </div><!-- /app -->
  888. <div class="toast" id="toast"></div>
  889. <script>
  890. 'use strict';
  891. const $=id=>document.getElementById(id);
  892. const RUNTIME_MS=1000,CONFIG_MS=8000,SPARK_LIMIT=40,TRANS_LIMIT=6;
  893. const FREQ_PRESETS=[87.6,94.5,99.5,100.0,107.9];
  894. 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'];
  895. const FLOW_NODES=[
  896. {key:'source',label:'Source',icon:'IN'},
  897. {key:'ingest',label:'Ingest / Buffer',icon:'BUF'},
  898. {key:'audio',label:'Audio',icon:'AUD'},
  899. {key:'processing',label:'Processing',icon:'DSP'},
  900. {key:'stereo',label:'Stereo',icon:'ST'},
  901. {key:'rds',label:'RDS',icon:'RDS'},
  902. {key:'mpx',label:'Composite / MPX',icon:'MPX'},
  903. {key:'tx',label:'TX / RF',icon:'RF'},
  904. ];
  905. const mobileMq=window.matchMedia('(max-width:640px)');
  906. let toastTimer=null;
  907. const S={
  908. server:{config:null,runtime:null,measurements:null,configOk:false,runtimeOk:false,lastConfigAt:0,lastRuntimeAt:0,lastMeasurementsAt:0},
  909. lastRTState:'',draft:{},errors:{},dirty:new Set(),
  910. fieldErrors:{},
  911. flowSelected:null,flowHover:null,flowAnchor:null,
  912. pending:0,txBusy:false,faultBusy:false,toggleBusy:{},
  913. cfgDraft:{},cfgDirty:{},cfgErrors:{},
  914. ingestDraft:null,ingestDirty:false,ingestSaving:false,ingestError:'',
  915. pollersStarted:false,mobilePanelsApplied:false,freqPresetIndex:0,
  916. charts:{audio:[],underruns:[],tx:[],hw:[],qf:[]},
  917. transitions:[],
  918. meterState:{audioL:{rms:0,peak:0,hold:0},audioR:{rms:0,peak:0,hold:0},mpx:{rms:0,peak:0,hold:0}},
  919. };
  920. // ── Field definitions ──────────────────────────────────────────────────────
  921. const FIELDS={
  922. frequencyMHz:{section:'freq',eq:(a,b)=>nearEq(a,b,1e-4)},
  923. ps:{section:'rds',eq:(a,b)=>String(a??'')===String(b??'')},
  924. radioText:{section:'rds',eq:(a,b)=>String(a??'')===String(b??'')},
  925. };
  926. // CFG_FIELDS: fields that go through /config PATCH but aren't in the main draft system
  927. const CFG={
  928. outputDrive: {sec:'audio', live:true, path:'fm.outputDrive', min:0, max:10, step:.05},
  929. limiterCeiling:{sec:'audio', live:true, path:'fm.limiterCeiling', min:.5, max:2, step:.05},
  930. preEmphasisTauUS:{sec:'audio', live:false,path:'fm.preEmphasisTauUS'},
  931. audioGain: {sec:'audio', live:false,path:'audio.gain', min:0, max:4, step:.05},
  932. pilotLevel: {sec:'rds-lvl', live:true, path:'fm.pilotLevel', min:0, max:.2, step:.001},
  933. rdsInjection: {sec:'rds-lvl', live:true, path:'fm.rdsInjection', min:0, max:.15, step:.001},
  934. pi: {sec:'rds-id', live:false,path:'rds.pi'},
  935. pty: {sec:'rds-id', live:false,path:'rds.pty'},
  936. rdsTP: {sec:'rds-feat', live:false,path:'rds.tp'},
  937. rdsTA: {sec:'rds-feat', live:false,path:'rds.ta'},
  938. rdsMS: {sec:'rds-feat', live:false,path:'rds.ms'},
  939. rdsCT: {sec:'rds-feat', live:false,path:'rds.ctEnabled'},
  940. rdsRTPlus: {sec:'rds-feat', live:false,path:'rds.rtPlusEnabled'},
  941. rdsRTPlusSep: {sec:'rds-feat', live:false,path:'rds.rtPlusSeparator'},
  942. rdsPTYN: {sec:'rds-feat', live:false,path:'rds.ptyn'},
  943. rdsLPS: {sec:'rds-feat', live:false,path:'rds.lps'},
  944. rdsERT: {sec:'rds-feat', live:false,path:'rds.ert'},
  945. rdsERTEnabled: {sec:'rds-feat', live:false,path:'rds.ertEnabled'},
  946. rdsRDS2Enabled:{sec:'rds2', live:false,path:'rds.rds2Enabled'},
  947. rdsLogoPath: {sec:'rds2', live:false,path:'rds.stationLogoPath'},
  948. rdsAF: {sec:'rds-feat', live:false,path:'rds.af'},
  949. bs412Enabled: {sec:'compliance',live:false,path:'fm.bs412Enabled'},
  950. bs412ThresholdDBr:{sec:'compliance',live:false,path:'fm.bs412ThresholdDBr',min:-6,max:6,step:.5},
  951. mpxGain: {sec:'compliance',live:false,path:'fm.mpxGain', min:.1, max:5, step:.05},
  952. toneLeftHz: {sec:'tones', live:true, path:'audio.toneLeftHz', min:0, max:20000,step:10},
  953. toneRightHz: {sec:'tones', live:true, path:'audio.toneRightHz', min:0, max:20000,step:10},
  954. toneAmplitude: {sec:'tones', live:true, path:'audio.toneAmplitude', min:0, max:1, step:.01},
  955. compositeClipperEnabled: {sec:'compclip',live:true, path:'fm.compositeClipper.enabled'},
  956. compositeClipperIterations: {sec:'compclip',live:false,path:'fm.compositeClipper.iterations',min:1,max:5,step:1},
  957. compositeClipperSoftKnee: {sec:'compclip',live:false,path:'fm.compositeClipper.softKnee',min:0,max:.5,step:.01},
  958. compositeClipperLookaheadMs:{sec:'compclip',live:false,path:'fm.compositeClipper.lookaheadMs',min:0,max:3,step:.1},
  959. };
  960. const CFG_PATCH_KEY={outputDrive:'outputDrive',limiterCeiling:'limiterCeiling',preEmphasisTauUS:'preEmphasisTauUS',audioGain:'audioGain',pilotLevel:'pilotLevel',rdsInjection:'rdsInjection',pi:'pi',pty:'pty',rdsTP:'tp',rdsTA:'ta',rdsMS:'ms',rdsCT:'ctEnabled',rdsRTPlus:'rtPlusEnabled',rdsRTPlusSep:'rtPlusSeparator',rdsPTYN:'ptyn',rdsLPS:'lps',rdsERT:'ert',rdsERTEnabled:'ertEnabled',rdsRDS2Enabled:'rds2Enabled',rdsLogoPath:'stationLogoPath',rdsAF:'af',bs412Enabled:'bs412Enabled',bs412ThresholdDBr:'bs412ThresholdDBr',mpxGain:'mpxGain',toneLeftHz:'toneLeftHz',toneRightHz:'toneRightHz',toneAmplitude:'toneAmplitude',compositeClipperEnabled:'compositeClipperEnabled',compositeClipperIterations:'compositeClipperIterations',compositeClipperSoftKnee:'compositeClipperSoftKnee',compositeClipperLookaheadMs:'compositeClipperLookaheadMs'};
  961. // ── Helpers ────────────────────────────────────────────────────────────────
  962. 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;}
  963. function clone(o){return JSON.parse(JSON.stringify(o??{}));}
  964. 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;}
  965. 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];}}
  966. function srvVal(key){const cfg=S.server.config||{};switch(key){case 'frequencyMHz':return cfg.fm?.frequencyMHz;case 'ps':return cfg.rds?.ps??'';case 'radioText':return cfg.rds?.radioText??'';case 'stereoEnabled':return cfg.fm?.stereoEnabled;case 'stereoMode':return cfg.fm?.stereoMode??'DSB';case 'rdsEnabled':return cfg.rds?.enabled;case 'limiterEnabled':return cfg.fm?.limiterEnabled;case 'compositeClipperEnabled':return cfg.fm?.compositeClipper?.enabled;}return undefined;}
  967. function cfgSrvVal(key){const cfg=S.server.config||{};const f=CFG[key];return f?gp(cfg,f.path):undefined;}
  968. function effVal(key){return S.dirty.has(key)?S.draft[key]:srvVal(key);}
  969. function cfgEff(key){return S.cfgDraft[key]!==undefined?S.cfgDraft[key]:cfgSrvVal(key);}
  970. 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);}
  971. function cfgSetDirty(key,val){
  972. const f=CFG[key];if(!f)return;
  973. S.cfgDraft[key]=val;
  974. 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)));
  975. render();
  976. }
  977. function setFieldError(key,msg){
  978. if(msg) S.fieldErrors[key]=msg;
  979. else delete S.fieldErrors[key];
  980. render();
  981. }
  982. 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();}
  983. 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;}
  984. function cfgHasRestart(sec){return Object.keys(CFG).filter(k=>CFG[k].sec===sec&&!CFG[k].live).some(k=>S.cfgDraft[k]!==undefined);}
  985. 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'';}}
  986. 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();}
  987. function clearDirty(keys){keys.forEach(k=>{S.dirty.delete(k);delete S.draft[k];S.errors[k]='';});render();}
  988. function secDirty(sec){for(const k of S.dirty){if(FIELDS[k]?.section===sec)return true;}return false;}
  989. function secErrors(sec){return Object.entries(FIELDS).some(([k,m])=>m.section===sec&&S.errors[k]);}
  990. 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;}
  991. // ── Ingest draft ───────────────────────────────────────────────────────────
  992. function ingFromSrv(){return S.server.config?.ingest||{};}
  993. 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());}
  994. function ingVal(path){return gp(S.ingestDraft||{},path);}
  995. 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();}
  996. // ── API ────────────────────────────────────────────────────────────────────
  997. 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};}}
  998. 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';}
  999. 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;}}
  1000. 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;}}
  1001. async function loadMeasurements({silent=true}={}){try{const ms=await api('/measurements');S.server.measurements=ms;S.server.lastMeasurementsAt=Date.now();render();return ms;}catch(e){if(!silent)log('Measurements load failed: '+e.message,'err');throw e;}}
  1002. 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)));});}
  1003. // ── History ────────────────────────────────────────────────────────────────
  1004. function pushChart(arr,v){arr.push(isFinite(v)?v:0);if(arr.length>SPARK_LIMIT)arr.splice(0,arr.length-SPARK_LIMIT);}
  1005. 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);}
  1006. 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;}
  1007. 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');}
  1008. 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('');}
  1009. 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;}
  1010. function normState(s){return(typeof s==='string'?s.trim().toLowerCase():'')||'idle';}
  1011. function stateSev(s){switch(normState(s)){case 'running':return'ok';case 'degraded':case 'muted':return'warn';case 'faulted':return'err';default:return'info';}}
  1012. 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';}}
  1013. function flowSeverityFromRuntime(){const rt=S.server.runtime||{},eng=rt.engine||{},ing=rt.ingest||{},src=ing.source||{},ir=ing.runtime||{};if(normState(eng.state)==='faulted')return{sev:'err',title:'Fault',text:eng.lastError||eng.runtimeAlert||'TX faulted'};if(eng.runtimeAlert)return{sev:'warn',title:'Runtime warning',text:String(eng.runtimeAlert)};if(ir.writeBlocked)return{sev:'err',title:'Ingest blocked',text:'Ingest runtime is write-blocked'};if(src.state&&String(src.state).toLowerCase()==='reconnecting')return{sev:'warn',title:'Source reconnecting',text:'Input source is reconnecting'};return null;}
  1014. function flowNodeData(){const cfg=S.server.config||{},rt=S.server.runtime||{},eng=rt.engine||{},measWrap=S.server.measurements||{},meas=measWrap.measurement||eng.measurement||{},ing=rt.ingest||{},src=ing.source||{},ir=ing.runtime||{},active=ing.active||{},rds=cfg.rds||{},fm=cfg.fm||{},audio=cfg.audio||{};const txState=normState(eng.state||measWrap.state);const queueHealth=String(eng.queue?.health||measWrap.queue?.health||'').toLowerCase();const sourceKind=String(active.kind||cfg.ingest?.kind||'none');const sourceState=String(src.state||'').toLowerCase();const sourceHost=(()=>{const ep=String(active.origin?.endpoint||'');try{return ep?new URL(ep).host:active.origin?.streamName||'--';}catch{return active.origin?.streamName||ep||ep||'--';}})();
  1015. return {
  1016. source:{state:sourceKind==='none'?'idle':(sourceState==='reconnecting'?'warn':(src.connected===false?'err':'good')),sub:sourceKind.toUpperCase(),detail:(sourceKind==='none'?'No source':(src.connected===false?'Disconnected':'Connected')),lines:[`Kind: ${sourceKind}`,`Origin: ${active.origin?.endpoint||active.origin?.streamName||'--'}`,`State: ${src.state||'idle'}`,`Reconnects: ${src.reconnects??0}`],applyMode:'reload'},
  1017. ingest:{state:ir.writeBlocked?'err':(String(ir.state||'').toLowerCase()==='degraded'||sourceState==='reconnecting'?'warn':(sourceKind==='none'?'idle':'good')),sub:(ir.state||src.state||'idle').toUpperCase(),detail:joinParts([isFinite(Number(src.bufferedSeconds))?`${Number(src.bufferedSeconds).toFixed(1)} s buffer`:'',ir.prebuffering?'prebuffer':'']).trim()||'Stable',lines:[`Runtime: ${ir.state||'--'}`,`Buffered: ${isFinite(Number(src.bufferedSeconds))?Number(src.bufferedSeconds).toFixed(2)+'s':'--'}`,`Last chunk: ${ageFrom(ir.lastChunkAt||src.lastChunkAt)}`,`Write blocked: ${ir.writeBlocked?'yes':'no'}`],applyMode:'reload'},
  1018. audio:{state:audio.ToneAmplitude>0&&sourceKind==='none'?'warn':(sourceKind==='none'?'idle':'good'),sub:`Gain ${Number(audio.gain??0).toFixed(2)}`,detail:sourceKind!=='none'?'External program feed':(audio.toneAmplitude>0?'Tone generator armed':'No input source'),lines:[`Gain: ${Number(audio.gain??0).toFixed(2)}`,`Tone L: ${audio.toneLeftHz??'--'} Hz`,`Tone R: ${audio.toneRightHz??'--'} Hz`,`Tone Amp: ${Number(audio.toneAmplitude??0).toFixed(2)}`,`Source kind: ${sourceKind}`],applyMode:'mixed'},
  1019. processing:{state:'good',sub:fm.limiterEnabled?'Limiter ON':'Limiter OFF',detail:`Pre-emphasis ${fm.preEmphasisTauUS||0} µs`,lines:[`Limiter: ${fm.limiterEnabled?'on':'off'}`,`Ceiling: ${Number(fm.limiterCeiling??0).toFixed(2)}`,`Pre-emphasis: ${fm.preEmphasisTauUS||0} µs`,`Mode: nominal audio processing path`],applyMode:'mixed'},
  1020. stereo:{state:fm.stereoEnabled?'good':'idle',sub:fm.stereoEnabled?((meas.flags?.stereoMode)||fm.stereoMode||'DSB'):'MONO',detail:`Pilot ${typeof meas.compositeFinalPreIq?.pilotInjectionEquivalentPercent==='number'?Number(meas.compositeFinalPreIq.pilotInjectionEquivalentPercent).toFixed(1):(Number(fm.pilotLevel??0)*100).toFixed(1)} %`,lines:[`Enabled: ${(meas.flags?.stereoEnabled??fm.stereoEnabled)?'yes':'no'}`,`Mode: ${(meas.flags?.stereoMode)||fm.stereoMode||'DSB'}`,`Pilot: ${typeof meas.compositeFinalPreIq?.pilotInjectionEquivalentPercent==='number'?Number(meas.compositeFinalPreIq.pilotInjectionEquivalentPercent).toFixed(1):(Number(fm.pilotLevel??0)*100).toFixed(1)}%`],applyMode:'live'},
  1021. rds:{state:rds.enabled?'good':'idle',sub:rds.enabled?String(eng.activePS||rds.ps||'--'):'DISABLED',detail:rds.enabled?(eng.activeRadioText?String(eng.activeRadioText).slice(0,28):'Radiotext active'):'No subcarrier data',lines:[`Enabled: ${rds.enabled?'yes':'no'}`,`PI: ${rds.pi||'--'}`,`PTY: ${fmtPTY(rds.pty)}`,`PS: ${eng.activePS||rds.ps||'--'}`,`RT: ${eng.activeRadioText||rds.radioText||'--'}`],applyMode:'mixed'},
  1022. mpx:{state:queueHealth==='critical'?'err':(queueHealth==='low'?'warn':'good'),sub:`Pilot ${typeof meas.compositeFinalPreIq?.pilotInjectionEquivalentPercent==='number'?Number(meas.compositeFinalPreIq.pilotInjectionEquivalentPercent).toFixed(1):(Number(fm.pilotLevel??0)*100).toFixed(1)}% / Peak ${typeof meas.compositeFinalPreIq?.peakAbs==='number'?Number(meas.compositeFinalPreIq.peakAbs).toFixed(2):'--'}`,detail:queueHealth==='critical'?'Queue critical':(queueHealth==='low'?'Queue low':(meas.flags?.bs412Enabled?`BS.412 active · gain ${typeof meas.audioMpxPostBs412?.bs412GainApplied==='number'?Number(meas.audioMpxPostBs412.bs412GainApplied).toFixed(2):'--'}`:((meas.flags?.compositeClipperEnabled??fm.compositeClipper?.enabled)?'Composite clipper enabled':`MPX gain ${Number(fm.mpxGain??1).toFixed(2)}`))),lines:[`Pilot: ${typeof meas.compositeFinalPreIq?.pilotInjectionEquivalentPercent==='number'?Number(meas.compositeFinalPreIq.pilotInjectionEquivalentPercent).toFixed(1):(Number(fm.pilotLevel??0)*100).toFixed(1)}%`,`Pilot RMS: ${typeof meas.compositeFinalPreIq?.pilotRms==='number'?Number(meas.compositeFinalPreIq.pilotRms).toFixed(3):'--'}`,`Composite peak: ${typeof meas.compositeFinalPreIq?.peakAbs==='number'?Number(meas.compositeFinalPreIq.peakAbs).toFixed(3):'--'}`,`Clipper LA gain: ${typeof meas.audioMpxPreBs412?.clipperLookaheadGain==='number'?Number(meas.audioMpxPreBs412.clipperLookaheadGain).toFixed(3):'--'}`,`BS.412 gain: ${typeof meas.audioMpxPostBs412?.bs412GainApplied==='number'?Number(meas.audioMpxPostBs412.bs412GainApplied).toFixed(3):'--'}`,`Flags: ${joinParts([(meas.flags?.watermarkEnabled?'WM':'').trim(),(meas.flags?.rds2Enabled?'RDS2':''),(meas.flags?.licenseInjectionActive?'JINGLE':'')])||'none'}`],applyMode:'mixed'},
  1023. tx:{state:txState==='running'?'good':(txState==='faulted'?'err':(txState==='idle'?'idle':'warn')),sub:isFinite(Number(eng.appliedFrequencyMHz))?`${Number(eng.appliedFrequencyMHz).toFixed(1)} MHz`:(isFinite(Number(measWrap.appliedFrequencyMHz))?`${Number(measWrap.appliedFrequencyMHz).toFixed(1)} MHz`:(isFinite(Number(fm.frequencyMHz))?`${Number(fm.frequencyMHz).toFixed(1)} MHz`:'--')),detail:String(eng.state||measWrap.state||'idle').toUpperCase(),lines:[`State: ${String(eng.state||measWrap.state||'idle').toUpperCase()}`,`Applied: ${isFinite(Number(eng.appliedFrequencyMHz))?Number(eng.appliedFrequencyMHz).toFixed(1)+' MHz':(isFinite(Number(measWrap.appliedFrequencyMHz))?Number(measWrap.appliedFrequencyMHz).toFixed(1)+' MHz':'--')}`,`Target: ${isFinite(Number(fm.frequencyMHz))?Number(fm.frequencyMHz).toFixed(1)+' MHz':'--'}`,`Queue: ${eng.queue?.health||measWrap.queue?.health||'--'}`,`Underruns: ${eng.underruns??'--'}`],applyMode:'live'},
  1024. };
  1025. }
  1026. function renderFlow(){const chain=$('flow-chain');if(!chain)return;const data=flowNodeData();const meas=S.server.measurements?.measurement||S.server.runtime?.engine?.measurement||{};chain.innerHTML=FLOW_NODES.map(node=>{const d=data[node.key]||{state:'idle',sub:'--',detail:'--'};const sel=S.flowSelected===node.key?' selected':'';const terminal=node.key==='tx'?' terminal':'';return `<button type="button" class="flow-node ${d.state}${sel}${terminal}" data-flow-node="${node.key}"><div class="flow-node-head"><div class="flow-node-icon">${node.icon}</div><div class="flow-node-state"></div></div><div class="flow-node-title">${node.label}</div><div class="flow-node-sub">${d.sub||'--'}</div><div class="flow-node-detail">${d.detail||'--'}</div></button>`;}).join('');
  1027. chain.querySelectorAll('[data-flow-node]').forEach(el=>{const key=el.dataset.flowNode;el.addEventListener('mouseenter',e=>showFlowTooltip(key,e.currentTarget));el.addEventListener('mouseleave',hideFlowTooltip);el.addEventListener('focus',e=>showFlowTooltip(key,e.currentTarget));el.addEventListener('blur',hideFlowTooltip);el.addEventListener('click',()=>openFlowPopover(key,el));});
  1028. const issue=flowSeverityFromRuntime();const banner=$('flow-banner');if(banner){if(issue){banner.className=`flow-banner show ${issue.sev}`;$('flow-banner-title').textContent=issue.title;$('flow-banner-text').textContent=issue.text;}else{banner.className='flow-banner';$('flow-banner-title').textContent='Status';$('flow-banner-text').textContent='No active issues.';}}
  1029. const masterState=String((S.server.runtime?.engine?.state||'idle')).toUpperCase();setText('flow-master-state',masterState);const fms=$('flow-master-state');if(fms){fms.className=`flow-master-state ${stateClass(S.server.runtime?.engine?.state)}`;}setText('flow-master-sub',issue?issue.text:(masterState==='RUNNING'?(typeof meas.compositeFinalPreIq?.peakAbs==='number'?`Transmission active · composite peak ${Number(meas.compositeFinalPreIq.peakAbs).toFixed(2)} · pilot ${typeof meas.compositeFinalPreIq?.pilotInjectionEquivalentPercent==='number'?Number(meas.compositeFinalPreIq.pilotInjectionEquivalentPercent).toFixed(1):'--'}%`:'Transmission active and signal path healthy.'):masterState==='IDLE'?'Transmitter idle. Configure and start when ready.':'Runtime state present; inspect flow modules for details.'));
  1030. const sourceSummary=(()=>{const kind=String(S.server.runtime?.ingest?.active?.kind||S.server.config?.ingest?.kind||'none');const endpoint=String(S.server.runtime?.ingest?.active?.origin?.endpoint||'');try{return joinParts([kind,new URL(endpoint).host]);}catch{return joinParts([kind,S.server.runtime?.ingest?.active?.origin?.streamName||'']);}})();
  1031. const ingestState=(S.server.runtime?.ingest?.runtime?.state||S.server.runtime?.ingest?.source?.state||'--');
  1032. const runtimeAge=Number(S.server.runtime?.engine?.runtimeStateDurationSeconds);
  1033. setText('flow-top-applied',isFinite(Number(S.server.runtime?.engine?.appliedFrequencyMHz))?`${Number(S.server.runtime.engine.appliedFrequencyMHz).toFixed(1)} MHz`:(isFinite(Number(S.server.measurements?.appliedFrequencyMHz))?`${Number(S.server.measurements.appliedFrequencyMHz).toFixed(1)} MHz`:'--'));setText('flow-top-target',isFinite(Number(S.server.config?.fm?.frequencyMHz))?`${Number(S.server.config.fm.frequencyMHz).toFixed(1)} MHz`:'--');setText('flow-top-source',sourceSummary||'--');setText('flow-top-alert',issue?issue.text:(S.server.measurements?.runtimeAlert||'None'));setText('flow-bottom-queue',String(S.server.runtime?.engine?.queue?.health||S.server.measurements?.queue?.health||'--').toUpperCase());setText('flow-bottom-ingest',String(ingestState||'--').toUpperCase());setText('flow-bottom-age',isFinite(runtimeAge)?fmtTime(runtimeAge):'--');setText('flow-bottom-update',ageStr(Math.max(S.server.lastConfigAt||0,S.server.lastRuntimeAt||0,S.server.lastMeasurementsAt||0)));renderFlowPopover();
  1034. }
  1035. function showFlowTooltip(key,anchor){const tip=$('flow-tooltip');if(!tip||S.flowSelected===key)return;const d=flowNodeData()[key];if(!d)return;tip.innerHTML=`<div class="flow-tooltip-title">${FLOW_NODES.find(n=>n.key===key)?.label||key}</div><div class="flow-tooltip-status">${String(d.state||'idle').toUpperCase()}</div><div class="flow-tooltip-lines">${(d.lines||[]).map(line=>`<div>${line}</div>`).join('')}</div>`;const r=anchor.getBoundingClientRect();tip.style.left=`${Math.min(window.innerWidth-300,Math.max(12,r.left + window.scrollX))}px`;tip.style.top=`${r.bottom + window.scrollY + 8}px`;tip.classList.add('show');}
  1036. function hideFlowTooltip(){const tip=$('flow-tooltip');if(tip)tip.classList.remove('show');}
  1037. function openFlowPopover(key,anchor){S.flowSelected=S.flowSelected===key?null:key;S.flowAnchor=S.flowSelected?anchor:null;hideFlowTooltip();render();}
  1038. function closeFlowPopover(){S.flowSelected=null;S.flowAnchor=null;render();}
  1039. function flowJump(tab){document.querySelectorAll('.tab-btn[data-tab]').forEach(b=>b.classList.toggle('active',b.dataset.tab===tab));document.querySelectorAll('.tab-panel[data-tab-panel]').forEach(p=>p.classList.toggle('active',p.dataset.tabPanel===tab));closeFlowPopover();}
  1040. function renderFlowPopover(){const pop=$('flow-popover');if(!pop)return;if(!S.flowSelected||!S.flowAnchor){pop.classList.remove('show');return;}const key=S.flowSelected;const d=flowNodeData()[key];if(!d){pop.classList.remove('show');return;}const cfg=S.server.config||{};let fields='';let actions='';let modeNote='';if(key==='tx'){modeNote='Applies immediately.';fields=`<div class="flow-popover-row"><label>Frequency MHz</label><input type="number" id="flow-field-frequency" min="65" max="110" step="0.1" value="${Number(cfg.fm?.frequencyMHz??100).toFixed(1)}"></div>`;actions=`<button class="apply-btn" id="flow-action-save-frequency" type="button">Apply Frequency</button><button class="tx-btn start" id="flow-action-start" type="button">Start TX</button><button class="tx-btn stop" id="flow-action-stop" type="button">Stop TX</button><button class="danger-btn" id="flow-action-reset-fault" type="button">Reset Fault</button><button class="ghost-btn" id="flow-open-control" type="button">Open TX Control</button>`;}else if(key==='rds'){modeNote='Mixed runtime impact: enable/PS/RT apply now; PI/PTY belong in detailed controls.';fields=`<div class="flow-popover-row"><label>Enable RDS</label><select id="flow-field-rds-enabled"><option value="true" ${cfg.rds?.enabled?'selected':''}>Enabled</option><option value="false" ${!cfg.rds?.enabled?'selected':''}>Disabled</option></select></div><div class="flow-popover-row"><label>PS</label><input type="text" id="flow-field-ps" maxlength="8" value="${String(cfg.rds?.ps||'').replace(/"/g,'&quot;')}"></div><div class="flow-popover-row"><label>RadioText</label><input type="text" id="flow-field-rt" maxlength="64" value="${String(cfg.rds?.radioText||'').replace(/"/g,'&quot;')}"></div><div class="flow-popover-row"><label>Traffic Program</label><select id="flow-field-rds-tp"><option value="true" ${cfg.rds?.tp?'selected':''}>On</option><option value="false" ${!cfg.rds?.tp?'selected':''}>Off</option></select></div><div class="flow-popover-row"><label>Traffic Announcement</label><select id="flow-field-rds-ta"><option value="true" ${cfg.rds?.ta?'selected':''}>On</option><option value="false" ${!cfg.rds?.ta?'selected':''}>Off</option></select></div>`;actions=`<button class="apply-btn" id="flow-action-save-rds" type="button">Apply Live RDS</button><button class="ghost-btn" id="flow-open-rds" type="button">Open RDS Details</button>`;}else if(key==='source'||key==='ingest'){modeNote='Requires hard reload via detailed ingest configuration.';fields=`<div class="flow-popover-row"><label>Kind</label><select id="flow-field-ingest-kind" disabled><option value="none" ${(cfg.ingest?.kind||'none')==='none'?'selected':''}>none</option><option value="icecast" ${cfg.ingest?.kind==='icecast'?'selected':''}>icecast</option><option value="srt" ${cfg.ingest?.kind==='srt'?'selected':''}>srt</option><option value="aes67" ${cfg.ingest?.kind==='aes67'?'selected':''}>aes67</option><option value="stdin" ${cfg.ingest?.kind==='stdin'?'selected':''}>stdin</option><option value="http-raw" ${cfg.ingest?.kind==='http-raw'?'selected':''}>http-raw</option></select></div><div class="flow-popover-row"><label>Prebuffer ms</label><input type="number" id="flow-field-prebuffer" min="0" step="100" value="${Number(cfg.ingest?.prebufferMs??0)}" disabled></div>`;actions=`<button class="ghost-btn" id="flow-open-ingest" type="button">Open Input Details</button><button class="ghost-btn" id="flow-open-diagnostics" type="button">Open Diagnostics</button>`;}else if(key==='processing'){modeNote='Mixed runtime impact: limiter and ceiling apply now; pre-emphasis stays in detailed controls.';fields=`<div class="flow-popover-row"><label>Limiter</label><select id="flow-field-limiter"><option value="true" ${cfg.fm?.limiterEnabled?'selected':''}>Enabled</option><option value="false" ${!cfg.fm?.limiterEnabled?'selected':''}>Disabled</option></select></div><div class="flow-popover-row"><label>Limiter Ceiling</label><input type="number" id="flow-field-ceiling" min="0.5" max="2" step="0.05" value="${Number(cfg.fm?.limiterCeiling??1).toFixed(2)}"></div>`;actions=`<button class="apply-btn" id="flow-action-save-processing" type="button">Apply Processing</button><button class="ghost-btn" id="flow-open-processing" type="button">Open Full Controls</button>`;}else if(key==='mpx'){const meas=S.server.measurements?.measurement||S.server.runtime?.engine?.measurement||{};const flags=meas.flags||{};modeNote='Mixed runtime impact: pilot/RDS injection and clipper enable apply now; BS.412, MPX gain and structural clipper settings stay in detailed controls.';fields=`<div class="flow-popover-row"><label>Pilot Level</label><input type="number" id="flow-field-pilot" min="0" max="0.2" step="0.001" value="${Number(cfg.fm?.pilotLevel??0.09).toFixed(3)}"></div><div class="flow-popover-row"><label>RDS Injection</label><input type="number" id="flow-field-rdsinj" min="0" max="0.15" step="0.001" value="${Number(cfg.fm?.rdsInjection??0.04).toFixed(3)}"></div><div class="flow-popover-row"><label>Composite Clipper</label><select id="flow-field-compclip"><option value="true" ${cfg.fm?.compositeClipper?.enabled?'selected':''}>Enabled</option><option value="false" ${!cfg.fm?.compositeClipper?.enabled?'selected':''}>Disabled</option></select></div><div class="section-note" style="margin-top:10px">Measured path: pre-BS.412 RMS ${typeof meas.audioMpxPreBs412?.rms==='number'?Number(meas.audioMpxPreBs412.rms).toFixed(3):'--'} · post-BS.412 RMS ${typeof meas.audioMpxPostBs412?.rms==='number'?Number(meas.audioMpxPostBs412.rms).toFixed(3):'--'} · final peak ${typeof meas.compositeFinalPreIq?.peakAbs==='number'?Number(meas.compositeFinalPreIq.peakAbs).toFixed(3):'--'}</div><div class="section-note">Pilot ${typeof meas.compositeFinalPreIq?.pilotInjectionEquivalentPercent==='number'?Number(meas.compositeFinalPreIq.pilotInjectionEquivalentPercent).toFixed(1):'--'}% · Pilot RMS ${typeof meas.compositeFinalPreIq?.pilotRms==='number'?Number(meas.compositeFinalPreIq.pilotRms).toFixed(3):'--'} · RDS peak ${typeof meas.compositeFinalPreIq?.rdsPeakAbs==='number'?Number(meas.compositeFinalPreIq.rdsPeakAbs).toFixed(3):'--'}</div><div class="section-note">Clipper LA ${typeof meas.audioMpxPreBs412?.clipperLookaheadGain==='number'?Number(meas.audioMpxPreBs412.clipperLookaheadGain).toFixed(3):'--'} · Env ${typeof meas.audioMpxPreBs412?.clipperEnvelope==='number'?Number(meas.audioMpxPreBs412.clipperEnvelope).toFixed(3):'--'} · BS.412 gain ${typeof meas.audioMpxPostBs412?.bs412GainApplied==='number'?Number(meas.audioMpxPostBs412.bs412GainApplied).toFixed(3):'--'}</div><div class="section-note">Flags: ${joinParts([flags.stereoEnabled?`stereo ${flags.stereoMode||'DSB'}`:'mono',flags.watermarkEnabled?'watermark':'',flags.rdsEnabled?'rds':'',flags.rds2Enabled?'rds2':'',flags.licenseInjectionActive?'jingle':''])||'none'}</div>`;actions=`<button class="apply-btn" id="flow-action-save-mpx" type="button">Apply MPX</button><button class="ghost-btn" id="flow-open-processing" type="button">Open MPX Controls</button>`;}else if(key==='audio'){modeNote='Mixed runtime impact: tone controls apply now; input gain remains in detailed controls.';fields=`<div class="flow-popover-row"><label>Tone Left Hz</label><input type="number" id="flow-field-tone-left" min="0" max="20000" step="10" value="${Number(cfg.audio?.toneLeftHz??1000)}"></div><div class="flow-popover-row"><label>Tone Right Hz</label><input type="number" id="flow-field-tone-right" min="0" max="20000" step="10" value="${Number(cfg.audio?.toneRightHz??1600)}"></div><div class="flow-popover-row"><label>Tone Amplitude</label><input type="number" id="flow-field-tone-amp" min="0" max="1" step="0.01" value="${Number(cfg.audio?.toneAmplitude??0).toFixed(2)}"></div>`;actions=`<button class="apply-btn" id="flow-action-save-audio" type="button">Apply Tones</button><button class="ghost-btn" id="flow-open-audio" type="button">Open Audio Controls</button>`;}else if(key==='stereo'){modeNote='Applies immediately.';fields=`<div class="flow-popover-row"><label>Stereo Enabled</label><select id="flow-field-stereo-enabled"><option value="true" ${cfg.fm?.stereoEnabled?'selected':''}>Enabled</option><option value="false" ${!cfg.fm?.stereoEnabled?'selected':''}>Disabled</option></select></div><div class="flow-popover-row"><label>Stereo Mode</label><select id="flow-field-stereo-mode"><option value="DSB" ${(cfg.fm?.stereoMode||'DSB')==='DSB'?'selected':''}>DSB</option><option value="SSB" ${cfg.fm?.stereoMode==='SSB'?'selected':''}>SSB</option><option value="VSB" ${cfg.fm?.stereoMode==='VSB'?'selected':''}>VSB</option></select></div>`;actions=`<button class="apply-btn" id="flow-action-save-stereo" type="button">Apply Stereo</button><button class="ghost-btn" id="flow-open-stereo" type="button">Open TX Control</button>`;}
  1041. pop.innerHTML=`<div class="flow-popover-head"><div><div class="flow-popover-title">${FLOW_NODES.find(n=>n.key===key)?.label||key}</div><div class="flow-popover-status">${String(d.state||'idle').toUpperCase()} · ${d.sub||''}</div></div><button class="flow-popover-close" id="flow-popover-close" type="button">Close</button></div><div class="flow-popover-lines">${(d.lines||[]).map(line=>`<div>${line}</div>`).join('')}</div><div class="section-note" style="margin-bottom:10px">${modeNote}</div><div class="flow-popover-fields">${fields}</div><div class="flow-popover-actions">${actions}</div>`;
  1042. const r=S.flowAnchor.getBoundingClientRect();pop.style.left=`${Math.min(window.innerWidth-380,Math.max(12,r.left + window.scrollX))}px`;pop.style.top=`${r.bottom + window.scrollY + 10}px`;pop.classList.add('show');$('flow-popover-close')?.addEventListener('click',closeFlowPopover);
  1043. $('flow-action-start')?.addEventListener('click',()=>{closeFlowPopover();txAction('start');});$('flow-action-stop')?.addEventListener('click',()=>{closeFlowPopover();txAction('stop');});$('flow-action-reset-fault')?.addEventListener('click',()=>{closeFlowPopover();resetFault();});$('flow-open-control')?.addEventListener('click',()=>flowJump('tx'));$('flow-open-rds')?.addEventListener('click',()=>flowJump('rds'));$('flow-open-ingest')?.addEventListener('click',()=>flowJump('ingest'));$('flow-open-diagnostics')?.addEventListener('click',()=>flowJump('diagnostics'));$('flow-open-processing')?.addEventListener('click',()=>flowJump('tx'));$('flow-open-audio')?.addEventListener('click',()=>flowJump('tx'));$('flow-open-stereo')?.addEventListener('click',()=>flowJump('tx'));
  1044. $('flow-action-save-frequency')?.addEventListener('click',async()=>{const frequencyMHz=Number($('flow-field-frequency')?.value||cfg.fm?.frequencyMHz||100);closeFlowPopover();await sendPatch({frequencyMHz},{ok:'Frequency updated',clearKeys:[]});});
  1045. $('flow-action-save-rds')?.addEventListener('click',async()=>{const rdsEnabled=$('flow-field-rds-enabled')?.value==='true';const ps=$('flow-field-ps')?.value||'';const radioText=$('flow-field-rt')?.value||'';const tp=$('flow-field-rds-tp')?.value==='true';const ta=$('flow-field-rds-ta')?.value==='true';closeFlowPopover();await sendPatch({rdsEnabled,ps,radioText,tp,ta},{ok:'RDS updated',clearKeys:[]});});
  1046. $('flow-action-save-processing')?.addEventListener('click',async()=>{const limiterEnabled=$('flow-field-limiter')?.value==='true';const limiterCeiling=Number($('flow-field-ceiling')?.value||1);closeFlowPopover();await sendPatch({limiterEnabled,limiterCeiling},{ok:'Processing updated',clearKeys:[]});});
  1047. $('flow-action-save-mpx')?.addEventListener('click',async()=>{const pilotLevel=Number($('flow-field-pilot')?.value||cfg.fm?.pilotLevel||0.09);const rdsInjection=Number($('flow-field-rdsinj')?.value||cfg.fm?.rdsInjection||0.04);const compositeClipperEnabled=$('flow-field-compclip')?.value==='true';closeFlowPopover();await sendPatch({pilotLevel,rdsInjection,compositeClipperEnabled},{ok:'MPX updated',clearKeys:[]});});
  1048. $('flow-action-save-audio')?.addEventListener('click',async()=>{const toneLeftHz=Number($('flow-field-tone-left')?.value||cfg.audio?.toneLeftHz||1000);const toneRightHz=Number($('flow-field-tone-right')?.value||cfg.audio?.toneRightHz||1600);const toneAmplitude=Number($('flow-field-tone-amp')?.value||cfg.audio?.toneAmplitude||0);closeFlowPopover();await sendPatch({toneLeftHz,toneRightHz,toneAmplitude},{ok:'Tone controls updated',clearKeys:[]});});
  1049. $('flow-action-save-stereo')?.addEventListener('click',async()=>{const stereoEnabled=$('flow-field-stereo-enabled')?.value==='true';const stereoMode=$('flow-field-stereo-mode')?.value||'DSB';closeFlowPopover();await sendPatch({stereoEnabled,stereoMode},{ok:'Stereo updated',clearKeys:[]});});
  1050. }
  1051. // ── Formatters ─────────────────────────────────────────────────────────────
  1052. 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);}
  1053. 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`;}
  1054. function fmtPct(v){return typeof v==='number'?(v*100).toFixed(0)+'%':'--';}
  1055. function fmtFreq(v){return typeof v==='number'?v.toFixed(1)+' MHz':'--';}
  1056. function fmtDur(v){if(!isFinite(v)||v<0)return'--';return v>=1?v.toFixed(2)+'s':(v*1000).toFixed(0)+'ms';}
  1057. function fmtPilot(v){return typeof v==='number'?(v*100).toFixed(1)+'%':'--';}
  1058. function fmtPI(v){if(!v||!String(v).trim())return'--';return'0x'+String(v).toUpperCase().padStart(4,'0');}
  1059. function fmtPTY(v){const n=Number(v);return(n>=0&&n<=31)?`${n} — ${PTY_NAMES[n]}`:'--';}
  1060. 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';}
  1061. function ageFrom(v){if(!v)return'--';if(typeof v==='number')return ageStr(v);const ts=Date.parse(String(v));return isNaN(ts)?'--':ageStr(ts);}
  1062. function joinParts(ps){return ps.filter(p=>String(p||'').trim()!=='').join(' · ');}
  1063. // ── DOM helpers ────────────────────────────────────────────────────────────
  1064. function setText(id,text){const el=$(id);if(el){const s=text==null?'--':String(text);if(el.textContent!==s)el.textContent=s;}}
  1065. function setHTML(id,h){const el=$(id);if(el&&el.innerHTML!==h)el.innerHTML=h;}
  1066. function setCls(id,c){const el=$(id);if(el)el.className=c;}
  1067. 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);}
  1068. 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>`;}
  1069. function updateHoldMeter(state,targetRms,targetPeak,{attack=1,decay=0.18,holdDecay=0.04}={}){state.rms=targetRms>state.rms?targetRms:Math.max(targetRms,state.rms-decay);state.peak=targetPeak>state.peak?targetPeak:Math.max(targetPeak,state.peak-decay*1.2);state.hold=targetPeak>=state.hold?targetPeak:Math.max(state.peak,state.hold-holdDecay);return state;}
  1070. function renderHifiMeter(fillId,peakId,textId,value,hold,text,scale=1){const fill=$(fillId),peak=$(peakId);if(fill)fill.style.width=`${Math.max(0,Math.min(100,(value/scale)*100))}%`;if(peak)peak.style.left=`${Math.max(0,Math.min(100,(hold/scale)*100))}%`;setText(textId,text);}
  1071. 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));}
  1072. 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'));}
  1073. 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');}
  1074. 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]);}
  1075. 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;}
  1076. function syncIngChk(id,path){const el=$(id);if(el)el.checked=!!ingVal(path);}
  1077. // ── Freq presets ───────────────────────────────────────────────────────────
  1078. 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;}
  1079. 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');}
  1080. 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)));}
  1081. // ── API actions ────────────────────────────────────────────────────────────
  1082. function beginReq(){S.pending++;setConn(true,'busy');render();}
  1083. function endReq(){S.pending=Math.max(0,S.pending-1);setConn(S.server.configOk||S.server.runtimeOk);render();}
  1084. 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();}}
  1085. 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});}
  1086. function resetSection(sec){clearDirty(Object.keys(FIELDS).filter(k=>FIELDS[k].section===sec));toast('Draft reset','info');}
  1087. function parseAFInput(raw){
  1088. const text=String(raw||'').trim();
  1089. if(text==='') return {values:[], error:''};
  1090. const parts=text.split(',').map(s=>s.trim()).filter(Boolean);
  1091. const values=[];
  1092. for(const part of parts){
  1093. const n=Number(part);
  1094. if(!isFinite(n)) return {values:null, error:`Invalid AF entry: ${part}`};
  1095. if(n<87.5||n>108.0) return {values:null, error:`AF out of FM band: ${part}`};
  1096. values.push(n);
  1097. }
  1098. return {values, error:''};
  1099. }
  1100. 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();}}
  1101. async function txAction(action){
  1102. if(S.txBusy)return;
  1103. S.txBusy=true;
  1104. if(action==='stop'&&S.server.runtime){
  1105. S.server.runtime.engine={...(S.server.runtime.engine||{}),state:'stopping'};
  1106. }
  1107. render();
  1108. beginReq();
  1109. try{
  1110. log('TX '+action+' requested','info');
  1111. await api(`/tx/${action}`,{method:'POST'});
  1112. toast(action==='start'?'TX started':'TX stop requested','ok');
  1113. log('TX '+action+' accepted','ok');
  1114. await Promise.allSettled([loadRuntime({silent:true}),loadConfig({silent:true})]);
  1115. }catch(e){
  1116. toast(e.message,'err');
  1117. log('TX '+action+' failed: '+e.message,'err');
  1118. }finally{
  1119. S.txBusy=false;
  1120. endReq();
  1121. render();
  1122. }
  1123. }
  1124. 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();}}
  1125. 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();}}
  1126. 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();}}
  1127. // ── Render ─────────────────────────────────────────────────────────────────
  1128. function render(){try{_render();}catch(e){console.error('[render]',e);}}
  1129. function _render(){
  1130. const cfg=S.server.config||{},rt=S.server.runtime||{},eng=rt.engine||{},drv=rt.driver||{},aud=rt.audioStream||null,measWrap=S.server.measurements||{},meas=measWrap.measurement||eng.measurement||{};
  1131. // ── Header
  1132. setText('badge-backend',cfg.backend?.kind||cfg.backend||'--');
  1133. setText('badge-mode',eng.state&&eng.state!=='idle'?'TX Active':'Control Plane');
  1134. setText('badge-live',S.server.runtimeOk?'Connected':'Waiting');
  1135. // ── Overview: freq display
  1136. const applied=isFinite(Number(eng.appliedFrequencyMHz))?Number(eng.appliedFrequencyMHz):(isFinite(Number(S.server.measurements?.appliedFrequencyMHz))?Number(S.server.measurements.appliedFrequencyMHz):NaN),desired=Number(cfg.fm?.frequencyMHz);
  1137. const dispFreq=isFinite(applied)?applied:effVal('frequencyMHz')??desired;
  1138. setHTML('freq-display',`${typeof dispFreq==='number'?dispFreq.toFixed(1):'---.-'}<span class="unit">MHz</span>`);
  1139. setText('freq-applied',isFinite(applied)?`Applied ${applied.toFixed(1)} MHz`:'Applied --');
  1140. setText('freq-desired',isFinite(desired)?`Desired ${desired.toFixed(1)} MHz`:'Desired --');
  1141. $('freq-note')?.classList.toggle('mismatch',isFinite(applied)&&isFinite(desired)&&!nearEq(applied,desired,.001));
  1142. // ── Overview: quick stats
  1143. setText('t-chunks',fmt(eng.chunksProduced));setText('t-samples',fmt(eng.totalSamples));
  1144. setText('t-uptime',fmtTime(eng.uptimeSeconds));
  1145. setText('t-rate',drv.effectiveSampleRateHz?(drv.effectiveSampleRateHz/1000).toFixed(0)+'k':'--');
  1146. 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':'');}
  1147. // ── Overview: TX state
  1148. const txSt=normState(eng.state);
  1149. $('tx-state').textContent=S.txBusy?'WORKING':txSt.toUpperCase();
  1150. $('tx-state').className='tx-state '+(S.txBusy?'working':txSt);
  1151. setText('tx-hint',eng.lastError?`Last error: ${eng.lastError}`:S.txBusy?'Command in progress':'Runtime polled every 1s');
  1152. const canStopStates=['running','arming','prebuffering','degraded','muted','faulted','stopping'];
  1153. const startDis=S.txBusy||txSt==='running';
  1154. const stopDis=S.txBusy||!canStopStates.includes(txSt);
  1155. $('btn-start').disabled=startDis;$('btn-stop').disabled=stopDis;$('btn-refresh').disabled=S.pending>0;
  1156. // ── Overview: meters + sparklines
  1157. 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');
  1158. const urN=Number(eng.underruns??drv.underruns??0);
  1159. 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');
  1160. const txR=txSt==='running'?1:S.txBusy?.55:.08;
  1161. setMeter('meter-tx-fill','meter-tx-text',txR,txSt==='running'?'Live':S.txBusy?'Working':'Idle',txSt==='running'?'good':S.txBusy?'warn':'err');
  1162. const audioL=updateHoldMeter(S.meterState.audioL,Number(meas.lrPreEncodePostWatermark?.lRms||0),Number(meas.lrPreEncodePostWatermark?.lPeakAbs||0));
  1163. const audioR=updateHoldMeter(S.meterState.audioR,Number(meas.lrPreEncodePostWatermark?.rRms||0),Number(meas.lrPreEncodePostWatermark?.rPeakAbs||0));
  1164. const mpx=updateHoldMeter(S.meterState.mpx,0,Number(meas.compositeFinalPreIq?.peakAbs||0),{decay:0.12,holdDecay:0.025});
  1165. renderHifiMeter('audio-l-rms-fill',null,'audio-l-rms-text',audioL.rms,0,typeof meas.lrPreEncodePostWatermark?.lRms==='number'?Number(meas.lrPreEncodePostWatermark.lRms).toFixed(2):'--');
  1166. renderHifiMeter('audio-r-rms-fill',null,'audio-r-rms-text',audioR.rms,0,typeof meas.lrPreEncodePostWatermark?.rRms==='number'?Number(meas.lrPreEncodePostWatermark.rRms).toFixed(2):'--');
  1167. renderHifiMeter('audio-l-peak-fill','audio-l-peak-marker','audio-l-peak-text',audioL.peak,audioL.hold,typeof meas.lrPreEncodePostWatermark?.lPeakAbs==='number'?Number(meas.lrPreEncodePostWatermark.lPeakAbs).toFixed(2):'--');
  1168. renderHifiMeter('audio-r-peak-fill','audio-r-peak-marker','audio-r-peak-text',audioR.peak,audioR.hold,typeof meas.lrPreEncodePostWatermark?.rPeakAbs==='number'?Number(meas.lrPreEncodePostWatermark.rPeakAbs).toFixed(2):'--');
  1169. renderHifiMeter('mpx-peak-fill','mpx-peak-marker','mpx-peak-text',mpx.peak,mpx.hold,typeof meas.compositeFinalPreIq?.peakAbs==='number'?Number(meas.compositeFinalPreIq.peakAbs).toFixed(2):'--',1.1);
  1170. drawSpark('spark-audio',S.charts.audio,'good',1);
  1171. drawSpark('spark-underruns',S.charts.underruns,urN>0?'err':'warn');
  1172. drawSpark('spark-tx',S.charts.tx,txSt==='running'?'good':'warn',1);
  1173. // ── Overview: sidebar
  1174. setText('info-backend',cfg.backend?.kind||cfg.backend||'--');setText('info-freq',fmtFreq(cfg.fm?.frequencyMHz));
  1175. setText('info-preemph',cfg.fm?.preEmphasisTauUS?`${cfg.fm.preEmphasisTauUS} µs`:'Off');
  1176. setText('info-pi',fmtPI(cfg.rds?.pi));setText('info-pty',fmtPTY(cfg.rds?.pty));
  1177. setText('info-runtime-age',ageStr(S.server.lastRuntimeAt));setText('info-last-alert',eng.runtimeAlert||eng.lastError||'None');
  1178. setText('info-drive',cfg.fm?.outputDrive!=null?Number(cfg.fm.outputDrive).toFixed(2):'--');
  1179. setText('info-limiter',cfg.fm?.limiterEnabled?(cfg.fm?.limiterCeiling!=null?`Limiter ON · clips always active · ceil ${Number(cfg.fm.limiterCeiling).toFixed(2)}`:'Limiter ON · clips always active'):'Limiter OFF · hard clips still active');
  1180. setText('info-pilot',typeof meas.compositeFinalPreIq?.pilotInjectionEquivalentPercent==='number'?`${Number(meas.compositeFinalPreIq.pilotInjectionEquivalentPercent).toFixed(1)}% (measured)`:fmtPilot(cfg.fm?.pilotLevel));setText('info-rdsinj',typeof meas.compositeFinalPreIq?.rdsPeakAbs==='number'?`${Number(meas.compositeFinalPreIq.rdsPeakAbs).toFixed(3)} pk`:fmtPilot(cfg.fm?.rdsInjection));
  1181. setText('info-mpxgain',cfg.fm?.mpxGain!=null?Number(cfg.fm.mpxGain).toFixed(2):'--');
  1182. setText('info-bs412',typeof meas.audioMpxPostBs412?.bs412GainApplied==='number'?`ON · gain ${Number(meas.audioMpxPostBs412.bs412GainApplied).toFixed(2)}`:(cfg.fm?.bs412Enabled?`ON (${cfg.fm?.bs412ThresholdDBr??0} dBr)`:'OFF'));
  1183. const cc=cfg.fm?.compositeClipper;setText('info-compclip',typeof meas.audioMpxPreBs412?.clipperLookaheadGain==='number'?`LA ${Number(meas.audioMpxPreBs412.clipperLookaheadGain).toFixed(3)} · Env ${Number(meas.audioMpxPreBs412.clipperEnvelope??0).toFixed(3)}`:(cc?.enabled?`ON (${cc.iterations??3}× ${cc.lookaheadMs?'LA ':''}${cc.softKnee>0?'soft':'hard'})`:'OFF'));
  1184. // ── TX Control tab
  1185. // Freq
  1186. syncDirtyInput('freq-slider','frequencyMHz',v=>typeof v==='number'?v.toFixed(1):'100.0');
  1187. syncDirtyInput('freq-num','frequencyMHz',v=>typeof v==='number'?v.toFixed(1):'100.0');
  1188. $('freq-apply').disabled=!secDirty('freq')||secErrors('freq');$('freq-reset').disabled=!secDirty('freq');
  1189. setText('freq-meta',secErrors('freq')?'Validation error':secDirty('freq')?'Apply immediately · draft pending':'Applies immediately');
  1190. const fe=$('freq-error');if(fe){fe.textContent=S.errors.frequencyMHz||'';fe.classList.toggle('show',!!S.errors.frequencyMHz);}
  1191. refreshFreqPresets();
  1192. // Audio sliders
  1193. syncSlider('drive-slider','drive-val','outputDrive',v=>v==null?'--':Number(v).toFixed(2));
  1194. syncSlider('lim-ceiling-slider','lim-ceiling-val','limiterCeiling',v=>v==null?'--':Number(v).toFixed(2));
  1195. syncSlider('gain-slider','gain-val','audioGain',v=>v==null?'--':Number(v).toFixed(2));
  1196. const peEl=$('preemph-select');if(peEl&&document.activeElement!==peEl)peEl.value=String(cfgEff('preEmphasisTauUS')??50);
  1197. setText('audio-meta',S.cfgDirty['audio']?'Immediate + restart-only · draft pending':'Immediate + restart-only');
  1198. $('audio-apply').disabled=!S.cfgDirty['audio'];$('audio-reset').disabled=!S.cfgDirty['audio'];
  1199. // Tones
  1200. syncSlider('tone-amp-slider','tone-amp-val','toneAmplitude',v=>v==null?'--':Number(v).toFixed(2));
  1201. 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);
  1202. 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);
  1203. // Switches
  1204. syncToggle('tog-stereo','stereo-label','stereoEnabled');syncToggle('tog-limiter','limiter-label','limiterEnabled');
  1205. const selMode=document.getElementById('sel-stereo-mode');if(selMode){const m=srvVal('stereoMode');if(m)selMode.value=m;}
  1206. // Compliance
  1207. syncCfgToggle('tog-bs412','bs412-label','bs412Enabled');
  1208. syncSlider('bs412-threshold-slider','bs412-threshold-val','bs412ThresholdDBr',v=>v==null?'--':Number(v).toFixed(1));
  1209. syncSlider('mpxgain-slider','mpxgain-val','mpxGain',v=>v==null?'--':Number(v).toFixed(2));
  1210. setText('compliance-meta',S.cfgDirty['compliance']?'Restart required · draft pending':'Restart required');
  1211. $('compliance-apply').disabled=!S.cfgDirty['compliance'];$('compliance-reset').disabled=!S.cfgDirty['compliance'];
  1212. // Composite Clipper
  1213. syncToggle('tog-compclip','compclip-label','compositeClipperEnabled');
  1214. syncSlider('compclip-iter-slider','compclip-iter-val','compositeClipperIterations',v=>v==null?'--':String(Math.round(Number(v))));
  1215. syncSlider('compclip-knee-slider','compclip-knee-val','compositeClipperSoftKnee',v=>v==null?'--':Number(v).toFixed(2));
  1216. syncSlider('compclip-la-slider','compclip-la-val','compositeClipperLookaheadMs',v=>v==null?'--':Number(v).toFixed(1));
  1217. setText('compclip-meta',S.cfgDirty['compclip']?'Immediate + restart-only · draft pending':'Immediate + restart-only');
  1218. $('compclip-apply').disabled=!S.cfgDirty['compclip'];$('compclip-reset').disabled=!S.cfgDirty['compclip'];
  1219. // Danger
  1220. const stopButtons=[$('header-danger-stop')].filter(Boolean);stopButtons.forEach(btn=>{btn.disabled=S.txBusy;btn.textContent='Emergency Stop TX';});const resetButtons=[$('header-danger-reset-fault')].filter(Boolean);resetButtons.forEach(btn=>{btn.disabled=S.faultBusy||!S.server.runtimeOk;btn.textContent=S.faultBusy?'Resetting...':'Reset Fault';});
  1221. // ── RDS tab
  1222. syncToggle('tog-rds','rds-label','rdsEnabled');
  1223. // PI
  1224. const piEl=$('rds-pi');if(piEl&&document.activeElement!==piEl)piEl.value=String(cfgEff('pi')||cfg.rds?.pi||'');
  1225. setText('pi-display',fmtPI(cfgEff('pi')||cfg.rds?.pi));
  1226. const piErr=$('pi-error');if(piErr){const e=S.cfgErrors?.pi||'';piErr.textContent=e;piErr.classList.toggle('show',!!e);}
  1227. // PTY
  1228. const ptyEl=$('rds-pty');if(ptyEl&&document.activeElement!==ptyEl)ptyEl.value=String(cfgEff('pty')??cfg.rds?.pty??0);
  1229. const idDirty=!!S.cfgDirty['rds-id'];setText('rds-identity-meta',S.cfgErrors?.pi?'Validation error':idDirty?'Mixed runtime impact · draft pending':'Mixed runtime impact');
  1230. $('rds-identity-apply').disabled=!idDirty||!!S.cfgErrors?.pi;$('rds-identity-reset').disabled=!idDirty;
  1231. // Text
  1232. syncDirtyInput('rds-ps','ps',v=>String(v??''));syncDirtyInput('rds-rt','radioText',v=>String(v??''));
  1233. const psV=String(effVal('ps')??cfg.rds?.ps??''),rtV=String(effVal('radioText')??cfg.rds?.radioText??'');
  1234. setText('ps-count',psV.length);setText('rt-count',rtV.length);
  1235. const rdsD=secDirty('rds');setText('rds-text-meta',secErrors('rds')?'Validation error':rdsD?'Applies on-air · draft pending':'Applies on-air');
  1236. $('rds-apply').disabled=!rdsD||secErrors('rds');$('rds-reset').disabled=!rdsD;
  1237. const psErr=$('ps-error');if(psErr){psErr.textContent=S.errors.ps||'';psErr.classList.toggle('show',!!S.errors.ps);}
  1238. const rtErr=$('rt-error');if(rtErr){rtErr.textContent=S.errors.radioText||'';rtErr.classList.toggle('show',!!S.errors.radioText);}
  1239. // Levels
  1240. syncSlider('pilot-slider','pilot-val','pilotLevel',v=>v==null?'--':(Number(v)*100).toFixed(1)+'%');
  1241. syncSlider('rdsinj-slider','rdsinj-val','rdsInjection',v=>v==null?'--':(Number(v)*100).toFixed(1)+'%');
  1242. const lvlDirty=!!S.cfgDirty['rds-lvl'];$('rds-levels-apply').disabled=!lvlDirty;$('rds-levels-reset').disabled=!lvlDirty;
  1243. // RDS Features sync
  1244. const rCfg=cfg.rds||{};
  1245. const syncCB=(id,key)=>{const el=$(id);if(el){const v=cfgEff(key);el.checked=v!=null?!!v:!!gp(cfg,CFG[key]?.path);}};
  1246. const tpEl=$('rds-tp');if(tpEl)tpEl.checked=!!rCfg.tp;
  1247. const taEl=$('rds-ta');if(taEl)taEl.checked=!!rCfg.ta;
  1248. syncCB('rds-ct','rdsCT');syncCB('rds-rtplus','rdsRTPlus');
  1249. const msEl=$('rds-ms');if(msEl&&document.activeElement!==msEl){const mv=cfgEff('rdsMS');msEl.value=String(mv!=null?mv:(rCfg.ms!=null?rCfg.ms:true));}
  1250. const sepEl=$('rds-rtplus-sep');if(sepEl&&document.activeElement!==sepEl){const sv=cfgEff('rdsRTPlusSep');sepEl.value=sv!=null?sv:(rCfg.rtPlusSeparator||' - ');}
  1251. const ptynEl=$('rds-ptyn');if(ptynEl&&document.activeElement!==ptynEl){const pv=cfgEff('rdsPTYN');ptynEl.value=pv!=null?pv:(rCfg.ptyn||'');}
  1252. const lpsEl=$('rds-lps');if(lpsEl&&document.activeElement!==lpsEl){const lv=cfgEff('rdsLPS');lpsEl.value=lv!=null?lv:(rCfg.lps||'');}
  1253. const afEl=$('rds-af');if(afEl&&document.activeElement!==afEl){const av=cfgEff('rdsAF');afEl.value=(av!=null?av:(rCfg.af||[])).join(', ');afEl.classList.toggle('input-error',!!S.fieldErrors.rdsAF);}
  1254. const afErr=$('rds-af-error');if(afErr){afErr.textContent=S.fieldErrors.rdsAF||'';afErr.classList.toggle('show',!!S.fieldErrors.rdsAF);}
  1255. const ertOnEl=$('rds-ert-on');if(ertOnEl){const ev=cfgEff('rdsERTEnabled');ertOnEl.checked=ev!=null?!!ev:!!rCfg.ertEnabled;}
  1256. const ertEl=$('rds-ert');if(ertEl&&document.activeElement!==ertEl){const etv=cfgEff('rdsERT');ertEl.value=etv!=null?etv:(rCfg.ert||'');}
  1257. const featDirty=!!S.cfgDirty['rds-feat'];$('rds-features-apply').disabled=!featDirty||!!S.fieldErrors.rdsAF;$('rds-features-reset').disabled=!featDirty;
  1258. // RDS2
  1259. const r2On=$('rds2-on');if(r2On){const r2v=cfgEff('rdsRDS2Enabled');r2On.checked=r2v!=null?!!r2v:!!rCfg.rds2Enabled;}
  1260. const r2Logo=$('rds2-logo');if(r2Logo&&document.activeElement!==r2Logo){const lv=cfgEff('rdsLogoPath');r2Logo.value=lv!=null?lv:(rCfg.stationLogoPath||'');}
  1261. const r2Dirty=!!S.cfgDirty['rds2'];if($('rds2-apply')){$('rds2-apply').disabled=!r2Dirty;$('rds2-reset').disabled=!r2Dirty;}
  1262. // Status card
  1263. const activePS=String(eng.activePS||cfg.rds?.ps||'').trim();
  1264. const activeRT=String(eng.activeRadioText||cfg.rds?.radioText||'').trim();
  1265. const savedRT=String(cfg.rds?.radioText||'').trim();
  1266. setText('rds-stat-enabled',cfg.rds?.enabled?'ON':'OFF');setText('rds-stat-pi',fmtPI(cfg.rds?.pi));
  1267. setText('rds-stat-pty',fmtPTY(cfg.rds?.pty));setText('rds-stat-ps',activePS||'--');setText('rds-stat-rt',activeRT||'--');setText('rds-stat-rt-saved',savedRT||'--');setText('rds-saved-rt',savedRT||'--');setText('rds-active-rt-inline',activeRT||'--');
  1268. setText('rds-stat-pilot',fmtPilot(cfg.fm?.pilotLevel));setText('rds-stat-inj',fmtPilot(cfg.fm?.rdsInjection));
  1269. // ── Ingest tab
  1270. const ingest=rt.ingest||{},ia=ingest.active||{},iSrc=ingest.source||{},iRt=ingest.runtime||{},hasIR=!!rt.ingest;
  1271. setText('ingest-summary-state',hasIR?joinParts([String(iRt.state||'').toUpperCase(),iSrc.state?`source ${String(iSrc.state).toUpperCase()}`:'',iRt.prebuffering?'PREBUFFERING':'',iRt.writeBlocked?'WRITE-BLOCKED':''])||'--':'--');
  1272. 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`:''])||'--');
  1273. 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`:''])||'--':'--');
  1274. setText('ingest-summary-detail',hasIR?(iSrc.streamTitle||ia.detail||iSrc.lastError||'--'):'--');
  1275. const org=ia.origin||{};setText('ingest-summary-origin',hasIR?joinParts([org.kind||'',org.endpoint||'',org.streamName||''])||'--':'--');
  1276. setText('ingest-summary-last',hasIR?ageFrom(iRt.lastChunkAt||iSrc.lastChunkAt):'--');
  1277. syncIngInput('ing-kind','kind',v=>String(v??'none'));syncIngInput('ing-prebuffer','prebufferMs',v=>isFinite(Number(v))?Number(v):0);
  1278. 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);
  1279. syncIngInput('ing-icecast-url','icecast.url',v=>String(v??''));syncIngInput('ing-icecast-decoder','icecast.decoder',v=>String(v??'auto'));
  1280. 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');
  1281. 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);
  1282. 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??''));
  1283. const kind=String(ingVal('kind')||'none').toLowerCase();
  1284. $('ing-group-icecast').style.display=kind==='icecast'?'':'none';$('ing-group-srt').style.display=kind==='srt'?'':'none';$('ing-group-aes67').style.display=kind==='aes67'?'':'none';
  1285. setText('ingest-meta',S.ingestSaving?'Saving…':S.ingestDirty?'Hard reload required · draft pending':'Hard reload required');
  1286. $('ingest-save-reload').disabled=!S.ingestDirty||S.ingestSaving||!S.server.configOk;$('ingest-save-reload').textContent=S.ingestSaving?'Saving...':'Save + Hard Reload';
  1287. $('ingest-reset').disabled=!S.ingestDirty||S.ingestSaving;
  1288. const iErr=$('ingest-error');if(iErr){iErr.textContent=S.ingestError||'';iErr.classList.toggle('show',!!S.ingestError);}
  1289. // ── Diagnostics tab
  1290. setText('health-http',S.server.configOk?'OK':'OFFLINE');setCls('health-http','val '+(S.server.configOk?'good':'err'));
  1291. 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'));
  1292. const dur=Number(eng.runtimeStateDurationSeconds);setText('health-state-age',isFinite(dur)&&dur>0?fmtTime(dur):'--');
  1293. 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':''));
  1294. const alert=String(eng.runtimeAlert||'').trim();setText('health-alert',alert||'None');setCls('health-alert','val '+(alert?'warn':'good'));
  1295. setText('health-transitions',eng.degradedTransitions!=null?`${eng.degradedTransitions??0} / ${eng.mutedTransitions??0} / ${eng.faultedTransitions??0}`:'--');
  1296. 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'):''));
  1297. 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');}
  1298. 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');}
  1299. setText('health-buffer-duration',fmtDur(Number(aud?.bufferedDurationSeconds)));
  1300. 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`:'--'));
  1301. 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'));
  1302. 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':''));
  1303. setText('health-last',ageStr(Math.max(S.server.lastConfigAt||0,S.server.lastRuntimeAt||0)));
  1304. const hwMode=hwF/(Number(aud?.capacity)||1)>=.95?'err':hwF/(Number(aud?.capacity)||1)>=.65?'warn':'good';
  1305. drawSpark('spark-high-watermark',S.charts.hw,hwMode);drawSpark('spark-queue-fill',S.charts.qf,qh==='critical'?'err':qh==='low'?'warn':'good',1);
  1306. // Audit
  1307. const audit=rt.controlAudit||{};let total=0;
  1308. ['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'):'');}});
  1309. const at=$('audit-total');if(at){at.textContent=String(total);at.className='audit-val'+(total>0?' warn':' good');}
  1310. // Fault history
  1311. 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('');}
  1312. renderFlow();
  1313. applyMobilePanels();
  1314. }
  1315. 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);});}
  1316. // ── Toast / Log ────────────────────────────────────────────────────────────
  1317. 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);}
  1318. 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;}
  1319. // ── Bindings ───────────────────────────────────────────────────────────────
  1320. function bindAll(){
  1321. document.addEventListener('keydown',e=>{if(e.key==='Escape'&&S.flowSelected)closeFlowPopover();});
  1322. document.addEventListener('click',e=>{const pop=$('flow-popover');if(!S.flowSelected||!pop)return;const inPopover=pop.contains(e.target);const inNode=e.target.closest&&e.target.closest('[data-flow-node]');if(!inPopover&&!inNode)closeFlowPopover();});
  1323. // Tabs
  1324. const tbs=Array.from(document.querySelectorAll('.tab-btn[data-tab]')),tps=Array.from(document.querySelectorAll('.tab-panel[data-tab-panel]'));
  1325. 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));}));
  1326. // Panels
  1327. document.querySelectorAll('[data-panel]').forEach(h=>h.addEventListener('click',()=>{h.classList.toggle('collapsed');h.nextElementSibling?.classList.toggle('collapsed');}));
  1328. // Live toggles
  1329. 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();}});});
  1330. // Stereo mode select (live)
  1331. $('sel-stereo-mode')?.addEventListener('change',e=>sendPatch({stereoMode:e.target.value},{ok:'Stereo mode: '+e.target.value}));
  1332. // Config-only toggles (restart-required)
  1333. 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();}});});
  1334. // Freq
  1335. $('freq-slider').addEventListener('input',e=>setDirty('frequencyMHz',Number(e.target.value)));
  1336. $('freq-num').addEventListener('input',e=>{const v=Number(e.target.value);if(!isNaN(v))setDirty('frequencyMHz',v);});
  1337. $('freq-apply').addEventListener('click',()=>applySection('freq'));$('freq-reset').addEventListener('click',()=>resetSection('freq'));
  1338. // Freq presets
  1339. 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');}));
  1340. // Audio sliders
  1341. 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)));}
  1342. bindCfgSlider('drive-slider','outputDrive');bindCfgSlider('lim-ceiling-slider','limiterCeiling');
  1343. bindCfgSlider('gain-slider','audioGain');$('preemph-select').addEventListener('change',e=>cfgSetDirty('preEmphasisTauUS',Number(e.target.value)));
  1344. $('audio-apply').addEventListener('click',()=>applyCfgSection('audio'));$('audio-reset').addEventListener('click',()=>{cfgClear('audio');toast('Draft reset','info');});
  1345. // Tones
  1346. bindCfgSlider('tone-l-slider','toneLeftHz');$('tone-l-num').addEventListener('input',e=>cfgSetDirty('toneLeftHz',Number(e.target.value)));
  1347. bindCfgSlider('tone-r-slider','toneRightHz');$('tone-r-num').addEventListener('input',e=>cfgSetDirty('toneRightHz',Number(e.target.value)));
  1348. bindCfgSlider('tone-amp-slider','toneAmplitude');
  1349. $('tones-apply').addEventListener('click',()=>applyCfgSection('tones'));$('tones-off').addEventListener('click',()=>{cfgSetDirty('toneAmplitude',0);toast('Tone disable queued for live apply + config save','info');applyCfgSection('tones');});
  1350. // Compliance
  1351. bindCfgSlider('bs412-threshold-slider','bs412ThresholdDBr');bindCfgSlider('mpxgain-slider','mpxGain');
  1352. $('compliance-apply').addEventListener('click',()=>applyCfgSection('compliance'));$('compliance-reset').addEventListener('click',()=>{cfgClear('compliance');toast('Draft reset','info');});
  1353. $('compclip-apply').addEventListener('click',()=>applyCfgSection('compclip'));$('compclip-reset').addEventListener('click',()=>{cfgClear('compclip');toast('Draft reset','info');});
  1354. // TX
  1355. $('btn-start').addEventListener('click',()=>txAction('start'));$('btn-stop').addEventListener('click',()=>txAction('stop'));
  1356. $('header-danger-stop').addEventListener('click',()=>txAction('stop'));$('btn-refresh').addEventListener('click',manualRefresh);
  1357. $('header-danger-reset-fault').addEventListener('click',()=>resetFault());
  1358. // RDS
  1359. $('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);});
  1360. $('rds-pty').addEventListener('change',e=>cfgSetDirty('pty',Number(e.target.value)));
  1361. $('rds-identity-apply').addEventListener('click',()=>applyCfgSection('rds-id'));$('rds-identity-reset').addEventListener('click',()=>{cfgClear('rds-id');toast('Draft reset','info');});
  1362. $('rds-ps').addEventListener('input',e=>setDirty('ps',e.target.value.toUpperCase().slice(0,8)));
  1363. $('rds-rt').addEventListener('input',e=>setDirty('radioText',e.target.value.slice(0,64)));
  1364. $('rds-apply').addEventListener('click',()=>applySection('rds'));$('rds-reset').addEventListener('click',()=>resetSection('rds'));
  1365. 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');}));
  1366. bindCfgSlider('pilot-slider','pilotLevel');bindCfgSlider('rdsinj-slider','rdsInjection');
  1367. $('rds-levels-apply').addEventListener('click',()=>applyCfgSection('rds-lvl'));$('rds-levels-reset').addEventListener('click',()=>{cfgClear('rds-lvl');toast('Draft reset','info');});
  1368. // RDS Features
  1369. $('rds-tp')?.addEventListener('change',e=>sendPatch({tp:e.target.checked},{ok:'TP '+(e.target.checked?'on':'off')}));
  1370. $('rds-ta')?.addEventListener('change',e=>sendPatch({ta:e.target.checked},{ok:'TA '+(e.target.checked?'on':'off')}));
  1371. $('rds-ms')?.addEventListener('change',e=>cfgSetDirty('rdsMS',e.target.value==='true'));
  1372. $('rds-ct')?.addEventListener('change',e=>cfgSetDirty('rdsCT',e.target.checked));
  1373. $('rds-rtplus')?.addEventListener('change',e=>cfgSetDirty('rdsRTPlus',e.target.checked));
  1374. $('rds-rtplus-sep')?.addEventListener('input',e=>cfgSetDirty('rdsRTPlusSep',e.target.value));
  1375. $('rds-ptyn')?.addEventListener('input',e=>cfgSetDirty('rdsPTYN',e.target.value.toUpperCase()));
  1376. $('rds-lps')?.addEventListener('input',e=>cfgSetDirty('rdsLPS',e.target.value));
  1377. $('rds-ert-on')?.addEventListener('change',e=>cfgSetDirty('rdsERTEnabled',e.target.checked));
  1378. $('rds-ert')?.addEventListener('input',e=>cfgSetDirty('rdsERT',e.target.value));
  1379. $('rds-af')?.addEventListener('input',e=>{const parsed=parseAFInput(e.target.value);if(parsed.error){setFieldError('rdsAF',parsed.error);return;}setFieldError('rdsAF','');cfgSetDirty('rdsAF',parsed.values);});
  1380. $('rds-features-apply')?.addEventListener('click',()=>applyCfgSection('rds-feat'));
  1381. $('rds-features-reset')?.addEventListener('click',()=>{cfgClear('rds-feat');setFieldError('rdsAF','');toast('Draft reset','info');});
  1382. // RDS2
  1383. $('rds2-on')?.addEventListener('change',e=>cfgSetDirty('rdsRDS2Enabled',e.target.checked));
  1384. $('rds2-logo')?.addEventListener('input',e=>cfgSetDirty('rdsLogoPath',e.target.value));
  1385. $('rds2-apply')?.addEventListener('click',()=>applyCfgSection('rds2'));
  1386. $('rds2-reset')?.addEventListener('click',()=>{cfgClear('rds2');toast('Draft reset','info');});
  1387. // Ingest
  1388. 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||''));});});
  1389. $('ingest-save-reload').addEventListener('click',()=>saveIngest());
  1390. $('ingest-reset').addEventListener('click',()=>{syncIngDraft(true);toast('Draft reset','info');log('Ingest draft reset','warn');render();});
  1391. // Log
  1392. $('btn-clear-log').addEventListener('click',()=>{$('log').innerHTML='<div class="empty-log">No activity recorded yet.</div>';toast('Activity log cleared','info');});
  1393. // Keyboard
  1394. 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&&e.key==='Enter'){e.preventDefault();if(S.dirty.has('frequencyMHz'))applySection('freq');else if(secDirty('rds'))applySection('rds');}});
  1395. // Responsive
  1396. const respHandler=()=>{if(!mobileMq.matches)S.mobilePanelsApplied=false;else applyMobilePanels();};if(mobileMq.addEventListener)mobileMq.addEventListener('change',respHandler);else mobileMq.addListener(respHandler);
  1397. }
  1398. async function manualRefresh(){beginReq();try{await Promise.allSettled([loadConfig({silent:true}),loadRuntime({silent:true}),loadMeasurements({silent:true})]);toast('UI data refreshed','info');log('Manual refresh','info');}finally{endReq();}}
  1399. function startPollers(){if(S.pollersStarted)return;S.pollersStarted=true;setInterval(()=>loadRuntime({silent:true}),RUNTIME_MS);setInterval(()=>loadConfig({silent:true}),CONFIG_MS);setInterval(()=>loadMeasurements({silent:true}),200);}
  1400. async function init(){
  1401. bindAll();render();
  1402. log('ferrite.fm control UI booting','info');
  1403. await Promise.allSettled([loadConfig({silent:false}),loadRuntime({silent:true}),loadMeasurements({silent:true})]);
  1404. render();startPollers();
  1405. log('Polling active: runtime 1s · config 8s · measurements 200ms','ok');
  1406. log('UI ready','info');
  1407. }
  1408. init();
  1409. </script>
  1410. </body>
  1411. </html>