From b6166012b6e2288b69c92a5aa07c6e4ef4594b0f Mon Sep 17 00:00:00 2001 From: daltoncox <171525401+daltoncox@users.noreply.github.com> Date: Sat, 8 Aug 2026 14:07:03 -0700 Subject: [PATCH] Added Silent Disco HackRF Tool using Liquid DSP --- .gitignore | 63 +++++ CMakeLists.txt | 50 ++++ README.md | 352 ++++++++++++++++++++++- src/audio.c | 136 +++++++++ src/audio.h | 20 ++ src/channel.c | 77 ++++++ src/channel.h | 24 ++ src/dsp.c | 448 ++++++++++++++++++++++++++++++ src/dsp.h | 126 +++++++++ src/hackrx.c | 149 ++++++++++ src/hackrx.h | 31 +++ src/main.c | 599 ++++++++++++++++++++++++++++++++++++++++ src/ring.c | 133 +++++++++ src/ring.h | 31 +++ tests/sim_test.c | 132 +++++++++ tools/dsp_from_file.c | 88 ++++++ tools/iqdump_analyze.py | 68 +++++ 17 files changed, 2521 insertions(+), 6 deletions(-) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 src/audio.c create mode 100644 src/audio.h create mode 100644 src/channel.c create mode 100644 src/channel.h create mode 100644 src/dsp.c create mode 100644 src/dsp.h create mode 100644 src/hackrx.c create mode 100644 src/hackrx.h create mode 100644 src/main.c create mode 100644 src/ring.c create mode 100644 src/ring.h create mode 100644 tests/sim_test.c create mode 100644 tools/dsp_from_file.c create mode 100644 tools/iqdump_analyze.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0990990 --- /dev/null +++ b/.gitignore @@ -0,0 +1,63 @@ +# Prerequisites +*.d + +# Object files +*.o +*.ko +*.obj +*.elf + +# Linker output +*.ilk +*.map +*.exp + +# Precompiled Headers +*.gch +*.pch + +# Libraries +*.lib +*.a +*.la +*.lo + +# Shared objects (inc. Windows DLLs) +*.dll +*.so +*.so.* +*.dylib + +# Executables +*.exe +*.out +*.app +*.i*86 +*.x86_64 +*.hex + +# Debug files +*.dSYM/ +*.su +*.idb +*.pdb + +# Kernel Module Compile Results +*.mod* +*.cmd +.tmp_versions/ +modules.order +Module.symvers +Mkfile.old +dkms.conf + +# debug information files +*.dwo + +# build directories +build/ +bin/ +Debug/ +Release/ +x64/ +out/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..7e6cc55 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,50 @@ +cmake_minimum_required(VERSION 3.20) +project(sdisco C) + +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) + +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) +endif() + +set(CMAKE_C_FLAGS_RELEASE "-O3") + +find_library(HACKRF_LIB hackrf REQUIRED) +find_library(LIQUID_LIB liquid REQUIRED) +find_library(PORTAUDIO_LIB portaudio REQUIRED) + +find_package(Threads REQUIRED) + +include_directories(/opt/homebrew/include) +include_directories(/opt/homebrew/include/libhackrf) + +add_executable(sdisco + src/main.c + src/channel.c + src/hackrx.c + src/dsp.c + src/audio.c + src/ring.c +) + +target_include_directories(sdisco PRIVATE src /opt/homebrew/include) +target_link_libraries(sdisco + ${HACKRF_LIB} + ${LIQUID_LIB} + ${PORTAUDIO_LIB} + Threads::Threads +) + +target_compile_options(sdisco PRIVATE -Wall -Wextra) + +add_executable(sim_test + tests/sim_test.c + src/channel.c + src/dsp.c + src/ring.c +) + +target_include_directories(sim_test PRIVATE src tests /opt/homebrew/include) +target_link_libraries(sim_test ${LIQUID_LIB} Threads::Threads) +target_compile_options(sim_test PRIVATE -Wall -Wextra) diff --git a/README.md b/README.md index 9e15baa..6125f61 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,351 @@ -# rfctf-sdr-tools +# SilentDisco -## GNU Radio ZMQ Receivers +A multi-channel FM receiver for the DEFCON 34 RF CTF **Silent Disco** transmitters +(TX1345 A-E, or Ultra-900 U9 with `-u`). One HackRF One captures the entire +908-927.5 MHz band in a single 20 M$/s stream; up to 10 selected channels are +FM-demodulated simultaneously in software and mixed into one stereo audio +output, with runtime per-channel mute/unmute and master volume. A fully +simulated FM source is included for offline validation without a HackRF or +transmitters. -These ZMQ receivers can be opened in GNUradio and used to modify a signal into a visible and audible format. The virtual machines used for the virtual wifi *can* be used, but performance will be poor. If possible, it is recommended to use Gnuradio on your local computer for better performance. +## Status (as of last handoff) -https://zeromq.org/ +- Multi-channel DSP path is **working end-to-end** in simulation: 1, 2, 3 and 5 + simultaneous chains all decode their distinct tones cleanly + (`tests/sim_test.c` reports PASS up to 5 chains). +- **Audio cleanup pass (latest):** the chain now matches what SDR++ does — + 60 dB Kaiser channel filter, 15 kHz audio LPF after demod (kills the + discriminator hiss, 19 kHz pilot and 38 kHz subcarrier), a per-chain audio + AGC (voice was previously ~40 dB under full scale with no leveling), and a + kf default derived from `--dev` using liquid's verified kf = dev/fs_band + convention. See "Audio cleanup" under "Bugs found and fixed" below. +- Verified against live RF captures from the DEF CON environment: decode is + correct and the residual static on all channels traces to the elevated + in-channel RF noise floor from 39+ co-active carriers, not the DSP (see the + "high noise floor" entry below). While away from the transmitter area, run + `sdisco` with channels selected and listen; each channel's message should be + audible and routes to the correct slot. +- **Dropout/crackle fixes (latest):** the DSP thread no longer discards + non-multiple-of-40 IQ remainders (that was the ~0.35 % tone offset — now + exactly 400.0 Hz in `sim_test` — and a constant live crackle), and the + HackRF-side IQ ring grew from 3.3 ms to ~26 ms with dropped samples now + counted (`IQDROPS` in the diag line) instead of silently lost. -The server is sdr.rfhackers.com ports 6550-6570. By modifying the 'port' variable, you can access each of the different challenges. +## Dependencies -**Note:** If you're on a low bandwidth connection, it's recommended to save the IQ stream to a file first, then decode the information from the file. There is a file sink in 3.8 and a wav file sink in 3.10. They are disabled by default, right click and enable to save to a file and use in your favorite program. +- liquid-dsp (Homebrew: `brew install liquid-dsp`) +- portaudio (`brew install portaudio`) +- libhackrf (`brew install hackrf`) + +Build with CMake (macOS; paths assume `/opt/homebrew`): + +```sh +cmake -S . -B build +cmake --build build +``` + +Produces `build/sdisco` (main program) and `build/sim_test` (unit/sim test). + +## Running + +### Simulated FM source (no HackRF) + +```sh +./build/sdisco -s -c A1,B3,E5 +``` + +Each selected channel is synthesised as an FM transmitter at its true RF +frequency, offset relative to the receiver LO, carrying a unique tone +(channel i -> 400*(i+1) Hz) so that mixing and routing are verifiable by ear +or by `sim_test`. + +### Real HackRF + +```sh +./build/sdisco -c A1,B3,E5 --lna 30 --vga 40 --amp +``` + +`-l` forces the LO; otherwise the LO starts at the midpoint of the selected +channels and is automatically nudged (up to ±5 MHz) so every channel lands +at least ~1 MHz away from baseband DC — the HackRF has a strong DC spur at +0 Hz that otherwise turns the demod into static. The whole selection must +fit within 20 MHz (the 45 channels span 19.4 MHz so the full band fits in +one capture). + +### CLI options + +``` +-c, --channels comma/space separated channel names, max 10 (MAX_DEMOD) +-l, --lo HackRF LO center (default = auto, nudged off the DC spur) +-r, --rate capture rate (default = auto: smallest of + 8/10/12.5/16/20 Msps covering the selection) +-k, --kf FM demod factor (default: derived from --dev) +-v, --volume output volume (default 1.0) + --deemph de-emphasis tau in us (0=off, 50, 75; default 75) + --dev assumed FM deviation, sets default kf (default 200) + --bw channel filter width (default 220) + --lpf audio low-pass cutoff (default 15) + --level audio AGC target level (default 0.3) + --no-agc disable the audio AGC + --lna HackRF LNA gain 0-40 (default 20) + --vga HackRF VGA gain 0-62 (default 40) + --amp enable HackRF amp (default off) +-s, --sim run simulated FM source (no HackRF) +-h, --help +``` + +### If it sounds bad, try these first + +```sh +./build/sdisco -u -c A3 # defaults are the clean path +./build/sdisco -u -c A3 --deemph 0 # TX may not use pre-emphasis +./build/sdisco -u -c A3 --deemph 50 # ... or the EU 50 us standard +./build/sdisco -u -c A3 --bw 260 # distorted on loud peaks: widen IF +./build/sdisco -u -c A3 --no-agc -k 0.15 # manual level control +``` + +### Runtime keys (terminal) + +``` +1-9 toggle mute/unmute for channel 1..9 (in selection order) +0 toggle all channels ++/- raise/lower master volume +q quit +``` + +While running, a diagnostic line updates every ~2 s: +`RATE IQ <0-1> A1 C5 …` — RATE is the audio +production rate (should sit at ~48000 live; below that means the DSP is +falling behind and the sound card races through what arrives), IQ is the +mean |IQ| of the HackRF stream (RF presence / gain), followed by each +selected channel's demod RMS level. A channel with a signal shows a healthy +level; a silent one reads ~0. Good for confirming a transmitter is up before +you tweak de-emphasis. Drop/underrun counters appear only when nonzero: + +- `IQDROPS n` — RF samples lost because the DSP thread fell behind (the + HackRF ring is ~26 ms deep). n frozen at startup is benign; climbing means + the CPU cannot sustain 20 Msps — deselect channels. +- `AUDUND n` — PortAudio asked for frames the audio ring did not have + (starvation; audible as choppy/robotic dropouts). Should stay at zero + after priming; if it climbs, the DSP is not sustaining 48 kHz of audio + (check RATE). +- `AUDDROPS n` — mixed audio the output ring could not hold (overflow). + +## Channel map + +5 transmitters (A–E) x 9 slots (1–9), row = transmitter, col = slot. + +| TX | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | +|------|-------|-------|-------|-------|-------|-------|-------|-------|-------| +| A | 908.1 | 909.9 | 911.8 | 913.6 | 915.1 | 920.7 | 922.8 | 924.7 | 926.2 | +| B | 908.4 | 910.3 | 912.2 | 913.9 | 915.5 | 921.2 | 923.1 | 925.0 | 926.5 | +| C | 908.7 | 910.8 | 912.7 | 914.2 | 915.8 | 921.5 | 923.4 | 925.3 | 926.8 | +| D | 909.2 | 911.1 | 913.0 | 914.5 | 920.1 | 921.9 | 923.8 | 925.6 | 927.1 | +| E | 909.5 | 911.4 | 913.3 | 914.8 | 920.4 | 922.3 | 924.2 | 925.9 | 927.5 | + +Source: `src/channel.c:chan_tbl`, from the TX1345-A..E datasheet. + +### Ultra-900 (U9) map + +The same 920-927 MHz band is used by the Ultra-900 US headphone units +(HP2310-U). Selectable with `-u` / `--u9`; channels are named `CH1..CH10` +plus three alternates `A1..A3` (note the A1-A3 frequencies differ from the +TX1345 map - they are non-overlapping quieter alternates): + +| Channel | MHz | Color | Channel | MHz | Color | +|---------|------|----------|---------|------|----------| +| CH1 | 920.1| Blue | CH6 | 922.8| Turquoise| +| CH2 | 920.7| Red | CH7 | 923.4| Baby Blue| +| CH3 | 921.2| Green | CH8 | 924.2| Orange | +| CH4 | 921.9| Purple | CH9 | 924.7| Pink | +| CH5 | 922.3| Yellow | CH10 | 925.9| Mint | +| A1 | 920.5| (alt) | A2 | 922.4| (alt) | +| A3 | 926.7| (alt) | | | + +Source: `src/channel.c:u9_tbl`, from the Ultra-900 (U9) US unit datasheet. + +## Architecture + +``` +HackRF RX callback DSP: 1 reader/mixer thread PortAudio callback +┌─────────────────┐ + 1 worker thread per channel ┌────────────────────┐ +│ int8 IQ -> float│ ┌──────────────────────────────┐ │ ring → stereo out │ +│ complex, N Msps │ → │ ring → chunk broadcast: │ │ (L=R mono, volume │ +│ → IQ ring │ │ per worker: NCO mix → │ │ applied) │ +└─────────────────┘ │ FIR decim → freqdem → │ └────────────────────┘ + │ de-emphasis → 48 kHz │ + │ msresamp → 15 kHz LPF → AGC │ + │ reader sums chains │ + └──────────────────────────────┘ +``` + +- `src/hackrx.c` — HackRF wrapper: int8 I/Q -> liquid float-complex, 4096 + samples at a time into a shared input ring. +- `src/dsp.c/h` — the DSP engine. A reader thread drains the IQ ring in + whole multiples of the decimation factor and broadcasts each chunk to one + worker thread per channel (a single thread could not sustain 20 Msps with + several channels: the per-channel FIR decimator and NCO mix are ~one core + each there). Per chain, on its own thread: + - `nco_crcf` (LIQUID_VCO, table-driven) mix-down of `(chan_freq - lo)` to DC + - `firdecim_crcf` decimate fs_in -> 500 kHz (M = fs_in/500 kHz, computed + at runtime), Kaiser windowed-sinc, cutoff ±110 kHz (`--bw`), 60 dB + stopband; rejects the 200/400 kHz-spaced neighbours that the old 40 dB + Cheby2 IIR let through as crosstalk + - `freqdem` FM discriminator (kf from `--dev`, or `-k` explicitly; liquid's + convention is kf = deviation/fs_band, verified empirically) + - one-pole de-emphasis, tau from `--deemph` 0/50/75 µs (default 75) + - `msresamp_rrrf` 500 kHz -> 48 kHz + - audio low-pass (`firfilt_rrrf` Kaiser, `--lpf` 15 kHz default, 60 dB): + strips the discriminator's high-frequency hiss (FM noise rises with f²), + the 19 kHz stereo pilot and 38 kHz subcarrier remnants + - audio AGC (on by default, `--no-agc` to disable): peak-envelope follower, + ~10 ms attack / ~150 ms release, target `--level` 0.3, gain clamped to + [-12, +40] dB and smoothed; levels the voice regardless of RF gain and + the transmitter's actual deviation + - per-chain gain multiply (mute/volume); the reader then sums all active + chains sample-by-sample into one mono output ring. +- `src/audio.c/h` — PortAudio output: pulls mono ring, applies master volume, + duplicates to L/R (stereo) as float32 at 48 kHz, 1024-frame callback at + high-latency setting (glitch-free over low-latency: it is a listening + tool). Playback primes first: the callback outputs silence until the ring + holds a ~0.34 s cushion (`PRIME_FRAMES`), because in live mode the producer + is paced by the RF clock and can never recover a cushion once drained — + without priming the ring hovers near empty and every scheduling jitter is + an audible underrun. Frames the ring could not supply are counted in + `aud.underruns`. +- `src/ring.c/h` — lock-protected single-producer/single-consumer ring used by + both HackRF and sim sources, and between DSP and audio. + +The capture rate is picked at runtime: the smallest of 8/10/12.5/16/20 Msps +that covers the selected span (`-r` forces it). The decimation factor is +chosen so the post-decimation band rate is always 500 kHz, keeping every +downstream stage identical at any capture rate. A lower rate matters +because a USB link that cannot sustain the capture drops samples +on-device — invisible to every in-process counter and audible as the audio +RATE sagging below 48 kHz. DSP timing constants live in `src/dsp.h`: +`DSP_FS_IN=20e6` (max), `DSP_FS_BAND=500e3`, `DSP_CHUNK=4000`, +`DSP_FS_AUD=48e3`. + +## Testing + +`tests/sim_test.c` validates the DSP chain offline, no RF and no PortAudio: + +```sh +cmake --build build +./build/sim_test 5 # 5 channels +``` + +For each selected channel it generates an FM carrier at the channel's RF +frequency carrying the 400*(i+1) Hz tone, runs it through the `dsp` engine +at 20 M$/s, reads the mixed audio, then band-peak-searches the DFT around each +expected tone. A channel passes when a peak within 1% of the expected +frequency exists with amplitude > 0.02. `RESULT: PASS` is printed at the end. +Run with different channel counts (1, 2, 3, 5) to exercise the mix path. + +## Known issues / things to pick up next + +1. **Fix the `sim_thread` static buffers** (they are declared `static` inside + the loop in `src/main.c`; fine for a single sim, but they are not + thread-local and would collide if ever instantiated twice). +2. **Cleanup / hardening:** remove the leftover `dump.raw` at the repo root + (test trace) before shipping; check `build_dir/` is stale vs `build/`. +3. **Validate against real signals** — with a HackRF and the TX1345 units + present, listen for N distinct voices mapped to the right slots. + +## Bugs found and fixed (read before touching signal chain) + +- **NCO reset order.** `nco_crcf_reset()` after `nco_crcf_set_frequency()` + zeroes the LO offset, so every channel except the one exactly at DC came out + demodulating 0 Hz (i.e., garbage). Fix: reset first, then set frequency + (`dsp_set_chain` in `src/dsp.c`). +- **Integer underflow on channel offset.** `(freq - lo)` computed in `uint64_t` + underflows when the channel is below the LO, breaking carrier generation. + Fix: cast both to `double` BEFORE subtracting (`src/main.c:sim_thread`, + `tests/sim_test.c`). +- **float time-step in the synthesizer.** Using `(float)t` in the message + tone rounding at 20 M$/s (dt=50ns) freezes phase steps and corrupts the + tone. Fix: keep `t` a `double` everywhere in the simulators (`sim_thread`, + `sim_test`); the per-sample FM deviation still uses float arithmetic, only + time and phase accumulation are double. +- **Carrier on the HackRF DC spur (static, single channel).** An LO tuned to + exactly a channel puts the carrier at baseband 0 Hz, right on the HackRF's + strong DC bias spur. Fix: `pick_lo_clear()` nudges the LO ±1.5–5 MHz so the + carrier lands ≥1 MHz from DC (single D8=925.6 → LO 927.1 → −1.5 MHz + baseband); `dsp_thread` also subtracts the chunk's complex DC component + before the NCO mix. +- **Static on ALL channels beaten down by in-band noise.** With 39+ FM + carriers packed in the 19.4 MHz span + strong dual-channel RF, the in-channel + noise floor at DEF CON is elevated (audio SNR ceiling ~20–25 dB), not a DSP + bug: identical static measured via an independent SciPy demod of raw HackRF + IQ, and the synth loop through the full C chain gives 70+ dB SNR / 0 clicks. + Evidence: varying IF width (110→220 kHz), LO nudges, DC-offset compensation, + and residuals at −2.5k…−500 Hz all leave audio SNR flat. Fix so far: default + LNA lowered 30→20 dB, VGA 40 dB (measured +3–8 dB audio SNR at gain 20/40 vs + 30/40 in-environment). +- **Muffled / over-rolled-off voice.** The old code forced a 75 µs + de-emphasis; if the TX1345 unit transmits without pre-emphasis (or uses + 50 µs), the HF is cut and speech sounds muffled. Fix: `--deemph <0|50|75>` + µs (default 75). Try `--deemph 0` for a no-pre-emphasis transmitter, or + `--deemph 50` for the EU standard. +- **Crackle/static from silently dropped IQ samples ("sounds like a sample + rate mismatch").** Two drop sites, both fixed: + 1. `dsp_thread` read up to `DSP_CHUNK` from the IQ ring and used + `got / DSP_DECIM_M`, discarding the `got % 40` remainder on every short + read. With the HackRF's 4096-sample pushes the leftover was 96 samples, + of which 16 were thrown away — ~0.4 % of the stream lost in little + jumps, audible as constant crackle and measurable as the old + ~0.35 % tone offset in `sim_test` (401.6 Hz for a 400 Hz tone; now + exactly 400.0). Fix: only consume whole multiples of `DSP_DECIM_M`, + leaving the remainder in the ring for the next pass. + 2. The HackRF-side IQ ring was only 2^16 complex samples (3.3 ms at + 20 Msps) and `rx_callback` ignored short writes, so any DSP scheduling + hiccup dropped RF silently. Fix: ring raised to 2^19 (~26 ms), + `hackrx_open` now zero-initialises the struct (the uninitialised + `hx.error` also caused a phantom "hackrf error seen" exit in `--sim` + mode), and overflows are counted in `hx.drops` — the diag line shows + `IQDROPS n` if any occur. The RF stream is also started only after the + DSP thread and PortAudio are running; previously ~64 ms of RF overflowed + the ring during `Pa_Initialize()` at every startup. +- **Chronic PortAudio underruns in live mode (~2 Hz click, "fast" voices, + AUDUND climbing).** The audio ring started empty and the producer is paced + by the real-time RF clock, so it could never get ahead of the sound card: + the ring hovered near zero forever and every burst-phase jitter dropped + frames. The sim never showed it because the sim producer runs at CPU + speed and keeps the ring full. Fix: playback primes on a ~0.34 s cushion + before draining (and re-primes after a full drain), and the diag line now + prints the audio production `RATE` so a rate problem is visible as a + number instead of a hunch. +- **Audio RATE sagging below 48 kHz (choppy "fast" audio, dropouts every + ~2 s).** Two distinct causes, both fixed: + 1. *Capture rate too high for the USB link.* At 20 Msps (40 MB/s) the + link dropped samples on the HackRF itself — invisible to every + in-process counter. Fix: the capture rate is now the smallest of + 8/10/12.5/16/20 Msps that covers the selection (a single channel runs + at 8 Msps), `-r` forces it, and a one-time warning prints if the live + audio RATE sags below 47 kHz. + 2. *Single DSP thread.* The per-channel FIR decimator + NCO are ~one core + each at 20 Msps; one thread topped out near 5 channels. Fix: one + worker thread per channel fed by a chunk broadcast from the reader + thread (10 channels at 20 Msps now holds RATE 48000 in sim). The NCO + also moved to liquid's table-driven `LIQUID_VCO`. + The sim got matching fixes: it is paced to wall-clock real time and its + (exactly periodic, 2.5 ms) multi-tone baseband is rendered once and + looped, so it behaves like a live source without the CPU cost. +- **Audio cleanup (garbled voice behind static).** Three gaps vs what SDR++ + does, all in the demod chain, all fixed: + 1. *No audio low-pass at all.* The full ~250 kHz-wide discriminator output + went straight into the resampler; FM discriminator noise rises with f², + so the 15-24 kHz hiss band landed on top of the voice (that was most of + the "static"), along with the 19 kHz stereo pilot. Fix: Kaiser FIR LPF + at 15 kHz (`--lpf`), applied at 48 kHz after the resampler, 60 dB stop; + measured 83 dB rejection of a 20 kHz tone with the voice band intact. + 2. *Weak channel filter.* The order-6 Cheby2 IIR decimator had a 40 dB + stopband, so loud FM neighbours 400 kHz away leaked in as crosstalk. + Fix: Kaiser windowed-sinc FIR decimator, cutoff ±110 kHz (`--bw`), + 60 dB stopband. + 3. *No AGC, arbitrary kf.* Raw demod output sat at ~0.004 (~-48 dBFS) with + no leveling, hence "barely audible". Also, liquid's kf convention is + kf = deviation/fs_band (verified with a matched freqmod/freqdem pair), + not 2*pi*dev/fs. Fix: default kf derived from `--dev 75` (-> 0.15), and + a per-chain peak-envelope AGC (`--level`, `--no-agc`) that levels the + voice to ~0.3 regardless of RF gain or actual deviation. \ No newline at end of file diff --git a/src/audio.c b/src/audio.c new file mode 100644 index 0000000..680bbc3 --- /dev/null +++ b/src/audio.c @@ -0,0 +1,136 @@ +#include "audio.h" + +#include +#include +#include +#include + +#define FRAMES_PER_BUFFER 1024 + +/* Cushion the ring must hold before playback starts draining it. In live + * mode the producer is paced by the RF clock and can never catch up once it + * falls behind, so without priming the ring level hovers near empty and + * every burst-phase jitter becomes an audible underrun click. ~0.34 s at + * 48 kHz absorbs tens of ms of scheduling jitter; latency is irrelevant + * for a listening tool. */ +#define PRIME_FRAMES 16384 +#define MAX_CH 2 + +typedef struct { + audio_t *a; + float mono[FRAMES_PER_BUFFER]; + PaStream *stream; +} audio_cb_ctx_t; + +static audio_cb_ctx_t g_ctx; + +static int pa_callback(const void *input, void *output, + unsigned long frameCount, + const PaStreamCallbackTimeInfo *timeInfo, + PaStreamCallbackFlags statusFlags, + void *userData) +{ + (void)input; (void)timeInfo; (void)statusFlags; + audio_cb_ctx_t *ctx = (audio_cb_ctx_t *)userData; + audio_t *a = ctx->a; + float *out = (float *)output; + unsigned long n = (unsigned long)frameCount; + + /* priming: output silence until the ring holds a real cushion, and + * re-prime if it ever drains completely (a stall would otherwise + * restart the chronic near-empty underrun cycle) */ + if (!a->primed) { + if (ring_avail(a->ring) >= PRIME_FRAMES) { + a->primed = 1; + } else { + memset(out, 0, n * 2 * sizeof(float)); + return paContinue; + } + } + + /* read mono samples from the ring (may be fewer on underrun) */ + unsigned long got = (unsigned long)ring_read(a->ring, ctx->mono, n); + if (got < n) a->underruns += (n - got); + if (got == 0) a->primed = 0; + float vol = a->volume; + + for (unsigned long i = 0; i < n; i++) { + float s = (i < got) ? ctx->mono[i] : 0.0f; + s *= vol; + out[2 * i] = s; /* L */ + out[2 * i + 1] = s; /* R */ + } + return paContinue; +} + +int audio_init(audio_t *a, ring_t *ring, float fs) +{ + (void)fs; + memset(a, 0, sizeof(*a)); + a->ring = ring; + a->volume = 1.0f; + + PaError err = Pa_Initialize(); + if (err != paNoError) { + fprintf(stderr, "Pa_Initialize: %s\n", Pa_GetErrorText(err)); + return -1; + } + return 0; +} + +int audio_start(audio_t *a) +{ + g_ctx.a = a; + g_ctx.stream = NULL; + + PaStreamParameters out; + memset(&out, 0, sizeof(out)); + out.device = Pa_GetDefaultOutputDevice(); + if (out.device == paNoDevice) { + fprintf(stderr, "No default audio output device\n"); + return -1; + } + const PaDeviceInfo *info = Pa_GetDeviceInfo(out.device); + if (!info) { fprintf(stderr, "Pa_GetDeviceInfo failed\n"); return -1; } + + out.channelCount = 2; + out.sampleFormat = paFloat32; + /* this is a monitoring tool: favour glitch-free over low latency */ + out.suggestedLatency = info->defaultHighOutputLatency; + out.hostApiSpecificStreamInfo = NULL; + + PaError err = Pa_OpenDefaultStream(&g_ctx.stream, 0, 2, paFloat32, 48000, + FRAMES_PER_BUFFER, pa_callback, &g_ctx); + if (err != paNoError) { + fprintf(stderr, "Pa_OpenDefaultStream: %s\n", Pa_GetErrorText(err)); + return -1; + } + err = Pa_StartStream(g_ctx.stream); + if (err != paNoError) { + fprintf(stderr, "Pa_StartStream: %s\n", Pa_GetErrorText(err)); + return -1; + } + a->running = 1; + return 0; +} + +void audio_stop(audio_t *a) +{ + a->running = 0; + if (g_ctx.stream) { + Pa_StopStream(g_ctx.stream); + Pa_CloseStream(g_ctx.stream); + g_ctx.stream = NULL; + } +} + +void audio_destroy(audio_t *a) +{ + if (g_ctx.stream) { + Pa_StopStream(g_ctx.stream); + Pa_CloseStream(g_ctx.stream); + g_ctx.stream = NULL; + } + Pa_Terminate(); + a->running = 0; +} \ No newline at end of file diff --git a/src/audio.h b/src/audio.h new file mode 100644 index 0000000..14217ca --- /dev/null +++ b/src/audio.h @@ -0,0 +1,20 @@ +#ifndef AUDIO_H +#define AUDIO_H + +#include "ring.h" + +typedef struct { + ring_t *ring; /* float mono at fs_audio (already summed) */ + float volume; /* master volume (0..2) */ + volatile int running; + volatile int primed; /* ring cushion built; safe to drain */ + volatile unsigned long underruns; /* frames PortAudio wanted but the + ring could not supply */ +} audio_t; + +int audio_init(audio_t *a, ring_t *ring, float fs); +int audio_start(audio_t *a); +void audio_stop(audio_t *a); +void audio_destroy(audio_t *a); + +#endif \ No newline at end of file diff --git a/src/channel.c b/src/channel.c new file mode 100644 index 0000000..af41dd0 --- /dev/null +++ b/src/channel.c @@ -0,0 +1,77 @@ +#include "channel.h" + +#include +#include +#include + +/* Silent Disco system: 5 transmitters (A-E) x 9 channels each */ +static const char * tx_names[5] = { "A", "B", "C", "D", "E" }; + +/* channel frequencies in MHz, row = transmitter (A..E), col = slot (1..9) + * from the TX1345-MA..ME datasheet channel map */ +static const double chan_tbl[5][9] = { + { 908.1, 909.9, 911.8, 913.6, 915.1, 920.7, 922.8, 924.7, 926.2 }, /* A */ + { 908.4, 910.3, 912.2, 913.9, 915.5, 921.2, 923.1, 925.0, 926.5 }, /* B */ + { 908.7, 910.8, 912.7, 914.2, 915.8, 921.5, 923.4, 925.3, 926.8 }, /* C */ + { 909.2, 911.1, 913.0, 914.5, 920.1, 921.9, 923.8, 925.6, 927.1 }, /* D */ + { 909.5, 911.4, 913.3, 914.8, 920.4, 922.3, 924.2, 925.9, 927.5 }, /* E */ +}; + +void channel_init(channel_table_t *t) +{ + int n = 0; + for (int tx = 0; tx < 5 && n < MAX_CHANNELS; tx++) { + for (int slot = 1; slot <= 9 && n < MAX_CHANNELS; slot++) { + channel_t *c = &t->list[n]; + c->freq_hz = (uint64_t)llround(chan_tbl[tx][slot-1] * 1e6); + snprintf(c->name, sizeof(c->name), "%c%1d", + tx_names[tx][0], slot); + n++; + } + } + t->count = n; +} + +/* Ultra-900 "U9" unit, US model (HP2310-U). 10 selectable channels + 3 + * alternate ("A") channels. Frequencies per the U9 US datasheet. */ +typedef struct { + const char *name; + double freq_mhz; +} u9_chan_t; + +static const u9_chan_t u9_tbl[] = { + { "CH1", 920.1 }, { "CH2", 920.7 }, { "CH3", 921.2 }, { "CH4", 921.9 }, + { "CH5", 922.3 }, { "CH6", 922.8 }, { "CH7", 923.4 }, { "CH8", 924.2 }, + { "CH9", 924.7 }, { "CH10", 925.9 }, + { "A1", 920.5 }, { "A2", 922.4 }, { "A3", 926.7 }, +}; + +int channel_init_u9(channel_table_t *t) +{ + const int n = (int)(sizeof(u9_tbl) / sizeof(u9_tbl[0])); + for (int i = 0; i < n && i < MAX_CHANNELS; i++) { + channel_t *c = &t->list[i]; + c->freq_hz = (uint64_t)llround(u9_tbl[i].freq_mhz * 1e6); + snprintf(c->name, sizeof(c->name), "%s", u9_tbl[i].name); + } + t->count = n; + return t->count; +} + +int channel_find(const channel_table_t *t, const char *name) +{ + for (int i = 0; i < t->count; i++) { + if (strcasecmp(t->list[i].name, name) == 0) return i; + } + return -1; +} + +int channel_lookup(const channel_table_t *t, uint64_t freq_hz) +{ + /* could binary search since table is sorted by slot rows; + * linear scan is fine for 45 entries */ + for (int i = 0; i < t->count; i++) { + if (t->list[i].freq_hz == freq_hz) return i; + } + return -1; +} \ No newline at end of file diff --git a/src/channel.h b/src/channel.h new file mode 100644 index 0000000..1495367 --- /dev/null +++ b/src/channel.h @@ -0,0 +1,24 @@ +#ifndef CHANNEL_H +#define CHANNEL_H + +#include + +#define MAX_CHANNELS 64 +#define MAX_DEMOD 10 + +typedef struct { + uint64_t freq_hz; /* center frequency of the FM channel */ + char name[48]; +} channel_t; + +typedef struct { + channel_t list[MAX_CHANNELS]; + int count; +} channel_table_t; + +void channel_init(channel_table_t *t); /* TX1345 A-E map (default) */ +int channel_init_u9(channel_table_t *t); /* Ultra-900 U9 map; returns count */ +int channel_find(const channel_table_t *t, const char *name); /* index, or -1 */ +int channel_lookup(const channel_table_t *t, uint64_t freq_hz); /* index, or -1 */ + +#endif \ No newline at end of file diff --git a/src/dsp.c b/src/dsp.c new file mode 100644 index 0000000..8d1acd9 --- /dev/null +++ b/src/dsp.c @@ -0,0 +1,448 @@ +#include "dsp.h" + +#include +#include +#include +#include +#include +#include + +static void build_chain(dsp_chan_t *c, uint64_t chan_freq, + float fs_in, float fs_audio, float kf, float deemp_tau, + int decim_m, float chan_bw_hz, float lpf_hz, + int agc_on, float agc_target) +{ + memset(c, 0, sizeof(*c)); + c->chan_freq = chan_freq; + c->fs_band = fs_in / (float)decim_m; /* ~500 kHz by construction */ + c->gain = 1.0f; + c->on = 1; + c->agc_on = agc_on; + c->agc_target = agc_target; + c->agc_env = 0.0f; + c->agc_gain = 1.0f; + +/* NCO: created at DC; the (chan_freq - lo_hz) offset is applied later by + * dsp_set_chain() before the mix thread starts. Setting a large offset + * here is fine, but chan_freq=0 during build would push nco_crcf_constrain + * out of range and hang. LIQUID_VCO: table-driven oscillator, much + * cheaper than per-sample sincos at 20 Msps. */ + c->mix = nco_crcf_create(LIQUID_VCO); + nco_crcf_reset(c->mix); + + /* channel-selection FIR decimator: Kaiser windowed-sinc low-pass, + * cutoff at +/-chan_bw/2, ~60 dB stopband starting half a cutoff + * above the pass edge (before the 200/400 kHz-spaced neighbours). + * The old order-6 Cheby2 IIR only had a 40 dB stopband, which let + * loud adjacent carriers leak into the demod as static. */ + { + const float As = 60.0f; + float fc = 0.5f * chan_bw_hz / fs_in; /* e.g. 110e3/20e6 */ + float ft = 1.5f * fc; /* stop edge */ + if (ft > 0.45f) ft = 0.45f; + float df = ft - fc; /* transition width */ + unsigned n = (unsigned)ceilf((As - 7.95f) / (14.36f * df)) + 1; + /* round up to a multiple of the decimation factor */ + n = ((n + (unsigned)decim_m - 1) / (unsigned)decim_m) * (unsigned)decim_m; + float *h = malloc(n * sizeof(float)); + if (h) { + liquid_firdes_kaiser(n, fc, As, 0.0f, h); + c->decim = firdecim_crcf_create((unsigned)decim_m, h, n); + free(h); + } + if (!c->decim) /* fall back to liquid's own Kaiser design */ + c->decim = firdecim_crcf_create_kaiser((unsigned)decim_m, 6, As); + } + + /* FM demodulator */ + c->dem = freqdem_create(kf); + + /* de-emphasis: one-pole low-pass, alpha = exp(-1/(RC*fs)); skipped when + * deemp_tau <= 0 */ + if (deemp_tau > 0.0f) { + float rc = deemp_tau; /* e.g. 75e-6 */ + float alpha = expf(-1.0f / (rc * c->fs_band)); + float b[2] = { 1.0f - alpha, 0.0f }; + float a[2] = { 1.0f, -alpha }; + c->deemph = iirfilt_rrrf_create(b, 2, a, 2); + } else { + c->deemph = NULL; + } + + /* audio resampler fs_band -> fs_audio */ + c->resamp = msresamp_rrrf_create(fs_audio / c->fs_band, 60.0f); + + /* audio low-pass at fs_audio: keeps the voice band, removes the FM + * discriminator's high-frequency noise (its noise power rises with + * f^2), the 19 kHz stereo pilot and the 38 kHz subcarrier remnants. + * Kaiser, ~3 kHz transition, 60 dB stopband. Without this filter the + * 15-24 kHz hiss band lands on top of the voice. */ + c->lpf = NULL; + if (lpf_hz > 0.0f && 2.0f * lpf_hz < fs_audio) { + const float As = 60.0f; + float fc = lpf_hz / fs_audio; /* e.g. 15e3/48e3 */ + float df = 3000.0f / fs_audio; /* transition width */ + unsigned n = (unsigned)ceilf((As - 7.95f) / (14.36f * df)) + 1; + n |= 1; /* odd length */ + float *h = malloc(n * sizeof(float)); + if (h) { + liquid_firdes_kaiser(n, fc, As, 0.0f, h); + c->lpf = firfilt_rrrf_create(h, n); + free(h); + } + } +} + +int dsp_init(dsp_t *d, ring_t *iq, ring_t *audio, + float fs_in, float fs_audio, uint64_t lo_hz, + float kf, float deemp_tau, int nchan, + float chan_bw_hz, float lpf_hz, + int agc_on, float agc_target) +{ + memset(d, 0, sizeof(*d)); + d->iq = iq; + d->audio = audio; + d->fs_in = fs_in; + d->fs_audio = fs_audio; + d->lo_hz = lo_hz; + d->kf = kf; + d->deemp_tau = deemp_tau; + d->chan_bw_hz = chan_bw_hz; + d->lpf_hz = lpf_hz; + d->nchan = nchan; + + /* decimation that lands the band rate at ~DSP_FS_BAND regardless of + * the capture rate, so every downstream stage is rate-independent */ + d->decim_m = (int)lroundf(fs_in / DSP_FS_BAND); + if (d->decim_m < DSP_MIN_DECIM) d->decim_m = DSP_MIN_DECIM; + + pthread_mutex_init(&d->ch_lock, NULL); + pthread_mutex_init(&d->sync_lock, NULL); + pthread_cond_init(&d->cv_go, NULL); + pthread_cond_init(&d->cv_done, NULL); + d->generation = 0; + d->done_count = 0; + d->running = 0; + + d->chunk = malloc(DSP_CHUNK * sizeof(liquid_float_complex)); + d->res_all = malloc((size_t)nchan * SCRATCH_RESAMP * sizeof(float)); + d->mix_buf = malloc(SCRATCH_RESAMP * sizeof(float)); + + /* build all nchan chains; frequencies assigned via dsp_set_chain() + before dsp_start() */ + for (int i = 0; i < nchan; i++) { + build_chain(&d->ch[i], 0, fs_in, fs_audio, kf, deemp_tau, + d->decim_m, chan_bw_hz, lpf_hz, agc_on, agc_target); + } + return 0; +} + +void dsp_destroy(dsp_t *d) +{ + for (int i = 0; i < d->nchan; i++) { + dsp_chan_t *c = &d->ch[i]; + if (c->mix) nco_crcf_destroy(c->mix); + if (c->decim) firdecim_crcf_destroy(c->decim); + if (c->dem) freqdem_destroy(c->dem); + if (c->deemph) iirfilt_rrrf_destroy(c->deemph); + if (c->resamp) msresamp_rrrf_destroy(c->resamp); + if (c->lpf) firfilt_rrrf_destroy(c->lpf); + } + free(d->chunk); + free(d->res_all); + free(d->mix_buf); + pthread_cond_destroy(&d->cv_go); + pthread_cond_destroy(&d->cv_done); + pthread_mutex_destroy(&d->sync_lock); + pthread_mutex_destroy(&d->ch_lock); +} + +/* assign channel frequency to chain index; only call before dsp_start */ +int dsp_set_chain(dsp_t *d, int idx, uint64_t chan_freq) +{ + if (idx < 0 || idx >= d->nchan) return -1; + dsp_chan_t *c = &d->ch[idx]; + c->chan_freq = chan_freq; + + double offset = (double)chan_freq - (double)d->lo_hz; /* Hz, +/-10 MHz */ + nco_crcf_reset(c->mix); + nco_crcf_set_frequency(c->mix, (float)(2.0 * M_PI * offset / (double)d->fs_in)); + firdecim_crcf_reset(c->decim); + freqdem_reset(c->dem); + if (c->deemph) iirfilt_rrrf_reset(c->deemph); + if (c->resamp) msresamp_rrrf_reset(c->resamp); + if (c->lpf) firfilt_rrrf_reset(c->lpf); + c->agc_env = 0.0f; + c->agc_gain = 1.0f; + return 0; +} + +void dsp_set_gain(dsp_t *d, int idx, float gain) +{ + if (idx < 0 || idx >= d->nchan) return; + pthread_mutex_lock(&d->ch_lock); + d->ch[idx].gain = (gain < 0.0f) ? 0.0f : gain; + d->ch[idx].on = d->ch[idx].gain > 0.0f; + pthread_mutex_unlock(&d->ch_lock); +} + +float dsp_get_gain(dsp_t *d, int idx) +{ + if (idx < 0 || idx >= d->nchan) return 0.0f; + return d->ch[idx].gain; +} + +float dsp_get_level(dsp_t *d, int idx) +{ + if (idx < 0 || idx >= d->nchan) return 0.0f; + pthread_mutex_lock(&d->ch_lock); + float v = d->ch[idx].lvl_est; + pthread_mutex_unlock(&d->ch_lock); + return v; +} + +float dsp_get_iq_level(dsp_t *d) +{ + pthread_mutex_lock(&d->ch_lock); + float v = d->iq_lvl; + pthread_mutex_unlock(&d->ch_lock); + return v; +} + +/* Per-chain worker: waits for each new input chunk, then runs its whole + * demod pipeline on it. Chains only read the shared chunk; all mutable + * state is per-chain, so no locking is needed on the DSP path itself. */ +static void *chain_thread(void *arg) +{ + dsp_worker_t *w = (dsp_worker_t *)arg; + dsp_t *d = w->d; + const int c = w->idx; + dsp_chan_t *ch = &d->ch[c]; + float *res = d->res_all + (size_t)c * SCRATCH_RESAMP; + + static _Thread_local liquid_float_complex band[DSP_CHUNK]; + static _Thread_local float det[SCRATCH_BAND]; + + uint64_t seen = 0; + for (;;) { + pthread_mutex_lock(&d->sync_lock); + while (d->generation == seen && d->running) + pthread_cond_wait(&d->cv_go, &d->sync_lock); + if (!d->running) { + pthread_mutex_unlock(&d->sync_lock); + break; + } + seen = d->generation; + size_t got = d->chunk_len; + pthread_mutex_unlock(&d->sync_lock); + + float gain; + pthread_mutex_lock(&d->ch_lock); + gain = ch->gain; + pthread_mutex_unlock(&d->ch_lock); + + size_t nband = got / (size_t)d->decim_m; + unsigned ny = 0; + + if (gain > 0.0f && nband > 0) { + /* shift channel to DC */ + nco_crcf_mix_block_down(ch->mix, d->chunk, band, (unsigned)got); + + /* decimate to fs_band (n*M in -> n out) */ + firdecim_crcf_execute_block(ch->decim, band, (unsigned)nband, band); + + /* FM demodulate */ + freqdem_demodulate_block(ch->dem, band, (unsigned)nband, det); + + /* per-chain demod level (pre gain, for diagnostics) */ + float acc = 0.0f; + for (unsigned i = 0; i < nband; i++) acc += det[i] * det[i]; + acc /= (float)nband; + pthread_mutex_lock(&d->ch_lock); + ch->lvl_est = 0.9f * ch->lvl_est + 0.1f * sqrtf(acc); + pthread_mutex_unlock(&d->ch_lock); + + /* de-emphasis (in place) */ + if (ch->deemph) + iirfilt_rrrf_execute_block(ch->deemph, det, (unsigned)nband, + det); + + /* resample to fs_audio */ + msresamp_rrrf_execute(ch->resamp, det, nband, res, &ny); + + if (ny > 0) { + /* audio low-pass: strip discriminator hiss above the voice + * band, the 19 kHz pilot and 38 kHz subcarrier remnants */ + if (ch->lpf) + firfilt_rrrf_execute_block(ch->lpf, res, ny, res); + + /* audio AGC: level the voice independent of RF gain and of + * the (unknown) transmitter deviation. Peak-envelope + * follower with fast-ish attack and slow release; gain is + * smoothed to avoid zipper noise and clamped so static is + * not boosted forever. */ + if (ch->agc_on) { + const float atk = 1.0f - expf(-1.0f / (0.010f * d->fs_audio)); + const float rel = 1.0f - expf(-1.0f / (0.150f * d->fs_audio)); + const float gmax = 100.0f; /* +40 dB */ + const float gmin = 0.25f; /* -12 dB */ + const float gsmooth = 0.002f; + for (unsigned i = 0; i < ny; i++) { + float m = fabsf(res[i]); + float a = (m > ch->agc_env) ? atk : rel; + ch->agc_env += a * (m - ch->agc_env); + float g = ch->agc_target / (ch->agc_env + 1e-6f); + if (g > gmax) g = gmax; + if (g < gmin) g = gmin; + ch->agc_gain += gsmooth * (g - ch->agc_gain); + res[i] *= ch->agc_gain; + } + } + + /* per-chain volume (after the AGC so muting is instant and + * does not disturb the AGC's envelope) */ + for (unsigned i = 0; i < ny; i++) res[i] *= gain; + } + } + d->chunk_ny[c] = ny; + + pthread_mutex_lock(&d->sync_lock); + if (++d->done_count >= d->nchan) + pthread_cond_signal(&d->cv_done); + pthread_mutex_unlock(&d->sync_lock); + } + return NULL; +} + +/* Reader/mixer: drains the IQ ring in whole multiples of the decimation + * factor, publishes each chunk to the workers, then mixes their audio. */ +static void *dsp_thread(void *arg) +{ + dsp_t *d = (dsp_t *)arg; + + while (d->running) { + /* pull one chunk of complex input; poll so we can exit promptly. + * Consume only whole multiples of the decimation factor. Reading + * "whatever is there" and flooring to a multiple of M silently + * discards the remainder (up to M-1 samples per read): with the + * HackRF's 4096-sample pushes this threw away ~0.4% of the stream + * in little jumps, audible as crackle/static and measurable as a + * ~0.35% time compression of the demodulated audio. Leaving the + * remainder in the ring costs nothing -- it is consumed next pass. */ + size_t navail = ring_avail(d->iq); + size_t want = navail - (navail % (size_t)d->decim_m); + size_t maxchunk = DSP_CHUNK - (DSP_CHUNK % (size_t)d->decim_m); + if (want > maxchunk) want = maxchunk; + if (want == 0) { + usleep(200); + continue; + } + size_t got = ring_read(d->iq, d->chunk, want); + if (got == 0) { + usleep(200); + continue; + } + + /* remove any DC bias in the chunk (HackRF DC spur / ADC offset). + * The NCO mix cannot fix this because the spur sits at baseband 0, + * so subtract the complex mean up front. */ + liquid_float_complex dc = 0.0f; + for (size_t i = 0; i < got; i++) dc += d->chunk[i]; + dc /= (float)got; + for (size_t i = 0; i < got; i++) d->chunk[i] -= dc; + + /* input level: mean |IQ| over the (DC-removed) chunk */ + float iq_lvl = 0.0f; + for (size_t i = 0; i < got; i++) + iq_lvl += cabsf(d->chunk[i]); + iq_lvl /= (float)got; + + /* publish the chunk and wait for every chain worker */ + pthread_mutex_lock(&d->sync_lock); + d->chunk_len = got; + d->done_count = 0; + d->generation++; + pthread_cond_broadcast(&d->cv_go); + while (d->done_count < d->nchan && d->running) + pthread_cond_wait(&d->cv_done, &d->sync_lock); + int completed = (d->done_count >= d->nchan); + pthread_mutex_unlock(&d->sync_lock); + if (!completed) continue; /* shutdown raced the chunk; abandon it */ + + /* mix the chains that produced audio this chunk */ + size_t nout = 0; + int nactive = 0; + int active[MAX_DEMOD]; + for (int c = 0; c < d->nchan; c++) { + active[c] = (d->chunk_ny[c] > 0); + if (active[c]) { + if (nactive == 0 || d->chunk_ny[c] < nout) + nout = d->chunk_ny[c]; + nactive++; + } + } + if (nactive == 0 || nout == 0) continue; + for (unsigned j = 0; j < nout; j++) { + float acc = 0.0f; + for (int c = 0; c < d->nchan; c++) + if (active[c]) + acc += d->res_all[(size_t)c * SCRATCH_RESAMP + j]; + d->mix_buf[j] = acc; + } + size_t aw = ring_write(d->audio, d->mix_buf, nout); + if (aw < nout) d->aud_drops += nout - aw; + d->aud_produced += aw; + + /* commit input level for diagnostics */ + pthread_mutex_lock(&d->ch_lock); + d->iq_lvl = iq_lvl; + pthread_mutex_unlock(&d->ch_lock); + } + return NULL; +} + +int dsp_start(dsp_t *d) +{ + d->running = 1; + for (int i = 0; i < d->nchan; i++) { + d->workers[i].d = d; + d->workers[i].idx = i; + if (pthread_create(&d->worker_th[i], NULL, chain_thread, + &d->workers[i]) != 0) { + d->running = 0; + pthread_mutex_lock(&d->sync_lock); + pthread_cond_broadcast(&d->cv_go); + pthread_mutex_unlock(&d->sync_lock); + for (int k = 0; k < i; k++) + pthread_join(d->worker_th[k], NULL); + return -1; + } + } + if (pthread_create(&d->thread, NULL, dsp_thread, d) != 0) { + d->running = 0; + pthread_mutex_lock(&d->sync_lock); + pthread_cond_broadcast(&d->cv_go); + pthread_mutex_unlock(&d->sync_lock); + for (int i = 0; i < d->nchan; i++) + pthread_join(d->worker_th[i], NULL); + return -1; + } + return 0; +} + +void dsp_stop(dsp_t *d) +{ + d->running = 0; + /* wake any thread parked on either cond so everyone can exit */ + pthread_mutex_lock(&d->sync_lock); + pthread_cond_broadcast(&d->cv_go); + pthread_cond_broadcast(&d->cv_done); + pthread_mutex_unlock(&d->sync_lock); +} + +void dsp_join(dsp_t *d) +{ + pthread_join(d->thread, NULL); + for (int i = 0; i < d->nchan; i++) + pthread_join(d->worker_th[i], NULL); +} \ No newline at end of file diff --git a/src/dsp.h b/src/dsp.h new file mode 100644 index 0000000..ae59858 --- /dev/null +++ b/src/dsp.h @@ -0,0 +1,126 @@ +#ifndef DSP_H +#define DSP_H + +#include +#include +#include + +#include "ring.h" +#include "channel.h" + +/* Input sample rate ceiling (hackrf): 20 Msps covers the whole 908-927.5 MHz + * band. The runtime rate may be lower (see main.c): the decimation factor + * is chosen so the post-decimation band rate is always DSP_FS_BAND, which + * keeps every downstream stage (channel filter, de-emphasis, kf, audio + * resampler) independent of the capture rate. */ +#define DSP_FS_IN 20000000.0f +#define DSP_FS_BAND 500000.0f + +/* smallest decimation factor supported (bounds scratch buffer sizing) */ +#define DSP_MIN_DECIM 8 + +/* DSP thread reads at most this many complex samples at a time */ +#define DSP_CHUNK 4000 + +/* scratch sizing: nband is largest at the smallest decimation factor */ +#define SCRATCH_BAND (DSP_CHUNK / DSP_MIN_DECIM + 64) +#define SCRATCH_RESAMP (SCRATCH_BAND * 4 + 64) + +#define DSP_FS_AUD 48000.0f + +/* one chain: full demod pipeline for a single selected channel */ +typedef struct { + uint64_t chan_freq; /* tuned channel (RF) */ + + /* DSP stages */ + nco_crcf mix; /* shift channel to DC */ + firdecim_crcf decim; /* channel-select FIR, decimate to fs_band */ + freqdem dem; /* FM demodulator */ + iirfilt_rrrf deemph; /* de-emphasis one-pole */ + msresamp_rrrf resamp; /* fs_band -> fs_audio */ + firfilt_rrrf lpf; /* audio low-pass at fs_audio (~15 kHz) */ + + float fs_band; + + /* audio AGC state (dsp_thread only, no lock needed) */ + int agc_on; + float agc_target; /* target peak envelope */ + float agc_env; /* |x| envelope estimate */ + float agc_gain; /* smoothed gain actually applied */ + + /* per-chain controls, guarded by dsp_t.ch_lock */ + float gain; /* 0 (muted) .. 2 */ + int on; + float lvl_est; /* running demod RMS level (post-gain) */ +} dsp_chan_t; + +typedef struct dsp_s dsp_t; +typedef struct { dsp_t *d; int idx; } dsp_worker_t; + +struct dsp_s { + dsp_chan_t ch[MAX_DEMOD]; + int nchan; /* number of chains built */ + + ring_t *iq; /* shared input ring @ fs_in */ + ring_t *audio; /* summed output ring @ fs_audio */ + + float fs_in; + float fs_audio; + uint64_t lo_hz; + float kf; + float deemp_tau; + float chan_bw_hz; /* channel-selection filter width */ + float lpf_hz; /* audio low-pass cutoff (0 = off) */ + int decim_m; /* fs_in / fs_band, computed in dsp_init */ + + /* per-chain gain lock */ + pthread_mutex_t ch_lock; + int chan_dirty; + + /* input diagnostics, written by dsp_thread, read under ch_lock */ + float iq_lvl; /* running mean |IQ| of the last chunk */ + + /* audio ring overflow: mixed samples the ring could not take */ + volatile size_t aud_drops; + /* total mixed samples written to the audio ring (rate diagnostics) */ + volatile size_t aud_produced; + + /* Threading: one reader/mixer thread plus one worker thread per + * channel. The reader fills chunk, bumps the generation, and every + * worker demodulates its own chain from the same chunk in parallel + * (the FIR decimator + NCO per channel are ~one core each at high + * rates x many channels; a single thread could not sustain 20 Msps + * with several channels selected). */ + volatile int running; + pthread_t thread; /* reader/mixer */ + pthread_t worker_th[MAX_DEMOD]; /* per-chain workers */ + dsp_worker_t workers[MAX_DEMOD]; + + liquid_float_complex *chunk; /* DSP_CHUNK samples, reader-owned */ + size_t chunk_len; + size_t chunk_ny[MAX_DEMOD]; /* per-chain audio count this chunk */ + float *res_all; /* nchan x SCRATCH_RESAMP audio */ + float *mix_buf; /* SCRATCH_RESAMP mix scratch */ + pthread_mutex_t sync_lock; + pthread_cond_t cv_go; + pthread_cond_t cv_done; + uint64_t generation; + int done_count; +}; + +int dsp_init(dsp_t *d, ring_t *iq, ring_t *audio, + float fs_in, float fs_audio, uint64_t lo_hz, + float kf, float deemp_tau, int nchan, + float chan_bw_hz, float lpf_hz, + int agc_on, float agc_target); +void dsp_destroy(dsp_t *d); +int dsp_set_chain(dsp_t *d, int idx, uint64_t chan_freq); +void dsp_set_gain(dsp_t *d, int idx, float gain); +float dsp_get_gain(dsp_t *d, int idx); +float dsp_get_level(dsp_t *d, int idx); /* running demod RMS level */ +float dsp_get_iq_level(dsp_t *d); /* running mean |IQ| */ +int dsp_start(dsp_t *d); +void dsp_stop(dsp_t *d); +void dsp_join(dsp_t *d); + +#endif \ No newline at end of file diff --git a/src/hackrx.c b/src/hackrx.c new file mode 100644 index 0000000..a1f3c7c --- /dev/null +++ b/src/hackrx.c @@ -0,0 +1,149 @@ +#include "hackrx.h" + +#include +#include +#include +#include + +/* RX buffer holds float-complex samples; transfer buffers are converted in + * chunks of this many samples */ +#define RX_PUSH_CHUNK 4096 + +/* Default libhackrf transfer layout: interleaved int8 I/Q pairs */ +static int rx_callback(hackrf_transfer *transfer) +{ + hackrx_t *h = (hackrx_t *)transfer->rx_ctx; + + if (!h->running) return -1; + + /* optionally save raw int8 I/Q for later off-line analysis */ + if (h->dump && h->dump_bytes < h->dump_cap) { + size_t room = h->dump_cap - h->dump_bytes; + size_t n = (size_t)transfer->valid_length < room + ? (size_t)transfer->valid_length : room; + if (n > 0) { + size_t w = fwrite(transfer->buffer, 1, n, h->dump); + h->dump_bytes += w; + } + /* leave the file open; close it on hackrx_stop */ + } + + int pairs = transfer->valid_length / 2; + if (pairs <= 0) return 0; + + int off = 0; + while (off < pairs) { + int n = RX_PUSH_CHUNK; + if (n > pairs - off) n = pairs - off; + + /* convert int8 pairs to float complex in a temp buffer */ + static liquid_float_complex tmp[RX_PUSH_CHUNK]; + const int8_t *b = (const int8_t *)transfer->buffer; + for (int i = 0; i < n; i++) { + tmp[i] = ((float)b[2 * (off + i)] / 128.0f) + + ((float)b[2 * (off + i) + 1] / 128.0f) * _Complex_I; + } + /* non-blocking write: if the DSP thread falls behind and the ring + * is full, the tail of this block is dropped. Count it so the + * diag line can show it -- dropped RF is audible as crackle. */ + size_t w = ring_write(&h->iq, tmp, (size_t)n); + if (w < (size_t)n) h->drops += (size_t)n - w; + off += n; + } + return 0; +} + +int hackrx_open(hackrx_t *h) +{ + memset(h, 0, sizeof(*h)); /* dump/error/drops must not be garbage */ + int r = hackrf_init(); + if (r != HACKRF_SUCCESS) { + fprintf(stderr, "hackrf_init: %s\n", hackrf_error_name(r)); + return -1; + } + r = hackrf_open(&h->dev); + if (r != HACKRF_SUCCESS) { + fprintf(stderr, "hackrf_open: %s\n", hackrf_error_name(r)); + return -1; + } + /* 2^19 complex samples ~= 26 ms at 20 Msps: absorbs DSP thread + * scheduling jitter. The old 2^16 (3.3 ms) overflowed on any hiccup + * and the dropped RF was audible as static. */ + ring_init(&h->iq, sizeof(liquid_float_complex), 1 << 19); + return 0; +} + +int hackrx_configure(hackrx_t *h, uint64_t lo_hz, double sample_rate, + uint32_t lna_gain, uint32_t vga_gain, int amp_enable) +{ + int r; + r = hackrf_set_sample_rate(h->dev, sample_rate); + if (r != HACKRF_SUCCESS) { fprintf(stderr, "set_sample_rate: %s\n", hackrf_error_name(r)); return -1; } + r = hackrf_set_freq(h->dev, lo_hz); + if (r != HACKRF_SUCCESS) { fprintf(stderr, "set_freq: %s\n", hackrf_error_name(r)); return -1; } + r = hackrf_set_lna_gain(h->dev, lna_gain); + if (r != HACKRF_SUCCESS) { fprintf(stderr, "lna_gain: %s\n", hackrf_error_name(r)); return -1; } + r = hackrf_set_vga_gain(h->dev, vga_gain); + if (r != HACKRF_SUCCESS) { fprintf(stderr, "vga_gain: %s\n", hackrf_error_name(r)); return -1; } + r = hackrf_set_amp_enable(h->dev, amp_enable); + if (r != HACKRF_SUCCESS) { fprintf(stderr, "amp: %s\n", hackrf_error_name(r)); return -1; } + return 0; +} + +int hackrx_set_freq(hackrx_t *h, uint64_t lo_hz) +{ + return hackrf_set_freq(h->dev, lo_hz); +} + +int hackrx_start(hackrx_t *h) +{ + h->running = 1; + int r = hackrf_start_rx(h->dev, rx_callback, h); + if (r != HACKRF_SUCCESS) { + fprintf(stderr, "start_rx: %s\n", hackrf_error_name(r)); + h->running = 0; + return -1; + } + return 0; +} + +int hackrx_stop(hackrx_t *h) +{ + h->running = 0; + int r = hackrf_stop_rx(h->dev); + if (r != HACKRF_SUCCESS) { + fprintf(stderr, "stop_rx: %s\n", hackrf_error_name(r)); + return -1; + } + return 0; +} + +void hackrx_close(hackrx_t *h) +{ + if (h->dump) { fclose(h->dump); h->dump = NULL; } + if (h->dev) hackrf_close(h->dev); + hackrf_exit(); + h->dev = NULL; +} + +int hackrx_dump_open(hackrx_t *h, const char *path, size_t cap_bytes) +{ + h->dump = fopen(path, "wb"); + if (!h->dump) { + fprintf(stderr, "dump: cannot open %s\n", path); + return -1; + } + h->dump_bytes = 0; + h->dump_cap = cap_bytes; + return 0; +} + +void hackrx_dump_close(hackrx_t *h) +{ + if (h->dump) { + fflush(h->dump); + fclose(h->dump); + h->dump = NULL; + printf("iq dump saved: %zu bytes\n", h->dump_bytes); + } +} \ No newline at end of file diff --git a/src/hackrx.h b/src/hackrx.h new file mode 100644 index 0000000..aa3fcb2 --- /dev/null +++ b/src/hackrx.h @@ -0,0 +1,31 @@ +#ifndef HACKRX_H +#define HACKRX_H + +#include +#include +#include + +#include "ring.h" + +typedef struct { + ring_t iq; /* ring of liquid_float_complex */ + hackrf_device *dev; + volatile int running; + volatile int error; + volatile size_t drops; /* IQ samples dropped on ring overflow */ + FILE *dump; /* raw int8 I/Q dump (optional) */ + size_t dump_bytes; + size_t dump_cap; +} hackrx_t; + +int hackrx_open(hackrx_t *h); +int hackrx_configure(hackrx_t *h, uint64_t lo_hz, double sample_rate, + uint32_t lna_gain, uint32_t vga_gain, int amp_enable); +int hackrx_set_freq(hackrx_t *h, uint64_t lo_hz); +int hackrx_start(hackrx_t *h); +int hackrx_stop(hackrx_t *h); +void hackrx_close(hackrx_t *h); +int hackrx_dump_open(hackrx_t *h, const char *path, size_t cap_bytes); +void hackrx_dump_close(hackrx_t *h); + +#endif \ No newline at end of file diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..f0fae49 --- /dev/null +++ b/src/main.c @@ -0,0 +1,599 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "channel.h" +#include "hackrx.h" +#include "dsp.h" +#include "audio.h" +#include "ring.h" + +static volatile int g_quit = 0; + +static void on_sigint(int s) { (void)s; g_quit = 1; } + +/* --- synthetic FM transmitter for offline validation -------------------- */ +typedef struct { + ring_t *iq; + uint64_t lo; /* receiver LO */ + float fs_in; + int nchan; + uint64_t ch_freqs[MAX_DEMOD]; + volatile int running; + pthread_t thread; +} sim_xmit_t; + +/* generate one block of the multi-channel sim baseband with rotating + * phasors (no transcendentals per sample). Fallback path for the rare + * non-periodic geometry; see sim_thread. */ +static void sim_generate(const sim_xmit_t *s, liquid_float_complex *buf, int n, + liquid_float_complex *mtone, liquid_float_complex *carr, + liquid_float_complex *dstate, + const liquid_float_complex *mrot, + const liquid_float_complex *crot, float kfm) +{ + for (int i = 0; i < n; i++) { + liquid_float_complex acc = 0.0f; + for (int c = 0; c < s->nchan; c++) { + mtone[c] *= mrot[c]; + float msg = 0.55f * cimagf(mtone[c]); + /* FM deviation phasor: the per-sample angle is tiny + * (<= ~0.01 rad), so a 2nd-order Taylor step is exact to + * ~1e-7 rad and needs no transcendental call */ + float d = kfm * msg; + dstate[c] *= 1.0f + _Complex_I * d - 0.5f * d * d; + carr[c] *= crot[c]; + acc += carr[c] * dstate[c]; + } + buf[i] = acc * (0.8f / (float)s->nchan); + } +} + +static void *sim_thread(void *arg) +{ + sim_xmit_t *s = (sim_xmit_t *)arg; + const float dev = 50000.0f; /* 50 kHz deviation */ + const double dt = 1.0 / (double)s->fs_in; + const float kfm = 2.0f * (float)M_PI * dev * (float)dt; /* rad/sample per unit msg */ + + /* The sim signal is exactly periodic: every message tone is a multiple + * of 400 Hz and every carrier offset is a multiple of 100 kHz, so the + * whole multi-channel baseband repeats every 2.5 ms. Render one period + * once (full sin/cexpf math is affordable one-off) and then just loop + * it: streaming generation cannot sustain high rates x many channels + * in real time, and once the sim falls behind wall time the whole + * downstream pipeline -- and the audio RATE -- sags with it. */ + size_t period = (size_t)llround((double)s->fs_in / 400.0); + liquid_float_complex *loop = malloc(period * sizeof(*loop)); + int periodic = (loop != NULL); + if (periodic) { + for (int c = 0; c < s->nchan; c++) { + double off = (double)s->ch_freqs[c] - (double)s->lo; + double cyc = off * ((double)period * dt); /* cycles per period */ + if (fabs(cyc - round(cyc)) > 1e-6) { periodic = 0; break; } + } + } + if (periodic) { + double t = 0.0; + double phase[MAX_DEMOD] = {0}; + for (size_t i = 0; i < period; i++) { + liquid_float_complex acc = 0.0f; + for (int c = 0; c < s->nchan; c++) { + double mf = 400.0 * (double)(c + 1); + double msg = 0.55 * sin(2.0 * M_PI * mf * t); + phase[c] += 2.0 * M_PI * msg * (dev * dt); + phase[c] -= 2.0 * M_PI * floor(phase[c] / (2.0 * M_PI)); + double off = 2.0 * M_PI * + ((double)s->ch_freqs[c] - (double)s->lo) * dt; + phase[c] += off; + acc += cexp(_Complex_I * phase[c]); + } + loop[i] = acc * (0.8f / (float)s->nchan); + t += dt; + } + } + + /* phasor state for the non-periodic fallback path */ + static liquid_float_complex mrot[MAX_DEMOD], mtone[MAX_DEMOD]; + static liquid_float_complex crot[MAX_DEMOD], carr[MAX_DEMOD]; + static liquid_float_complex dstate[MAX_DEMOD]; + if (!periodic) { + for (int c = 0; c < s->nchan; c++) { + double mf = 400.0 * (double)(c + 1); + mrot[c] = cexpf(_Complex_I * (float)(2.0 * M_PI * mf * dt)); + mtone[c] = 1.0f; + double off = 2.0 * M_PI * + ((double)s->ch_freqs[c] - (double)s->lo) * dt; + crot[c] = cexpf(_Complex_I * (float)off); + carr[c] = 1.0f; + dstate[c] = 1.0f; + } + } + + /* pace production to wall-clock real time, like a live radio: without + * this the sim runs at CPU speed, the audio ring overflows (AUDDROPS) + * and every dropped burst is an audible click */ + struct timespec t0; + clock_gettime(CLOCK_MONOTONIC, &t0); + uint64_t produced = 0; + size_t pos = 0; + + while (s->running) { + static liquid_float_complex buf[4096]; + + if (periodic) { + for (int i = 0; i < 4096; i++) { + buf[i] = loop[pos]; + if (++pos == period) pos = 0; + } + } else { + sim_generate(s, buf, 4096, mtone, carr, dstate, mrot, crot, kfm); + /* keep the phasors on the unit circle */ + for (int c = 0; c < s->nchan; c++) { + mtone[c] /= cabsf(mtone[c]); + carr[c] /= cabsf(carr[c]); + dstate[c] /= cabsf(dstate[c]); + } + } + ring_write(s->iq, buf, 4096); + produced += 4096; + + /* sleep off however far ahead of real time we are */ + double due = (double)produced / (double)s->fs_in; + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + double elapsed = (double)(now.tv_sec - t0.tv_sec) + + 1e-9 * (double)(now.tv_nsec - t0.tv_nsec); + double ahead = due - elapsed; + if (ahead > 0.0005) usleep((useconds_t)(ahead * 1e6 * 0.9)); + } + free(loop); + return NULL; +} + +static void sim_start(sim_xmit_t *s) +{ + s->running = 1; + pthread_create(&s->thread, NULL, sim_thread, s); +} + +static void usage(const char *prog) +{ + printf("usage: %s [options]\n" + " -c, --channels demod channels, e.g. A1,B3,E5 (max %d)\n" + " -l, --lo hackrf LO center (default = auto, kept off\n" + " the DC spur)\n" + " -r, --rate capture rate (default = auto: smallest of\n" + " 8/10/12.5/16/20 Msps covering the selection)\n" + " -k, --kf FM demod factor (default: derived from --dev)\n" + " -v, --volume output volume (default 1.0)\n" + " --deemph de-emphasis time const 0,50,75 (default 75)\n" + " --dev assumed FM deviation, sets default kf (default 200)\n" + " --bw channel filter width (default 220)\n" + " --lpf audio low-pass cutoff (default 15)\n" + " --level audio AGC target level (default 0.3)\n" + " --no-agc disable the audio AGC\n" + " --dump save N MiB of raw int8 I/Q for analysis\n" + " --lna hackrf LNA gain 0-40 (default 20)\n" + " --vga hackrf VGA gain 0-62 (default 40)\n" + " --amp enable hackrf amp (default off)\n" + " -u, --u9 use Ultra-900 channel map (CH1-CH10, A1-A3)\n" + " -s, --sim run simulated FM source (no hackrf)\n" + " -h, --help\n" + "\n" + "Channel map (TX1345 A-E):\n", prog, MAX_DEMOD); +} + +/* parse a comma/space separated channel list into freq[]; returns count */ +static int parse_channels(const char *list, const channel_table_t *t, + uint64_t *freq, int max) +{ + int n = 0; + char buf[256]; + snprintf(buf, sizeof(buf), "%s", list); + buf[sizeof(buf) - 1] = '\0'; + + char *saveptr = NULL; + for (char *tok = strtok_r(buf, ", ", &saveptr); tok; + tok = strtok_r(NULL, ", ", &saveptr)) { + int idx = channel_find(t, tok); + if (idx < 0) { + fprintf(stderr, "unknown channel '%s'\n", tok); + continue; + } + freq[n++] = t->list[idx].freq_hz; + if (n >= max) break; + } + return n; +} + +/* LO = midpoint of min/max selected frequency */ +static uint64_t pick_lo(const uint64_t *freq, int nchan) +{ + uint64_t lo_min = ~0ULL, lo_max = 0; + for (int i = 0; i < nchan; i++) { + if (freq[i] < lo_min) lo_min = freq[i]; + if (freq[i] > lo_max) lo_max = freq[i]; + } + return (lo_min + lo_max) / 2; +} + +/* Distance from the nearest selected channel to DC in baseband, in Hz, for + * a given LO. Returns -1.0 if any channel falls outside the capture + * window. */ +static double lo_clearance(const uint64_t *freq, int nchan, uint64_t lo, + double fs) +{ + const double window_half = fs / 2.0; + double min_off = 1e300; + for (int i = 0; i < nchan; i++) { + double off = (double)freq[i] - (double)lo; + if (fabs(off) > window_half) return -1.0; + if (fabs(off) < min_off) min_off = fabs(off); + } + return min_off; +} + +/* Choose an LO that keeps every selected channel at least ~1 MHz away from + * baseband DC. The HackRF has a strong DC spur at 0 Hz baseband that would + * otherwise ride on any channel tuned there and turn the audio to static. + * All candidates must keep every channel inside the capture window; + * otherwise that geometry is impossible. */ +static uint64_t pick_lo_clear(const uint64_t *freq, int nchan, double fs) +{ + uint64_t lo_mid = pick_lo(freq, nchan); + uint64_t best = lo_mid; + double best_cl = 0.0; + + /* try nudging the LO by these offsets and keep the one with the largest + * channel-to-DC clearance */ + static const int64_t offs[] = { + 1500000LL, -1500000LL, 2500000LL, -2500000LL, + 4000000LL, -4000000LL, 5000000LL, -5000000LL, + }; + for (size_t k = 0; k < sizeof(offs)/sizeof(offs[0]); k++) { + int64_t cand = (int64_t)lo_mid + offs[k]; + if (cand < 0) continue; + double cl = lo_clearance(freq, nchan, (uint64_t)cand, fs); + if (cl < 0) continue; /* outside capture window */ + if (cl > best_cl) { best_cl = cl; best = (uint64_t)cand; } + if (best_cl >= 1100000.0) break; + } + return best; +} + +/* Smallest standard capture rate that covers the selection, with margin + * for the LO nudge and the channel filter skirt. A single channel does not + * need 20 Msps: lower rates halve (or better) the USB throughput, and a + * link that cannot sustain the rate drops samples on-device, which shows + * up as the audio production RATE falling below 48 kHz. */ +static double pick_sample_rate(uint64_t span_hz, float chan_bw_hz, + double forced) +{ + if (forced > 0.0) return forced; + static const double rates[] = {8e6, 10e6, 12.5e6, 16e6, 20e6}; + double need = (double)span_hz + (double)chan_bw_hz + 0.5e6; + for (size_t i = 0; i < sizeof(rates)/sizeof(rates[0]); i++) + if (rates[i] >= need) return rates[i]; + return DSP_FS_IN; +} + +int main(int argc, char **argv) +{ + uint64_t lo = 0; + double rate = 0.0; /* 0: auto */ + float kf = -1.0f; /* <0: derive from dev */ + float vol = 1.0f; + float deemph = 75.0f; /* us; 0 disables */ + float dev_khz = 200.0f;/* assumed FM deviation, for default kf */ + float bw_khz = 220.0f;/* channel filter width */ + float lpf_khz = 15.0f; /* audio low-pass cutoff */ + int agc_on = 1; + float agc_lvl = 0.3f; + uint32_t lna = 20, vga = 40; + int amp = 0; + int sim = 0; + int use_u9 = 0; + char chan_arg[256] = ""; + + enum { OPT_LNA = 1000, OPT_VGA, OPT_AMP, OPT_DEEMPH, OPT_DUMP, + OPT_LPF, OPT_BW, OPT_DEV, OPT_LEVEL, OPT_NOAGC }; + static const struct option longopts[] = { + {"channels", required_argument, NULL, 'c'}, + {"lo", required_argument, NULL, 'l'}, + {"rate", required_argument, NULL, 'r'}, + {"kf", required_argument, NULL, 'k'}, + {"volume", required_argument, NULL, 'v'}, + {"deemph", required_argument, NULL, OPT_DEEMPH}, + {"dump", required_argument, NULL, OPT_DUMP}, + {"lpf", required_argument, NULL, OPT_LPF}, + {"bw", required_argument, NULL, OPT_BW}, + {"dev", required_argument, NULL, OPT_DEV}, + {"level", required_argument, NULL, OPT_LEVEL}, + {"no-agc", no_argument, NULL, OPT_NOAGC}, + {"sim", no_argument, NULL, 's'}, + {"u9", no_argument, NULL, 'u'}, + {"lna-gain", required_argument, NULL, OPT_LNA}, + {"vga-gain", required_argument, NULL, OPT_VGA}, + {"amp", no_argument, NULL, OPT_AMP}, + {"help", no_argument, NULL, 'h'}, + {NULL, 0, NULL, 0} + }; + int c; + char dump_path[512] = ""; + while ((c = getopt_long(argc, argv, "c:l:r:k:v:shu", longopts, NULL)) != -1) { + switch (c) { + case 'c': snprintf(chan_arg, sizeof(chan_arg), "%s", optarg); break; + case 'l': lo = strtoull(optarg, NULL, 0); break; + case 'r': rate = strtod(optarg, NULL); break; + case 'k': kf = atof(optarg); break; + case 'v': vol = atof(optarg); break; + case OPT_DEEMPH: deemph = atof(optarg); break; + case OPT_LPF: lpf_khz = atof(optarg); break; + case OPT_BW: bw_khz = atof(optarg); break; + case OPT_DEV: dev_khz = atof(optarg); break; + case OPT_LEVEL: agc_lvl = atof(optarg); break; + case OPT_NOAGC: agc_on = 0; break; + case 's': sim = 1; break; + case 'u': use_u9 = 1; break; + case OPT_LNA: lna = (uint32_t)atoi(optarg); break; + case OPT_VGA: vga = (uint32_t)atoi(optarg); break; + case OPT_AMP: amp = 1; break; + case OPT_DUMP: snprintf(dump_path, sizeof(dump_path), "%s", optarg); break; + case 'h': default: usage(argv[0]); return 0; + } + } + + /* kf not given: derive from the assumed deviation. liquid's freqmod + * increments phase by 2*pi*kf*m per sample and freqdem divides it back + * out, so the kf that normalizes a full-deviation signal to ~unit + * amplitude is kf = dev/fs_band (verified empirically with a matched + * freqmod/freqdem pair). The audio AGC absorbs any residual mismatch. */ + if (kf < 0.0f) + kf = (float)((dev_khz * 1e3) / (double)DSP_FS_BAND); + + signal(SIGINT, on_sigint); + signal(SIGTERM, on_sigint); + + /* ---- channel table ---- */ + channel_table_t table; + if (use_u9) { + channel_init_u9(&table); + printf("Ultra-900 channel map\n"); + } else { + channel_init(&table); + } + + /* ---- select channels ---- */ + uint64_t freq[MAX_DEMOD]; + int nchan = 0; + if (chan_arg[0]) + nchan = parse_channels(chan_arg, &table, freq, MAX_DEMOD); + + while (nchan == 0) { + /* interactive mode: print menu and read channel names */ + printf("\nChannel selection (up to %d, within 20 MHz):\n", MAX_DEMOD); + for (int i = 0; i < table.count; i++) { + if (i % 5 == 0) printf(" "); + printf("%s=%.1f ", table.list[i].name, + table.list[i].freq_hz / 1e6); + if (i % 5 == 4) printf("\n"); + } + printf("\nChannels (comma separated, e.g. %s): ", + use_u9 ? "CH1,CH3,A2" : "A1,B3,C5"); + fflush(stdout); + char buf[256]; + if (!fgets(buf, sizeof(buf), stdin)) break; + buf[strcspn(buf, "\n")] = '\0'; + nchan = parse_channels(buf, &table, freq, MAX_DEMOD); + if (nchan == 0) + printf("No valid channels. Try again.\n"); + } + + /* ---- span check, sample rate and LO ---- */ + uint64_t lo_min = ~0ULL, lo_max = 0; + for (int i = 0; i < nchan; i++) { + if (freq[i] < lo_min) lo_min = freq[i]; + if (freq[i] > lo_max) lo_max = freq[i]; + } + if (lo_max - lo_min > 20000000ULL) { + fprintf(stderr, "channels span %.1f MHz (>20 MHz)\n", + (double)(lo_max - lo_min) / 1e6); + return 1; + } + /* pick the smallest rate covering the selection: a USB link that cannot + * sustain the capture rate drops samples on-device, which then shows up + * as the audio RATE sagging below 48 kHz (choppy "fast" audio) */ + double fs = pick_sample_rate(lo_max - lo_min, bw_khz * 1e3f, rate); + if (lo == 0) lo = pick_lo_clear(freq, nchan, fs); + + printf("selected %d channel%s, LO = %.1f MHz, span %.1f MHz, rate %.1f Msps\n", + nchan, nchan == 1 ? "" : "s", lo / 1e6, + (double)(lo_max - lo_min) / 1e6, fs / 1e6); + + /* ---- ring buffers ---- */ + ring_t iq, audio; + ring_init(&iq, sizeof(liquid_float_complex), 1 << 19); + ring_init(&audio, sizeof(float), 1 << 16); + + /* ---- hackrf or simulated source ---- */ + hackrx_t hx; + int have_hx = 0, hx_started = 0; + sim_xmit_t sx; + ring_t *iq_in = &iq; /* DSP input ring */ + if (!sim) { + if (hackrx_open(&hx) != 0) goto fail_hx; + have_hx = 1; + if (dump_path[0] && + hackrx_dump_open(&hx, dump_path, (size_t)4 << 20) != 0) goto fail_hx; + if (hackrx_configure(&hx, lo, fs, lna, vga, amp) != 0) goto fail_hx; + iq_in = &hx.iq; /* DSP reads what hackrf writes */ + /* NOTE: hackrx_start() deferred until the DSP and audio consumers + * are running -- streaming into a ring nobody drains yet just + * overflows it (the frozen IQDROPS burst seen at startup). */ + } else { + printf("simulating %d FM channels\n", nchan); + sx.iq = &iq; /* output goes to the DSP input ring */ + sx.lo = lo; + sx.fs_in = (float)fs; + sx.nchan = nchan; + for (int i = 0; i < nchan; i++) sx.ch_freqs[i] = freq[i]; + sim_start(&sx); + } + + /* ---- DSP ---- */ + dsp_t dsp; + dsp_init(&dsp, iq_in, &audio, (float)fs, DSP_FS_AUD, lo, + kf, deemph > 0.0f ? deemph * 1e-6f : 0.0f, nchan, + bw_khz * 1e3f, lpf_khz * 1e3f, agc_on, agc_lvl); + for (int i = 0; i < nchan; i++) { + dsp_set_chain(&dsp, i, freq[i]); + dsp_set_gain(&dsp, i, 1.0f); + } + + /* ---- audio ---- */ + audio_t aud; + if (audio_init(&aud, &audio, DSP_FS_AUD) != 0) return 1; + aud.volume = vol; + + if (dsp_start(&dsp) != 0) goto fail_dsp; + if (audio_start(&aud) != 0) goto fail_aud; + + /* RF stream starts last, once every consumer is draining */ + if (have_hx) { + if (hackrx_start(&hx) != 0) goto fail_stream; + hx_started = 1; + } + + /* ---- interactive key loop (termios raw) ---- */ + struct termios oldt, newt; + tcgetattr(STDIN_FILENO, &oldt); + newt = oldt; + newt.c_lflag &= ~(ICANON | ECHO); + newt.c_cc[VMIN] = 0; + newt.c_cc[VTIME] = 0; + tcsetattr(STDIN_FILENO, TCSANOW, &newt); + + printf("\nkeys: 1-0 toggle channels, +/- volume, q quit\n"); + long diag = 0; + while (!g_quit) { + char ch = 0; + if (read(STDIN_FILENO, &ch, 1) == 1) { + if (ch == 'q') break; + else if (ch >= '1' && ch <= '9' && (ch - '1') < nchan) { + int idx = ch - '1'; + float g = dsp_get_gain(&dsp, idx); + dsp_set_gain(&dsp, idx, (g > 0) ? 0.0f : 1.0f); + printf("channel %d %s: %s\n", idx + 1, + table.list[channel_lookup(&table, freq[idx])].name, + (g > 0) ? "muted" : "on"); + } else if (ch == '0') { + /* toggle ALL */ + int alloff = 1; + for (int i = 0; i < nchan; i++) + if (dsp_get_gain(&dsp, i) > 0) { alloff = 0; break; } + for (int i = 0; i < nchan; i++) + dsp_set_gain(&dsp, i, alloff ? 1.0f : 0.0f); + printf("all channels %s\n", alloff ? "on" : "muted"); + } else if (ch == '=' || ch == '+') { + aud.volume = aud.volume * 1.2f; + if (aud.volume > 2.0f) aud.volume = 2.0f; + printf("volume %4.2f\n", aud.volume); + } else if (ch == '-' || ch == '_') { + aud.volume = aud.volume * 0.8f; + if (aud.volume < 0.05f) aud.volume = 0.05f; + printf("volume %4.2f\n", aud.volume); + } + } else { + if (have_hx && hx.error) { printf("\nhackrf error seen\n"); break; } + if ((++diag % 100) == 0) { + /* audio production rate: should sit at ~48000 in live mode. + * Below that = DSP falling behind; the voice sounds fast + * because the sound card plays whatever arrives at 48 kHz. */ + static size_t last_prod = 0; + static struct timespec last_t; + static int have_last = 0; + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + printf("\r"); + if (have_last) { + double dt = (double)(now.tv_sec - last_t.tv_sec) + + 1e-9 * (double)(now.tv_nsec - last_t.tv_nsec); + if (dt > 0.0) { + double arate = (double)(dsp.aud_produced - last_prod) / dt; + printf("RATE %.0f ", arate); + /* live production sagging below 48k means the host + * is not receiving the full capture rate (USB link + * dropping samples on-device). Say so once. */ + static int rate_warned = 0; + if (have_hx && !rate_warned && arate < 47000.0) { + rate_warned = 1; + printf("\n*** audio rate %.0f < 48000: the USB " + "link is not sustaining %.1f Msps. " + "Try fewer channels, a lower --rate, or a " + "different USB port/cable. ***\n", + arate, fs / 1e6); + } + } + } + last_prod = dsp.aud_produced; + last_t = now; + have_last = 1; + printf("IQ %.3f ", dsp_get_iq_level(&dsp)); + if (have_hx && hx.drops) + printf("IQDROPS %zu ", hx.drops); + if (aud.underruns) + printf("AUDUND %lu ", aud.underruns); + if (dsp.aud_drops) + printf("AUDDROPS %zu ", dsp.aud_drops); + for (int i = 0; i < nchan; i++) { + int g = dsp_get_gain(&dsp, i) > 0 ? 1 : 0; + printf("%s %.3f ", + table.list[channel_lookup(&table, freq[i])].name, + g ? dsp_get_level(&dsp, i) : 0.0f); + } + fflush(stdout); + } + usleep(20000); + } + } + + tcsetattr(STDIN_FILENO, TCSANOW, &oldt); + + dsp_stop(&dsp); + dsp_join(&dsp); + audio_stop(&aud); + if (have_hx) { + if (hx_started) hackrx_stop(&hx); + if (dump_path[0]) hackrx_dump_close(&hx); + hackrx_close(&hx); + } else { + sx.running = 0; + pthread_join(sx.thread, NULL); + } + audio_destroy(&aud); + dsp_destroy(&dsp); + ring_destroy(&iq); + ring_destroy(&audio); + return 0; + +fail_stream: + audio_stop(&aud); +fail_aud: + dsp_stop(&dsp); +fail_dsp: + if (have_hx) { + if (hx_started) hackrx_stop(&hx); + hackrx_close(&hx); + } +fail_hx: + return 1; +} \ No newline at end of file diff --git a/src/ring.c b/src/ring.c new file mode 100644 index 0000000..8ddd354 --- /dev/null +++ b/src/ring.c @@ -0,0 +1,133 @@ +#include "ring.h" +#include +#include + +void ring_init(ring_t *r, size_t elem_size, size_t cap_pow2) +{ + size_t cap = 1; + while (cap < cap_pow2) cap <<= 1; + r->buf = malloc(cap * elem_size); + r->elem_size = elem_size; + r->cap = cap; + r->mask = cap - 1; + r->head = 0; + r->tail = 0; + pthread_mutex_init(&r->lock, NULL); + pthread_cond_init(&r->cv, NULL); +} + +void ring_destroy(ring_t *r) +{ + free(r->buf); + pthread_mutex_destroy(&r->lock); + pthread_cond_destroy(&r->cv); + r->buf = NULL; + r->cap = 0; +} + +size_t ring_avail(ring_t *r) +{ + size_t n; + pthread_mutex_lock(&r->lock); + n = (r->head - r->tail); + pthread_mutex_unlock(&r->lock); + return n; +} + +size_t ring_space(ring_t *r) +{ + size_t n; + pthread_mutex_lock(&r->lock); + n = (r->tail + r->cap - r->head); + pthread_mutex_unlock(&r->lock); + return n; +} + +size_t ring_write(ring_t *r, const void *data, size_t n) +{ + pthread_mutex_lock(&r->lock); + size_t space = (r->tail + r->cap - r->head); + if (n > space) n = space; + size_t written = n; + /* copy in at most two contiguous runs (wrap): per-element memcpy at + * 20 Msamples/s was a measurable CPU load on both endpoints */ + while (n) { + size_t idx = r->head & r->mask; + size_t run = r->cap - idx; + if (run > n) run = n; + memcpy(r->buf + idx * r->elem_size, data, run * r->elem_size); + data = (const uint8_t *)data + run * r->elem_size; + r->head += run; + n -= run; + } + pthread_cond_broadcast(&r->cv); + pthread_mutex_unlock(&r->lock); + return written; +} + +size_t ring_read(ring_t *r, void *out, size_t n) +{ + pthread_mutex_lock(&r->lock); + size_t avail = (r->head - r->tail); + if (n > avail) n = avail; + size_t got = n; + while (n) { + size_t idx = r->tail & r->mask; + size_t run = r->cap - idx; + if (run > n) run = n; + memcpy(out, r->buf + idx * r->elem_size, run * r->elem_size); + out = (uint8_t *)out + run * r->elem_size; + r->tail += run; + n -= run; + } + pthread_cond_broadcast(&r->cv); + pthread_mutex_unlock(&r->lock); + return got; +} + +void ring_write_block(ring_t *r, const void *data, size_t n) +{ + size_t done = 0; + pthread_mutex_lock(&r->lock); + while (done < n) { + size_t space = (r->tail + r->cap - r->head); + if (space == 0) { + pthread_cond_wait(&r->cv, &r->lock); + continue; + } + size_t step = (n - done < space) ? (n - done) : space; + for (size_t i = 0; i < step; i++) { + memcpy(r->buf + (r->head & r->mask) * r->elem_size, + (const uint8_t *)data + (done + i) * r->elem_size, + r->elem_size); + r->head++; + } + done += step; + } + pthread_cond_broadcast(&r->cv); + pthread_mutex_unlock(&r->lock); +} + +size_t ring_read_block(ring_t *r, void *out, size_t n) +{ + size_t got = 0; + pthread_mutex_lock(&r->lock); + while (got < n) { + size_t avail = (r->head - r->tail); + if (avail == 0) { + pthread_cond_wait(&r->cv, &r->lock); + continue; + } + size_t step = (n - got < avail) ? (n - got) : avail; + for (size_t i = 0; i < step; i++) { + memcpy((uint8_t *)out + (got + i) * r->elem_size, + r->buf + (r->tail & r->mask) * r->elem_size, + r->elem_size); + r->tail++; + } + got += step; + } + pthread_cond_broadcast(&r->cv); + pthread_mutex_unlock(&r->lock); + return got; +} \ No newline at end of file diff --git a/src/ring.h b/src/ring.h new file mode 100644 index 0000000..6e68d03 --- /dev/null +++ b/src/ring.h @@ -0,0 +1,31 @@ +#ifndef RING_H +#define RING_H + +#include +#include +#include + +typedef struct { + uint8_t *buf; + size_t elem_size; + size_t cap; /* capacity in elements (power of two) */ + size_t mask; + size_t head; /* producer write index */ + size_t tail; /* consumer read index */ + pthread_mutex_t lock; + pthread_cond_t cv; +} ring_t; + +void ring_init(ring_t *r, size_t elem_size, size_t cap_pow2); +void ring_destroy(ring_t *r); + +/* non-blocking, returns number of elements copied (may be < n) */ +size_t ring_avail(ring_t *r); +size_t ring_write(ring_t *r, const void *data, size_t n); +size_t ring_read(ring_t *r, void *out, size_t n); + +/* blocking variants (blocks while insufficient space/data) */ +void ring_write_block(ring_t *r, const void *data, size_t n); +size_t ring_read_block(ring_t *r, void *out, size_t n); + +#endif \ No newline at end of file diff --git a/tests/sim_test.c b/tests/sim_test.c new file mode 100644 index 0000000..23c7ec3 --- /dev/null +++ b/tests/sim_test.c @@ -0,0 +1,132 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "ring.h" +#include "dsp.h" +#include "channel.h" + +#define TRACE_SEC 2.0f + +static float sim_msg(double t, int idx) +{ + double f = 400.0 * (double)(idx + 1); + return 0.55f * (float)sin(2.0 * M_PI * f * t); +} + +int main(int argc, char **argv) +{ + uint64_t lo = 0; + float kf = 0.5f; + int nchan = 1; + if (argc > 1) nchan = atoi(argv[1]); + if (nchan < 1) nchan = 1; + if (nchan > MAX_DEMOD) nchan = MAX_DEMOD; + if (argc > 2) kf = atof(argv[2]); + + channel_table_t table; + channel_init(&table); + + uint64_t freq[MAX_DEMOD]; + for (int i = 0; i < nchan; i++) freq[i] = table.list[i].freq_hz; /* A1.. */ + + uint64_t lo_min = ~0ULL, lo_max = 0; + for (int i = 0; i < nchan; i++) { + if (freq[i] < lo_min) lo_min = freq[i]; + if (freq[i] > lo_max) lo_max = freq[i]; + } + lo = (lo_min + lo_max) / 2; + + ring_t iq, au; + ring_init(&iq, sizeof(liquid_float_complex), 1 << 19); + ring_init(&au, sizeof(float), 1 << 16); + + dsp_t dsp; + dsp_init(&dsp, &iq, &au, DSP_FS_IN, DSP_FS_AUD, lo, kf, 75e-6f, nchan, + 220000.0f, 15000.0f, 1, 0.3f); + for (int i = 0; i < nchan; i++) { + dsp_set_chain(&dsp, i, freq[i]); + dsp_set_gain(&dsp, i, 1.0f); + } + dsp_start(&dsp); + + /* generate all nchan FM carriers on one baseband */ + float dev = 50000.0f; + double t = 0.0, dt = 1.0 / DSP_FS_IN; + size_t total_in = (size_t)(TRACE_SEC * DSP_FS_IN); + size_t n = 0; + float phase[MAX_DEMOD]; + memset(phase, 0, sizeof(phase)); + while (n < total_in) { + static liquid_float_complex buf[4096]; + for (int i = 0; i < 4096; i++) { + liquid_float_complex acc = 0.0f; + for (int c = 0; c < nchan; c++) { + float m = sim_msg(t, c); + phase[c] += 2.0f * (float)M_PI * m * (dev * dt); + phase[c] -= 2.0f * (float)M_PI * floorf(phase[c] / (2.0f * (float)M_PI)); + float off = 2.0f * (float)M_PI * (float)((double)freq[c] - (double)lo) * dt; + phase[c] += off; + acc += cexpf(_Complex_I * phase[c]); + } + buf[i] = acc * (0.8f / (float)nchan); + t += dt; + } + ring_write(&iq, buf, 4096); + n += 4096; + usleep(100); + } + dsp_stop(&dsp); + dsp_join(&dsp); + + /* read the mixed audio out (up to ~2s worth) */ + size_t cap = (size_t)(TRACE_SEC * DSP_FS_AUD); + float *a = malloc(sizeof(float) * cap); + size_t got = 0; + while (got < cap) { + size_t r = ring_read(&au, a + got, cap - got); + if (r == 0) break; + got += r; + } + + /* peak search per expected tone. The simulated message is a pure tone + * at 400*(i+1) Hz, but the resampler queue tail leaves the audio a few + * samples shy of the nominal length, so an exact-bin DFT over a fixed + * window leaks. Instead scan a small band around each expected tone and + * report the strongest component, and pass if that peak is within 1% and + * strong enough to be real signal. */ + size_t N = got < (size_t)(0.5 * DSP_FS_AUD) ? got : (size_t)(0.5 * DSP_FS_AUD); + printf("got %zu audio samples (using %zu)\n", got, N); + int fail = 0; + for (int c = 0; c < nchan; c++) { + float f0 = 400.0f * (float)(c + 1); + double best = 0, bestf = f0; + for (double f = f0 - 12.0; f <= f0 + 12.0; f += 0.2) { + double re = 0, im = 0; + for (size_t i = 0; i < N; i++) { + double ph = 2.0 * M_PI * f * (double)i / DSP_FS_AUD; + re += a[i] * cos(ph); + im += a[i] * sin(ph); + } + re /= (double)N; im /= (double)N; + double mag = 2.0 * sqrt(re * re + im * im); + if (mag > best) { best = mag; bestf = f; } + } + double rel = fabs(bestf - f0) / f0; + int ok = (rel < 0.01) && (best > 0.02); + if (!ok) fail = 1; + printf("chain %d (%.1f MHz, tone %.0f Hz): peak %.1f Hz |DFT| = %.3f %s\n", + c + 1, (double)freq[c] / 1e6, f0, bestf, best, ok ? "OK" : "FAIL"); + } + printf(fail ? "RESULT: FAIL\n" : "RESULT: PASS\n"); + + free(a); + dsp_destroy(&dsp); + ring_destroy(&iq); + ring_destroy(&au); + return 0; +} \ No newline at end of file diff --git a/tools/dsp_from_file.c b/tools/dsp_from_file.c new file mode 100644 index 0000000..de522c9 --- /dev/null +++ b/tools/dsp_from_file.c @@ -0,0 +1,88 @@ +/* dsp_from_file.c -- run the REAL sdisco DSP chain on a raw int8 I/Q dump and + * write the demodulated audio to a .f32 file, mirroring src/main.c wiring so + * bugs are reproduced faithfully. + * + * usage: dsp_from_file + */ +#define _USE_MATH_DEFINES +#include +#include +#include +#include +#include +#include +#include +#include "ring.h" +#include "dsp.h" + +int main(int argc, char **argv) +{ + if (argc < 5) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + const char *path = argv[1]; + uint64_t lo = strtoull(argv[2], NULL, 0); + uint64_t chan = strtoull(argv[3], NULL, 0); + int nchan = atoi(argv[4]); + /* liquid kf convention: kf = dev/fs_band normalizes full deviation to + * ~unit amplitude (verified empirically); 75 kHz / 500 kHz = 0.15 */ + float kf = (float)(75000.0 / (double)DSP_FS_BAND); + float deemph = 75e-6f; + + ring_t iq, au; + ring_init(&iq, sizeof(liquid_float_complex), 1 << 19); + ring_init(&au, sizeof(float), 1 << 24); + + dsp_t dsp; + dsp_init(&dsp, &iq, &au, DSP_FS_IN, DSP_FS_AUD, lo, kf, deemph, nchan, + 220000.0f, 15000.0f, 1, 0.3f); + for (int i = 0; i < nchan; i++) dsp_set_chain(&dsp, i, chan); + for (int i = 0; i < nchan; i++) dsp_set_gain(&dsp, i, 1.0f); + dsp_start(&dsp); + + uint64_t tgt_samp = 20000000ULL * 3; + size_t total = 0; + FILE *f = fopen(path, "rb"); + if (!f) { perror("open"); return 1; } + int8_t buf[4096 * 2]; + static liquid_float_complex cbuf[4096]; + int done = 0; + while (!done) { + size_t got = fread(buf, 2, 4096, f); + if (got == 0) { done = 1; break; } + for (size_t i = 0; i < got; i++) + cbuf[i] = (float)buf[2*i]/128.0f + + (float)buf[2*i+1]/128.0f * _Complex_I; + /* write with backpressure so no samples are dropped */ + size_t off = 0; + while (off < got) { + size_t w = ring_write(&iq, cbuf + off, got - off); + if (w == 0) { usleep(200); continue; } + off += w; + } + total += got; + } + fclose(f); + + /* let the DSP drain: wait until the input ring is empty, then a little more */ + for (int tries = 0; tries < 600 && ring_avail(&iq) > 0; tries++) + usleep(20000); + usleep(500000); + dsp_stop(&dsp); + dsp_join(&dsp); + + FILE *o = fopen(argv[5], "wb"); + float *a = malloc(sizeof(float) * (1 << 24)); + size_t got_aud = ring_read(&au, a, 1 << 24); + fwrite(a, sizeof(float), got_aud, o); + fclose(o); + printf("wrote %zu audio samples (%.2f s) to %s\n", got_aud, got_aud/DSP_FS_AUD, argv[5]); + printf("IQ level %.3f chan1 level %.3f\n", + dsp_get_iq_level(&dsp), dsp_get_level(&dsp, 0)); + free(a); + dsp_destroy(&dsp); + ring_destroy(&iq); + ring_destroy(&au); + return 0; +} \ No newline at end of file diff --git a/tools/iqdump_analyze.py b/tools/iqdump_analyze.py new file mode 100644 index 0000000..f7e322d --- /dev/null +++ b/tools/iqdump_analyze.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""iqdump_analyze.py -- spectrum analysis of a raw int8 I/Q dump +(hackrf transfer layout: interleaved int8 I, Q). + +Reads a raw capture taken at 20 MSps around LO and prints the noise floor, +the strongest spectral peaks (with RF frequencies), and the signal/noise in +a +/-250 kHz window around an optional target channel. + +Usage: iqdump_analyze.py [chan_freq_hz] +""" +import sys +import numpy as np + +FS_IN = 20e6 + + +def main(): + if len(sys.argv) < 3: + print(__doc__) + return 2 + path = sys.argv[1] + lo = float(sys.argv[2]) + target = float(sys.argv[3]) if len(sys.argv) > 3 else 0.0 + + data = np.fromfile(path, dtype=np.int8) + if data.size % 2: + data = data[:-1] + iq = data[0::2].astype(np.float32) + 1j * data[1::2].astype(np.float32) + ns = iq.size + print(f"file: {ns} samples ({ns / FS_IN:.3f}s @20MS/s) LO={lo/1e6:.3f} MHz") + + nfft = min(ns, 1 << 20) + start = max(0, (ns - nfft) // 2) + seg = iq[start:start + nfft] / 128.0 + seg = seg - np.mean(seg) + win = np.hanning(nfft) + S = np.fft.fftshift(np.fft.fft(seg * win)) + freqs = np.fft.fftshift(np.fft.fftfreq(nfft, 1 / FS_IN)) + pwr = np.abs(S) ** 2 + pdb = 10 * np.log10(pwr + 1e-30) + + floor = np.percentile(pdb, 10) + print(f"noise floor: {floor:.1f} dB peak: {pdb.max():.1f} dB") + + thresh = floor + 6 + shown = 0 + print(f"\n--- peaks >= {thresh:.1f} dB ---") + for i in range(1, nfft - 1): + if pdb[i] >= thresh and pdb[i] >= pdb[i - 1] and pdb[i] >= pdb[i + 1]: + rf = lo + freqs[i] + print(f" {rf/1e6:9.3f} MHz (baseband {freqs[i]/1e6:+7.2f}) {pdb[i]:6.1f} dB") + shown += 1 + if shown >= 40: + break + + if target: + off = target - lo + half = 250_000 + near = np.abs(freqs - off) <= half + sig = np.max(pdb[near]) if np.any(near) else floor + far = np.abs(np.abs(freqs - off) - 500_000) <= half + flr = np.percentile(pdb[far], 50) + print(f"\nchannel {target/1e6:.3f} MHz (offset {off/1e6:+.3f}): " + f"peak {sig:.1f} dB, local noise {flr:.1f} dB, margin {sig-flr:.1f} dB") + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file