feat(otasim): real-HF noise-bed channel model (task #71)#35
Merged
Conversation
Adds RealHfLoopChannelModel — a drop-in alongside AWGN that uses
a recorded HF WAV as the noise source instead of generated Gaussian
noise. Same kModemReferenceRms * 10^(-snr/20) scaling formula, so
"set-channel --model real_hf_loop --snr 12" delivers per-sample
noise RMS = 0.0799, identical to AWGN at SNR=12 in level — but with
real-HF spectral character (atmospherics, NCDXF beacon rotation,
band noise) preserved from the recording.
Default behavior unchanged: lobby still starts with AWGN; real-HF
noise is opt-in via the server flag + SetChannel switch.
Server interface:
./build/ota_simulator serve --bind 127.0.0.1:50051 --udp-bind ... \
--tokens /tmp/ota_tokens.conf \
--noise-bed-wav recordings/ota_noise_bed_2026-05-18_20m_14100/noise_bed.wav
./build/otasim_ctl --token admin_tok set-channel --model real_hf_loop --snr 12
If --noise-bed-wav is not provided, SetChannel for real_hf_loop
returns INVALID_ARGUMENT with a clear message. WAV format must be
16-bit PCM mono 48 kHz; loaded once at startup, normalized to unit
RMS, shared across sessions, per-session position counter wraps
modulo the loop length.
Implementation:
- src/ota_channel_core/models.{cpp,hpp}: new ChannelType::REAL_HF_LOOP,
WAV loader + normalizer, RealHfLoopChannelModel class mirroring
AWGNChannelModel structure, parseChannelType / channelTypeName
string round-trip for "real_hf_loop".
- src/ota_channel_core/{session_config.hpp,session_context.cpp}:
carry the shared normalized noise vector into per-session channel
construction.
- src/ota_simulator_service/ota_simulator_service.{hpp,cpp}: cache
the noise bed at service start, reject "real_hf_loop" SetChannel
requests when the cache is empty.
- tools/ota_simulator.cpp: --noise-bed-wav serve flag, startup load,
cached handoff to the service.
- tools/otasim_ctl.cpp: usage string lists real_hf_loop.
- tools/decode_bench.cpp: usage string update.
Tests:
- tests/test_real_hf_loop_channel.cpp: scaling at fixed SNR, position
wrap, setSNR while in flight, empty-loop passthrough, parse/name
round-trip.
- tests/test_grpc_service_smoke.cpp: asserts SetChannel real_hf_loop
is rejected when --noise-bed-wav was not provided.
Test gate (user's unrestricted Mac):
cmake --build build -j4 -> clean
ctest --test-dir build -R "RealHfLoopChannel|OTASimulatorFileTransferSNR15"
--output-on-failure -j1
-> 2/2 PASS (incl. the 300s file-transfer regression that Codex's
sandbox couldn't sit through).
End-to-end smoke verified live: ota_simulator serve --noise-bed-wav
accepts the asset, otasim_ctl set-channel --model real_hf_loop --snr 12
switches the lobby cleanly, get-channel reflects the change,
switching back to awgn works.
Out of scope (queued for follow-ups):
- Auto-discover the asset and default to real_hf_loop (separate PR,
needs test fixups; the explicit opt-in design here is intentional
for now).
- Multi-bed support (one bed per server start; multi-band selection
by name later).
- Combined AWGN + loop overlay (single model per session today).
- Loop-seam zero-crossing alignment (10-min loop has no audible
seam; optimize only if anyone notices).
3-perspective check:
- PHY: nominal SNR uses the same modem reference RMS formula as AWGN
(matched per-sample noise RMS). Real-HF noise is colored (300-3000
Hz SSB-passband shape), so OFDM per-carrier SNR differs from AWGN
at the same nominal SNR — this is intentional realism.
- DSP: WAV strictly 16-bit PCM mono 48 kHz; normalized to unit RMS
once at load; shared shared_ptr across sessions; per-instance
position counter wraps modulo loop length.
- Operator: explicit server flag means there's no surprise — the
channel only becomes real-HF when an admin explicitly switches it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds `RealHfLoopChannelModel` — uses a recorded HF WAV (the 20m noise
bed committed in #34) as the noise source, with the same SNR scaling
formula as AWGN. `otasim_ctl set-channel --model real_hf_loop --snr 12`
gives per-sample noise RMS identical to AWGN at SNR=12 in level,
but with real-HF spectral character (atmospherics, NCDXF beacon
rotation, band noise) preserved.
Default unchanged: lobby still starts on AWGN. Real-HF noise is
opt-in via the server flag + admin SetChannel switch. Tests untouched.
How to use
```bash
./build/ota_simulator serve \
--bind 127.0.0.1:50051 --udp-bind 127.0.0.1:50052 \
--tokens /tmp/ota_tokens.conf \
--noise-bed-wav recordings/ota_noise_bed_2026-05-18_20m_14100/noise_bed.wav
./build/otasim_ctl --token admin_tok set-channel --model real_hf_loop --snr 12
./build/otasim_ctl --token alpha_tok get-channel
→ session=lobby model=real_hf_loop snr_db=12.00
```
If `--noise-bed-wav` isn't provided and someone tries to set the model,
the server returns INVALID_ARGUMENT with a clear message.
Implementation
WAV loader (16-bit PCM mono 48 kHz, normalized to unit RMS),
`RealHfLoopChannelModel` class mirroring AWGN structure.
noise vector into per-session model construction.
startup load.
changes; --model already passes through as a free string).
setSNR, empty-loop passthrough, parse/name round-trip.
rejected when --noise-bed-wav was not provided.
Test plan
unrestricted Mac (Codex sandbox timed out, which is environment
not regression)
set-channel real_hf_loop SNR=12 switches lobby; get-channel
reflects; switch back to awgn works.
Worked example at SNR=12
```
target noise RMS = kModemReferenceRms × 10^(-12/20)
= 0.3180724 × 0.25118864
= 0.07989617
```
AWGN at SNR=12 produces noise stddev = 0.07989617. The real_hf_loop
model produces noise RMS = 0.07989617 (because the loop is
pre-normalized to unit RMS, and the scaling factor is identical).
Per-sample level matches; spectral character differs.
Follow-ups (queued)
(separate PR; will need test fixups, intentionally not in this one).
🤖 Generated with Claude Code