Go-based FM stereo transmitter with RDS, Windows-first and cross-platform
25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

876 lines
24KB

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>fm-rds-tx</title>
  7. <style>
  8. @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Archivo+Black&display=swap');
  9. :root {
  10. --bg: #0a0a0c;
  11. --surface: #111116;
  12. --surface2: #18181e;
  13. --border: #2a2a35;
  14. --text: #d4d4dc;
  15. --text-dim: #6a6a78;
  16. --accent: #ff3b30;
  17. --accent-glow: #ff3b3044;
  18. --green: #30d158;
  19. --green-glow: #30d15844;
  20. --amber: #ff9f0a;
  21. --amber-glow: #ff9f0a44;
  22. --blue: #0a84ff;
  23. --mono: 'JetBrains Mono', monospace;
  24. --display: 'Archivo Black', sans-serif;
  25. --radius: 6px;
  26. }
  27. * { box-sizing: border-box; margin: 0; padding: 0; }
  28. body {
  29. background: var(--bg);
  30. color: var(--text);
  31. font-family: var(--mono);
  32. font-size: 13px;
  33. line-height: 1.5;
  34. min-height: 100vh;
  35. overflow-x: hidden;
  36. }
  37. /* Scan lines overlay */
  38. body::before {
  39. content: '';
  40. position: fixed; inset: 0;
  41. background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.03) 2px, rgba(0,0,0,0.03) 4px);
  42. pointer-events: none; z-index: 1000;
  43. }
  44. .app {
  45. max-width: 900px;
  46. margin: 0 auto;
  47. padding: 16px;
  48. }
  49. /* Header */
  50. .header {
  51. display: flex;
  52. align-items: center;
  53. justify-content: space-between;
  54. padding: 16px 0 24px;
  55. border-bottom: 1px solid var(--border);
  56. margin-bottom: 20px;
  57. }
  58. .header h1 {
  59. font-family: var(--display);
  60. font-size: 22px;
  61. letter-spacing: 2px;
  62. text-transform: uppercase;
  63. color: var(--accent);
  64. text-shadow: 0 0 20px var(--accent-glow), 0 0 40px var(--accent-glow);
  65. }
  66. .header-status {
  67. display: flex;
  68. align-items: center;
  69. gap: 12px;
  70. }
  71. /* LED indicator */
  72. .led {
  73. width: 10px; height: 10px;
  74. border-radius: 50%;
  75. background: #333;
  76. box-shadow: none;
  77. transition: all 0.3s;
  78. }
  79. .led.on-green {
  80. background: var(--green);
  81. box-shadow: 0 0 8px var(--green), 0 0 20px var(--green-glow);
  82. }
  83. .led.on-red {
  84. background: var(--accent);
  85. box-shadow: 0 0 8px var(--accent), 0 0 20px var(--accent-glow);
  86. }
  87. .led.on-amber {
  88. background: var(--amber);
  89. box-shadow: 0 0 8px var(--amber), 0 0 20px var(--amber-glow);
  90. }
  91. /* TX control bar */
  92. .tx-bar {
  93. display: flex;
  94. gap: 10px;
  95. align-items: center;
  96. background: var(--surface);
  97. border: 1px solid var(--border);
  98. border-radius: var(--radius);
  99. padding: 12px 16px;
  100. margin-bottom: 16px;
  101. }
  102. .tx-bar .freq-display {
  103. font-family: var(--display);
  104. font-size: 32px;
  105. color: var(--green);
  106. text-shadow: 0 0 15px var(--green-glow);
  107. letter-spacing: 1px;
  108. min-width: 200px;
  109. }
  110. .tx-bar .freq-display .unit {
  111. font-family: var(--mono);
  112. font-size: 14px;
  113. color: var(--text-dim);
  114. margin-left: 4px;
  115. }
  116. .tx-btn {
  117. padding: 8px 20px;
  118. border: 1px solid var(--border);
  119. border-radius: var(--radius);
  120. background: var(--surface2);
  121. color: var(--text);
  122. font-family: var(--mono);
  123. font-size: 12px;
  124. font-weight: 600;
  125. cursor: pointer;
  126. text-transform: uppercase;
  127. letter-spacing: 1px;
  128. transition: all 0.15s;
  129. }
  130. .tx-btn:hover { border-color: var(--text-dim); }
  131. .tx-btn.start { border-color: var(--green); color: var(--green); }
  132. .tx-btn.start:hover { background: var(--green); color: var(--bg); }
  133. .tx-btn.stop { border-color: var(--accent); color: var(--accent); }
  134. .tx-btn.stop:hover { background: var(--accent); color: #fff; }
  135. .tx-state {
  136. font-size: 11px;
  137. text-transform: uppercase;
  138. letter-spacing: 2px;
  139. color: var(--text-dim);
  140. margin-left: auto;
  141. }
  142. .tx-state.running { color: var(--green); }
  143. .tx-state.idle { color: var(--text-dim); }
  144. /* Telemetry strip */
  145. .telem {
  146. display: flex;
  147. gap: 1px;
  148. background: var(--border);
  149. border-radius: var(--radius);
  150. overflow: hidden;
  151. margin-bottom: 16px;
  152. }
  153. .telem-cell {
  154. flex: 1;
  155. background: var(--surface);
  156. padding: 10px 12px;
  157. text-align: center;
  158. }
  159. .telem-cell .label {
  160. font-size: 9px;
  161. text-transform: uppercase;
  162. letter-spacing: 1.5px;
  163. color: var(--text-dim);
  164. margin-bottom: 4px;
  165. }
  166. .telem-cell .value {
  167. font-size: 16px;
  168. font-weight: 700;
  169. color: var(--text);
  170. }
  171. .telem-cell .value.warn { color: var(--amber); }
  172. .telem-cell .value.err { color: var(--accent); }
  173. /* Section panels */
  174. .panel {
  175. background: var(--surface);
  176. border: 1px solid var(--border);
  177. border-radius: var(--radius);
  178. margin-bottom: 12px;
  179. overflow: hidden;
  180. }
  181. .panel-head {
  182. display: flex;
  183. align-items: center;
  184. gap: 8px;
  185. padding: 10px 14px;
  186. border-bottom: 1px solid var(--border);
  187. background: var(--surface2);
  188. cursor: pointer;
  189. user-select: none;
  190. }
  191. .panel-head h2 {
  192. font-family: var(--mono);
  193. font-size: 11px;
  194. font-weight: 600;
  195. text-transform: uppercase;
  196. letter-spacing: 2px;
  197. color: var(--text-dim);
  198. }
  199. .panel-head .chevron {
  200. margin-left: auto;
  201. color: var(--text-dim);
  202. transition: transform 0.2s;
  203. font-size: 10px;
  204. }
  205. .panel-head.collapsed .chevron { transform: rotate(-90deg); }
  206. .panel-body { padding: 14px; }
  207. .panel-body.collapsed { display: none; }
  208. /* Form controls */
  209. .ctrl-row {
  210. display: flex;
  211. align-items: center;
  212. gap: 12px;
  213. padding: 6px 0;
  214. border-bottom: 1px solid #1a1a22;
  215. }
  216. .ctrl-row:last-child { border-bottom: none; }
  217. .ctrl-label {
  218. font-size: 11px;
  219. color: var(--text-dim);
  220. min-width: 110px;
  221. text-transform: uppercase;
  222. letter-spacing: 0.5px;
  223. }
  224. .ctrl-input {
  225. flex: 1;
  226. display: flex;
  227. align-items: center;
  228. gap: 8px;
  229. }
  230. input[type="range"] {
  231. -webkit-appearance: none;
  232. appearance: none;
  233. flex: 1;
  234. height: 4px;
  235. background: var(--border);
  236. border-radius: 2px;
  237. outline: none;
  238. }
  239. input[type="range"]::-webkit-slider-thumb {
  240. -webkit-appearance: none;
  241. width: 14px; height: 14px;
  242. border-radius: 50%;
  243. background: var(--text);
  244. border: 2px solid var(--bg);
  245. cursor: pointer;
  246. transition: background 0.15s;
  247. }
  248. input[type="range"]::-webkit-slider-thumb:hover { background: var(--accent); }
  249. input[type="number"], input[type="text"] {
  250. background: var(--bg);
  251. border: 1px solid var(--border);
  252. border-radius: 4px;
  253. color: var(--text);
  254. font-family: var(--mono);
  255. font-size: 13px;
  256. padding: 5px 8px;
  257. width: 80px;
  258. outline: none;
  259. transition: border-color 0.15s;
  260. }
  261. input[type="text"] { width: 100%; }
  262. input:focus { border-color: var(--accent); }
  263. .val-display {
  264. font-size: 12px;
  265. font-weight: 600;
  266. min-width: 55px;
  267. text-align: right;
  268. color: var(--text);
  269. }
  270. /* Toggle switch */
  271. .toggle {
  272. position: relative;
  273. width: 36px; height: 20px;
  274. background: var(--border);
  275. border-radius: 10px;
  276. cursor: pointer;
  277. transition: background 0.2s;
  278. flex-shrink: 0;
  279. }
  280. .toggle.on { background: var(--green); }
  281. .toggle::after {
  282. content: '';
  283. position: absolute;
  284. top: 2px; left: 2px;
  285. width: 16px; height: 16px;
  286. background: var(--text);
  287. border-radius: 50%;
  288. transition: transform 0.2s;
  289. }
  290. .toggle.on::after { transform: translateX(16px); }
  291. /* RDS section */
  292. .rds-input {
  293. width: 100%;
  294. background: var(--bg);
  295. border: 1px solid var(--border);
  296. border-radius: 4px;
  297. color: var(--green);
  298. font-family: var(--mono);
  299. font-size: 15px;
  300. font-weight: 700;
  301. padding: 8px 10px;
  302. outline: none;
  303. letter-spacing: 2px;
  304. text-transform: uppercase;
  305. transition: border-color 0.15s;
  306. }
  307. .rds-input:focus { border-color: var(--accent); }
  308. .rds-input.rt {
  309. font-size: 12px;
  310. font-weight: 400;
  311. letter-spacing: 0.5px;
  312. text-transform: none;
  313. color: var(--text);
  314. }
  315. .rds-charcount {
  316. font-size: 10px;
  317. color: var(--text-dim);
  318. text-align: right;
  319. margin-top: 2px;
  320. }
  321. /* Apply button */
  322. .apply-btn {
  323. display: block;
  324. width: 100%;
  325. padding: 10px;
  326. margin-top: 8px;
  327. background: var(--accent);
  328. border: none;
  329. border-radius: var(--radius);
  330. color: #fff;
  331. font-family: var(--mono);
  332. font-size: 12px;
  333. font-weight: 700;
  334. text-transform: uppercase;
  335. letter-spacing: 2px;
  336. cursor: pointer;
  337. transition: all 0.15s;
  338. opacity: 0;
  339. transform: translateY(-4px);
  340. pointer-events: none;
  341. }
  342. .apply-btn.visible {
  343. opacity: 1;
  344. transform: translateY(0);
  345. pointer-events: auto;
  346. }
  347. .apply-btn:hover { filter: brightness(1.2); }
  348. .apply-btn.sending {
  349. opacity: 0.6;
  350. pointer-events: none;
  351. }
  352. .apply-btn.ok {
  353. background: var(--green);
  354. }
  355. /* Toast notification */
  356. .toast {
  357. position: fixed;
  358. bottom: 20px;
  359. right: 20px;
  360. padding: 10px 16px;
  361. border-radius: var(--radius);
  362. font-size: 12px;
  363. font-weight: 600;
  364. z-index: 2000;
  365. transform: translateY(60px);
  366. opacity: 0;
  367. transition: all 0.3s;
  368. }
  369. .toast.show { transform: translateY(0); opacity: 1; }
  370. .toast.ok { background: var(--green); color: var(--bg); }
  371. .toast.err { background: var(--accent); color: #fff; }
  372. /* Log */
  373. .log {
  374. background: var(--bg);
  375. border: 1px solid var(--border);
  376. border-radius: 4px;
  377. padding: 8px 10px;
  378. font-size: 10px;
  379. color: var(--text-dim);
  380. max-height: 120px;
  381. overflow-y: auto;
  382. white-space: pre-wrap;
  383. word-break: break-all;
  384. }
  385. .log .entry { padding: 1px 0; }
  386. .log .entry.err { color: var(--accent); }
  387. .log .entry.ok { color: var(--green); }
  388. /* Responsive */
  389. @media (max-width: 600px) {
  390. .tx-bar { flex-wrap: wrap; }
  391. .tx-bar .freq-display { font-size: 24px; min-width: auto; }
  392. .telem { flex-wrap: wrap; }
  393. .telem-cell { flex: 1 1 30%; }
  394. .ctrl-row { flex-wrap: wrap; }
  395. .ctrl-label { min-width: auto; width: 100%; }
  396. }
  397. </style>
  398. </head>
  399. <body>
  400. <div class="app" id="app">
  401. <!-- Header -->
  402. <div class="header">
  403. <h1>FM-RDS-TX</h1>
  404. <div class="header-status">
  405. <div class="led" id="led-conn"></div>
  406. <span style="font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:1px" id="conn-label">connecting</span>
  407. </div>
  408. </div>
  409. <!-- TX Control Bar -->
  410. <div class="tx-bar">
  411. <div class="freq-display" id="freq-display">---.--<span class="unit">MHz</span></div>
  412. <button class="tx-btn start" id="btn-start" onclick="txStart()">TX ON</button>
  413. <button class="tx-btn stop" id="btn-stop" onclick="txStop()">TX OFF</button>
  414. <div class="tx-state" id="tx-state">--</div>
  415. </div>
  416. <!-- Telemetry -->
  417. <div class="telem" id="telem">
  418. <div class="telem-cell"><div class="label">Chunks</div><div class="value" id="t-chunks">--</div></div>
  419. <div class="telem-cell"><div class="label">Samples</div><div class="value" id="t-samples">--</div></div>
  420. <div class="telem-cell"><div class="label">Underruns</div><div class="value" id="t-underruns">0</div></div>
  421. <div class="telem-cell"><div class="label">Uptime</div><div class="value" id="t-uptime">--</div></div>
  422. <div class="telem-cell"><div class="label">Rate</div><div class="value" id="t-rate">--</div></div>
  423. </div>
  424. <!-- Frequency -->
  425. <div class="panel">
  426. <div class="panel-head" onclick="togglePanel(this)">
  427. <div class="led on-green" style="width:6px;height:6px"></div>
  428. <h2>Frequency</h2>
  429. <span class="chevron">▼</span>
  430. </div>
  431. <div class="panel-body">
  432. <div class="ctrl-row">
  433. <span class="ctrl-label">TX Freq</span>
  434. <div class="ctrl-input">
  435. <input type="range" min="87.5" max="108.0" step="0.1" id="freq-slider"
  436. oninput="onFreqSlider(this.value)">
  437. <input type="number" min="65" max="110" step="0.1" id="freq-num"
  438. onchange="onFreqNum(this.value)">
  439. <span class="val-display">MHz</span>
  440. </div>
  441. </div>
  442. <button class="apply-btn" id="freq-apply" onclick="applyFreq()">Apply Frequency</button>
  443. </div>
  444. </div>
  445. <!-- Levels -->
  446. <div class="panel">
  447. <div class="panel-head" onclick="togglePanel(this)">
  448. <div class="led on-amber" style="width:6px;height:6px"></div>
  449. <h2>Levels</h2>
  450. <span class="chevron">▼</span>
  451. </div>
  452. <div class="panel-body">
  453. <div class="ctrl-row">
  454. <span class="ctrl-label">Output Drive</span>
  455. <div class="ctrl-input">
  456. <input type="range" min="0" max="3" step="0.01" id="drive-slider"
  457. oninput="onSlider('outputDrive', this.value, 'drive-val')">
  458. <span class="val-display" id="drive-val">--</span>
  459. </div>
  460. </div>
  461. <div class="ctrl-row">
  462. <span class="ctrl-label">Pilot Level</span>
  463. <div class="ctrl-input">
  464. <input type="range" min="0" max="0.2" step="0.001" id="pilot-slider"
  465. oninput="onSlider('pilotLevel', this.value, 'pilot-val')">
  466. <span class="val-display" id="pilot-val">--</span>
  467. </div>
  468. </div>
  469. <div class="ctrl-row">
  470. <span class="ctrl-label">RDS Inject</span>
  471. <div class="ctrl-input">
  472. <input type="range" min="0" max="0.15" step="0.001" id="rds-inj-slider"
  473. oninput="onSlider('rdsInjection', this.value, 'rds-inj-val')">
  474. <span class="val-display" id="rds-inj-val">--</span>
  475. </div>
  476. </div>
  477. <div class="ctrl-row">
  478. <span class="ctrl-label">Limiter Ceil</span>
  479. <div class="ctrl-input">
  480. <input type="range" min="0" max="2" step="0.01" id="ceil-slider"
  481. oninput="onSlider('limiterCeiling', this.value, 'ceil-val')">
  482. <span class="val-display" id="ceil-val">--</span>
  483. </div>
  484. </div>
  485. <button class="apply-btn" id="levels-apply" onclick="applyLevels()">Apply Levels</button>
  486. </div>
  487. </div>
  488. <!-- Switches -->
  489. <div class="panel">
  490. <div class="panel-head" onclick="togglePanel(this)">
  491. <div class="led on-green" style="width:6px;height:6px"></div>
  492. <h2>Switches</h2>
  493. <span class="chevron">▼</span>
  494. </div>
  495. <div class="panel-body">
  496. <div class="ctrl-row">
  497. <span class="ctrl-label">Stereo</span>
  498. <div class="ctrl-input">
  499. <div class="toggle" id="tog-stereo" onclick="applyToggle('stereoEnabled', this)"></div>
  500. <span class="val-display" id="stereo-label">--</span>
  501. </div>
  502. </div>
  503. <div class="ctrl-row">
  504. <span class="ctrl-label">RDS</span>
  505. <div class="ctrl-input">
  506. <div class="toggle" id="tog-rds" onclick="applyToggle('rdsEnabled', this)"></div>
  507. <span class="val-display" id="rds-label">--</span>
  508. </div>
  509. </div>
  510. <div class="ctrl-row">
  511. <span class="ctrl-label">Limiter</span>
  512. <div class="ctrl-input">
  513. <div class="toggle" id="tog-limiter" onclick="applyToggle('limiterEnabled', this)"></div>
  514. <span class="val-display" id="limiter-label">--</span>
  515. </div>
  516. </div>
  517. </div>
  518. </div>
  519. <!-- RDS -->
  520. <div class="panel">
  521. <div class="panel-head" onclick="togglePanel(this)">
  522. <div class="led on-amber" style="width:6px;height:6px"></div>
  523. <h2>RDS</h2>
  524. <span class="chevron">▼</span>
  525. </div>
  526. <div class="panel-body">
  527. <div class="ctrl-row" style="flex-direction:column;align-items:stretch;gap:4px">
  528. <span class="ctrl-label">Program Service (PS)</span>
  529. <input type="text" class="rds-input" id="rds-ps" maxlength="8" placeholder="STATION">
  530. <div class="rds-charcount"><span id="ps-count">0</span>/8</div>
  531. </div>
  532. <div class="ctrl-row" style="flex-direction:column;align-items:stretch;gap:4px;margin-top:8px">
  533. <span class="ctrl-label">RadioText (RT)</span>
  534. <input type="text" class="rds-input rt" id="rds-rt" maxlength="64" placeholder="Now playing...">
  535. <div class="rds-charcount"><span id="rt-count">0</span>/64</div>
  536. </div>
  537. <button class="apply-btn" id="rds-apply" onclick="applyRDS()">Apply RDS Text</button>
  538. </div>
  539. </div>
  540. <!-- Log -->
  541. <div class="panel">
  542. <div class="panel-head" onclick="togglePanel(this)">
  543. <h2>Log</h2>
  544. <span class="chevron">▼</span>
  545. </div>
  546. <div class="panel-body">
  547. <div class="log" id="log"></div>
  548. </div>
  549. </div>
  550. </div>
  551. <!-- Toast -->
  552. <div class="toast" id="toast"></div>
  553. <script>
  554. const $ = id => document.getElementById(id);
  555. // State
  556. let cfg = {};
  557. let pending = {};
  558. let pollTimer = null;
  559. // --- API ---
  560. async function api(path, opts) {
  561. try {
  562. const r = await fetch(path, opts);
  563. const text = await r.text();
  564. if (!r.ok) throw new Error(text.trim() || `HTTP ${r.status}`);
  565. try { return JSON.parse(text); }
  566. catch(e) { return {ok: true}; }
  567. } catch(e) {
  568. throw e;
  569. }
  570. }
  571. async function loadConfig() {
  572. try {
  573. cfg = await api('/config');
  574. $('led-conn').className = 'led on-green';
  575. $('conn-label').textContent = 'connected';
  576. syncUI();
  577. } catch(e) {
  578. $('led-conn').className = 'led on-red';
  579. $('conn-label').textContent = 'offline';
  580. log('config load failed: ' + e.message, 'err');
  581. }
  582. }
  583. async function loadRuntime() {
  584. try {
  585. const rt = await api('/runtime');
  586. const eng = rt.engine || {};
  587. const drv = rt.driver || {};
  588. // TX state
  589. const state = eng.state || 'idle';
  590. const el = $('tx-state');
  591. el.textContent = state.toUpperCase();
  592. el.className = 'tx-state ' + state;
  593. // Telemetry
  594. $('t-chunks').textContent = fmt(eng.chunksProduced || 0);
  595. $('t-samples').textContent = fmt(eng.totalSamples || 0);
  596. const ur = eng.underruns || 0;
  597. const urEl = $('t-underruns');
  598. urEl.textContent = ur;
  599. urEl.className = 'value' + (ur > 0 ? ' err' : '');
  600. $('t-uptime').textContent = fmtTime(eng.uptimeSeconds || 0);
  601. $('t-rate').textContent = drv.effectiveSampleRateHz ? (drv.effectiveSampleRateHz/1000).toFixed(0) + 'k' : '--';
  602. $('led-conn').className = 'led on-green';
  603. $('conn-label').textContent = 'connected';
  604. } catch(e) {
  605. // Silent on poll errors
  606. }
  607. }
  608. async function txStart() {
  609. try {
  610. await api('/tx/start', {method:'POST'});
  611. toast('TX started', 'ok');
  612. log('TX started', 'ok');
  613. } catch(e) { toast(e.message, 'err'); log('TX start failed: ' + e.message, 'err'); }
  614. }
  615. async function txStop() {
  616. try {
  617. await api('/tx/stop', {method:'POST'});
  618. toast('TX stopped', 'ok');
  619. log('TX stopped', 'ok');
  620. } catch(e) { toast(e.message, 'err'); log('TX stop failed: ' + e.message, 'err'); }
  621. }
  622. async function sendPatch(patch, btnId) {
  623. const btn = btnId ? $(btnId) : null;
  624. if (btn) btn.classList.add('sending');
  625. try {
  626. const r = await api('/config', {
  627. method: 'POST',
  628. headers: {'Content-Type':'application/json'},
  629. body: JSON.stringify(patch)
  630. });
  631. Object.assign(cfg, flatCfg(patch));
  632. toast('Applied' + (r.live ? ' (live)' : ''), 'ok');
  633. log('PATCH ' + JSON.stringify(patch) + (r.live ? ' [live]' : ''), 'ok');
  634. if (btn) { btn.classList.remove('sending'); btn.classList.add('ok'); setTimeout(()=>{btn.classList.remove('ok','visible')}, 800); }
  635. pending = {};
  636. } catch(e) {
  637. toast(e.message, 'err');
  638. log('PATCH failed: ' + e.message, 'err');
  639. if (btn) btn.classList.remove('sending');
  640. }
  641. }
  642. // --- UI sync ---
  643. function syncUI() {
  644. // Frequency
  645. const freq = cfg.fm?.frequencyMHz || 100;
  646. $('freq-display').innerHTML = freq.toFixed(1) + '<span class="unit">MHz</span>';
  647. $('freq-slider').value = freq;
  648. $('freq-num').value = freq;
  649. // Levels
  650. setSlider('drive-slider', 'drive-val', cfg.fm?.outputDrive, 2);
  651. setSlider('pilot-slider', 'pilot-val', cfg.fm?.pilotLevel, 3);
  652. setSlider('rds-inj-slider', 'rds-inj-val', cfg.fm?.rdsInjection, 3);
  653. setSlider('ceil-slider', 'ceil-val', cfg.fm?.limiterCeiling, 2);
  654. // Toggles
  655. setToggle('tog-stereo', 'stereo-label', cfg.fm?.stereoEnabled);
  656. setToggle('tog-rds', 'rds-label', cfg.rds?.enabled);
  657. setToggle('tog-limiter', 'limiter-label', cfg.fm?.limiterEnabled);
  658. // RDS text
  659. $('rds-ps').value = cfg.rds?.ps || '';
  660. $('rds-rt').value = cfg.rds?.radioText || '';
  661. $('ps-count').textContent = ($('rds-ps').value || '').length;
  662. $('rt-count').textContent = ($('rds-rt').value || '').length;
  663. }
  664. function setSlider(sliderId, valId, value, decimals) {
  665. const v = value ?? 0;
  666. $(sliderId).value = v;
  667. $(valId).textContent = v.toFixed(decimals || 2);
  668. }
  669. function setToggle(togId, labelId, on) {
  670. $(togId).className = 'toggle' + (on ? ' on' : '');
  671. $(labelId).textContent = on ? 'ON' : 'OFF';
  672. }
  673. // --- Handlers ---
  674. function onFreqSlider(v) {
  675. v = parseFloat(v);
  676. $('freq-num').value = v.toFixed(1);
  677. $('freq-display').innerHTML = v.toFixed(1) + '<span class="unit">MHz</span>';
  678. pending.frequencyMHz = v;
  679. showApply('freq-apply');
  680. }
  681. function onFreqNum(v) {
  682. v = parseFloat(v);
  683. if (isNaN(v)) return;
  684. $('freq-slider').value = v;
  685. $('freq-display').innerHTML = v.toFixed(1) + '<span class="unit">MHz</span>';
  686. pending.frequencyMHz = v;
  687. showApply('freq-apply');
  688. }
  689. function applyFreq() {
  690. if (pending.frequencyMHz != null) sendPatch({frequencyMHz: pending.frequencyMHz}, 'freq-apply');
  691. }
  692. function onSlider(key, v, valId) {
  693. v = parseFloat(v);
  694. $(valId).textContent = v.toFixed(key === 'outputDrive' || key === 'limiterCeiling' ? 2 : 3);
  695. pending[key] = v;
  696. showApply('levels-apply');
  697. }
  698. function applyLevels() {
  699. const patch = {};
  700. for (const k of ['outputDrive','pilotLevel','rdsInjection','limiterCeiling']) {
  701. if (pending[k] != null) patch[k] = pending[k];
  702. }
  703. if (Object.keys(patch).length) sendPatch(patch, 'levels-apply');
  704. }
  705. function applyToggle(key, el) {
  706. const isOn = el.classList.contains('on');
  707. const newVal = !isOn;
  708. const patch = {};
  709. patch[key] = newVal;
  710. sendPatch(patch);
  711. // Optimistic UI
  712. el.classList.toggle('on');
  713. const labelId = el.id.replace('tog-', '') + '-label';
  714. const lbl = document.getElementById(labelId);
  715. if (lbl) lbl.textContent = newVal ? 'ON' : 'OFF';
  716. }
  717. function applyRDS() {
  718. const ps = $('rds-ps').value;
  719. const rt = $('rds-rt').value;
  720. const patch = {};
  721. if (ps !== (cfg.rds?.ps || '')) patch.ps = ps;
  722. if (rt !== (cfg.rds?.radioText || '')) patch.radioText = rt;
  723. if (Object.keys(patch).length) sendPatch(patch, 'rds-apply');
  724. else toast('No changes', 'ok');
  725. }
  726. // RDS char counters
  727. $('rds-ps').addEventListener('input', function() {
  728. $('ps-count').textContent = this.value.length;
  729. showApply('rds-apply');
  730. });
  731. $('rds-rt').addEventListener('input', function() {
  732. $('rt-count').textContent = this.value.length;
  733. showApply('rds-apply');
  734. });
  735. // --- Panel toggle ---
  736. function togglePanel(head) {
  737. head.classList.toggle('collapsed');
  738. head.nextElementSibling.classList.toggle('collapsed');
  739. }
  740. // --- Apply button visibility ---
  741. function showApply(btnId) {
  742. $(btnId).classList.add('visible');
  743. }
  744. // --- Toast ---
  745. function toast(msg, type) {
  746. const t = $('toast');
  747. t.textContent = msg;
  748. t.className = 'toast ' + type + ' show';
  749. clearTimeout(t._timer);
  750. t._timer = setTimeout(() => t.classList.remove('show'), 2500);
  751. }
  752. // --- Log ---
  753. function log(msg, type) {
  754. const el = $('log');
  755. const ts = new Date().toLocaleTimeString();
  756. const d = document.createElement('div');
  757. d.className = 'entry ' + (type || '');
  758. d.textContent = ts + ' ' + msg;
  759. el.appendChild(d);
  760. el.scrollTop = el.scrollHeight;
  761. // Keep max 200 entries
  762. while (el.children.length > 200) el.removeChild(el.firstChild);
  763. }
  764. // --- Helpers ---
  765. function fmt(n) {
  766. if (n >= 1e9) return (n/1e9).toFixed(2) + 'G';
  767. if (n >= 1e6) return (n/1e6).toFixed(2) + 'M';
  768. if (n >= 1e3) return (n/1e3).toFixed(1) + 'k';
  769. return n.toString();
  770. }
  771. function fmtTime(s) {
  772. if (!s || s <= 0) return '--';
  773. const h = Math.floor(s/3600);
  774. const m = Math.floor((s%3600)/60);
  775. const sec = Math.floor(s%60);
  776. if (h > 0) return h + 'h ' + m + 'm';
  777. if (m > 0) return m + 'm ' + sec + 's';
  778. return sec + 's';
  779. }
  780. function flatCfg(patch) {
  781. // Update local cfg mirror from patch keys
  782. const map = {
  783. frequencyMHz: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.frequencyMHz=v; },
  784. outputDrive: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.outputDrive=v; },
  785. stereoEnabled: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.stereoEnabled=v; },
  786. pilotLevel: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.pilotLevel=v; },
  787. rdsInjection: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.rdsInjection=v; },
  788. rdsEnabled: (v) => { if(!cfg.rds) cfg.rds={}; cfg.rds.enabled=v; },
  789. limiterEnabled: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.limiterEnabled=v; },
  790. limiterCeiling: (v) => { if(!cfg.fm) cfg.fm={}; cfg.fm.limiterCeiling=v; },
  791. ps: (v) => { if(!cfg.rds) cfg.rds={}; cfg.rds.ps=v; },
  792. radioText: (v) => { if(!cfg.rds) cfg.rds={}; cfg.rds.radioText=v; },
  793. };
  794. for (const [k,v] of Object.entries(patch)) { if (map[k]) map[k](v); }
  795. return {};
  796. }
  797. // --- Init ---
  798. async function init() {
  799. log('fm-rds-tx web control initializing');
  800. await loadConfig();
  801. // Poll runtime every 500ms
  802. setInterval(loadRuntime, 500);
  803. // Reload config every 5s (catch external changes)
  804. setInterval(loadConfig, 5000);
  805. log('polling active', 'ok');
  806. }
  807. init();
  808. </script>
  809. </body>
  810. </html>