Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

1482 rindas
169KB

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