const socket = io(); const state = { mode: "beamforming", gain_db: 0, agc: true, attack_ms: 6, release_ms: 280, noise_suppression: true, speech_gate: false, hum_filter: true, limiter: true, beam_clarity: true, hifi_mode: false, hifi_mic: "mic1", angle: 0, auto_beam: true, auto_angle: 0, speech_detected: false, monitor_on: false, monitor_source: "beam", sample_rate: 16000, recording: false, recDuration: 0, record_duration_sec: 0, buffers: { mic1: [], mic2: [], beam: [], mono_mix: [], }, }; const MAX_POINTS = 3500; const els = { status: document.getElementById("serverStatus"), audioStatus: document.getElementById("audioStatus"), waveMic1: document.getElementById("waveMic1"), waveMic2: document.getElementById("waveMic2"), waveBeam: document.getElementById("waveBeam"), beamBlock: document.getElementById("beamBlock"), vuMic1: document.getElementById("vuMic1"), vuMic2: document.getElementById("vuMic2"), vuBeam: document.getElementById("vuBeam"), gainDb: document.getElementById("gainDb"), gainValue: document.getElementById("gainValue"), agcEnabled: document.getElementById("agcEnabled"), agcControls: document.getElementById("agcControls"), attackMs: document.getElementById("attackMs"), attackValue: document.getElementById("attackValue"), releaseMs: document.getElementById("releaseMs"), releaseValue: document.getElementById("releaseValue"), noiseSuppression: document.getElementById("noiseSuppression"), speechGate: document.getElementById("speechGate"), humFilter: document.getElementById("humFilter"), limiterEnabled: document.getElementById("limiterEnabled"), beamClarity: document.getElementById("beamClarity"), hifiMode: document.getElementById("hifiMode"), hifiMic: document.getElementById("hifiMic"), beamControls: document.getElementById("beamControls"), beamAuto: document.getElementById("beamAuto"), manualBeamRow: document.getElementById("manualBeamRow"), beamAngle: document.getElementById("beamAngle"), angleValue: document.getElementById("angleValue"), autoAngleValue: document.getElementById("autoAngleValue"), speechState: document.getElementById("speechState"), monitorOn: document.getElementById("monitorOn"), monitorSource: document.getElementById("monitorSource"), sampleRate: document.getElementById("sampleRate"), recordBtn: document.getElementById("recordBtn"), recordSource: document.getElementById("recordSource"), recordDurationSec: document.getElementById("recordDurationSec"), recordTimer: document.getElementById("recordTimer"), recordingsBody: document.getElementById("recordingsBody"), }; const monitorAudio = { context: null, nextTime: 0, }; function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } function formatDuration(totalSeconds) { const sec = Math.max(0, Math.floor(totalSeconds)); const h = String(Math.floor(sec / 3600)).padStart(2, "0"); const m = String(Math.floor((sec % 3600) / 60)).padStart(2, "0"); const s = String(sec % 60).padStart(2, "0"); return `${h}:${m}:${s}`; } function formatBytes(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; } function pushWaveData(key, samples) { if (!Array.isArray(samples) || samples.length === 0) return; const target = state.buffers[key]; target.push(...samples); if (target.length > MAX_POINTS) { target.splice(0, target.length - MAX_POINTS); } } function drawWave(canvas, samples, color) { const ctx = canvas.getContext("2d"); const width = canvas.width; const height = canvas.height; ctx.clearRect(0, 0, width, height); ctx.strokeStyle = "rgba(140, 190, 200, 0.35)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, height / 2); ctx.lineTo(width, height / 2); ctx.stroke(); if (!samples.length) return; const step = samples.length / width; ctx.strokeStyle = color; ctx.lineWidth = 1.7; ctx.beginPath(); for (let x = 0; x < width; x += 1) { const idx = Math.floor(x * step); const sample = clamp(samples[idx] ?? 0, -1, 1); const y = (1 - (sample + 1) / 2) * height; if (x === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } } ctx.stroke(); } function setVu(el, rms) { const db = 20 * Math.log10(Math.max(rms, 1e-6)); const norm = clamp((db + 60) / 60, 0, 1); el.style.width = `${Math.round(norm * 100)}%`; } function ensureMonitorContext() { if (!monitorAudio.context) { const Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) return null; monitorAudio.context = new Ctx({ sampleRate: 48000 }); } return monitorAudio.context; } function stopMonitorPlayback() { const ctx = monitorAudio.context; if (!ctx) return; monitorAudio.nextTime = ctx.currentTime; } function decodePcm16Base64(base64Chunk) { if (!base64Chunk) return null; const binary = atob(base64Chunk); const len = binary.length; if (len < 2) return null; const samples = new Float32Array(Math.floor(len / 2)); for (let i = 0, s = 0; i + 1 < len; i += 2, s += 1) { let value = (binary.charCodeAt(i) | (binary.charCodeAt(i + 1) << 8)); if (value & 0x8000) value -= 0x10000; samples[s] = value / 32768.0; } return samples; } function playMonitorChunk(base64Chunk, sampleRate) { const chunk = decodePcm16Base64(base64Chunk); if (!chunk || chunk.length === 0) return; const ctx = ensureMonitorContext(); if (!ctx) return; if (ctx.state === "suspended") { void ctx.resume(); } const now = ctx.currentTime; if (monitorAudio.nextTime < now + 0.02) { monitorAudio.nextTime = now + 0.02; } else if (monitorAudio.nextTime > now + 0.6) { monitorAudio.nextTime = now + 0.05; } const buffer = ctx.createBuffer(1, chunk.length, sampleRate); buffer.copyToChannel(chunk, 0); const src = ctx.createBufferSource(); src.buffer = buffer; src.connect(ctx.destination); src.start(monitorAudio.nextTime); monitorAudio.nextTime += buffer.duration; } function sendSettings() { const payload = { type: "settings", mode: state.mode, gain_db: state.gain_db, agc: state.agc, attack_ms: state.attack_ms, release_ms: state.release_ms, noise_suppression: state.noise_suppression, speech_gate: state.speech_gate, hum_filter: state.hum_filter, limiter: state.limiter, beam_clarity: state.beam_clarity, hifi_mode: state.hifi_mode, hifi_mic: state.hifi_mic, angle: state.angle, auto_beam: state.auto_beam, monitor_on: state.monitor_on, monitor_source: state.monitor_source, sample_rate: state.sample_rate, }; socket.emit("client_message", payload); } function refreshControlVisibility() { const hifi = state.hifi_mode; const beamVisible = state.mode === "beamforming" && !hifi; els.beamControls.classList.toggle("hidden", !beamVisible); els.beamBlock.classList.toggle("hidden", !beamVisible); if (els.manualBeamRow) { els.manualBeamRow.classList.toggle("hidden", !beamVisible || state.auto_beam); } if (els.monitorSource) { els.monitorSource.disabled = hifi || !state.monitor_on; } els.agcControls.classList.toggle("hidden", hifi || !state.agc); document.querySelectorAll("input[name='mode']").forEach((radio) => { radio.disabled = hifi; }); if (els.gainDb) els.gainDb.disabled = hifi; if (els.agcEnabled) els.agcEnabled.disabled = hifi; if (els.attackMs) els.attackMs.disabled = hifi || !state.agc; if (els.releaseMs) els.releaseMs.disabled = hifi || !state.agc; if (els.noiseSuppression) els.noiseSuppression.disabled = hifi; if (els.speechGate) els.speechGate.disabled = hifi; if (els.humFilter) els.humFilter.disabled = hifi; if (els.limiterEnabled) els.limiterEnabled.disabled = hifi; if (els.beamClarity) els.beamClarity.disabled = hifi; if (els.beamAuto) els.beamAuto.disabled = hifi || !beamVisible; if (els.beamAngle) els.beamAngle.disabled = hifi || !beamVisible || state.auto_beam; if (els.sampleRate) els.sampleRate.disabled = hifi; if (els.monitorOn) els.monitorOn.disabled = hifi; if (els.hifiMic) els.hifiMic.disabled = !hifi; if (els.recordSource) { if (hifi) { els.recordSource.value = "hifi_raw"; els.recordSource.disabled = true; } else { els.recordSource.disabled = false; if (els.recordSource.value === "hifi_raw") { els.recordSource.value = "beam"; } } } } function syncUiFromState() { document.querySelectorAll("input[name='mode']").forEach((radio) => { radio.checked = radio.value === state.mode; }); els.gainDb.value = String(state.gain_db); els.gainValue.textContent = String(state.gain_db); els.agcEnabled.checked = state.agc; els.attackMs.value = String(state.attack_ms); els.attackValue.textContent = String(state.attack_ms); els.releaseMs.value = String(state.release_ms); els.releaseValue.textContent = String(state.release_ms); if (els.noiseSuppression) { els.noiseSuppression.checked = state.noise_suppression; } if (els.speechGate) { els.speechGate.checked = state.speech_gate; } if (els.humFilter) { els.humFilter.checked = state.hum_filter; } if (els.limiterEnabled) { els.limiterEnabled.checked = state.limiter; } if (els.beamClarity) { els.beamClarity.checked = state.beam_clarity; } if (els.hifiMode) { els.hifiMode.checked = state.hifi_mode; } if (els.hifiMic) { els.hifiMic.value = state.hifi_mic; } els.beamAngle.value = String(state.angle); els.angleValue.textContent = String(state.angle); if (els.beamAuto) { els.beamAuto.checked = state.auto_beam; } if (els.autoAngleValue) { els.autoAngleValue.textContent = Number(state.auto_angle).toFixed(1); } if (els.speechState) { els.speechState.textContent = state.speech_detected ? "mowa" : "cisza"; } if (els.monitorOn) { els.monitorOn.checked = state.monitor_on; } if (els.monitorSource) { els.monitorSource.value = state.monitor_source; } els.sampleRate.value = String(state.sample_rate); els.recordBtn.classList.toggle("recording", state.recording); els.recordBtn.textContent = state.recording ? "STOP" : "RECORD"; refreshControlVisibility(); } async function loadStatus() { const response = await fetch("/api/status"); const data = await response.json(); if (data.settings) { state.mode = data.settings.mode; state.gain_db = data.settings.gain_db; state.agc = data.settings.agc; state.attack_ms = data.settings.attack_ms; state.release_ms = data.settings.release_ms; state.noise_suppression = Boolean(data.settings.noise_suppression); state.speech_gate = Boolean(data.settings.speech_gate); state.hum_filter = Boolean(data.settings.hum_filter); state.limiter = Boolean(data.settings.limiter); state.beam_clarity = Boolean(data.settings.beam_clarity); state.hifi_mode = Boolean(data.settings.hifi_mode); state.hifi_mic = data.settings.hifi_mic || "mic1"; state.angle = data.settings.angle; state.auto_beam = Boolean(data.settings.auto_beam); state.monitor_on = Boolean(data.settings.monitor_on); state.monitor_source = data.settings.monitor_source || "beam"; state.sample_rate = data.settings.sample_rate; } state.auto_angle = Number(data.auto_beam_angle_deg ?? state.angle ?? 0); state.speech_detected = false; state.recording = Boolean(data.recording); els.audioStatus.textContent = data.audio_error ? `Audio: blad (${data.audio_error})` : data.audio_running ? "Audio: aktywne" : "Audio: zatrzymane"; syncUiFromState(); } async function loadRecordings() { const response = await fetch("/api/recordings"); const recordings = await response.json(); els.recordingsBody.innerHTML = ""; recordings.forEach((rec) => { const tr = document.createElement("tr"); const nameTd = document.createElement("td"); nameTd.textContent = rec.filename; const dateTd = document.createElement("td"); dateTd.textContent = rec.date; const durTd = document.createElement("td"); durTd.textContent = formatDuration(rec.duration_sec || 0); const sizeTd = document.createElement("td"); sizeTd.textContent = formatBytes(rec.size_bytes || 0); const actionsTd = document.createElement("td"); actionsTd.className = "actions"; const playBtn = document.createElement("button"); playBtn.className = "btn-small"; playBtn.textContent = "Play"; playBtn.addEventListener("click", () => { const audio = new Audio(`/api/recordings/${encodeURIComponent(rec.filename)}`); void audio.play(); }); const dlBtn = document.createElement("a"); dlBtn.className = "btn-small"; dlBtn.textContent = "Download"; dlBtn.href = `/api/recordings/${encodeURIComponent(rec.filename)}`; const delBtn = document.createElement("button"); delBtn.className = "btn-small danger"; delBtn.textContent = "Delete"; delBtn.addEventListener("click", async () => { await fetch(`/api/recordings/${encodeURIComponent(rec.filename)}`, { method: "DELETE", }); await loadRecordings(); }); actionsTd.append(playBtn, dlBtn, delBtn); tr.append(nameTd, dateTd, durTd, sizeTd, actionsTd); els.recordingsBody.appendChild(tr); }); } function bindControls() { document.querySelectorAll("input[name='mode']").forEach((radio) => { radio.addEventListener("change", () => { state.mode = radio.value; refreshControlVisibility(); sendSettings(); }); }); els.gainDb.addEventListener("input", () => { state.gain_db = Number(els.gainDb.value); els.gainValue.textContent = String(state.gain_db); }); els.gainDb.addEventListener("change", sendSettings); els.agcEnabled.addEventListener("change", () => { state.agc = els.agcEnabled.checked; refreshControlVisibility(); sendSettings(); }); els.attackMs.addEventListener("input", () => { state.attack_ms = Number(els.attackMs.value); els.attackValue.textContent = String(state.attack_ms); }); els.attackMs.addEventListener("change", sendSettings); els.releaseMs.addEventListener("input", () => { state.release_ms = Number(els.releaseMs.value); els.releaseValue.textContent = String(state.release_ms); }); els.releaseMs.addEventListener("change", sendSettings); if (els.noiseSuppression) { els.noiseSuppression.addEventListener("change", () => { state.noise_suppression = els.noiseSuppression.checked; sendSettings(); }); } if (els.speechGate) { els.speechGate.addEventListener("change", () => { state.speech_gate = els.speechGate.checked; sendSettings(); }); } if (els.humFilter) { els.humFilter.addEventListener("change", () => { state.hum_filter = els.humFilter.checked; sendSettings(); }); } if (els.limiterEnabled) { els.limiterEnabled.addEventListener("change", () => { state.limiter = els.limiterEnabled.checked; sendSettings(); }); } if (els.beamClarity) { els.beamClarity.addEventListener("change", () => { state.beam_clarity = els.beamClarity.checked; sendSettings(); }); } if (els.hifiMode) { els.hifiMode.addEventListener("change", () => { state.hifi_mode = els.hifiMode.checked; if (state.hifi_mode) { state.monitor_on = false; } refreshControlVisibility(); sendSettings(); }); } if (els.hifiMic) { els.hifiMic.addEventListener("change", () => { state.hifi_mic = els.hifiMic.value; sendSettings(); }); } els.beamAngle.addEventListener("input", () => { state.angle = Number(els.beamAngle.value); els.angleValue.textContent = String(state.angle); }); els.beamAngle.addEventListener("change", sendSettings); if (els.beamAuto) { els.beamAuto.addEventListener("change", () => { state.auto_beam = els.beamAuto.checked; refreshControlVisibility(); sendSettings(); }); } els.sampleRate.addEventListener("change", () => { state.sample_rate = Number(els.sampleRate.value); sendSettings(); }); els.recordBtn.addEventListener("click", () => { if (!state.recording) { const duration = Number(els.recordDurationSec?.value || 0); state.record_duration_sec = Number.isFinite(duration) ? Math.max(0, duration) : 0; socket.emit("client_message", { type: "record_start", source: els.recordSource.value, duration_sec: state.record_duration_sec, }); return; } socket.emit("client_message", { type: "record_stop" }); }); if (els.recordDurationSec) { els.recordDurationSec.addEventListener("change", () => { const duration = Number(els.recordDurationSec.value || 0); state.record_duration_sec = Number.isFinite(duration) ? clamp(duration, 0, 3600) : 0; els.recordDurationSec.value = String(Math.round(state.record_duration_sec)); }); } if (els.monitorOn) { els.monitorOn.addEventListener("change", () => { state.monitor_on = els.monitorOn.checked; if (state.monitor_on) { const ctx = ensureMonitorContext(); if (ctx && ctx.state === "suspended") { void ctx.resume(); } } else { stopMonitorPlayback(); } refreshControlVisibility(); sendSettings(); }); } if (els.monitorSource) { els.monitorSource.addEventListener("change", () => { state.monitor_source = els.monitorSource.value; sendSettings(); }); } } function animate() { drawWave(els.waveMic1, state.buffers.mic1, "#32b3ff"); drawWave(els.waveMic2, state.buffers.mic2, "#50d27a"); if (state.mode === "beamforming") { drawWave(els.waveBeam, state.buffers.beam, "#ff6d5a"); } els.recordTimer.textContent = formatDuration(state.recDuration); requestAnimationFrame(animate); } function bindSocket() { socket.on("connect", () => { els.status.textContent = "WebSocket: polaczono"; }); socket.on("disconnect", () => { els.status.textContent = "WebSocket: rozlaczono"; }); socket.on("status", (payload) => { if (payload?.settings) { state.mode = payload.settings.mode; state.gain_db = payload.settings.gain_db; state.agc = payload.settings.agc; state.attack_ms = payload.settings.attack_ms; state.release_ms = payload.settings.release_ms; state.noise_suppression = Boolean(payload.settings.noise_suppression); state.speech_gate = Boolean(payload.settings.speech_gate); state.hum_filter = Boolean(payload.settings.hum_filter); state.limiter = Boolean(payload.settings.limiter); state.beam_clarity = Boolean(payload.settings.beam_clarity); state.hifi_mode = Boolean(payload.settings.hifi_mode); state.hifi_mic = payload.settings.hifi_mic || "mic1"; state.angle = payload.settings.angle; state.auto_beam = Boolean(payload.settings.auto_beam); state.monitor_on = Boolean(payload.settings.monitor_on); state.monitor_source = payload.settings.monitor_source || "beam"; state.sample_rate = payload.settings.sample_rate; syncUiFromState(); } }); socket.on("audio_data", (payload) => { const wasRecording = state.recording; pushWaveData("mic1", payload.mic1 || []); pushWaveData("mic2", payload.mic2 || []); pushWaveData("beam", payload.beam || []); pushWaveData("mono_mix", payload.mono_mix || []); setVu(els.vuMic1, payload.rms_mic1 || 0); setVu(els.vuMic2, payload.rms_mic2 || 0); setVu(els.vuBeam, payload.rms_beam || payload.rms_mono_mix || 0); state.recording = Boolean(payload.recording); state.recDuration = Number(payload.rec_duration || 0); const gateOpen = Boolean(payload.speech_gate_open ?? payload.speech_detected); state.speech_detected = gateOpen; state.hifi_mode = Boolean(payload.hifi_mode ?? state.hifi_mode); state.auto_angle = Number(payload.beam_angle_deg ?? state.auto_angle); state.monitor_on = Boolean(payload.monitor_on ?? state.monitor_on); state.monitor_source = payload.monitor_source || state.monitor_source; if (state.auto_beam) { state.angle = state.auto_angle; els.angleValue.textContent = Number(state.angle).toFixed(1); } if (els.autoAngleValue) { els.autoAngleValue.textContent = Number(state.auto_angle).toFixed(1); } if (els.speechState) { els.speechState.textContent = state.hifi_mode ? "hifi" : state.speech_detected ? "mowa/gate" : "cisza"; } els.recordBtn.classList.toggle("recording", state.recording); els.recordBtn.textContent = state.recording ? "STOP" : "RECORD"; if (state.monitor_on && payload.monitor_chunk_b64) { const sr = Number(payload.monitor_sr || state.sample_rate || 16000); playMonitorChunk(payload.monitor_chunk_b64, sr); } if (wasRecording && !state.recording) { state.recDuration = 0; void loadRecordings(); } }); socket.on("recordings_updated", async () => { await loadRecordings(); }); socket.on("server_ack", (payload) => { if (payload?.type === "record_started") { state.recording = true; els.recordBtn.classList.add("recording"); els.recordBtn.textContent = "STOP"; void loadRecordings(); } if (payload?.type === "record_stopped") { state.recording = false; els.recordBtn.classList.remove("recording"); els.recordBtn.textContent = "RECORD"; state.recDuration = 0; void loadRecordings(); } if (payload?.type === "settings_applied" && payload.settings) { state.mode = payload.settings.mode; state.gain_db = payload.settings.gain_db; state.agc = payload.settings.agc; state.attack_ms = payload.settings.attack_ms; state.release_ms = payload.settings.release_ms; state.noise_suppression = Boolean(payload.settings.noise_suppression); state.speech_gate = Boolean(payload.settings.speech_gate); state.hum_filter = Boolean(payload.settings.hum_filter); state.limiter = Boolean(payload.settings.limiter); state.beam_clarity = Boolean(payload.settings.beam_clarity); state.hifi_mode = Boolean(payload.settings.hifi_mode); state.hifi_mic = payload.settings.hifi_mic || "mic1"; state.angle = payload.settings.angle; state.auto_beam = Boolean(payload.settings.auto_beam); state.monitor_on = Boolean(payload.settings.monitor_on); state.monitor_source = payload.settings.monitor_source || "beam"; state.sample_rate = payload.settings.sample_rate; refreshControlVisibility(); syncUiFromState(); } }); socket.on("server_error", (payload) => { if (payload?.message) { els.audioStatus.textContent = `Audio: blad (${payload.message})`; } }); } async function init() { bindControls(); bindSocket(); await loadStatus(); await loadRecordings(); syncUiFromState(); requestAnimationFrame(animate); } void init();