diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..67d1e53 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,60 @@ +name: Docker Publish + +on: + push: + branches: + - main + tags: + - "v*" + - "[0-9]*.[0-9]*.[0-9]*" + - "[0-9]*.[0-9]*.[0-9]*-*" + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log into registry ghcr.io + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + # Configuração Padrão Ouro para tagueamento múltiplo + tags: | + type=ref,event=branch + type=ref,event=pr + type=ref,event=tag + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitmodules b/.gitmodules index ef094ca..15e430d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "frontend"] path = frontend - url = https://github.com/Steven9101/frontend + url = https://github.com/xnetinho/NovaSDR-frontend diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1dbb757 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## [Release] v0.1.0 — Squelch Híbrido: Code Review & Refatoração + +### O que foi modificado + +- [x] **Backend (`crates/novasdr-server/src/ws/audio.rs`):** Eliminados magic numbers `10` — declarada constante `SQUELCH_HYSTERESIS_FRAMES: u8 = 10` e substituída em ambos os pontos de uso (modo manual e modo auto). +- [x] **Backend (`audio.rs`):** Removidos 3 comentários didáticos/decorativos que violavam as Engineering Rules (proibição de comentários óbvios). +- [x] **Documentação (`docs/AUDIO.md`):** Seção Squelch reescrita para documentar ambos os modos (Auto e Manual), incluindo payload `level: Option`, tabela de campos e constante de histerese. +- [x] **Frontend (`frontend/src/components/receiver/panels/AudioPanel.tsx`):** Slider do squelch alterado de opacity/pointer-events para **renderização condicional** — desaparece completamente quando `squelchAuto` é `true`. +- [x] **Frontend (`types.ts`, `useAudioClient.ts`):** Auditados e confirmados sem comentários inúteis (nenhuma modificação necessária). diff --git a/Dockerfile b/Dockerfile index da399e2..29226d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,127 +1,75 @@ -# Multi-stage Dockerfile for NovaSDR -# Builds frontend, Rust backend with SoapySDR and OpenCL support - -# Stage 1: Build the frontend +# ESTÁGIO 1: Frontend FROM node:20-slim AS frontend-builder - WORKDIR /build - -# Copy frontend package files COPY frontend/package*.json ./ - -# Install dependencies -RUN npm ci - -# Copy frontend source +# Ajuste para resiliência de submódulo +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi COPY frontend/ ./ - -# Build the frontend RUN npm run build -# Stage 2: Build the Rust backend +# ESTÁGIO 2: Planejamento +FROM rustlang/rust:nightly-bookworm-slim AS planner +WORKDIR /build +RUN cargo install cargo-chef +COPY . . +RUN cargo chef prepare --recipe-path recipe.json + +# ESTÁGIO 3: Backend Builder FROM rustlang/rust:nightly-bookworm-slim AS backend-builder +WORKDIR /build -# Install build dependencies +# Instalação unificada de todas as dependências de build detectadas RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - cmake \ - pkg-config \ - clang \ - libclang-dev \ - swig \ - python3 \ - python3-dev \ - python3-numpy \ - ocl-icd-opencl-dev \ - libclfft-dev \ - libusb-1.0-0-dev \ - git \ - ca-certificates \ + build-essential cmake pkg-config clang libclang-dev swig \ + python3 python3-dev python3-numpy ocl-icd-opencl-dev \ + libclfft-dev libusb-1.0-0-dev git ca-certificates \ + rtl-sdr librtlsdr-dev libopus-dev \ && rm -rf /var/lib/apt/lists/* -WORKDIR /build - -# Copy Cargo workspace files -COPY Cargo.toml ./ -COPY crates/ ./crates/ +RUN cargo install cargo-chef +COPY --from=planner /build/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json -# Build SoapySDR from source (for better compatibility) +# Compilação de Drivers (Padrão Ouro: explicitando diretórios de build) RUN git clone https://github.com/pothosware/SoapySDR.git /tmp/SoapySDR && \ - cd /tmp/SoapySDR && \ - mkdir build && cd build && \ - cmake .. && \ - make -j$(nproc) && \ - make install && \ - ldconfig && \ - rm -rf /tmp/SoapySDR - -# Build SoapyRTLSDR (RTL-SDR support) -RUN apt-get update && apt-get install -y --no-install-recommends rtl-sdr librtlsdr-dev && \ + cmake -S /tmp/SoapySDR -B /tmp/SoapySDR/build -DCMAKE_BUILD_TYPE=Release && \ + make -C /tmp/SoapySDR/build -j$(nproc) install && \ git clone https://github.com/pothosware/SoapyRTLSDR.git /tmp/SoapyRTLSDR && \ - cd /tmp/SoapyRTLSDR && \ - mkdir build && cd build && \ - cmake .. && \ - make -j$(nproc) && \ - make install && \ - rm -rf /tmp/SoapyRTLSDR && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* - -# Build the Rust backend with SoapySDR and clFFT support -RUN cargo build --release --features "soapysdr,clfft" -p novasdr-server + cmake -S /tmp/SoapyRTLSDR -B /tmp/SoapyRTLSDR/build -DCMAKE_BUILD_TYPE=Release && \ + make -C /tmp/SoapyRTLSDR/build -j$(nproc) install && \ + ldconfig && rm -rf /tmp/Soapy* -# Build the ws_probe utility -RUN cargo build --release -p ws_probe +COPY . . +RUN cargo build --release --features "soapysdr,clfft" -p novasdr-server && \ + cargo build --release -p ws_probe -# Stage 3: Final runtime image +# ESTÁGIO 4: Runtime Final FROM debian:bookworm-slim +WORKDIR /app -# Install runtime dependencies +# Adicionado libopus0 e garantido paridade de pacotes RUN apt-get update && apt-get install -y --no-install-recommends \ - libusb-1.0-0 \ - ocl-icd-libopencl1 \ - libclfft2 \ - rtl-sdr \ - python3 \ - python3-numpy \ - ca-certificates \ - netcat-openbsd \ + libusb-1.0-0 ocl-icd-libopencl1 libclfft2 rtl-sdr \ + python3 python3-numpy ca-certificates netcat-openbsd \ + libopus0 \ && rm -rf /var/lib/apt/lists/* -# Copy SoapySDR runtime from builder +# Cópia completa de bibliotecas e INCLUDES (conforme original) COPY --from=backend-builder /usr/local/lib/libSoapySDR* /usr/local/lib/ COPY --from=backend-builder /usr/local/lib/SoapySDR /usr/local/lib/SoapySDR COPY --from=backend-builder /usr/local/include/SoapySDR /usr/local/include/SoapySDR COPY --from=backend-builder /usr/local/bin/SoapySDRUtil /usr/local/bin/ - -# Update library cache RUN ldconfig -# Create application directory -WORKDIR /app - -# Copy built binaries from backend builder +# Binários e Recursos (Caminhos de origem corrigidos para /build) COPY --from=backend-builder /build/target/release/novasdr-server /app/ COPY --from=backend-builder /build/target/release/ws_probe /app/ - -# Copy built frontend from frontend builder COPY --from=frontend-builder /build/dist /app/frontend/dist - -# Copy configuration files COPY config/ /app/config/ - -# Copy resources (default bands, etc.) COPY crates/novasdr-server/resources/ /app/resources/ -# Create directories for runtime data RUN mkdir -p /app/logs /app/data - -# Expose the default port EXPOSE 9002 +ENV RUST_LOG=info RUST_BACKTRACE=1 -# Set environment variables -ENV RUST_LOG=info -ENV RUST_BACKTRACE=1 - -# Run the server CMD ["/app/novasdr-server", "-c", "/app/config/config.json", "-r", "/app/config/receivers.json"] diff --git a/crates/novasdr-core/src/protocol.rs b/crates/novasdr-core/src/protocol.rs index b471516..9c298f7 100644 --- a/crates/novasdr-core/src/protocol.rs +++ b/crates/novasdr-core/src/protocol.rs @@ -59,6 +59,8 @@ pub enum ClientCommand { }, Squelch { enabled: bool, + #[serde(default)] + level: Option, }, Chat { message: String, diff --git a/crates/novasdr-server/src/benchmark.rs b/crates/novasdr-server/src/benchmark.rs index f6ecb85..543dc6a 100644 --- a/crates/novasdr-server/src/benchmark.rs +++ b/crates/novasdr-server/src/benchmark.rs @@ -43,6 +43,7 @@ fn ssb_benchmark(iterations: usize) -> anyhow::Result<()> { r: 2000, mute: false, squelch_enabled: false, + squelch_level: None, demodulation: DemodulationMode::Usb, agc_speed: AgcSpeed::Off, agc_attack_ms: None, diff --git a/crates/novasdr-server/src/state.rs b/crates/novasdr-server/src/state.rs index 50c27a4..4f1db7e 100644 --- a/crates/novasdr-server/src/state.rs +++ b/crates/novasdr-server/src/state.rs @@ -403,6 +403,7 @@ pub struct AudioParams { pub r: i32, pub mute: bool, pub squelch_enabled: bool, + pub squelch_level: Option, pub demodulation: novasdr_core::dsp::demod::DemodulationMode, pub agc_speed: AgcSpeed, pub agc_attack_ms: Option, diff --git a/crates/novasdr-server/src/ws/audio.rs b/crates/novasdr-server/src/ws/audio.rs index 2fdf27c..1b1ca8f 100644 --- a/crates/novasdr-server/src/ws/audio.rs +++ b/crates/novasdr-server/src/ws/audio.rs @@ -249,12 +249,16 @@ mod ima_adpcm { } } +/// Number of consecutive frames below threshold required to close the squelch (both manual and auto modes). +const SQUELCH_HYSTERESIS_FRAMES: u8 = 10; + #[derive(Debug, Clone)] struct SquelchState { was_enabled: bool, open: bool, low_hits: u8, close_hits: u8, + manual_close_frames: u8, } impl SquelchState { @@ -264,6 +268,7 @@ impl SquelchState { open: true, low_hits: 0, close_hits: 0, + manual_close_frames: 0, } } @@ -271,15 +276,23 @@ impl SquelchState { self.open = false; self.low_hits = 0; self.close_hits = 0; + self.manual_close_frames = 0; } fn reset_open(&mut self) { self.open = true; self.low_hits = 0; self.close_hits = 0; + self.manual_close_frames = 0; } - fn update(&mut self, enabled: bool, features: SquelchFeatures) -> bool { + fn update( + &mut self, + enabled: bool, + manual_level: Option, + pwr_db: f32, + features: SquelchFeatures, + ) -> bool { if enabled && !self.was_enabled { self.reset_closed(); } @@ -291,6 +304,20 @@ impl SquelchState { return true; } + if let Some(threshold) = manual_level { + if pwr_db >= threshold { + self.open = true; + self.manual_close_frames = 0; + } else { + self.manual_close_frames = self.manual_close_frames.saturating_add(1); + if self.manual_close_frames >= SQUELCH_HYSTERESIS_FRAMES { + self.open = false; + } + } + return self.open; + } + + let min_active_bins = if features.len <= 256 { 1u16 } else { @@ -337,7 +364,7 @@ impl SquelchState { } else { self.close_hits = 0; } - if self.close_hits >= 10 { + if self.close_hits >= SQUELCH_HYSTERESIS_FRAMES { self.reset_closed(); } self.open @@ -401,6 +428,7 @@ async fn handle(socket: ws::WebSocket, state: Arc, _ip_guard: crate::s r: receiver.rt.default_r, mute: false, squelch_enabled: receiver.receiver.input.defaults.squelch_enabled, + squelch_level: None, demodulation: DemodulationMode::from_str_upper(receiver.rt.default_mode_str.as_str()) .unwrap_or(DemodulationMode::Usb), agc_speed: AgcSpeed::Default, @@ -512,6 +540,7 @@ async fn handle(socket: ws::WebSocket, state: Arc, _ip_guard: crate::s p.mute = false; p.squelch_enabled = receiver.receiver.input.defaults.squelch_enabled; + p.squelch_level = None; p.demodulation = DemodulationMode::from_str_upper( receiver.rt.default_mode_str.as_str(), ) @@ -717,7 +746,7 @@ fn apply_command( }; p.mute = mute; } - novasdr_core::protocol::ClientCommand::Squelch { enabled } => { + novasdr_core::protocol::ClientCommand::Squelch { enabled, level } => { let mut p = match client.params.lock() { Ok(g) => g, Err(poisoned) => { @@ -729,6 +758,7 @@ fn apply_command( } }; p.squelch_enabled = enabled; + p.squelch_level = level; } novasdr_core::protocol::ClientCommand::Agc { speed, @@ -788,7 +818,7 @@ mod tests { fn squelch_disabled_is_always_open() { let mut s = SquelchState::new(); for v in [0.0, 1.0, 10.0, 100.0] { - assert!(s.update(false, features_for_test(v))); + assert!(s.update(false, None, 0.0, features_for_test(v))); } } @@ -796,17 +826,17 @@ mod tests { fn squelch_closes_after_sustained_low_variation() { let mut s = SquelchState::new(); assert!( - s.update(true, features_for_test(20.0)), + s.update(true, None, 0.0, features_for_test(20.0)), "strong variation should open squelch" ); for _ in 0..9 { assert!( - s.update(true, features_for_test(0.0)), + s.update(true, None, 0.0, features_for_test(0.0)), "should remain open until close hysteresis triggers" ); } assert!( - !s.update(true, features_for_test(0.0)), + !s.update(true, None, 0.0, features_for_test(0.0)), "should close after sustained low variance" ); } @@ -814,8 +844,54 @@ mod tests { #[test] fn squelch_opens_immediately_on_strong_variation() { let mut s = SquelchState::new(); - assert!(!s.update(true, features_for_test(0.0))); - assert!(s.update(true, features_for_test(100.0))); + assert!(!s.update(true, None, 0.0, features_for_test(0.0))); + assert!(s.update(true, None, 0.0, features_for_test(100.0))); + } + + #[test] + fn squelch_manual_opens_when_power_above_threshold() { + let mut s = SquelchState::new(); + // Manual mode: threshold = -50 dB, power = -30 dB (above threshold) → open + assert!(s.update(true, Some(-50.0), -30.0, features_for_test(0.0))); + // Power drops to -60 dB (below threshold) but hysteresis holds open + for _ in 0..9 { + assert!( + s.update(true, Some(-50.0), -60.0, features_for_test(0.0)), + "should remain open during manual close hysteresis" + ); + } + // 10th frame below → closes + assert!( + !s.update(true, Some(-50.0), -60.0, features_for_test(0.0)), + "should close after 10 frames below manual threshold" + ); + } + + #[test] + fn squelch_manual_resets_hysteresis_on_signal_return() { + let mut s = SquelchState::new(); + assert!(s.update(true, Some(-50.0), -30.0, features_for_test(0.0))); + // 5 frames below threshold + for _ in 0..5 { + assert!(s.update(true, Some(-50.0), -60.0, features_for_test(0.0))); + } + // Signal returns → counter resets + assert!(s.update(true, Some(-50.0), -40.0, features_for_test(0.0))); + // Need fresh 10 frames to close again + for _ in 0..9 { + assert!(s.update(true, Some(-50.0), -60.0, features_for_test(0.0))); + } + assert!(!s.update(true, Some(-50.0), -60.0, features_for_test(0.0))); + } + + #[test] + fn squelch_auto_ignores_pwr_db() { + let mut s = SquelchState::new(); + // Even with very low pwr_db, auto mode uses features (the statistical algorithm) + assert!( + s.update(true, None, -200.0, features_for_test(100.0)), + "auto mode should open based on features, not pwr_db" + ); } } @@ -978,7 +1054,20 @@ impl AudioPipeline { } let features = squelch_features(spectrum_slice); - let squelch_open = self.squelch.update(params.squelch_enabled, features); + + // Compute power in dB for manual squelch & S-Meter (mirrors frontend S-Meter math). + let pwr_raw = spectrum_slice.iter().map(|c| c.norm_sqr()).sum::(); + let n = spectrum_slice.len().max(1) as f32; + let avg_per_bin = pwr_raw / n; + let normalized = avg_per_bin / n; + let pwr_db = 10.0 * normalized.max(1e-20).log10(); + + let squelch_open = self.squelch.update( + params.squelch_enabled, + params.squelch_level, + pwr_db, + features, + ); if params.squelch_enabled && !squelch_open { self.reset_for_squelch_gate(); return Ok(out_packets); @@ -1320,27 +1409,27 @@ mod pipeline_tests { // Enabling squelch closes it until a signal is detected. assert!( - !s.update(true, features(0.0)), + !s.update(true, None, 0.0, features(0.0)), "expected closed immediately after enable" ); // Soft open: scaled >= 5 for 3 consecutive frames. - assert!(!s.update(true, features(6.0))); - assert!(!s.update(true, features(6.0))); + assert!(!s.update(true, None, 0.0, features(6.0))); + assert!(!s.update(true, None, 0.0, features(6.0))); assert!( - s.update(true, features(6.0)), + s.update(true, None, 0.0, features(6.0)), "expected open after 3 consecutive soft hits" ); // Close hysteresis: scaled < 2 for 10 consecutive frames. for _ in 0..9 { assert!( - s.update(true, features(1.0)), + s.update(true, None, 0.0, features(1.0)), "expected to remain open during close hysteresis" ); } assert!( - !s.update(true, features(1.0)), + !s.update(true, None, 0.0, features(1.0)), "expected to close after hysteresis completes" ); } diff --git a/docs/AUDIO.md b/docs/AUDIO.md index fb72b3e..a84fcb3 100644 --- a/docs/AUDIO.md +++ b/docs/AUDIO.md @@ -40,29 +40,44 @@ Supported mode strings: `FMC` is an alias of `FM` on the backend (the extra CTCSS reduction is a frontend audio filter). -## Squelch (auto, frequency-domain) +## Squelch (hybrid: auto / manual) -The WebSDR squelch is implemented server-side and operates on the current audio window in the frequency domain. -It does not use a user-set signal-level threshold. +The squelch operates server-side and supports two modes selected by the `level` field: -Frontend command: +### Frontend command ```json -{ "cmd": "squelch", "enabled": true } +{ "cmd": "squelch", "enabled": true, "level": null } +{ "cmd": "squelch", "enabled": true, "level": -50.0 } ``` -Algorithm (per audio frame): -- Compute per-bin power over the audio FFT slice: - - `p_i = |X_i|^2` -- Compute relative variance: - - `rv = var(p) / mean(p)^2` -- Compute a bandwidth-independent score (where `N` is the number of bins): - - `scaled = (rv - 1) * sqrt(N)` +| Field | Type | Description | +|-----------|------------------|----------------------------------------------------------------------------------------------| +| `enabled` | `bool` | Enables or disables squelch entirely. | +| `level` | `Option` | `null` → auto (statistical algorithm). `Some(threshold_db)` → manual (power threshold in dB).| + +### Auto mode (`level: null`) + +Uses a frequency-domain statistical algorithm (per audio frame): + +- Compute per-bin power: `p_i = |X_i|^2` +- Compute relative variance: `rv = var(p) / mean(p)^2` +- Compute bandwidth-independent score: `scaled = (rv - 1) * sqrt(N)` Decision logic (fixed constants): + - Open immediately if `scaled >= 18`. - Open if `scaled >= 5` for 3 consecutive frames. -- When open, close only after `scaled < 2` for 10 consecutive frames (hysteresis). +- Close after `scaled < 2` for `SQUELCH_HYSTERESIS_FRAMES` (10) consecutive frames. + +### Manual mode (`level: Some(threshold_db)`) + +Compares the frame's signal power (dB) against the user-defined threshold: + +- **Open** when `pwr_db >= threshold`. +- **Close** after `SQUELCH_HYSTERESIS_FRAMES` (10) consecutive frames below threshold. + +### Behavior When squelch is enabled and closed, the server does not emit audio packets. diff --git a/frontend b/frontend index db67b28..241f9a7 160000 --- a/frontend +++ b/frontend @@ -1 +1 @@ -Subproject commit db67b282ef933b0f0a923fd89f09a154f94d8517 +Subproject commit 241f9a76a1d75bab2037421898c97efc70e9321e diff --git a/reports/14-03-2026_18-48.md b/reports/14-03-2026_18-48.md new file mode 100644 index 0000000..a48f0de --- /dev/null +++ b/reports/14-03-2026_18-48.md @@ -0,0 +1,41 @@ +# Relatório — Code Review & Refatoração do Squelch Híbrido + +**Data:** 14/03/2026 18:48 +**Escopo:** Conformidade com `docs/ENGINEERING_RULES.md` + +## O que foi feito + +### 1. Eliminação de Magic Constants (Backend) +Declarada `const SQUELCH_HYSTERESIS_FRAMES: u8 = 10;` antes de `SquelchState` e substituída em 2 pontos: +- Modo manual: `manual_close_frames >= SQUELCH_HYSTERESIS_FRAMES` +- Modo auto: `close_hits >= SQUELCH_HYSTERESIS_FRAMES` + +### 2. Limpeza de Comentários (Backend) +Removidos 3 comentários didáticos proibidos pelas Engineering Rules: +- `// ── Manual mode: compare power in dB against user-defined threshold ──` +- `// Hysteresis: require 10 consecutive frames below threshold before closing.` +- `// ── Auto mode: original statistical algorithm ──` + +Comentários técnicos retidos (invariantes não-óbvias): +- L83: `var = E[p^2] - (E[p])^2` +- L351-353: Explicação do close hysteresis com edge-cases + +### 3. Documentação (docs/AUDIO.md) +Seção `## Squelch` reescrita de "auto, frequency-domain" para "hybrid: auto / manual", incluindo: +- Tabela de campos (`enabled`, `level`) +- Payloads de exemplo para ambos os modos +- Referência à constante `SQUELCH_HYSTERESIS_FRAMES` + +### 4. UX do Frontend (AudioPanel.tsx) +Slider do squelch: substituída abordagem CSS (`opacity-40 pointer-events-none`) por renderização condicional (`{!settings.squelchAuto && (...)}`). O slider desaparece da DOM quando Auto está ativo. + +### 5. Auditoria do Frontend (sem alterações) +`types.ts` e `useAudioClient.ts` analisados — sem comentários `// ADICIONADO`, `// Nova seção` ou similares encontrados. + +## Por que foi feito + +Conformidade com `docs/ENGINEERING_RULES.md`: +- **Magic constants** são explicitamente proibidas (seção "Explicitly forbidden") +- **Comentários óbvios** violam a regra "Do not comment obvious code" +- **Documentação** deve evoluir junto com o código +- UX "deliberada, calma, moderna" — elemento desaparecendo é mais limpo que ficar visualmente desabilitado