Explorar o código

Initial commit: mic_system - RPi audio streaming, beamforming, recording

Paweł Chodaczek hai 1 mes
achega
d48b1895c6
Modificáronse 18 ficheiros con 4038 adicións e 0 borrados
  1. 6 0
      .gitignore
  2. 79 0
      README.md
  3. 119 0
      agc.py
  4. 583 0
      app.js
  5. 164 0
      app.py
  6. 1168 0
      audio_capture.py
  7. 42 0
      beamforming.py
  8. 16 0
      deploy/mic_system.service
  9. 167 0
      index.html
  10. 183 0
      recorder.py
  11. 6 0
      requirements.txt
  12. 18 0
      scripts/deploy_from_windows.ps1
  13. 191 0
      scripts/diag_ws_record.py
  14. 56 0
      scripts/setup_rpi.sh
  15. 730 0
      static/app.js
  16. 315 0
      static/style.css
  17. 5 0
      static/vendor/socket.io.min.js
  18. 190 0
      templates/index.html

+ 6 - 0
.gitignore

@@ -0,0 +1,6 @@
+.venv/
+__pycache__/
+*.pyc
+recordings/
+*.wav
+.DS_Store

+ 79 - 0
README.md

@@ -0,0 +1,79 @@
+# Mic System (RPi Zero 2W + INMP441)
+
+Aplikacja webowa do monitoringu i nagrywania audio mono z 2 mikrofonow I2S (INMP441):
+- waveform w czasie rzeczywistym,
+- tryby mono (mic1, mic2, mono mix) i beamforming (mono),
+- AGC + gain,
+- zapis WAV,
+- lista nagran z odtwarzaniem/pobieraniem/usuwaniem.
+- tryb porownawczy nagrania: jednym kliknieciem zapisuje `mic1`, `mono_mix`, `beam` jako 3 osobne pliki.
+- opcjonalny czas nagrania w sekundach (auto-stop), np. 10 s.
+- podsluch live w przegladarce z wyborem zrodla (`mic1`, `mic2`, `mono_mix`, `beam`).
+
+## Struktura
+
+- `app.py` - serwer Flask + Socket.IO + REST
+- `audio_capture.py` - odczyt I2S, downsample, RMS, routing do nagrywania
+- `beamforming.py` - delay-and-sum
+- `agc.py` - AGC stateful
+- `recorder.py` - zapis WAV w osobnym watku
+- `templates/index.html` - UI
+- `static/app.js` - logika frontu
+- `static/style.css` - styl UI
+- `recordings/` - pliki WAV
+- `deploy/mic_system.service` - autostart przez systemd
+- `scripts/setup_rpi.sh` - konfiguracja RPi (I2S/ALSA/deps/service)
+- `scripts/deploy_from_windows.ps1` - deployment przez SSH z Windows
+
+## Start lokalny
+
+```bash
+cd mic_system
+python -m venv .venv
+source .venv/bin/activate  # Linux
+pip install -r requirements.txt
+python app.py
+```
+
+## Deployment na RPi
+
+Z Windows (PowerShell):
+
+```powershell
+cd "C:\Users\jeste\OneDrive\BB_projekty_zaruskiego\Projekty 3d\mikrofon_jak_z_telefonu"
+.\mic_system\scripts\deploy_from_windows.ps1 -Host 10.0.100.24 -User pch
+```
+
+Skrypt:
+1. tworzy klucz `ed25519` jesli brak,
+2. dodaje klucz do `~/.ssh/authorized_keys` na RPi,
+3. kopiuje projekt do `/home/pch/mic_system`,
+4. odpala setup I2S/ALSA/deps/systemd.
+
+Po pierwszym deployu wykonaj reboot RPi:
+
+```bash
+sudo reboot
+```
+
+Po starcie systemd:
+
+```bash
+systemctl status mic_system.service
+```
+
+Aplikacja bedzie dostepna na porcie `5000`:
+
+```text
+http://10.0.100.24:5000
+```
+
+## Uwagi techniczne
+
+- INMP441: 24-bit MSB w ramce 32-bit, konwersja jest robiona jako `>> 8`.
+- Dla beamformingu przyjeto odstep mikrofonow sasiednich `~0.0424 m` (4 sloty na okregu, srednica 6 cm, kat 90 stopni).
+- Karta voiceHAT pracuje sprzetowo na `48000 Hz`; aplikacja resampluje dane do 16000/22050/44100.
+- Beamforming ma tryb auto (domyslnie ON): kierunek jest estymowany z pasma mowy (~300-3400 Hz).
+- Pierwsze 300 ms po starcie streamu jest ignorowane.
+- WebSocket wysyla probki downsample do UI, a nagrywanie dostaje pelne dane.
+- Beamforming i AGC dzialaja w czasie rzeczywistym na chunkach.

+ 119 - 0
agc.py

@@ -0,0 +1,119 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import numpy as np
+
+
+@dataclass
+class AgcConfig:
+    target_level: float = 0.22
+    attack_ms: float = 6.0
+    release_ms: float = 280.0
+    min_gain: float = 0.2
+    max_gain: float = 20.0
+    noise_floor_init: float = 8e-5
+    noise_floor_rise_alpha: float = 0.03
+    noise_floor_fall_alpha: float = 0.002
+    gate_open_ratio: float = 1.20
+    silence_attenuation: float = 0.85
+
+
+class AgcProcessor:
+    """Stateful AGC to preserve envelope between chunks."""
+
+    def __init__(self, sample_rate: int, config: AgcConfig | None = None) -> None:
+        self.sample_rate = int(sample_rate)
+        self.config = config or AgcConfig()
+        self._envelope = 0.0
+        self._gain = 1.0
+        self._noise_floor = max(self.config.noise_floor_init, 1e-7)
+        self._attack_seconds = 0.005
+        self._release_seconds = 0.300
+        self._recompute_coeffs()
+
+    def update(
+        self,
+        *,
+        sample_rate: int | None = None,
+        attack_ms: float | None = None,
+        release_ms: float | None = None,
+        target_level: float | None = None,
+    ) -> None:
+        if sample_rate is not None:
+            self.sample_rate = int(sample_rate)
+        if attack_ms is not None:
+            self.config.attack_ms = float(attack_ms)
+        if release_ms is not None:
+            self.config.release_ms = float(release_ms)
+        if target_level is not None:
+            self.config.target_level = float(target_level)
+        self._recompute_coeffs()
+
+    def reset(self) -> None:
+        self._envelope = 0.0
+        self._gain = 1.0
+        self._noise_floor = max(self.config.noise_floor_init, 1e-7)
+
+    def _recompute_coeffs(self) -> None:
+        self._attack_seconds = max(self.config.attack_ms, 1e-3) / 1000.0
+        self._release_seconds = max(self.config.release_ms, 1e-3) / 1000.0
+
+    def process(self, audio: np.ndarray, *, speech_hint: bool = False) -> np.ndarray:
+        if audio.size == 0:
+            return audio.astype(np.float32, copy=False)
+
+        samples = audio.astype(np.float32, copy=False)
+        chunk_seconds = max(samples.size / float(self.sample_rate), 1e-6)
+        chunk_rms = float(np.sqrt(np.mean(samples * samples, dtype=np.float64)))
+        target_env = max(chunk_rms, 1e-6)
+
+        env = self._envelope
+        if target_env > env:
+            coeff = np.exp(-chunk_seconds / self._attack_seconds)
+        else:
+            coeff = np.exp(-chunk_seconds / self._release_seconds)
+
+        env = coeff * env + (1.0 - coeff) * target_env
+        env = max(env, 1e-6)
+
+        noise_floor = self._noise_floor
+        if speech_hint:
+            alpha = self.config.noise_floor_fall_alpha
+        elif target_env > noise_floor:
+            alpha = self.config.noise_floor_rise_alpha
+        else:
+            alpha = self.config.noise_floor_fall_alpha
+        noise_floor = (1.0 - alpha) * noise_floor + alpha * target_env
+        noise_floor = max(noise_floor, 1e-7)
+
+        speech_ratio = target_env / noise_floor
+        voiced = bool(speech_hint or speech_ratio >= self.config.gate_open_ratio)
+
+        desired_gain = float(np.clip(self.config.target_level / env, self.config.min_gain, self.config.max_gain))
+        peak = float(np.max(np.abs(samples)))
+        if peak > 1e-6:
+            desired_gain = min(desired_gain, 0.92 / peak)
+        if not voiced:
+            desired_gain = min(desired_gain, 2.2)
+
+        current_gain = self._gain
+        if desired_gain > current_gain:
+            gain_coeff = np.exp(-chunk_seconds / self._attack_seconds)
+        else:
+            gain_coeff = np.exp(-chunk_seconds / self._release_seconds)
+        gain = gain_coeff * current_gain + (1.0 - gain_coeff) * desired_gain
+        if not voiced:
+            gain = min(gain, 2.2)
+
+        self._envelope = env
+        self._gain = gain
+        self._noise_floor = noise_floor
+
+        out = samples * gain
+        if not voiced:
+            gate_span = max(self.config.gate_open_ratio - 1.0, 1e-6)
+            gate_t = float(np.clip((speech_ratio - 1.0) / gate_span, 0.0, 1.0))
+            gate = self.config.silence_attenuation + (1.0 - self.config.silence_attenuation) * (gate_t * gate_t)
+            out *= gate
+        return np.clip(out, -1.0, 1.0).astype(np.float32, copy=False)

+ 583 - 0
app.js

@@ -0,0 +1,583 @@
+const socket = io();
+
+const state = {
+    mode: "beamforming",
+    gain_db: 0,
+    agc: true,
+    attack_ms: 8,
+    release_ms: 260,
+    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"),
+    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,
+        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 beamVisible = state.mode === "beamforming";
+    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 = !state.monitor_on;
+    }
+
+    els.agcControls.classList.toggle("hidden", !state.agc);
+}
+
+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);
+
+    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.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);
+
+    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.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);
+        state.speech_detected = Boolean(payload.speech_detected);
+        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.speech_detected ? "mowa" : "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.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();

+ 164 - 0
app.py

@@ -0,0 +1,164 @@
+from __future__ import annotations
+
+from dataclasses import asdict
+from pathlib import Path
+import os
+
+from flask import Flask, abort, jsonify, render_template, request, send_from_directory
+from flask_socketio import SocketIO, emit
+
+from audio_capture import AudioEngine
+from recorder import list_recordings, resolve_recording_path
+
+
+BASE_DIR = Path(__file__).resolve().parent
+RECORDINGS_DIR = BASE_DIR / "recordings"
+
+app = Flask(__name__, template_folder="templates", static_folder="static")
+app.config["SECRET_KEY"] = os.environ.get("MIC_SYSTEM_SECRET", "mic-system-dev")
+
+socketio = SocketIO(app, cors_allowed_origins="*", async_mode="eventlet")
+audio_engine = AudioEngine(RECORDINGS_DIR)
+
+_stream_task_started = False
+audio_start_error = ""
+
+
+def ensure_audio_started() -> None:
+    global audio_start_error
+    if audio_engine.is_running():
+        return
+
+    try:
+        audio_engine.start()
+        audio_start_error = ""
+    except Exception as exc:  # pragma: no cover - runtime environment specific
+        audio_start_error = str(exc)
+
+
+def stream_audio_packets() -> None:
+    while True:
+        packet = audio_engine.get_latest_packet()
+        socketio.emit("audio_data", packet)
+        socketio.sleep(0.08)
+
+
+@app.route("/")
+def index() -> str:
+    ensure_audio_started()
+    return render_template("index.html")
+
+
+@app.route("/api/status")
+def api_status():
+    ensure_audio_started()
+    status = audio_engine.get_status()
+    status["audio_error"] = audio_start_error
+    return jsonify(status)
+
+
+@app.route("/api/recordings")
+def api_list_recordings():
+    return jsonify(list_recordings(RECORDINGS_DIR))
+
+
+@app.route("/api/recordings/<path:filename>")
+def api_get_recording(filename: str):
+    try:
+        path = resolve_recording_path(RECORDINGS_DIR, filename)
+    except ValueError:
+        abort(400, description="Invalid filename")
+
+    if not path.exists():
+        abort(404)
+
+    return send_from_directory(RECORDINGS_DIR, path.name, as_attachment=True)
+
+
+@app.route("/api/recordings/<path:filename>", methods=["DELETE"])
+def api_delete_recording(filename: str):
+    try:
+        path = resolve_recording_path(RECORDINGS_DIR, filename)
+    except ValueError:
+        abort(400, description="Invalid filename")
+
+    if not path.exists():
+        abort(404)
+
+    path.unlink()
+    socketio.emit("recordings_updated", {"ok": True})
+    return jsonify({"ok": True})
+
+
+def _handle_command(payload: dict) -> dict:
+    msg_type = payload.get("type")
+
+    if msg_type == "settings":
+        settings = audio_engine.update_settings(payload)
+        return {"type": "settings_applied", "settings": settings}
+
+    if msg_type == "record_start":
+        source = str(payload.get("source", "mic1"))
+        duration_sec = payload.get("duration_sec")
+        record_info = audio_engine.start_recording(source, duration_sec=duration_sec)
+        socketio.emit("recordings_updated", {"ok": True})
+        return {"type": "record_started", **record_info}
+
+    if msg_type == "record_stop":
+        status = audio_engine.stop_recording()
+        socketio.emit("recordings_updated", {"ok": True})
+        return {"type": "record_stopped", "status": asdict(status)}
+
+    if msg_type == "delete_recording":
+        filename = str(payload.get("filename", ""))
+        if not filename:
+            raise ValueError("Missing filename")
+        path = resolve_recording_path(RECORDINGS_DIR, filename)
+        if path.exists():
+            path.unlink()
+            socketio.emit("recordings_updated", {"ok": True})
+        return {"type": "recording_deleted", "filename": filename}
+
+    raise ValueError(f"Unsupported message type: {msg_type}")
+
+
+@socketio.on("connect")
+def ws_connect():
+    global _stream_task_started
+
+    ensure_audio_started()
+
+    if not _stream_task_started:
+        socketio.start_background_task(stream_audio_packets)
+        _stream_task_started = True
+
+    emit("status", audio_engine.get_status())
+    if audio_start_error:
+        emit("server_error", {"message": audio_start_error})
+
+
+@socketio.on("client_message")
+def ws_client_message(payload):
+    if not isinstance(payload, dict):
+        emit("server_error", {"message": "Invalid payload"})
+        return
+
+    try:
+        response = _handle_command(payload)
+    except Exception as exc:
+        emit("server_error", {"message": str(exc)})
+        return
+
+    emit("server_ack", response)
+
+
+@socketio.on("disconnect")
+def ws_disconnect():
+    return
+
+
+if __name__ == "__main__":
+    ensure_audio_started()
+    host = os.environ.get("MIC_SYSTEM_HOST", "0.0.0.0")
+    port = int(os.environ.get("MIC_SYSTEM_PORT", "5000"))
+    socketio.run(app, host=host, port=port)

