app.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  1. const socket = io();
  2. const state = {
  3. mode: "beamforming",
  4. gain_db: 0,
  5. agc: true,
  6. attack_ms: 8,
  7. release_ms: 260,
  8. angle: 0,
  9. auto_beam: true,
  10. auto_angle: 0,
  11. speech_detected: false,
  12. monitor_on: false,
  13. monitor_source: "beam",
  14. sample_rate: 16000,
  15. recording: false,
  16. recDuration: 0,
  17. record_duration_sec: 0,
  18. buffers: {
  19. mic1: [],
  20. mic2: [],
  21. beam: [],
  22. mono_mix: [],
  23. },
  24. };
  25. const MAX_POINTS = 3500;
  26. const els = {
  27. status: document.getElementById("serverStatus"),
  28. audioStatus: document.getElementById("audioStatus"),
  29. waveMic1: document.getElementById("waveMic1"),
  30. waveMic2: document.getElementById("waveMic2"),
  31. waveBeam: document.getElementById("waveBeam"),
  32. beamBlock: document.getElementById("beamBlock"),
  33. vuMic1: document.getElementById("vuMic1"),
  34. vuMic2: document.getElementById("vuMic2"),
  35. vuBeam: document.getElementById("vuBeam"),
  36. gainDb: document.getElementById("gainDb"),
  37. gainValue: document.getElementById("gainValue"),
  38. agcEnabled: document.getElementById("agcEnabled"),
  39. agcControls: document.getElementById("agcControls"),
  40. attackMs: document.getElementById("attackMs"),
  41. attackValue: document.getElementById("attackValue"),
  42. releaseMs: document.getElementById("releaseMs"),
  43. releaseValue: document.getElementById("releaseValue"),
  44. beamControls: document.getElementById("beamControls"),
  45. beamAuto: document.getElementById("beamAuto"),
  46. manualBeamRow: document.getElementById("manualBeamRow"),
  47. beamAngle: document.getElementById("beamAngle"),
  48. angleValue: document.getElementById("angleValue"),
  49. autoAngleValue: document.getElementById("autoAngleValue"),
  50. speechState: document.getElementById("speechState"),
  51. monitorOn: document.getElementById("monitorOn"),
  52. monitorSource: document.getElementById("monitorSource"),
  53. sampleRate: document.getElementById("sampleRate"),
  54. recordBtn: document.getElementById("recordBtn"),
  55. recordSource: document.getElementById("recordSource"),
  56. recordDurationSec: document.getElementById("recordDurationSec"),
  57. recordTimer: document.getElementById("recordTimer"),
  58. recordingsBody: document.getElementById("recordingsBody"),
  59. };
  60. const monitorAudio = {
  61. context: null,
  62. nextTime: 0,
  63. };
  64. function clamp(value, min, max) {
  65. return Math.min(Math.max(value, min), max);
  66. }
  67. function formatDuration(totalSeconds) {
  68. const sec = Math.max(0, Math.floor(totalSeconds));
  69. const h = String(Math.floor(sec / 3600)).padStart(2, "0");
  70. const m = String(Math.floor((sec % 3600) / 60)).padStart(2, "0");
  71. const s = String(sec % 60).padStart(2, "0");
  72. return `${h}:${m}:${s}`;
  73. }
  74. function formatBytes(bytes) {
  75. if (bytes < 1024) return `${bytes} B`;
  76. if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
  77. return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
  78. }
  79. function pushWaveData(key, samples) {
  80. if (!Array.isArray(samples) || samples.length === 0) return;
  81. const target = state.buffers[key];
  82. target.push(...samples);
  83. if (target.length > MAX_POINTS) {
  84. target.splice(0, target.length - MAX_POINTS);
  85. }
  86. }
  87. function drawWave(canvas, samples, color) {
  88. const ctx = canvas.getContext("2d");
  89. const width = canvas.width;
  90. const height = canvas.height;
  91. ctx.clearRect(0, 0, width, height);
  92. ctx.strokeStyle = "rgba(140, 190, 200, 0.35)";
  93. ctx.lineWidth = 1;
  94. ctx.beginPath();
  95. ctx.moveTo(0, height / 2);
  96. ctx.lineTo(width, height / 2);
  97. ctx.stroke();
  98. if (!samples.length) return;
  99. const step = samples.length / width;
  100. ctx.strokeStyle = color;
  101. ctx.lineWidth = 1.7;
  102. ctx.beginPath();
  103. for (let x = 0; x < width; x += 1) {
  104. const idx = Math.floor(x * step);
  105. const sample = clamp(samples[idx] ?? 0, -1, 1);
  106. const y = (1 - (sample + 1) / 2) * height;
  107. if (x === 0) {
  108. ctx.moveTo(x, y);
  109. } else {
  110. ctx.lineTo(x, y);
  111. }
  112. }
  113. ctx.stroke();
  114. }
  115. function setVu(el, rms) {
  116. const db = 20 * Math.log10(Math.max(rms, 1e-6));
  117. const norm = clamp((db + 60) / 60, 0, 1);
  118. el.style.width = `${Math.round(norm * 100)}%`;
  119. }
  120. function ensureMonitorContext() {
  121. if (!monitorAudio.context) {
  122. const Ctx = window.AudioContext || window.webkitAudioContext;
  123. if (!Ctx) return null;
  124. monitorAudio.context = new Ctx({ sampleRate: 48000 });
  125. }
  126. return monitorAudio.context;
  127. }
  128. function stopMonitorPlayback() {
  129. const ctx = monitorAudio.context;
  130. if (!ctx) return;
  131. monitorAudio.nextTime = ctx.currentTime;
  132. }
  133. function decodePcm16Base64(base64Chunk) {
  134. if (!base64Chunk) return null;
  135. const binary = atob(base64Chunk);
  136. const len = binary.length;
  137. if (len < 2) return null;
  138. const samples = new Float32Array(Math.floor(len / 2));
  139. for (let i = 0, s = 0; i + 1 < len; i += 2, s += 1) {
  140. let value = (binary.charCodeAt(i) | (binary.charCodeAt(i + 1) << 8));
  141. if (value & 0x8000) value -= 0x10000;
  142. samples[s] = value / 32768.0;
  143. }
  144. return samples;
  145. }
  146. function playMonitorChunk(base64Chunk, sampleRate) {
  147. const chunk = decodePcm16Base64(base64Chunk);
  148. if (!chunk || chunk.length === 0) return;
  149. const ctx = ensureMonitorContext();
  150. if (!ctx) return;
  151. if (ctx.state === "suspended") {
  152. void ctx.resume();
  153. }
  154. const now = ctx.currentTime;
  155. if (monitorAudio.nextTime < now + 0.02) {
  156. monitorAudio.nextTime = now + 0.02;
  157. } else if (monitorAudio.nextTime > now + 0.6) {
  158. monitorAudio.nextTime = now + 0.05;
  159. }
  160. const buffer = ctx.createBuffer(1, chunk.length, sampleRate);
  161. buffer.copyToChannel(chunk, 0);
  162. const src = ctx.createBufferSource();
  163. src.buffer = buffer;
  164. src.connect(ctx.destination);
  165. src.start(monitorAudio.nextTime);
  166. monitorAudio.nextTime += buffer.duration;
  167. }
  168. function sendSettings() {
  169. const payload = {
  170. type: "settings",
  171. mode: state.mode,
  172. gain_db: state.gain_db,
  173. agc: state.agc,
  174. attack_ms: state.attack_ms,
  175. release_ms: state.release_ms,
  176. angle: state.angle,
  177. auto_beam: state.auto_beam,
  178. monitor_on: state.monitor_on,
  179. monitor_source: state.monitor_source,
  180. sample_rate: state.sample_rate,
  181. };
  182. socket.emit("client_message", payload);
  183. }
  184. function refreshControlVisibility() {
  185. const beamVisible = state.mode === "beamforming";
  186. els.beamControls.classList.toggle("hidden", !beamVisible);
  187. els.beamBlock.classList.toggle("hidden", !beamVisible);
  188. if (els.manualBeamRow) {
  189. els.manualBeamRow.classList.toggle("hidden", !beamVisible || state.auto_beam);
  190. }
  191. if (els.monitorSource) {
  192. els.monitorSource.disabled = !state.monitor_on;
  193. }
  194. els.agcControls.classList.toggle("hidden", !state.agc);
  195. }
  196. function syncUiFromState() {
  197. document.querySelectorAll("input[name='mode']").forEach((radio) => {
  198. radio.checked = radio.value === state.mode;
  199. });
  200. els.gainDb.value = String(state.gain_db);
  201. els.gainValue.textContent = String(state.gain_db);
  202. els.agcEnabled.checked = state.agc;
  203. els.attackMs.value = String(state.attack_ms);
  204. els.attackValue.textContent = String(state.attack_ms);
  205. els.releaseMs.value = String(state.release_ms);
  206. els.releaseValue.textContent = String(state.release_ms);
  207. els.beamAngle.value = String(state.angle);
  208. els.angleValue.textContent = String(state.angle);
  209. if (els.beamAuto) {
  210. els.beamAuto.checked = state.auto_beam;
  211. }
  212. if (els.autoAngleValue) {
  213. els.autoAngleValue.textContent = Number(state.auto_angle).toFixed(1);
  214. }
  215. if (els.speechState) {
  216. els.speechState.textContent = state.speech_detected ? "mowa" : "cisza";
  217. }
  218. if (els.monitorOn) {
  219. els.monitorOn.checked = state.monitor_on;
  220. }
  221. if (els.monitorSource) {
  222. els.monitorSource.value = state.monitor_source;
  223. }
  224. els.sampleRate.value = String(state.sample_rate);
  225. els.recordBtn.classList.toggle("recording", state.recording);
  226. els.recordBtn.textContent = state.recording ? "STOP" : "RECORD";
  227. refreshControlVisibility();
  228. }
  229. async function loadStatus() {
  230. const response = await fetch("/api/status");
  231. const data = await response.json();
  232. if (data.settings) {
  233. state.mode = data.settings.mode;
  234. state.gain_db = data.settings.gain_db;
  235. state.agc = data.settings.agc;
  236. state.attack_ms = data.settings.attack_ms;
  237. state.release_ms = data.settings.release_ms;
  238. state.angle = data.settings.angle;
  239. state.auto_beam = Boolean(data.settings.auto_beam);
  240. state.monitor_on = Boolean(data.settings.monitor_on);
  241. state.monitor_source = data.settings.monitor_source || "beam";
  242. state.sample_rate = data.settings.sample_rate;
  243. }
  244. state.auto_angle = Number(data.auto_beam_angle_deg ?? state.angle ?? 0);
  245. state.speech_detected = false;
  246. state.recording = Boolean(data.recording);
  247. els.audioStatus.textContent = data.audio_error
  248. ? `Audio: blad (${data.audio_error})`
  249. : data.audio_running
  250. ? "Audio: aktywne"
  251. : "Audio: zatrzymane";
  252. syncUiFromState();
  253. }
  254. async function loadRecordings() {
  255. const response = await fetch("/api/recordings");
  256. const recordings = await response.json();
  257. els.recordingsBody.innerHTML = "";
  258. recordings.forEach((rec) => {
  259. const tr = document.createElement("tr");
  260. const nameTd = document.createElement("td");
  261. nameTd.textContent = rec.filename;
  262. const dateTd = document.createElement("td");
  263. dateTd.textContent = rec.date;
  264. const durTd = document.createElement("td");
  265. durTd.textContent = formatDuration(rec.duration_sec || 0);
  266. const sizeTd = document.createElement("td");
  267. sizeTd.textContent = formatBytes(rec.size_bytes || 0);
  268. const actionsTd = document.createElement("td");
  269. actionsTd.className = "actions";
  270. const playBtn = document.createElement("button");
  271. playBtn.className = "btn-small";
  272. playBtn.textContent = "Play";
  273. playBtn.addEventListener("click", () => {
  274. const audio = new Audio(`/api/recordings/${encodeURIComponent(rec.filename)}`);
  275. void audio.play();
  276. });
  277. const dlBtn = document.createElement("a");
  278. dlBtn.className = "btn-small";
  279. dlBtn.textContent = "Download";
  280. dlBtn.href = `/api/recordings/${encodeURIComponent(rec.filename)}`;
  281. const delBtn = document.createElement("button");
  282. delBtn.className = "btn-small danger";
  283. delBtn.textContent = "Delete";
  284. delBtn.addEventListener("click", async () => {
  285. await fetch(`/api/recordings/${encodeURIComponent(rec.filename)}`, {
  286. method: "DELETE",
  287. });
  288. await loadRecordings();
  289. });
  290. actionsTd.append(playBtn, dlBtn, delBtn);
  291. tr.append(nameTd, dateTd, durTd, sizeTd, actionsTd);
  292. els.recordingsBody.appendChild(tr);
  293. });
  294. }
  295. function bindControls() {
  296. document.querySelectorAll("input[name='mode']").forEach((radio) => {
  297. radio.addEventListener("change", () => {
  298. state.mode = radio.value;
  299. refreshControlVisibility();
  300. sendSettings();
  301. });
  302. });
  303. els.gainDb.addEventListener("input", () => {
  304. state.gain_db = Number(els.gainDb.value);
  305. els.gainValue.textContent = String(state.gain_db);
  306. });
  307. els.gainDb.addEventListener("change", sendSettings);
  308. els.agcEnabled.addEventListener("change", () => {
  309. state.agc = els.agcEnabled.checked;
  310. refreshControlVisibility();
  311. sendSettings();
  312. });
  313. els.attackMs.addEventListener("input", () => {
  314. state.attack_ms = Number(els.attackMs.value);
  315. els.attackValue.textContent = String(state.attack_ms);
  316. });
  317. els.attackMs.addEventListener("change", sendSettings);
  318. els.releaseMs.addEventListener("input", () => {
  319. state.release_ms = Number(els.releaseMs.value);
  320. els.releaseValue.textContent = String(state.release_ms);
  321. });
  322. els.releaseMs.addEventListener("change", sendSettings);
  323. els.beamAngle.addEventListener("input", () => {
  324. state.angle = Number(els.beamAngle.value);
  325. els.angleValue.textContent = String(state.angle);
  326. });
  327. els.beamAngle.addEventListener("change", sendSettings);
  328. if (els.beamAuto) {
  329. els.beamAuto.addEventListener("change", () => {
  330. state.auto_beam = els.beamAuto.checked;
  331. refreshControlVisibility();
  332. sendSettings();
  333. });
  334. }
  335. els.sampleRate.addEventListener("change", () => {
  336. state.sample_rate = Number(els.sampleRate.value);
  337. sendSettings();
  338. });
  339. els.recordBtn.addEventListener("click", () => {
  340. if (!state.recording) {
  341. const duration = Number(els.recordDurationSec?.value || 0);
  342. state.record_duration_sec = Number.isFinite(duration) ? Math.max(0, duration) : 0;
  343. socket.emit("client_message", {
  344. type: "record_start",
  345. source: els.recordSource.value,
  346. duration_sec: state.record_duration_sec,
  347. });
  348. return;
  349. }
  350. socket.emit("client_message", { type: "record_stop" });
  351. });
  352. if (els.recordDurationSec) {
  353. els.recordDurationSec.addEventListener("change", () => {
  354. const duration = Number(els.recordDurationSec.value || 0);
  355. state.record_duration_sec = Number.isFinite(duration)
  356. ? clamp(duration, 0, 3600)
  357. : 0;
  358. els.recordDurationSec.value = String(Math.round(state.record_duration_sec));
  359. });
  360. }
  361. if (els.monitorOn) {
  362. els.monitorOn.addEventListener("change", () => {
  363. state.monitor_on = els.monitorOn.checked;
  364. if (state.monitor_on) {
  365. const ctx = ensureMonitorContext();
  366. if (ctx && ctx.state === "suspended") {
  367. void ctx.resume();
  368. }
  369. } else {
  370. stopMonitorPlayback();
  371. }
  372. refreshControlVisibility();
  373. sendSettings();
  374. });
  375. }
  376. if (els.monitorSource) {
  377. els.monitorSource.addEventListener("change", () => {
  378. state.monitor_source = els.monitorSource.value;
  379. sendSettings();
  380. });
  381. }
  382. }
  383. function animate() {
  384. drawWave(els.waveMic1, state.buffers.mic1, "#32b3ff");
  385. drawWave(els.waveMic2, state.buffers.mic2, "#50d27a");
  386. if (state.mode === "beamforming") {
  387. drawWave(els.waveBeam, state.buffers.beam, "#ff6d5a");
  388. }
  389. els.recordTimer.textContent = formatDuration(state.recDuration);
  390. requestAnimationFrame(animate);
  391. }
  392. function bindSocket() {
  393. socket.on("connect", () => {
  394. els.status.textContent = "WebSocket: polaczono";
  395. });
  396. socket.on("disconnect", () => {
  397. els.status.textContent = "WebSocket: rozlaczono";
  398. });
  399. socket.on("status", (payload) => {
  400. if (payload?.settings) {
  401. state.mode = payload.settings.mode;
  402. state.gain_db = payload.settings.gain_db;
  403. state.agc = payload.settings.agc;
  404. state.attack_ms = payload.settings.attack_ms;
  405. state.release_ms = payload.settings.release_ms;
  406. state.angle = payload.settings.angle;
  407. state.auto_beam = Boolean(payload.settings.auto_beam);
  408. state.monitor_on = Boolean(payload.settings.monitor_on);
  409. state.monitor_source = payload.settings.monitor_source || "beam";
  410. state.sample_rate = payload.settings.sample_rate;
  411. syncUiFromState();
  412. }
  413. });
  414. socket.on("audio_data", (payload) => {
  415. const wasRecording = state.recording;
  416. pushWaveData("mic1", payload.mic1 || []);
  417. pushWaveData("mic2", payload.mic2 || []);
  418. pushWaveData("beam", payload.beam || []);
  419. pushWaveData("mono_mix", payload.mono_mix || []);
  420. setVu(els.vuMic1, payload.rms_mic1 || 0);
  421. setVu(els.vuMic2, payload.rms_mic2 || 0);
  422. setVu(els.vuBeam, payload.rms_beam || payload.rms_mono_mix || 0);
  423. state.recording = Boolean(payload.recording);
  424. state.recDuration = Number(payload.rec_duration || 0);
  425. state.speech_detected = Boolean(payload.speech_detected);
  426. state.auto_angle = Number(payload.beam_angle_deg ?? state.auto_angle);
  427. state.monitor_on = Boolean(payload.monitor_on ?? state.monitor_on);
  428. state.monitor_source = payload.monitor_source || state.monitor_source;
  429. if (state.auto_beam) {
  430. state.angle = state.auto_angle;
  431. els.angleValue.textContent = Number(state.angle).toFixed(1);
  432. }
  433. if (els.autoAngleValue) {
  434. els.autoAngleValue.textContent = Number(state.auto_angle).toFixed(1);
  435. }
  436. if (els.speechState) {
  437. els.speechState.textContent = state.speech_detected ? "mowa" : "cisza";
  438. }
  439. els.recordBtn.classList.toggle("recording", state.recording);
  440. els.recordBtn.textContent = state.recording ? "STOP" : "RECORD";
  441. if (state.monitor_on && payload.monitor_chunk_b64) {
  442. const sr = Number(payload.monitor_sr || state.sample_rate || 16000);
  443. playMonitorChunk(payload.monitor_chunk_b64, sr);
  444. }
  445. if (wasRecording && !state.recording) {
  446. state.recDuration = 0;
  447. void loadRecordings();
  448. }
  449. });
  450. socket.on("recordings_updated", async () => {
  451. await loadRecordings();
  452. });
  453. socket.on("server_ack", (payload) => {
  454. if (payload?.type === "record_started") {
  455. state.recording = true;
  456. els.recordBtn.classList.add("recording");
  457. els.recordBtn.textContent = "STOP";
  458. void loadRecordings();
  459. }
  460. if (payload?.type === "record_stopped") {
  461. state.recording = false;
  462. els.recordBtn.classList.remove("recording");
  463. els.recordBtn.textContent = "RECORD";
  464. state.recDuration = 0;
  465. void loadRecordings();
  466. }
  467. if (payload?.type === "settings_applied" && payload.settings) {
  468. state.mode = payload.settings.mode;
  469. state.gain_db = payload.settings.gain_db;
  470. state.agc = payload.settings.agc;
  471. state.attack_ms = payload.settings.attack_ms;
  472. state.release_ms = payload.settings.release_ms;
  473. state.angle = payload.settings.angle;
  474. state.auto_beam = Boolean(payload.settings.auto_beam);
  475. state.monitor_on = Boolean(payload.settings.monitor_on);
  476. state.monitor_source = payload.settings.monitor_source || "beam";
  477. state.sample_rate = payload.settings.sample_rate;
  478. refreshControlVisibility();
  479. syncUiFromState();
  480. }
  481. });
  482. socket.on("server_error", (payload) => {
  483. if (payload?.message) {
  484. els.audioStatus.textContent = `Audio: blad (${payload.message})`;
  485. }
  486. });
  487. }
  488. async function init() {
  489. bindControls();
  490. bindSocket();
  491. await loadStatus();
  492. await loadRecordings();
  493. syncUiFromState();
  494. requestAnimationFrame(animate);
  495. }
  496. void init();