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

1405 строки
148KB

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