+ 1168 - 0
audio_capture.py

@@ -0,0 +1,1168 @@
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass
+from datetime import datetime
+import base64
+from collections import deque
+from concurrent.futures import Future, ProcessPoolExecutor
+import math
+import os
+from pathlib import Path
+import threading
+import time
+
+import numpy as np
+from scipy import signal
+import sounddevice as sd
+
+from agc import AgcProcessor
+from beamforming import beamform_delay_and_sum
+from recorder import RecorderStatus, WavRecorder
+
+
+def _gcc_phat_job(sig: np.ndarray, refsig: np.ndarray, sample_rate: int, max_tau: float) -> float:
+    n = sig.size + refsig.size
+    sig_fft = np.fft.rfft(sig, n=n)
+    ref_fft = np.fft.rfft(refsig, n=n)
+    cross = sig_fft * np.conj(ref_fft)
+    denom = np.abs(cross)
+    cross = cross / np.maximum(denom, 1e-10)
+    cc = np.fft.irfft(cross, n=n)
+
+    max_shift = int(min(n // 2, max_tau * sample_rate))
+    if max_shift <= 0:
+        return 0.0
+
+    cc_window = np.concatenate((cc[-max_shift:], cc[: max_shift + 1]))
+    shift = int(np.argmax(np.abs(cc_window)) - max_shift)
+    return float(shift) / float(sample_rate)
+
+
+def _estimate_speech_angle_job(
+    mic1: np.ndarray,
+    mic2: np.ndarray,
+    sample_rate: int,
+    mic_spacing: float,
+    prev_angle_deg: float,
+    prev_noise_floor: float,
+) -> tuple[float, bool, float]:
+    if mic1.size < 64 or mic2.size < 64:
+        return float(prev_angle_deg), False, float(prev_noise_floor)
+
+    high = min(3400.0, 0.45 * sample_rate)
+    low = min(300.0, high * 0.5)
+    if high <= low + 1.0:
+        return float(prev_angle_deg), False, float(prev_noise_floor)
+
+    try:
+        sos = signal.butter(4, [low, high], btype="bandpass", fs=sample_rate, output="sos")
+    except ValueError:
+        return float(prev_angle_deg), False, float(prev_noise_floor)
+
+    speech1 = signal.sosfilt(sos, mic1).astype(np.float32, copy=False)
+    speech2 = signal.sosfilt(sos, mic2).astype(np.float32, copy=False)
+
+    speech_energy = 0.5 * (np.mean(speech1 * speech1) + np.mean(speech2 * speech2))
+    full_energy = 0.5 * (np.mean(mic1 * mic1) + np.mean(mic2 * mic2))
+    speech_ratio = float(speech_energy / max(full_energy, 1e-12))
+
+    noise_floor = 0.995 * float(prev_noise_floor) + 0.005 * float(speech_energy)
+    speech_threshold = max(2.5e-7, noise_floor * 2.0)
+    speech_detected = bool(speech_energy > speech_threshold and speech_ratio > 0.08)
+    if not speech_detected:
+        return float(prev_angle_deg), False, float(noise_floor)
+
+    max_tau = mic_spacing / 343.0
+    tau = _gcc_phat_job(speech1, speech2, sample_rate, max_tau=max_tau)
+    sin_theta = np.clip((tau * 343.0) / max(mic_spacing, 1e-6), -1.0, 1.0)
+    raw_angle = float(np.rad2deg(np.arcsin(sin_theta)))
+    raw_angle = float(np.clip(raw_angle, -90.0, 90.0))
+
+    angle = 0.88 * float(prev_angle_deg) + 0.12 * raw_angle
+    return float(angle), True, float(noise_floor)
+
+
+ALLOWED_SAMPLE_RATES = {16000, 22050, 24000, 32000, 48000}
+ALLOWED_MODES = {"mic1", "mic2", "mono_mix", "beamforming"}
+ALLOWED_SOURCES = {"mic1", "mic2", "mono_mix", "beam", "compare_all", "hifi_raw"}
+ALLOWED_MONITOR_SOURCES = {"mic1", "mic2", "mono_mix", "beam"}
+ALLOWED_HIFI_MICS = {"mic1", "mic2"}
+
+
+@dataclass
+class AudioSettings:
+    mode: str = "beamforming"
+    gain_db: float = 0.0
+    agc: bool = True
+    attack_ms: float = 6.0
+    release_ms: float = 280.0
+    noise_suppression: bool = True
+    speech_gate: bool = False
+    hum_filter: bool = True
+    limiter: bool = True
+    beam_clarity: bool = True
+    hifi_mode: bool = False
+    hifi_mic: str = "mic1"
+    angle: float = 0.0
+    auto_beam: bool = True
+    monitor_on: bool = False
+    monitor_source: str = "beam"
+    sample_rate: int = 16000
+
+
+class AudioEngine:
+    CHANNELS = 2
+    BIT_DEPTH = 32
+    CHUNK_SIZE = 2048
+    HARDWARE_SAMPLE_RATE = 48000
+    RING_DIAMETER_M = 0.06
+    RING_SLOTS = 4
+    SLOT_STEP = 1  # neighboring slots (90 degrees). Use 2 for opposite mics.
+    MIC_SPACING = RING_DIAMETER_M * math.sin(math.pi * SLOT_STEP / RING_SLOTS)
+    STARTUP_IGNORE_SECONDS = 0.30
+    SPEECH_GATE_HOLD_SECONDS = 0.85
+    SPEECH_GATE_FLOOR = 0.55
+    SPEECH_GATE_ATTACK_SECONDS = 0.012
+    SPEECH_GATE_RELEASE_SECONDS = 0.360
+    NOISE_SUPPRESS_OPEN_FLOOR = 0.72
+    NOISE_SUPPRESS_CLOSED_FLOOR = 0.40
+    NOISE_SUPPRESS_OPEN_STRENGTH = 0.30
+    NOISE_SUPPRESS_CLOSED_STRENGTH = 0.55
+    HUM_HPF_CUTOFF_HZ = 75.0
+    HUM_NOTCH_HZ = 50.0
+    HUM_NOTCH_Q = 22.0
+    BEAM_CLARITY_BLEND = 0.22
+    BEAM_PRESENCE_BOOST = 0.20
+
+    def __init__(self, recordings_dir: Path) -> None:
+        self._lock = threading.Lock()
+        self._stream: sd.InputStream | None = None
+        self._stream_channels = self.CHANNELS
+        self._input_device_name = "unknown"
+        self._settings = AudioSettings()
+        self._running = False
+        self._startup_deadline = 0.0
+        self._auto_angle_deg = 0.0
+        self._noise_floor = 1e-7
+        self._vad_noise_floor = 1e-7
+        self._speech_sos_cache: dict[int, np.ndarray | None] = {}
+        self._hpf_sos_cache: dict[int, np.ndarray | None] = {}
+        self._notch_cache: dict[int, tuple[np.ndarray, np.ndarray] | None] = {}
+        self._hpf_state: dict[tuple[str, int], np.ndarray] = {}
+        self._notch_state: dict[tuple[str, int], np.ndarray] = {}
+        self._last_speech_detected = False
+        self._angle_update_counter = 0
+        self._angle_update_interval = 4
+        cpu_total = max(1, int(os.cpu_count() or 1))
+        self._cpu_workers = max(1, min(4, cpu_total))
+        self._angle_workers = max(1, min(3, self._cpu_workers - 1))
+        self._max_pending_angle_jobs = max(1, min(2, self._angle_workers))
+        self._angle_executor: ProcessPoolExecutor | None = ProcessPoolExecutor(max_workers=self._angle_workers)
+        self._angle_futures: deque[Future] = deque()
+        self._speech_gate_hold_chunks = 0
+        self._speech_gate_gain = 1.0
+        self._ns_noise_power = {
+            "mic1": 1e-7,
+            "mic2": 1e-7,
+            "mono_mix": 1e-7,
+            "beam": 1e-7,
+        }
+        self._presence_prev = {"beam": 0.0}
+
+        self._recordings_dir = Path(recordings_dir)
+        self._recorder = WavRecorder(recordings_dir)
+        self._compare_recorders: dict[str, WavRecorder] = {}
+        self._compare_filenames: dict[str, str] = {}
+        self._record_duration_limit_sec: float | None = None
+        self._auto_stop_requested = False
+        self._monitor_queue: deque[np.ndarray] = deque()
+        self._monitor_queue_samples = 0
+
+        self._agc_mic1 = AgcProcessor(self._settings.sample_rate)
+        self._agc_mic2 = AgcProcessor(self._settings.sample_rate)
+        self._agc_beam = AgcProcessor(self._settings.sample_rate)
+
+        self._latest_frame: dict[str, object] = self._make_empty_frame()
+
+    def start(self) -> None:
+        with self._lock:
+            if self._running:
+                return
+        self._open_stream()
+
+    def stop(self) -> None:
+        with self._lock:
+            stream = self._stream
+            self._stream = None
+            self._running = False
+            angle_executor = self._angle_executor
+            self._angle_executor = None
+            angle_futures = list(self._angle_futures)
+            self._angle_futures.clear()
+
+        if stream is not None:
+            stream.stop()
+            stream.close()
+
+        self.stop_recording()
+        for fut in angle_futures:
+            fut.cancel()
+        if angle_executor is not None:
+            angle_executor.shutdown(wait=False, cancel_futures=True)
+
+    def is_running(self) -> bool:
+        with self._lock:
+            return self._running
+
+    def get_settings(self) -> dict[str, object]:
+        with self._lock:
+            return asdict(self._settings)
+
+    def get_status(self) -> dict[str, object]:
+        rec_status = self._current_recording_status()
+        return {
+            "recording": rec_status.recording,
+            "mic_count": self.CHANNELS,
+            "hardware_sample_rate": self.HARDWARE_SAMPLE_RATE,
+            "mic_spacing_m": self.MIC_SPACING,
+            "auto_beam_angle_deg": self._auto_angle_deg,
+            "input_device": self._input_device_name,
+            "settings": self.get_settings(),
+            "recording_status": asdict(rec_status),
+            "audio_running": self.is_running(),
+        }
+
+    def get_latest_packet(self) -> dict[str, object]:
+        empty = np.empty(0, dtype=np.float32)
+        with self._lock:
+            frame = dict(self._latest_frame)
+            mic1 = np.asarray(frame.get("mic1", empty), dtype=np.float32)
+            mic2 = np.asarray(frame.get("mic2", empty), dtype=np.float32)
+            beam = np.asarray(frame.get("beam", empty), dtype=np.float32)
+            mono_mix = np.asarray(frame.get("mono_mix", empty), dtype=np.float32)
+            show_mic2 = bool(frame.get("show_mic2", True))
+            show_beam = bool(frame.get("show_beam", False))
+            show_mono_mix = bool(frame.get("show_mono_mix", False))
+            beam_angle_deg = float(frame.get("beam_angle_deg", 0.0))
+            auto_beam = bool(frame.get("auto_beam", True))
+            speech_detected = bool(frame.get("speech_detected", False))
+            speech_gate_open = bool(frame.get("speech_gate_open", True))
+            hifi_mode = bool(frame.get("hifi_mode", False))
+            monitor_on = bool(frame.get("monitor_on", False))
+            monitor_source = str(frame.get("monitor_source", "beam"))
+            recording = bool(frame.get("recording", False))
+            rec_duration = float(frame.get("rec_duration", 0.0))
+
+            monitor_rate = int(self.HARDWARE_SAMPLE_RATE if hifi_mode else self._settings.sample_rate)
+            monitor_chunk = None
+            if monitor_on:
+                monitor_chunk = self._pop_monitor_chunk(max_samples=max(512, int(monitor_rate * 0.08)))
+
+        packet = {
+            "type": "audio_data",
+            "mic1": self._downsample_for_ui(mic1).tolist(),
+            "mic2": self._downsample_for_ui(mic2).tolist() if show_mic2 else [],
+            "beam": self._downsample_for_ui(beam).tolist() if show_beam else [],
+            "mono_mix": self._downsample_for_ui(mono_mix).tolist() if show_mono_mix else [],
+            "rms_mic1": self._rms(mic1),
+            "rms_mic2": self._rms(mic2) if show_mic2 else 0.0,
+            "rms_beam": self._rms(beam) if show_beam else 0.0,
+            "rms_mono_mix": self._rms(mono_mix) if show_mono_mix else 0.0,
+            "beam_angle_deg": beam_angle_deg,
+            "auto_beam": auto_beam,
+            "speech_detected": speech_detected,
+            "speech_gate_open": speech_gate_open,
+            "hifi_mode": hifi_mode,
+            "monitor_on": monitor_on,
+            "monitor_source": monitor_source,
+            "monitor_sr": monitor_rate,
+            "monitor_chunk_b64": "",
+            "recording": recording,
+            "rec_duration": rec_duration,
+        }
+        if monitor_chunk is not None and monitor_chunk.size > 0:
+            packet["monitor_chunk_b64"] = self._encode_pcm16_base64(monitor_chunk)
+        return packet
+
+    def update_settings(self, updates: dict[str, object]) -> dict[str, object]:
+        with self._lock:
+            current = self._settings
+
+            mode = str(updates.get("mode", current.mode))
+            if mode not in ALLOWED_MODES:
+                mode = current.mode
+
+            sample_rate = int(updates.get("sample_rate", current.sample_rate))
+            if sample_rate not in ALLOWED_SAMPLE_RATES:
+                sample_rate = current.sample_rate
+
+            gain_db = float(updates.get("gain_db", current.gain_db))
+            gain_db = float(np.clip(gain_db, 0.0, 30.0))
+
+            agc = bool(updates.get("agc", current.agc))
+
+            attack_ms = float(updates.get("attack_ms", current.attack_ms))
+            attack_ms = float(np.clip(attack_ms, 1.0, 50.0))
+
+            release_ms = float(updates.get("release_ms", current.release_ms))
+            release_ms = float(np.clip(release_ms, 50.0, 1000.0))
+
+            noise_suppression = bool(updates.get("noise_suppression", current.noise_suppression))
+            speech_gate = bool(updates.get("speech_gate", current.speech_gate))
+            hum_filter = bool(updates.get("hum_filter", current.hum_filter))
+            limiter = bool(updates.get("limiter", current.limiter))
+            beam_clarity = bool(updates.get("beam_clarity", current.beam_clarity))
+            hifi_mode = bool(updates.get("hifi_mode", current.hifi_mode))
+            hifi_mic = str(updates.get("hifi_mic", current.hifi_mic))
+            if hifi_mic not in ALLOWED_HIFI_MICS:
+                hifi_mic = current.hifi_mic
+
+            angle = float(updates.get("angle", current.angle))
+            angle = float(np.clip(angle, -90.0, 90.0))
+            auto_beam = bool(updates.get("auto_beam", current.auto_beam))
+            monitor_on = bool(updates.get("monitor_on", current.monitor_on))
+            monitor_source = str(updates.get("monitor_source", current.monitor_source))
+            if monitor_source not in ALLOWED_MONITOR_SOURCES:
+                monitor_source = current.monitor_source
+            if hifi_mode:
+                monitor_on = False
+
+            self._settings = AudioSettings(
+                mode=mode,
+                gain_db=gain_db,
+                agc=agc,
+                attack_ms=attack_ms,
+                release_ms=release_ms,
+                noise_suppression=noise_suppression,
+                speech_gate=speech_gate,
+                hum_filter=hum_filter,
+                limiter=limiter,
+                beam_clarity=beam_clarity,
+                hifi_mode=hifi_mode,
+                hifi_mic=hifi_mic,
+                angle=angle,
+                auto_beam=auto_beam,
+                monitor_on=monitor_on,
+                monitor_source=monitor_source,
+                sample_rate=sample_rate,
+            )
+
+            if not auto_beam:
+                self._auto_angle_deg = angle
+            if not monitor_on or hifi_mode:
+                self._clear_monitor_queue_locked()
+
+            self._agc_mic1.update(
+                sample_rate=sample_rate,
+                attack_ms=attack_ms,
+                release_ms=release_ms,
+            )
+            self._agc_mic2.update(
+                sample_rate=sample_rate,
+                attack_ms=attack_ms,
+                release_ms=release_ms,
+            )
+            self._agc_beam.update(
+                sample_rate=sample_rate,
+                attack_ms=attack_ms,
+                release_ms=release_ms,
+            )
+
+        return self.get_settings()
+
+    def start_recording(self, source: str, duration_sec: float | None = None) -> dict[str, object]:
+        source = source.lower().strip()
+        if source not in ALLOWED_SOURCES:
+            raise ValueError("Invalid recording source")
+        if self._current_recording_status().recording:
+            raise RuntimeError("Recording is already active")
+
+        limit = None
+        if duration_sec is not None:
+            try:
+                duration_value = float(duration_sec)
+            except (TypeError, ValueError):
+                duration_value = 0.0
+            if duration_value > 0.0:
+                limit = float(np.clip(duration_value, 1.0, 3600.0))
+
+        with self._lock:
+            self._record_duration_limit_sec = limit
+            self._auto_stop_requested = False
+
+        settings = self.get_settings()
+        sample_rate = int(settings["sample_rate"])
+        hifi_mode = bool(settings.get("hifi_mode", False))
+        hifi_mic = str(settings.get("hifi_mic", "mic1"))
+        if hifi_mic not in ALLOWED_HIFI_MICS:
+            hifi_mic = "mic1"
+
+        if hifi_mode:
+            timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+            filename = self._recorder.start(
+                source="hifi_raw",
+                sample_rate=self.HARDWARE_SAMPLE_RATE,
+                channels=1,
+                filename=f"rec_{timestamp}_hifi_{hifi_mic}_48k.wav",
+            )
+            with self._lock:
+                self._compare_recorders = {}
+                self._compare_filenames = {}
+            return {
+                "source": "hifi_raw",
+                "filenames": [filename],
+                "duration_limit_sec": limit or 0.0,
+            }
+
+        if source == "hifi_raw":
+            source = hifi_mic
+
+        if source == "compare_all":
+            timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+            recorders = {
+                "mic1": WavRecorder(self._recordings_dir),
+                "mono_mix": WavRecorder(self._recordings_dir),
+                "beam": WavRecorder(self._recordings_dir),
+            }
+            filenames = {
+                "mic1": f"rec_{timestamp}_mic1.wav",
+                "mono_mix": f"rec_{timestamp}_mono_mix.wav",
+                "beam": f"rec_{timestamp}_beam.wav",
+            }
+            for key, recorder in recorders.items():
+                recorder.start(
+                    source=key,
+                    sample_rate=sample_rate,
+                    channels=1,
+                    filename=filenames[key],
+                )
+            with self._lock:
+                self._compare_recorders = recorders
+                self._compare_filenames = filenames
+            return {
+                "source": source,
+                "filenames": [filenames["mic1"], filenames["mono_mix"], filenames["beam"]],
+                "duration_limit_sec": limit or 0.0,
+            }
+
+        channels = 1
+        filename = self._recorder.start(
+            source=source,
+            sample_rate=sample_rate,
+            channels=channels,
+        )
+        with self._lock:
+            self._compare_recorders = {}
+            self._compare_filenames = {}
+        return {
+            "source": source,
+            "filenames": [filename],
+            "duration_limit_sec": limit or 0.0,
+        }
+
+    def stop_recording(self) -> RecorderStatus:
+        with self._lock:
+            compare_recorders = self._compare_recorders
+            compare_filenames = dict(self._compare_filenames)
+            self._compare_recorders = {}
+            self._compare_filenames = {}
+            self._record_duration_limit_sec = None
+            self._auto_stop_requested = False
+
+        if compare_recorders:
+            max_duration = 0.0
+            sample_rate = self._settings.sample_rate
+            for recorder in compare_recorders.values():
+                status = recorder.stop()
+                max_duration = max(max_duration, status.duration_sec)
+                sample_rate = status.sample_rate
+            return RecorderStatus(
+                recording=False,
+                filename=",".join(compare_filenames.values()),
+                duration_sec=max_duration,
+                channels=1,
+                sample_rate=sample_rate,
+                source="compare_all",
+            )
+
+        return self._recorder.stop()
+
+    def get_recording_status(self) -> RecorderStatus:
+        return self._current_recording_status()
+
+    def _current_recording_status(self) -> RecorderStatus:
+        with self._lock:
+            compare_recorders = dict(self._compare_recorders)
+            compare_filenames = dict(self._compare_filenames)
+
+        if compare_recorders:
+            statuses = [recorder.get_status() for recorder in compare_recorders.values()]
+            is_recording = any(status.recording for status in statuses)
+            duration = max((status.duration_sec for status in statuses), default=0.0)
+            sample_rate = statuses[0].sample_rate if statuses else self._settings.sample_rate
+            return RecorderStatus(
+                recording=is_recording,
+                filename=",".join(compare_filenames.values()),
+                duration_sec=duration,
+                channels=1,
+                sample_rate=sample_rate,
+                source="compare_all",
+            )
+
+        return self._recorder.get_status()
+
+    def _open_stream(self) -> None:
+        device_idx, device_name, input_channels = self._resolve_input_device()
+
+        stream = sd.InputStream(
+            samplerate=self.HARDWARE_SAMPLE_RATE,
+            device=device_idx,
+            channels=input_channels,
+            dtype="int32",
+            blocksize=self.CHUNK_SIZE,
+            callback=self._audio_callback,
+        )
+        stream.start()
+
+        with self._lock:
+            self._stream = stream
+            self._stream_channels = input_channels
+            self._input_device_name = device_name
+            self._running = True
+            self._startup_deadline = time.monotonic() + self.STARTUP_IGNORE_SECONDS
+
+    def _restart_stream(self) -> None:
+        with self._lock:
+            stream = self._stream
+            self._stream = None
+            self._running = False
+
+        if stream is not None:
+            stream.stop()
+            stream.close()
+
+        self._open_stream()
+
+    def _audio_callback(self, indata: np.ndarray, frames: int, time_info: dict, status: sd.CallbackFlags) -> None:
+        del frames, time_info
+        if status:
+            return
+
+        with self._lock:
+            if not self._running:
+                return
+            if time.monotonic() < self._startup_deadline:
+                return
+            settings = self._settings
+            stream_channels = self._stream_channels
+
+        pcm32 = indata.astype(np.int32, copy=False)
+        pcm24 = pcm32 >> 8
+        normalized = np.clip(pcm24.astype(np.float32) / 8388608.0, -1.0, 1.0)
+
+        mic1_raw = normalized[:, 0]
+        mic2_raw = normalized[:, 1] if stream_channels > 1 else mic1_raw.copy()
+        processing_rate = self.HARDWARE_SAMPLE_RATE if settings.hifi_mode else settings.sample_rate
+
+        if (not settings.hifi_mode) and settings.sample_rate != self.HARDWARE_SAMPLE_RATE:
+            mic1_raw = self._resample_audio(mic1_raw, self.HARDWARE_SAMPLE_RATE, settings.sample_rate)
+            mic2_raw = self._resample_audio(mic2_raw, self.HARDWARE_SAMPLE_RATE, settings.sample_rate)
+            common_len = min(mic1_raw.size, mic2_raw.size)
+            mic1_raw = mic1_raw[:common_len]
+            mic2_raw = mic2_raw[:common_len]
+
+        if settings.hifi_mode:
+            hifi_mic = settings.hifi_mic if settings.hifi_mic in ALLOWED_HIFI_MICS else "mic1"
+            hifi_signal = mic1_raw if hifi_mic == "mic1" else mic2_raw
+            mic1_proc = hifi_signal
+            mic2_proc = mic2_raw if hifi_mic == "mic1" else mic1_raw
+            mono_mix_proc = hifi_signal
+            beam_proc = hifi_signal
+            angle_to_use = 0.0
+            speech_active = True
+            gate_open = True
+        else:
+            gain_linear = 10.0 ** (settings.gain_db / 20.0)
+            mic1_base = np.clip(mic1_raw * gain_linear, -1.0, 1.0)
+            mic2_base = np.clip(mic2_raw * gain_linear, -1.0, 1.0)
+            mono_base = np.clip(0.5 * (mic1_base + mic2_base), -1.0, 1.0)
+            angle_to_use = settings.angle
+            speech_detected = False
+            if settings.mode == "beamforming" and settings.auto_beam:
+                latest_done: Future | None = None
+                pending: deque[Future] = deque()
+                while self._angle_futures:
+                    fut = self._angle_futures.popleft()
+                    if fut.done():
+                        latest_done = fut
+                    else:
+                        pending.append(fut)
+                self._angle_futures = pending
+
+                if latest_done is not None:
+                    try:
+                        angle_est, speech_est, noise_floor = latest_done.result(timeout=0)
+                        self._auto_angle_deg = float(angle_est)
+                        self._last_speech_detected = bool(speech_est)
+                        self._noise_floor = max(float(noise_floor), 1e-9)
+                    except Exception:
+                        pass
+
+                self._angle_update_counter += 1
+                if (
+                    self._angle_update_counter >= self._angle_update_interval
+                    and len(self._angle_futures) < self._max_pending_angle_jobs
+                    and self._angle_executor is not None
+                ):
+                    self._angle_update_counter = 0
+                    try:
+                        fut = self._angle_executor.submit(
+                            _estimate_speech_angle_job,
+                            mic1_base.astype(np.float32, copy=True),
+                            mic2_base.astype(np.float32, copy=True),
+                            int(processing_rate),
+                            float(self.MIC_SPACING),
+                            float(self._auto_angle_deg),
+                            float(self._noise_floor),
+                        )
+                        self._angle_futures.append(fut)
+                    except Exception:
+                        pass
+
+                angle_to_use = self._auto_angle_deg
+                speech_detected = self._last_speech_detected
+            else:
+                while self._angle_futures:
+                    self._angle_futures.popleft().cancel()
+                if not settings.auto_beam:
+                    self._auto_angle_deg = settings.angle
+                speech_detected = False
+
+            speech_presence = self._estimate_speech_presence_fast(mic1_base, mic2_base)
+            speech_active = bool(speech_detected or speech_presence)
+
+            beam_proc = beamform_delay_and_sum(
+                mic1_base,
+                mic2_base,
+                angle_deg=angle_to_use,
+                sample_rate=processing_rate,
+                mic_spacing=self.MIC_SPACING,
+            )
+            if settings.beam_clarity:
+                beam_proc = self._apply_beam_clarity_blend(beam_proc, mono_base)
+            gate_open, gate_gain = self._update_speech_gate(
+                speech_detected=speech_active,
+                sample_rate=processing_rate,
+                chunk_len=max(beam_proc.size, 1),
+                enabled=settings.speech_gate,
+            )
+
+            if settings.agc:
+                mic1_proc = self._agc_mic1.process(mic1_base, speech_hint=gate_open)
+                mic2_proc = self._agc_mic2.process(mic2_base, speech_hint=gate_open)
+                beam_proc = self._agc_beam.process(beam_proc, speech_hint=gate_open)
+            else:
+                mic1_proc = mic1_base
+                mic2_proc = mic2_base
+            mono_mix_proc = np.clip(0.5 * (mic1_proc + mic2_proc), -1.0, 1.0)
+
+            if settings.hum_filter:
+                mic1_proc = self._apply_hum_filter(mic1_proc, processing_rate, "mic1")
+                mic2_proc = self._apply_hum_filter(mic2_proc, processing_rate, "mic2")
+                beam_proc = self._apply_hum_filter(beam_proc, processing_rate, "beam")
+                mono_mix_proc = np.clip(0.5 * (mic1_proc + mic2_proc), -1.0, 1.0)
+                mono_mix_proc = self._apply_hum_filter(mono_mix_proc, processing_rate, "mono_mix")
+
+            if settings.noise_suppression:
+                mic1_proc = self._apply_noise_suppression(mic1_proc, "mic1", speech_active=gate_open)
+                mic2_proc = self._apply_noise_suppression(mic2_proc, "mic2", speech_active=gate_open)
+                mono_mix_proc = self._apply_noise_suppression(mono_mix_proc, "mono_mix", speech_active=gate_open)
+                beam_proc = self._apply_noise_suppression(beam_proc, "beam", speech_active=gate_open)
+
+            if settings.speech_gate:
+                mic1_proc = np.clip(mic1_proc * gate_gain, -1.0, 1.0).astype(np.float32, copy=False)
+                mic2_proc = np.clip(mic2_proc * gate_gain, -1.0, 1.0).astype(np.float32, copy=False)
+                mono_mix_proc = np.clip(mono_mix_proc * gate_gain, -1.0, 1.0).astype(np.float32, copy=False)
+                beam_proc = np.clip(beam_proc * gate_gain, -1.0, 1.0).astype(np.float32, copy=False)
+
+            if settings.limiter:
+                mic1_proc = self._apply_limiter(mic1_proc)
+                mic2_proc = self._apply_limiter(mic2_proc)
+                mono_mix_proc = self._apply_limiter(mono_mix_proc)
+                beam_proc = self._apply_limiter(beam_proc)
+
+        rec_status = self._current_recording_status()
+        if rec_status.recording:
+            self._write_recording_chunk(rec_status.source, mic1_raw, mic2_raw, mic1_proc, mono_mix_proc, beam_proc)
+            rec_status = self._current_recording_status()
+
+        should_auto_stop = False
+        with self._lock:
+            duration_limit = self._record_duration_limit_sec
+            if (
+                rec_status.recording
+                and duration_limit is not None
+                and rec_status.duration_sec >= duration_limit
+                and not self._auto_stop_requested
+            ):
+                self._auto_stop_requested = True
+                should_auto_stop = True
+
+        if should_auto_stop:
+            threading.Thread(target=self.stop_recording, daemon=True).start()
+
+        show_beam = (settings.mode == "beamforming") and (not settings.hifi_mode)
+        show_mono_mix = (settings.mode == "mono_mix") and (not settings.hifi_mode)
+        show_mic2 = not settings.hifi_mode
+
+        if settings.monitor_on:
+            monitor_map = {
+                "mic1": mic1_proc,
+                "mic2": mic2_proc,
+                "mono_mix": mono_mix_proc,
+                "beam": beam_proc,
+            }
+            monitor_signal = monitor_map.get(settings.monitor_source, beam_proc)
+            with self._lock:
+                self._push_monitor_chunk_locked(monitor_signal, processing_rate)
+
+        frame = {
+            "mic1": mic1_proc.astype(np.float32, copy=False),
+            "mic2": mic2_proc.astype(np.float32, copy=False),
+            "beam": beam_proc.astype(np.float32, copy=False),
+            "mono_mix": mono_mix_proc.astype(np.float32, copy=False),
+            "show_mic2": show_mic2,
+            "show_beam": show_beam,
+            "show_mono_mix": show_mono_mix,
+            "beam_angle_deg": float(angle_to_use),
+            "auto_beam": settings.auto_beam,
+            "speech_detected": speech_active,
+            "speech_gate_open": gate_open,
+            "hifi_mode": settings.hifi_mode,
+            "monitor_on": settings.monitor_on,
+            "monitor_source": settings.monitor_source,
+            "recording": rec_status.recording,
+            "rec_duration": rec_status.duration_sec,
+        }
+        with self._lock:
+            self._latest_frame = frame
+
+    def _write_recording_chunk(
+        self,
+        source: str,
+        mic1_raw: np.ndarray,
+        mic2_raw: np.ndarray,
+        mic1_proc: np.ndarray,
+        mono_mix_proc: np.ndarray,
+        beam_proc: np.ndarray,
+    ) -> None:
+        if source == "compare_all":
+            with self._lock:
+                mic1_rec = self._compare_recorders.get("mic1")
+                mix_rec = self._compare_recorders.get("mono_mix")
+                beam_rec = self._compare_recorders.get("beam")
+            if mic1_rec is not None:
+                mic1_rec.write(mic1_proc)
+            if mix_rec is not None:
+                mix_rec.write(mono_mix_proc)
+            if beam_rec is not None:
+                beam_rec.write(beam_proc)
+            return
+
+        if source == "mic1":
+            self._recorder.write(mic1_raw)
+            return
+        if source == "mic2":
+            self._recorder.write(mic2_raw)
+            return
+        if source == "mono_mix":
+            self._recorder.write(mono_mix_proc)
+            return
+        if source == "beam":
+            self._recorder.write(beam_proc)
+            return
+        if source == "hifi_raw":
+            with self._lock:
+                hifi_mic = self._settings.hifi_mic
+            if hifi_mic == "mic2":
+                self._recorder.write(mic2_raw)
+            else:
+                self._recorder.write(mic1_raw)
+            return
+
+    def _apply_beam_clarity_blend(self, beam: np.ndarray, mono: np.ndarray) -> np.ndarray:
+        if beam.size == 0 or mono.size == 0:
+            return beam.astype(np.float32, copy=False)
+        n = min(beam.size, mono.size)
+        if n <= 0:
+            return beam.astype(np.float32, copy=False)
+        blend = float(np.clip(self.BEAM_CLARITY_BLEND, 0.0, 0.5))
+        out = ((1.0 - blend) * beam[:n] + blend * mono[:n]).astype(np.float32, copy=False)
+
+        prev = float(self._presence_prev.get("beam", 0.0))
+        hp = np.empty_like(out)
+        hp[0] = out[0] - prev
+        if out.size > 1:
+            hp[1:] = out[1:] - out[:-1]
+        self._presence_prev["beam"] = float(out[-1])
+
+        out = out + float(self.BEAM_PRESENCE_BOOST) * hp
+        return np.clip(out, -1.0, 1.0).astype(np.float32, copy=False)
+
+    def _update_speech_gate(
+        self,
+        *,
+        speech_detected: bool,
+        sample_rate: int,
+        chunk_len: int,
+        enabled: bool,
+    ) -> tuple[bool, float]:
+        if not enabled:
+            self._speech_gate_hold_chunks = 0
+            self._speech_gate_gain = 1.0
+            return True, 1.0
+
+        chunk_seconds = max(float(chunk_len) / float(sample_rate), 1e-6)
+        hold_chunks = max(1, int(round(self.SPEECH_GATE_HOLD_SECONDS / chunk_seconds)))
+        if speech_detected:
+            self._speech_gate_hold_chunks = hold_chunks
+        elif self._speech_gate_hold_chunks > 0:
+            self._speech_gate_hold_chunks -= 1
+
+        gate_open = bool(speech_detected or self._speech_gate_hold_chunks > 0)
+        target_gain = 1.0 if gate_open else self.SPEECH_GATE_FLOOR
+        tau = self.SPEECH_GATE_ATTACK_SECONDS if target_gain > self._speech_gate_gain else self.SPEECH_GATE_RELEASE_SECONDS
+        coeff = np.exp(-chunk_seconds / max(tau, 1e-4))
+        self._speech_gate_gain = float(coeff * self._speech_gate_gain + (1.0 - coeff) * target_gain)
+        return gate_open, self._speech_gate_gain
+
+    def _apply_noise_suppression(self, audio: np.ndarray, key: str, *, speech_active: bool) -> np.ndarray:
+        if audio.size == 0:
+            return audio.astype(np.float32, copy=False)
+
+        power = float(np.mean(audio * audio, dtype=np.float64))
+        prev_noise = float(self._ns_noise_power.get(key, 1e-7))
+        alpha = 0.01 if speech_active else 0.07
+        noise = (1.0 - alpha) * prev_noise + alpha * power
+        noise = max(noise, 1e-9)
+        self._ns_noise_power[key] = noise
+
+        ratio = noise / max(power, 1e-9)
+        if speech_active:
+            strength = self.NOISE_SUPPRESS_OPEN_STRENGTH
+            floor = self.NOISE_SUPPRESS_OPEN_FLOOR
+        else:
+            strength = self.NOISE_SUPPRESS_CLOSED_STRENGTH
+            floor = self.NOISE_SUPPRESS_CLOSED_FLOOR
+
+        gain = float(np.clip(1.0 - strength * ratio, floor, 1.0))
+        out = audio.astype(np.float32, copy=False) * gain
+        return np.clip(out, -1.0, 1.0).astype(np.float32, copy=False)
+
+    def _apply_hum_filter(self, audio: np.ndarray, sample_rate: int, channel_key: str) -> np.ndarray:
+        if audio.size == 0:
+            return audio.astype(np.float32, copy=False)
+
+        out = audio.astype(np.float32, copy=False)
+        state_key = (channel_key, int(sample_rate))
+
+        sos = self._get_hpf_sos(sample_rate)
+        if sos is not None:
+            zi = self._hpf_state.get(state_key)
+            if zi is None or zi.shape != (sos.shape[0], 2):
+                zi = np.zeros((sos.shape[0], 2), dtype=np.float32)
+            out, zi_new = signal.sosfilt(sos, out, zi=zi)
+            self._hpf_state[state_key] = zi_new.astype(np.float32, copy=False)
+            out = out.astype(np.float32, copy=False)
+
+        notch = self._get_notch_coeff(sample_rate)
+        if notch is not None:
+            b, a = notch
+            zi = self._notch_state.get(state_key)
+            expected = max(len(a), len(b)) - 1
+            if zi is None or zi.size != expected:
+                zi = np.zeros(expected, dtype=np.float32)
+            out, zi_new = signal.lfilter(b, a, out, zi=zi)
+            self._notch_state[state_key] = zi_new.astype(np.float32, copy=False)
+            out = out.astype(np.float32, copy=False)
+
+        return out
+
+    def _get_hpf_sos(self, sample_rate: int) -> np.ndarray | None:
+        cached = self._hpf_sos_cache.get(sample_rate, "__missing__")
+        if isinstance(cached, np.ndarray):
+            return cached
+        if cached is None:
+            return None
+        cutoff = min(self.HUM_HPF_CUTOFF_HZ, 0.45 * sample_rate)
+        if cutoff < 20.0:
+            self._hpf_sos_cache[sample_rate] = None
+            return None
+        try:
+            sos = signal.butter(2, cutoff, btype="highpass", fs=sample_rate, output="sos")
+        except ValueError:
+            self._hpf_sos_cache[sample_rate] = None
+            return None
+        self._hpf_sos_cache[sample_rate] = sos
+        return sos
+
+    def _get_notch_coeff(self, sample_rate: int) -> tuple[np.ndarray, np.ndarray] | None:
+        cached = self._notch_cache.get(sample_rate, "__missing__")
+        if isinstance(cached, tuple):
+            return cached
+        if cached is None:
+            return None
+        nyquist = 0.5 * sample_rate
+        if nyquist <= self.HUM_NOTCH_HZ * 1.2:
+            self._notch_cache[sample_rate] = None
+            return None
+        w0 = self.HUM_NOTCH_HZ / nyquist
+        try:
+            b, a = signal.iirnotch(w0, self.HUM_NOTCH_Q)
+        except ValueError:
+            self._notch_cache[sample_rate] = None
+            return None
+        coeff = (b.astype(np.float32), a.astype(np.float32))
+        self._notch_cache[sample_rate] = coeff
+        return coeff
+
+    @staticmethod
+    def _apply_limiter(audio: np.ndarray) -> np.ndarray:
+        if audio.size == 0:
+            return audio.astype(np.float32, copy=False)
+        x = np.clip(audio.astype(np.float32, copy=False), -1.0, 1.0)
+        threshold = 0.82
+        abs_x = np.abs(x)
+        if not np.any(abs_x > threshold):
+            return x.astype(np.float32, copy=False)
+
+        out = x.copy()
+        over = abs_x > threshold
+        norm = (abs_x[over] - threshold) / max(1.0 - threshold, 1e-6)
+        compressed = threshold + (1.0 - threshold) * (np.tanh(2.2 * norm) / np.tanh(2.2))
+        out[over] = np.sign(x[over]) * compressed
+        return out.astype(np.float32, copy=False)
+
+    @staticmethod
+    def _downsample_for_ui(audio: np.ndarray, target_points: int = 320) -> np.ndarray:
+        if audio.size <= target_points:
+            return audio.astype(np.float32, copy=False)
+
+        step = int(np.ceil(audio.size / target_points))
+        sampled = audio[::step]
+        if sampled.size > target_points:
+            sampled = sampled[:target_points]
+        return sampled.astype(np.float32, copy=False)
+
+    @staticmethod
+    def _rms(audio: np.ndarray) -> float:
+        if audio.size == 0:
+            return 0.0
+        return float(np.sqrt(np.mean(np.square(audio), dtype=np.float64)))
+
+    @staticmethod
+    def _resample_audio(audio: np.ndarray, source_rate: int, target_rate: int) -> np.ndarray:
+        if audio.size == 0 or source_rate == target_rate:
+            return audio.astype(np.float32, copy=False)
+
+        if source_rate == 48000 and target_rate == 16000:
+            usable = audio.size - (audio.size % 3)
+            if usable <= 0:
+                return audio.astype(np.float32, copy=False)
+            # Fast decimation-by-3 tuned for speech workloads on Zero 2W.
+            grouped = audio[:usable].reshape(-1, 3)
+            return grouped.mean(axis=1, dtype=np.float32).astype(np.float32, copy=False)
+
+        gcd = math.gcd(source_rate, target_rate)
+        up = target_rate // gcd
+        down = source_rate // gcd
+        resampled = signal.resample_poly(audio, up=up, down=down)
+        return resampled.astype(np.float32, copy=False)
+
+    def _resolve_input_device(self) -> tuple[int | None, str, int]:
+        try:
+            default_input = sd.default.device[0]
+            if isinstance(default_input, int) and default_input >= 0:
+                info = sd.query_devices(default_input, "input")
+                max_inputs = int(info.get("max_input_channels", 0))
+                if max_inputs > 0:
+                    return int(default_input), str(info.get("name", f"device-{default_input}")), min(self.CHANNELS, max_inputs)
+        except Exception:
+            pass
+
+        all_devices = sd.query_devices()
+        preferred: tuple[int, dict] | None = None
+        fallback: tuple[int, dict] | None = None
+
+        for idx, raw_info in enumerate(all_devices):
+            info = dict(raw_info)
+            max_inputs = int(info.get("max_input_channels", 0))
+            if max_inputs <= 0:
+                continue
+
+            device = (idx, info)
+            name = str(info.get("name", "")).lower()
+            if max_inputs >= self.CHANNELS and any(tag in name for tag in ("google", "voicehat", "i2s", "mic")):
+                preferred = device
+                break
+            if max_inputs >= self.CHANNELS and fallback is None:
+                fallback = device
+            if fallback is None:
+                fallback = device
+
+        chosen = preferred or fallback
+        if chosen is None:
+            raise RuntimeError("No audio input device available")
+
+        idx, info = chosen
+        max_inputs = int(info.get("max_input_channels", 1))
+        channels = min(self.CHANNELS, max_inputs)
+        return int(idx), str(info.get("name", f"device-{idx}")), channels
+
+    def _push_monitor_chunk_locked(self, chunk: np.ndarray, sample_rate: int) -> None:
+        samples = chunk.astype(np.float32, copy=True)
+        if samples.size == 0:
+            return
+        self._monitor_queue.append(samples)
+        self._monitor_queue_samples += samples.size
+        max_samples = max(sample_rate * 2, 2048)
+        while self._monitor_queue_samples > max_samples and self._monitor_queue:
+            dropped = self._monitor_queue.popleft()
+            self._monitor_queue_samples -= dropped.size
+
+    def _pop_monitor_chunk(self, max_samples: int) -> np.ndarray | None:
+        if self._monitor_queue_samples <= 0 or not self._monitor_queue:
+            return None
+
+        take: list[np.ndarray] = []
+        collected = 0
+        while self._monitor_queue and collected < max_samples:
+            chunk = self._monitor_queue[0]
+            remaining = max_samples - collected
+            if chunk.size <= remaining:
+                take.append(chunk)
+                collected += chunk.size
+                self._monitor_queue.popleft()
+            else:
+                take.append(chunk[:remaining])
+                self._monitor_queue[0] = chunk[remaining:]
+                collected += remaining
+                break
+
+        self._monitor_queue_samples -= collected
+        if not take:
+            return None
+        if len(take) == 1:
+            return take[0]
+        return np.concatenate(take).astype(np.float32, copy=False)
+
+    @staticmethod
+    def _encode_pcm16_base64(audio: np.ndarray) -> str:
+        pcm16 = (np.clip(audio, -1.0, 1.0) * 32767.0).astype(np.int16)
+        return base64.b64encode(pcm16.tobytes()).decode("ascii")
+
+    def _clear_monitor_queue_locked(self) -> None:
+        self._monitor_queue.clear()
+        self._monitor_queue_samples = 0
+
+    def _estimate_speech_presence_fast(self, mic1: np.ndarray, mic2: np.ndarray) -> bool:
+        if mic1.size == 0 or mic2.size == 0:
+            return False
+        energy = 0.5 * (np.mean(mic1 * mic1) + np.mean(mic2 * mic2))
+        energy = float(max(energy, 1e-10))
+
+        if energy < self._vad_noise_floor:
+            alpha = 0.05
+        else:
+            alpha = 0.004
+        self._vad_noise_floor = (1.0 - alpha) * self._vad_noise_floor + alpha * energy
+        threshold = max(2.2e-7, self._vad_noise_floor * 1.9)
+        return bool(energy > threshold)
+
+    @staticmethod
+    def _gcc_phat(sig: np.ndarray, refsig: np.ndarray, sample_rate: int, max_tau: float) -> float:
+        n = sig.size + refsig.size
+        sig_fft = np.fft.rfft(sig, n=n)
+        ref_fft = np.fft.rfft(refsig, n=n)
+        cross = sig_fft * np.conj(ref_fft)
+        denom = np.abs(cross)
+        cross = cross / np.maximum(denom, 1e-10)
+        cc = np.fft.irfft(cross, n=n)
+
+        max_shift = int(min(n // 2, max_tau * sample_rate))
+        if max_shift <= 0:
+            return 0.0
+
+        cc_window = np.concatenate((cc[-max_shift:], cc[: max_shift + 1]))
+        shift = int(np.argmax(np.abs(cc_window)) - max_shift)
+        return float(shift) / float(sample_rate)
+
+    def _estimate_speech_angle(self, mic1: np.ndarray, mic2: np.ndarray, sample_rate: int) -> tuple[float, bool]:
+        if mic1.size < 64 or mic2.size < 64:
+            return self._auto_angle_deg, False
+
+        high = min(3400.0, 0.45 * sample_rate)
+        low = min(300.0, high * 0.5)
+        if high <= low + 1.0:
+            return self._auto_angle_deg, False
+
+        sos = self._get_speech_sos(sample_rate, low, high)
+        if sos is None:
+            return self._auto_angle_deg, False
+        speech1 = signal.sosfilt(sos, mic1).astype(np.float32, copy=False)
+        speech2 = signal.sosfilt(sos, mic2).astype(np.float32, copy=False)
+
+        speech_energy = 0.5 * (np.mean(speech1 * speech1) + np.mean(speech2 * speech2))
+        full_energy = 0.5 * (np.mean(mic1 * mic1) + np.mean(mic2 * mic2))
+        speech_ratio = float(speech_energy / max(full_energy, 1e-12))
+
+        self._noise_floor = 0.995 * self._noise_floor + 0.005 * float(speech_energy)
+        speech_threshold = max(2.5e-7, self._noise_floor * 2.0)
+        speech_detected = bool(speech_energy > speech_threshold and speech_ratio > 0.08)
+        if not speech_detected:
+            return self._auto_angle_deg, False
+
+        max_tau = self.MIC_SPACING / 343.0
+        tau = self._gcc_phat(speech1, speech2, sample_rate, max_tau=max_tau)
+        sin_theta = np.clip((tau * 343.0) / self.MIC_SPACING, -1.0, 1.0)
+        raw_angle = float(np.rad2deg(np.arcsin(sin_theta)))
+        raw_angle = float(np.clip(raw_angle, -90.0, 90.0))
+
+        self._auto_angle_deg = 0.88 * self._auto_angle_deg + 0.12 * raw_angle
+        return self._auto_angle_deg, True
+
+    def _get_speech_sos(self, sample_rate: int, low: float, high: float) -> np.ndarray | None:
+        cached = self._speech_sos_cache.get(sample_rate, "__missing__")
+        if isinstance(cached, np.ndarray):
+            return cached
+        if cached is None:
+            return None
+        try:
+            sos = signal.butter(4, [low, high], btype="bandpass", fs=sample_rate, output="sos")
+        except ValueError:
+            self._speech_sos_cache[sample_rate] = None
+            return None
+        self._speech_sos_cache[sample_rate] = sos
+        return sos
+
+    @staticmethod
+    def _make_empty_frame() -> dict[str, object]:
+        empty = np.empty(0, dtype=np.float32)
+        return {
+            "mic1": empty,
+            "mic2": empty,
+            "beam": empty,
+            "mono_mix": empty,
+            "show_mic2": True,
+            "show_beam": False,
+            "show_mono_mix": False,
+            "beam_angle_deg": 0.0,
+            "auto_beam": True,
+            "speech_detected": False,
+            "speech_gate_open": True,
+            "hifi_mode": False,
+            "monitor_on": False,
+            "monitor_source": "beam",
+            "recording": False,
+            "rec_duration": 0.0,
+        }

+ 42 - 0
beamforming.py

@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+import numpy as np
+
+SPEED_OF_SOUND = 343.0
+
+
+def _fractional_delay(signal: np.ndarray, delay_samples: float) -> np.ndarray:
+    """Apply fractional sample delay using linear interpolation."""
+    if signal.size == 0:
+        return signal.astype(np.float32, copy=False)
+
+    x = np.arange(signal.size, dtype=np.float32)
+    shifted_x = x - np.float32(delay_samples)
+    delayed = np.interp(shifted_x, x, signal, left=0.0, right=0.0)
+    return delayed.astype(np.float32)
+
+
+def beamform_delay_and_sum(
+    mic1_data: np.ndarray,
+    mic2_data: np.ndarray,
+    angle_deg: float,
+    sample_rate: int,
+    mic_spacing: float,
+    speed_of_sound: float = SPEED_OF_SOUND,
+) -> np.ndarray:
+    """Delay-and-sum beamforming for two microphones arranged in a line."""
+    if mic1_data.shape != mic2_data.shape:
+        raise ValueError("mic1_data and mic2_data must have the same shape")
+
+    mic1 = mic1_data.astype(np.float32, copy=False)
+    mic2 = mic2_data.astype(np.float32, copy=False)
+
+    angle_rad = np.deg2rad(float(angle_deg))
+    delay_seconds = float(mic_spacing) * np.sin(angle_rad) / float(speed_of_sound)
+    delay_samples = delay_seconds * float(sample_rate)
+
+    aligned_mic1 = _fractional_delay(mic1, delay_samples / 2.0)
+    aligned_mic2 = _fractional_delay(mic2, -delay_samples / 2.0)
+
+    output = (aligned_mic1 + aligned_mic2) * 0.5
+    return np.clip(output, -1.0, 1.0).astype(np.float32)

+ 16 - 0
deploy/mic_system.service

@@ -0,0 +1,16 @@
+[Unit]
+Description=Mic System Web App
+After=network.target sound.target
+
+[Service]
+Type=simple
+User=pch
+WorkingDirectory=/home/pch/mic_system
+Environment=MIC_SYSTEM_HOST=0.0.0.0
+Environment=MIC_SYSTEM_PORT=5000
+ExecStart=/home/pch/mic_system/.venv/bin/python /home/pch/mic_system/app.py
+Restart=always
+RestartSec=2
+
+[Install]
+WantedBy=multi-user.target

+ 167 - 0
index.html

@@ -0,0 +1,167 @@
+<!doctype html>
+<html lang="pl">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Mic System - RPi Zero 2W</title>
+    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
+</head>
+<body>
+    <div class="bg-glow bg-glow-a"></div>
+    <div class="bg-glow bg-glow-b"></div>
+
+    <main class="layout">
+        <header class="header card">
+            <div>
+                <h1>System Mikrofonowy</h1>
+                <p>RPi Zero 2W + 2x INMP441 + WebSocket</p>
+            </div>
+            <div class="status-block">
+                <div id="serverStatus" class="pill">Laczenie...</div>
+                <div id="audioStatus" class="pill">Audio: start</div>
+            </div>
+        </header>
+
+        <section class="wave-panel card">
+            <div class="wave-grid">
+                <article class="wave-item">
+                    <div class="wave-title">Mikrofon 1 (L)</div>
+                    <canvas id="waveMic1" width="640" height="160"></canvas>
+                    <div class="vu-wrap">
+                        <div class="vu-label">VU</div>
+                        <div class="vu-track"><div id="vuMic1" class="vu-fill mic1"></div></div>
+                    </div>
+                </article>
+
+                <article class="wave-item">
+                    <div class="wave-title">Mikrofon 2 (R)</div>
+                    <canvas id="waveMic2" width="640" height="160"></canvas>
+                    <div class="vu-wrap">
+                        <div class="vu-label">VU</div>
+                        <div class="vu-track"><div id="vuMic2" class="vu-fill mic2"></div></div>
+                    </div>
+                </article>
+
+                <article class="wave-item" id="beamBlock">
+                    <div class="wave-title">Beamforming</div>
+                    <canvas id="waveBeam" width="640" height="160"></canvas>
+                    <div class="vu-wrap">
+                        <div class="vu-label">VU</div>
+                        <div class="vu-track"><div id="vuBeam" class="vu-fill beam"></div></div>
+                    </div>
+                </article>
+            </div>
+        </section>
+
+        <section class="controls card">
+            <h2>Ustawienia</h2>
+
+            <div class="group">
+                <label>Tryb mikrofonow</label>
+                <div class="radio-row">
+                    <label><input type="radio" name="mode" value="mic1"> Mikrofon 1 (L) - mono</label>
+                    <label><input type="radio" name="mode" value="mic2"> Mikrofon 2 (R) - mono</label>
+                    <label><input type="radio" name="mode" value="mono_mix"> Mono mix (L+R)</label>
+                    <label><input type="radio" name="mode" value="beamforming" checked> Beamforming (mono)</label>
+                </div>
+            </div>
+
+            <div class="row two-col">
+                <div class="group">
+                    <label for="gainDb">Gain bazowy: <span id="gainValue">0</span> dB</label>
+                    <input id="gainDb" type="range" min="0" max="30" value="0" step="1">
+                </div>
+
+                <div class="group">
+                    <label for="sampleRate">Sample rate</label>
+                    <select id="sampleRate">
+                        <option value="16000" selected>16000 Hz</option>
+                        <option value="22050">22050 Hz</option>
+                        <option value="44100">44100 Hz</option>
+                    </select>
+                </div>
+            </div>
+
+            <div class="group">
+                <label><input id="agcEnabled" type="checkbox" checked> Auto gain + redukcja szumu</label>
+            </div>
+
+            <div id="agcControls" class="row two-col">
+                <div class="group">
+                    <label for="attackMs">Attack: <span id="attackValue">8</span> ms</label>
+                    <input id="attackMs" type="range" min="1" max="50" value="8" step="1">
+                </div>
+                <div class="group">
+                    <label for="releaseMs">Release: <span id="releaseValue">260</span> ms</label>
+                    <input id="releaseMs" type="range" min="50" max="1000" value="260" step="10">
+                </div>
+            </div>
+
+            <div id="beamControls" class="group hidden">
+                <label><input id="beamAuto" type="checkbox" checked> Auto beamforming (priorytet mowy)</label>
+                <label>Kierunek aktywny: <span id="autoAngleValue">0</span> deg (<span id="speechState">cisza</span>)</label>
+                <div id="manualBeamRow">
+                    <label for="beamAngle">Kierunek reczny: <span id="angleValue">0</span> deg</label>
+                    <input id="beamAngle" type="range" min="-90" max="90" value="0" step="1">
+                </div>
+            </div>
+
+            <div class="group">
+                <label><input id="monitorOn" type="checkbox"> Podsluch live</label>
+                <label for="monitorSource">Zrodlo podsluchu</label>
+                <select id="monitorSource">
+                    <option value="beam" selected>Beamforming (mono)</option>
+                    <option value="mono_mix">Mono mix (L+R)</option>
+                    <option value="mic1">Mikrofon 1 (L)</option>
+                    <option value="mic2">Mikrofon 2 (R)</option>
+                </select>
+            </div>
+        </section>
+
+        <section class="recording card">
+            <h2>Nagrywanie</h2>
+            <div class="record-row">
+                <button id="recordBtn" class="record-btn">RECORD</button>
+                <div id="recordTimer" class="timer">00:00:00</div>
+            </div>
+
+            <div class="group">
+                <label for="recordSource">Zrodlo nagrywania</label>
+                <select id="recordSource">
+                    <option value="mic1">Mikrofon 1 (raw)</option>
+                    <option value="mic2">Mikrofon 2 (raw)</option>
+                    <option value="mono_mix">Mono mix (L+R)</option>
+                    <option value="beam">Beamforming (mono)</option>
+                    <option value="compare_all">Porownanie: mic1 + mono_mix + beam</option>
+                </select>
+            </div>
+
+            <div class="group">
+                <label for="recordDurationSec">Czas nagrania [s] (0 = recznie stop)</label>
+                <input id="recordDurationSec" type="number" min="0" max="3600" step="1" value="0">
+            </div>
+        </section>
+
+        <section class="files card">
+            <h2>Nagrania</h2>
+            <div class="table-wrap">
+                <table>
+                    <thead>
+                        <tr>
+                            <th>Nazwa</th>
+                            <th>Data</th>
+                            <th>Czas</th>
+                            <th>Rozmiar</th>
+                            <th>Akcje</th>
+                        </tr>
+                    </thead>
+                    <tbody id="recordingsBody"></tbody>
+                </table>
+            </div>
+        </section>
+    </main>
+
+    <script src="{{ url_for('static', filename='vendor/socket.io.min.js') }}"></script>
+    <script src="{{ url_for('static', filename='app.js') }}"></script>
+</body>
+</html>

+ 183 - 0
recorder.py

@@ -0,0 +1,183 @@
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass
+from datetime import datetime
+from pathlib import Path
+import queue
+import threading
+import wave
+
+import numpy as np
+
+
+@dataclass
+class RecorderStatus:
+    recording: bool = False
+    filename: str = ""
+    duration_sec: float = 0.0
+    channels: int = 1
+    sample_rate: int = 16000
+    source: str = "mic1"
+
+
+class WavRecorder:
+    """Threaded WAV writer to avoid blocking the audio callback."""
+
+    def __init__(self, recordings_dir: Path) -> None:
+        self.recordings_dir = Path(recordings_dir)
+        self.recordings_dir.mkdir(parents=True, exist_ok=True)
+
+        self._lock = threading.Lock()
+        self._queue: queue.Queue[bytes] = queue.Queue(maxsize=512)
+        self._stop_event = threading.Event()
+        self._worker: threading.Thread | None = None
+        self._wave_file: wave.Wave_write | None = None
+        self._frames_written = 0
+        self._status = RecorderStatus()
+
+    def get_status(self) -> RecorderStatus:
+        with self._lock:
+            return RecorderStatus(**asdict(self._status))
+
+    def start(self, source: str, sample_rate: int, channels: int, filename: str | None = None) -> str:
+        with self._lock:
+            if self._status.recording:
+                raise RuntimeError("Recording is already active")
+
+            source_clean = source.replace(" ", "_").replace("/", "_")
+            if filename is None:
+                timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
+                filename = f"rec_{timestamp}_{source_clean}.wav"
+            filepath = self.recordings_dir / filename
+
+            self._wave_file = wave.open(str(filepath), "wb")
+            self._wave_file.setnchannels(channels)
+            self._wave_file.setsampwidth(2)
+            self._wave_file.setframerate(sample_rate)
+
+            self._stop_event.clear()
+            self._frames_written = 0
+            self._clear_queue_locked()
+
+            self._status = RecorderStatus(
+                recording=True,
+                filename=filename,
+                duration_sec=0.0,
+                channels=channels,
+                sample_rate=sample_rate,
+                source=source_clean,
+            )
+
+            self._worker = threading.Thread(target=self._writer_loop, daemon=True)
+            self._worker.start()
+
+            return filename
+
+    def write(self, audio: np.ndarray) -> None:
+        status = self.get_status()
+        if not status.recording:
+            return
+
+        data = np.asarray(audio, dtype=np.float32)
+
+        if data.ndim == 1:
+            if status.channels != 1:
+                return
+            frames = data.shape[0]
+            int16_data = (np.clip(data, -1.0, 1.0) * 32767.0).astype(np.int16)
+            payload = int16_data.tobytes()
+        elif data.ndim == 2:
+            if data.shape[1] != status.channels:
+                return
+            frames = data.shape[0]
+            int16_data = (np.clip(data, -1.0, 1.0) * 32767.0).astype(np.int16)
+            payload = int16_data.reshape(-1).tobytes()
+        else:
+            return
+
+        try:
+            self._queue.put_nowait(payload)
+        except queue.Full:
+            return
+
+        with self._lock:
+            self._frames_written += frames
+            self._status.duration_sec = self._frames_written / float(status.sample_rate)
+
+    def stop(self) -> RecorderStatus:
+        with self._lock:
+            if not self._status.recording:
+                return RecorderStatus(**asdict(self._status))
+
+            self._status.recording = False
+            worker = self._worker
+            self._stop_event.set()
+
+        if worker is not None:
+            worker.join(timeout=2.0)
+
+        with self._lock:
+            if self._wave_file is not None:
+                self._wave_file.close()
+            self._wave_file = None
+            self._worker = None
+            self._stop_event.clear()
+            return RecorderStatus(**asdict(self._status))
+
+    def _writer_loop(self) -> None:
+        while not self._stop_event.is_set() or not self._queue.empty():
+            try:
+                payload = self._queue.get(timeout=0.1)
+            except queue.Empty:
+                continue
+
+            with self._lock:
+                if self._wave_file is not None:
+                    self._wave_file.writeframes(payload)
+
+    def _clear_queue_locked(self) -> None:
+        while True:
+            try:
+                self._queue.get_nowait()
+            except queue.Empty:
+                break
+
+
+def resolve_recording_path(recordings_dir: Path, filename: str) -> Path:
+    root = Path(recordings_dir).resolve()
+    candidate = (root / filename).resolve()
+    if root not in candidate.parents and candidate != root:
+        raise ValueError("Invalid recording path")
+    return candidate
+
+
+def list_recordings(recordings_dir: Path) -> list[dict[str, object]]:
+    results: list[dict[str, object]] = []
+    folder = Path(recordings_dir)
+    folder.mkdir(parents=True, exist_ok=True)
+
+    for wav_file in sorted(folder.glob("*.wav"), key=lambda p: p.stat().st_mtime, reverse=True):
+        try:
+            with wave.open(str(wav_file), "rb") as wf:
+                frames = wf.getnframes()
+                sample_rate = wf.getframerate()
+                channels = wf.getnchannels()
+        except (wave.Error, EOFError, OSError):
+            continue
+
+        size_bytes = wav_file.stat().st_size
+        mtime = datetime.fromtimestamp(wav_file.stat().st_mtime).isoformat(timespec="seconds")
+        duration_sec = float(frames) / float(sample_rate) if sample_rate else 0.0
+
+        results.append(
+            {
+                "filename": wav_file.name,
+                "date": mtime,
+                "duration_sec": round(duration_sec, 3),
+                "size_bytes": size_bytes,
+                "channels": channels,
+                "sample_rate": sample_rate,
+            }
+        )
+
+    return results

+ 6 - 0
requirements.txt

@@ -0,0 +1,6 @@
+flask
+flask-socketio
+eventlet
+numpy
+scipy
+sounddevice

+ 18 - 0
scripts/deploy_from_windows.ps1

@@ -0,0 +1,18 @@
+param(
+    [string]$Host = "10.0.100.24",
+    [string]$User = "pch",
+    [string]$ProjectPath = ".\mic_system"
+)
+
+$ErrorActionPreference = "Stop"
+
+if (-not (Test-Path "$HOME\.ssh\id_ed25519")) {
+    ssh-keygen -t ed25519 -N "" -f "$HOME\.ssh\id_ed25519"
+}
+
+type "$HOME\.ssh\id_ed25519.pub" | ssh "$User@$Host" "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
+
+scp -r "$ProjectPath" "$User@$Host:/home/$User/"
+ssh "$User@$Host" "chmod +x /home/$User/mic_system/scripts/setup_rpi.sh && /home/$User/mic_system/scripts/setup_rpi.sh"
+
+Write-Host "Deployment complete."

+ 191 - 0
scripts/diag_ws_record.py

@@ -0,0 +1,191 @@
+from __future__ import annotations
+
+import argparse
+import json
+import time
+import wave
+from pathlib import Path
+
+import numpy as np
+import socketio
+
+
+def wav_stats(path: Path) -> dict[str, float | int | str]:
+    with wave.open(str(path), "rb") as wf:
+        channels = wf.getnchannels()
+        sample_rate = wf.getframerate()
+        frames = wf.getnframes()
+        raw = wf.readframes(frames)
+
+    if not raw:
+        return {
+            "file": path.name,
+            "channels": channels,
+            "sample_rate": sample_rate,
+            "frames": frames,
+            "duration_sec": 0.0,
+            "rms": 0.0,
+            "peak": 0.0,
+        }
+
+    pcm = np.frombuffer(raw, dtype=np.int16)
+    if channels > 1:
+        pcm = pcm.reshape(-1, channels)[:, 0]
+    norm = pcm.astype(np.float32) / 32768.0
+
+    rms = float(np.sqrt(np.mean(norm * norm, dtype=np.float64)))
+    peak = float(np.max(np.abs(norm)))
+    duration = float(frames / sample_rate) if sample_rate > 0 else 0.0
+    return {
+        "file": path.name,
+        "channels": channels,
+        "sample_rate": sample_rate,
+        "frames": frames,
+        "duration_sec": duration,
+        "rms": rms,
+        "peak": peak,
+    }
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--url", default="http://127.0.0.1:5000")
+    parser.add_argument("--duration", type=float, default=10.0)
+    parser.add_argument("--source", default="compare_all")
+    parser.add_argument("--agc", action="store_true")
+    parser.add_argument("--hifi", action="store_true")
+    parser.add_argument("--hifi-mic", default="mic1", choices=["mic1", "mic2"])
+    parser.add_argument("--sample-rate", type=int, default=16000)
+    parser.add_argument("--timeout", type=float, default=60.0)
+    parser.add_argument("--recordings-dir", default="/home/pch/mic_system/recordings")
+    args = parser.parse_args()
+
+    sio = socketio.Client(reconnection=False, logger=False, engineio_logger=False)
+
+    state: dict[str, object] = {
+        "record_started": False,
+        "recording_now": False,
+        "filenames": [],
+        "last_rec_duration": 0.0,
+        "max_rec_duration": 0.0,
+        "first_rec_wall": None,
+        "last_rec_wall": None,
+        "events": [],
+    }
+
+    @sio.on("audio_data")
+    def on_audio_data(payload: dict) -> None:  # type: ignore[no-redef]
+        now = time.monotonic()
+        recording = bool(payload.get("recording", False))
+        rec_duration = float(payload.get("rec_duration", 0.0))
+        state["recording_now"] = recording
+        state["last_rec_duration"] = rec_duration
+        if rec_duration > float(state["max_rec_duration"]):
+            state["max_rec_duration"] = rec_duration
+        if recording:
+            if state["first_rec_wall"] is None:
+                state["first_rec_wall"] = now
+            state["last_rec_wall"] = now
+
+    @sio.on("server_ack")
+    def on_server_ack(payload: dict) -> None:  # type: ignore[no-redef]
+        events = state["events"]
+        assert isinstance(events, list)
+        events.append(payload)
+        if payload.get("type") == "record_started":
+            state["record_started"] = True
+            state["filenames"] = list(payload.get("filenames", []))
+        if payload.get("type") == "record_stopped":
+            state["recording_now"] = False
+
+    sio.connect(args.url)
+
+    sio.emit(
+        "client_message",
+        {
+            "type": "settings",
+            "mode": "beamforming",
+            "auto_beam": True,
+            "sample_rate": int(args.sample_rate),
+            "gain_db": 0.0,
+            "agc": bool(args.agc),
+            "attack_ms": 5.0,
+            "release_ms": 300.0,
+            "hifi_mode": bool(args.hifi),
+            "hifi_mic": str(args.hifi_mic),
+            "monitor_on": False,
+        },
+    )
+    time.sleep(0.4)
+
+    wall_start = time.monotonic()
+    sio.emit(
+        "client_message",
+        {
+            "type": "record_start",
+            "source": args.source,
+            "duration_sec": float(args.duration),
+        },
+    )
+
+    deadline = wall_start + float(args.timeout)
+    saw_recording = False
+    quiet_ticks = 0
+
+    while time.monotonic() < deadline:
+        time.sleep(0.2)
+        recording_now = bool(state["recording_now"])
+        if recording_now:
+            saw_recording = True
+            quiet_ticks = 0
+            continue
+        if saw_recording:
+            quiet_ticks += 1
+            if quiet_ticks >= 8:
+                break
+
+    if bool(state["recording_now"]):
+        sio.emit("client_message", {"type": "record_stop"})
+        time.sleep(0.5)
+
+    wall_end = time.monotonic()
+    sio.disconnect()
+
+    filenames = [str(x) for x in state["filenames"] if isinstance(x, str) and x]
+    rec_dir = Path(args.recordings_dir)
+    stats = []
+    for name in filenames:
+        path = rec_dir / name
+        wait_deadline = time.monotonic() + 5.0
+        while (not path.exists()) and time.monotonic() < wait_deadline:
+            time.sleep(0.1)
+        if path.exists():
+            stats.append(wav_stats(path))
+        else:
+            stats.append({"file": name, "error": "missing"})
+
+    first_rec_wall = state["first_rec_wall"]
+    last_rec_wall = state["last_rec_wall"]
+    rec_wall = 0.0
+    if isinstance(first_rec_wall, float) and isinstance(last_rec_wall, float) and last_rec_wall >= first_rec_wall:
+        rec_wall = last_rec_wall - first_rec_wall
+
+    out = {
+        "agc": bool(args.agc),
+        "hifi": bool(args.hifi),
+        "hifi_mic": str(args.hifi_mic),
+        "source": args.source,
+        "target_duration_sec": float(args.duration),
+        "wall_elapsed_sec": wall_end - wall_start,
+        "recording_wall_sec": rec_wall,
+        "reported_rec_duration_sec": float(state["last_rec_duration"]),
+        "reported_max_rec_duration_sec": float(state["max_rec_duration"]),
+        "record_started": bool(state["record_started"]),
+        "recordings": stats,
+    }
+    print(json.dumps(out, ensure_ascii=True))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 56 - 0
scripts/setup_rpi.sh

@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+OVERLAY="googlevoicehat-soundcard"
+if [[ "${1:-}" == "--overlay" && -n "${2:-}" ]]; then
+  OVERLAY="$2"
+fi
+
+BOOT_CONFIG="/boot/firmware/config.txt"
+if [[ ! -f "$BOOT_CONFIG" ]]; then
+  BOOT_CONFIG="/boot/config.txt"
+fi
+
+echo "[1/6] Enabling I2S in ${BOOT_CONFIG}"
+sudo sed -i '/^dtparam=i2s=on/d' "$BOOT_CONFIG"
+sudo sed -i '/^dtoverlay=googlevoicehat-soundcard/d' "$BOOT_CONFIG"
+sudo sed -i '/^dtoverlay=i2s-mmap/d' "$BOOT_CONFIG"
+sudo sed -i '/^dtoverlay=adau7002-simple/d' "$BOOT_CONFIG"
+echo "dtparam=i2s=on" | sudo tee -a "$BOOT_CONFIG" >/dev/null
+echo "dtoverlay=${OVERLAY}" | sudo tee -a "$BOOT_CONFIG" >/dev/null
+
+echo "[2/6] Writing ~/.asoundrc"
+cat > "$HOME/.asoundrc" <<'EOF'
+pcm.!default {
+    type asym
+    capture.pcm "mic"
+}
+
+pcm.mic {
+    type hw
+    card 0
+    format S32_LE
+    rate 48000
+    channels 2
+}
+EOF
+
+echo "[3/6] Installing dependencies"
+sudo apt-get update
+sudo apt-get install -y python3-venv python3-dev build-essential portaudio19-dev git
+
+echo "[4/6] Creating venv"
+cd /home/pch/mic_system
+python3 -m venv .venv
+source .venv/bin/activate
+pip install --upgrade pip
+pip install -r requirements.txt
+
+echo "[5/6] Installing systemd service"
+sudo cp deploy/mic_system.service /etc/systemd/system/mic_system.service
+sudo systemctl daemon-reload
+sudo systemctl enable mic_system.service
+sudo systemctl restart mic_system.service || true
+
+echo "[6/6] Done. Reboot required for I2S overlay."
+echo "Run: sudo reboot"

+ 730 - 0
static/app.js

@@ -0,0 +1,730 @@
+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();

+ 315 - 0
static/style.css

@@ -0,0 +1,315 @@
+@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap');
+
+:root {
+    --bg: #091724;
+    --panel: rgba(12, 32, 48, 0.82);
+    --line: rgba(129, 181, 191, 0.35);
+    --text: #e9f6f9;
+    --muted: #9bc0c6;
+    --mic1: #32b3ff;
+    --mic2: #50d27a;
+    --beam: #ff6d5a;
+    --accent: #f4c34f;
+    --danger: #ff4c55;
+}
+
+* {
+    box-sizing: border-box;
+}
+
+body {
+    margin: 0;
+    min-height: 100vh;
+    color: var(--text);
+    background: radial-gradient(circle at 12% 14%, #20435c 0%, #0d2738 30%, #08131f 100%);
+    font-family: "Space Grotesk", "Segoe UI", sans-serif;
+    position: relative;
+    overflow-x: hidden;
+}
+
+.bg-glow {
+    position: fixed;
+    z-index: 0;
+    border-radius: 999px;
+    filter: blur(80px);
+    opacity: 0.22;
+    pointer-events: none;
+}
+
+.bg-glow-a {
+    width: 300px;
+    height: 300px;
+    background: #00d5ff;
+    top: -80px;
+    left: -80px;
+}
+
+.bg-glow-b {
+    width: 340px;
+    height: 340px;
+    background: #ff8a4d;
+    right: -120px;
+    bottom: -120px;
+}
+
+.layout {
+    max-width: 1200px;
+    margin: 0 auto;
+    padding: 20px;
+    display: grid;
+    gap: 16px;
+    position: relative;
+    z-index: 1;
+}
+
+.card {
+    background: var(--panel);
+    border: 1px solid var(--line);
+    border-radius: 16px;
+    backdrop-filter: blur(10px);
+    box-shadow: 0 14px 34px rgba(3, 8, 13, 0.35);
+    padding: 16px;
+}
+
+.header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    gap: 16px;
+}
+
+h1,
+h2 {
+    margin: 0;
+}
+
+h1 {
+    font-size: 1.7rem;
+}
+
+h2 {
+    font-size: 1.1rem;
+    margin-bottom: 12px;
+}
+
+p {
+    margin: 8px 0 0;
+    color: var(--muted);
+}
+
+.status-block {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+}
+
+.pill {
+    border: 1px solid var(--line);
+    border-radius: 999px;
+    padding: 6px 10px;
+    font-size: 0.85rem;
+    background: rgba(8, 20, 30, 0.7);
+}
+
+.wave-grid {
+    display: grid;
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+    gap: 12px;
+}
+
+.wave-item {
+    border: 1px solid rgba(116, 162, 170, 0.3);
+    border-radius: 12px;
+    padding: 10px;
+    background: rgba(9, 25, 36, 0.8);
+}
+
+.wave-title {
+    font-weight: 600;
+    margin-bottom: 8px;
+}
+
+canvas {
+    width: 100%;
+    height: 160px;
+    border: 1px solid rgba(97, 154, 163, 0.35);
+    border-radius: 10px;
+    background: linear-gradient(180deg, rgba(5, 16, 24, 0.95), rgba(7, 21, 31, 0.82));
+}
+
+.vu-wrap {
+    margin-top: 8px;
+    display: flex;
+    align-items: center;
+    gap: 8px;
+}
+
+.vu-label {
+    min-width: 24px;
+    color: var(--muted);
+    font-size: 0.85rem;
+}
+
+.vu-track {
+    height: 12px;
+    flex: 1;
+    border-radius: 999px;
+    border: 1px solid rgba(125, 169, 176, 0.4);
+    background: rgba(7, 19, 29, 0.85);
+    overflow: hidden;
+}
+
+.vu-fill {
+    height: 100%;
+    width: 0%;
+    transition: width 0.1s linear;
+}
+
+.vu-fill.mic1 {
+    background: linear-gradient(90deg, #58c8ff, #1f8ef2);
+}
+
+.vu-fill.mic2 {
+    background: linear-gradient(90deg, #74ef98, #22b455);
+}
+
+.vu-fill.beam {
+    background: linear-gradient(90deg, #ff9569, #ff5948);
+}
+
+.group {
+    margin-bottom: 12px;
+}
+
+.group label {
+    display: block;
+    margin-bottom: 6px;
+}
+
+input[type="range"],
+input[type="number"],
+select {
+    width: 100%;
+}
+
+select,
+input[type="range"] {
+    accent-color: var(--accent);
+}
+
+.radio-row {
+    display: grid;
+    gap: 6px;
+}
+
+.row.two-col {
+    display: grid;
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    gap: 10px;
+}
+
+.hidden {
+    display: none;
+}
+
+.record-row {
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    margin-bottom: 12px;
+}
+
+.record-btn {
+    border: none;
+    border-radius: 999px;
+    font-size: 1rem;
+    font-weight: 700;
+    letter-spacing: 0.05em;
+    color: #fff;
+    background: linear-gradient(135deg, #f15444, #e92e42);
+    padding: 12px 24px;
+    cursor: pointer;
+    transition: transform 0.2s ease, box-shadow 0.2s ease;
+}
+
+.record-btn:hover {
+    transform: translateY(-1px);
+    box-shadow: 0 10px 22px rgba(226, 54, 70, 0.35);
+}
+
+.record-btn.recording {
+    animation: pulse 1s infinite;
+    background: linear-gradient(135deg, #ff7f66, #ff304a);
+}
+
+.timer {
+    font-variant-numeric: tabular-nums;
+    font-size: 1.1rem;
+    font-weight: 600;
+}
+
+.table-wrap {
+    overflow-x: auto;
+}
+
+table {
+    width: 100%;
+    border-collapse: collapse;
+}
+
+th,
+td {
+    padding: 8px;
+    border-bottom: 1px solid rgba(123, 171, 179, 0.25);
+    text-align: left;
+    white-space: nowrap;
+}
+
+.actions {
+    display: flex;
+    gap: 8px;
+}
+
+.btn-small {
+    border: 1px solid rgba(123, 173, 181, 0.45);
+    border-radius: 8px;
+    background: rgba(9, 22, 33, 0.85);
+    color: var(--text);
+    padding: 4px 8px;
+    cursor: pointer;
+}
+
+.btn-small.danger {
+    border-color: rgba(255, 93, 109, 0.6);
+    color: #ffcdd2;
+}
+
+@keyframes pulse {
+    0% {
+        transform: scale(1);
+        box-shadow: 0 0 0 0 rgba(255, 87, 92, 0.45);
+    }
+    70% {
+        transform: scale(1.02);
+        box-shadow: 0 0 0 12px rgba(255, 87, 92, 0);
+    }
+    100% {
+        transform: scale(1);
+        box-shadow: 0 0 0 0 rgba(255, 87, 92, 0);
+    }
+}
+
+@media (max-width: 960px) {
+    .wave-grid {
+        grid-template-columns: 1fr;
+    }
+
+    .row.two-col {
+        grid-template-columns: 1fr;
+    }
+
+    .header {
+        flex-direction: column;
+        align-items: flex-start;
+    }
+}

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 5 - 0
static/vendor/socket.io.min.js


+ 190 - 0
templates/index.html

@@ -0,0 +1,190 @@
+<!doctype html>
+<html lang="pl">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Mic System - RPi Zero 2W</title>
+    <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
+</head>
+<body>
+    <div class="bg-glow bg-glow-a"></div>
+    <div class="bg-glow bg-glow-b"></div>
+
+    <main class="layout">
+        <header class="header card">
+            <div>
+                <h1>System Mikrofonowy</h1>
+                <p>RPi Zero 2W + 2x INMP441 + WebSocket</p>
+            </div>
+            <div class="status-block">
+                <div id="serverStatus" class="pill">Laczenie...</div>
+                <div id="audioStatus" class="pill">Audio: start</div>
+            </div>
+        </header>
+
+        <section class="wave-panel card">
+            <div class="wave-grid">
+                <article class="wave-item">
+                    <div class="wave-title">Mikrofon 1 (L)</div>
+                    <canvas id="waveMic1" width="640" height="160"></canvas>
+                    <div class="vu-wrap">
+                        <div class="vu-label">VU</div>
+                        <div class="vu-track"><div id="vuMic1" class="vu-fill mic1"></div></div>
+                    </div>
+                </article>
+
+                <article class="wave-item">
+                    <div class="wave-title">Mikrofon 2 (R)</div>
+                    <canvas id="waveMic2" width="640" height="160"></canvas>
+                    <div class="vu-wrap">
+                        <div class="vu-label">VU</div>
+                        <div class="vu-track"><div id="vuMic2" class="vu-fill mic2"></div></div>
+                    </div>
+                </article>
+
+                <article class="wave-item" id="beamBlock">
+                    <div class="wave-title">Beamforming</div>
+                    <canvas id="waveBeam" width="640" height="160"></canvas>
+                    <div class="vu-wrap">
+                        <div class="vu-label">VU</div>
+                        <div class="vu-track"><div id="vuBeam" class="vu-fill beam"></div></div>
+                    </div>
+                </article>
+            </div>
+        </section>
+
+        <section class="controls card">
+            <h2>Ustawienia</h2>
+
+            <div class="group">
+                <label>Tryb mikrofonow</label>
+                <div class="radio-row">
+                    <label><input type="radio" name="mode" value="mic1"> Mikrofon 1 (L) - mono</label>
+                    <label><input type="radio" name="mode" value="mic2"> Mikrofon 2 (R) - mono</label>
+                    <label><input type="radio" name="mode" value="mono_mix"> Mono mix (L+R)</label>
+                    <label><input type="radio" name="mode" value="beamforming" checked> Beamforming (mono)</label>
+                </div>
+            </div>
+
+            <div class="row two-col">
+                <div class="group">
+                    <label for="gainDb">Gain bazowy: <span id="gainValue">0</span> dB</label>
+                    <input id="gainDb" type="range" min="0" max="30" value="0" step="1">
+                </div>
+
+                <div class="group">
+                    <label for="sampleRate">Sample rate</label>
+                    <select id="sampleRate">
+                        <option value="16000" selected>16000 Hz (najstabilniejsze)</option>
+                        <option value="22050">22050 Hz (lepsza gora)</option>
+                        <option value="24000">24000 Hz (rekomendowane po optymalizacji)</option>
+                        <option value="32000">32000 Hz (eksperymentalne)</option>
+                        <option value="48000">48000 Hz (bez resamplingu, duze obciazenie)</option>
+                    </select>
+                </div>
+            </div>
+
+            <div class="group">
+                <label><input id="agcEnabled" type="checkbox" checked> Auto gain + redukcja szumu</label>
+            </div>
+
+            <div id="agcControls" class="row two-col">
+                <div class="group">
+                    <label for="attackMs">Attack: <span id="attackValue">6</span> ms</label>
+                    <input id="attackMs" type="range" min="1" max="50" value="6" step="1">
+                </div>
+                <div class="group">
+                    <label for="releaseMs">Release: <span id="releaseValue">280</span> ms</label>
+                    <input id="releaseMs" type="range" min="50" max="1000" value="280" step="10">
+                </div>
+            </div>
+
+            <div class="group">
+                <label>Ulepszanie mowy</label>
+                <div class="radio-row">
+                    <label><input id="noiseSuppression" type="checkbox" checked> Noise suppression</label>
+                    <label><input id="speechGate" type="checkbox"> Speech gate (VAD, bliska mowa)</label>
+                    <label><input id="humFilter" type="checkbox" checked> Filtr brumu 50Hz + HPF</label>
+                    <label><input id="limiterEnabled" type="checkbox" checked> Limiter</label>
+                    <label><input id="beamClarity" type="checkbox" checked> Beam clarity (wysokie tony)</label>
+                </div>
+            </div>
+
+            <div class="group">
+                <label><input id="hifiMode" type="checkbox"> Tryb HiFi test (raw, 48k, bez DSP)</label>
+                <label for="hifiMic">Mikrofon HiFi</label>
+                <select id="hifiMic">
+                    <option value="mic1" selected>Mikrofon 1 (L)</option>
+                    <option value="mic2">Mikrofon 2 (R)</option>
+                </select>
+            </div>
+
+            <div id="beamControls" class="group hidden">
+                <label><input id="beamAuto" type="checkbox" checked> Auto beamforming (priorytet mowy)</label>
+                <label>Kierunek aktywny: <span id="autoAngleValue">0</span> deg (<span id="speechState">cisza</span>)</label>
+                <div id="manualBeamRow">
+                    <label for="beamAngle">Kierunek reczny: <span id="angleValue">0</span> deg</label>
+                    <input id="beamAngle" type="range" min="-90" max="90" value="0" step="1">
+                </div>
+            </div>
+
+            <div class="group">
+                <label><input id="monitorOn" type="checkbox"> Podsluch live</label>
+                <label for="monitorSource">Zrodlo podsluchu</label>
+                <select id="monitorSource">
+                    <option value="beam" selected>Beamforming (mono)</option>
+                    <option value="mono_mix">Mono mix (L+R)</option>
+                    <option value="mic1">Mikrofon 1 (L)</option>
+                    <option value="mic2">Mikrofon 2 (R)</option>
+                </select>
+            </div>
+        </section>
+
+        <section class="recording card">
+            <h2>Nagrywanie</h2>
+            <div class="record-row">
+                <button id="recordBtn" class="record-btn">RECORD</button>
+                <div id="recordTimer" class="timer">00:00:00</div>
+            </div>
+
+            <div class="group">
+                <label for="recordSource">Zrodlo nagrywania</label>
+                <select id="recordSource">
+                    <option value="mic1">Mikrofon 1 (raw)</option>
+                    <option value="mic2">Mikrofon 2 (raw)</option>
+                    <option value="mono_mix">Mono mix (L+R)</option>
+                    <option value="beam">Beamforming (mono)</option>
+                    <option value="compare_all">Porownanie: mic1 + mono_mix + beam</option>
+                    <option value="hifi_raw">HiFi RAW (48k, 1 mic)</option>
+                </select>
+            </div>
+
+            <div class="group">
+                <label for="recordDurationSec">Czas nagrania [s] (0 = recznie stop)</label>
+                <input id="recordDurationSec" type="number" min="0" max="3600" step="1" value="0">
+            </div>
+        </section>
+
+        <section class="files card">
+            <h2>Nagrania</h2>
+            <div class="table-wrap">
+                <table>
+                    <thead>
+                        <tr>
+                            <th>Nazwa</th>
+                            <th>Data</th>
+                            <th>Czas</th>
+                            <th>Rozmiar</th>
+                            <th>Akcje</th>
+                        </tr>
+                    </thead>
+                    <tbody id="recordingsBody"></tbody>
+                </table>
+            </div>
+        </section>
+    </main>
+
+    <script src="{{ url_for('static', filename='vendor/socket.io.min.js') }}"></script>
+    <script src="{{ url_for('static', filename='app.js') }}"></script>
+</body>
+</html>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio