feat(cw): optionally shift out-of-window CW tones into the model's range

DeepCW only analyses 400-1200 Hz - its input tensor is 65 bins wide, fixed at
training time - so a CW note outside that range is invisible to the decoder.
This adds an opt-in preprocessing step that moves such a tone to 800 Hz, the
window centre, extending the usable pitch range without touching the model.

Single-sideband mixing via a 63-tap Hilbert transformer. Plain real mixing was
measured and rejected: shifting 1500 Hz to 800 Hz left a fold-back image at
1000 Hz at 0.999 of the wanted amplitude, inside the window. Zero-stuff
upsampling plus lowpass handled downward shifts but left a 0.996 image when
shifting 300 Hz upward. The Hilbert approach measures clean on nine tones from
150 to 1550 Hz: one peak at the target, nothing above 0.3 relative amplitude.
In-window energy for a 1500 Hz input goes from 6.8% to 94.6%.

Only out-of-range audio is processed. A tone already inside 400-1200 Hz is
returned untouched (same array instance, no copy), and with the setting off the
audio path is exactly what it was before.

CwToneShifter.Streaming carries the Hilbert filter history and mixer phase
across capture chunks. Shifting each chunk in isolation left 62 of every 320
samples convolving against zeros, inflating envelope ripple to 8.7x the
whole-buffer baseline. A residual difference in the last ~3 samples of each
chunk is causal and documented: those output samples would need input that has
not been captured yet.

Detection pools chunks rather than gating on one. A capture chunk is 4410
samples at 44.1 kHz but only 320 after resampling to 3200 Hz, so requiring
1280 samples in a single chunk would have made the feature dead code - the two
independent audits both found this before it shipped. Detection now runs on a
pooled 0.4 s window, at most every 2 s.

Toggling the setting or a change in the detected shift drops the buffered
audio: the 20 s window would otherwise keep decoding samples moved by the old
amount, and the pitch readout could only be correct for one of them. The
readout itself subtracts the active shift so it shows the pitch on the radio,
not the shifted one.

Settings: OtherSettings.cwToneShiftEnabled, off by default, persisted and read
back in SettingsRepo, toggled from the Other card in Settings with a help line
explaining the 400-1200 Hz limit. Strings added to all nine locales. The
decoder reads the flag per chunk, so the toggle applies without restarting
capture.

Debug: the enabled-state transition, each detection verdict (no tone / inside
window / shifting by N Hz), and every shift change are logged, with the noisy
paths throttled to the 2 s detection interval. CwProbe records shift changes
only, keeping well inside its 1 MiB cap.

Tests: 8 shifter tests (detection sweep, noise rejection, pass-through
identity, image-free shifting across 8 tones, end-to-end spectrogram energy),
8 streaming tests (chunk continuity, history retention, reset semantics, chunk
sizes above and below the history window), and 6 gate tests including a
regression guard that a 320-sample chunk must be able to reach the detection
threshold. All 53 CW tests pass, golden vectors included.
This commit is contained in:
mckero committed 2026-08-22 01:07:38 +00:00
1 parent e3d7238721
commit 9798107d37
21 files changed
+1040 -9

No files matched your search

@@ -169,4 +169,6 @@
<string name="prefs_outro_title">Me gustaría dar las gracias a:</string>
<string name="prefs_outro_license">La app viene sin garantías de ningún tipo.</string>
<string name="prefs_other_switch_cw_tone_shift">Desplazar tonos CW fuera de rango</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW solo analiza 400-1200 Hz. Si está activo, un tono fuera de ese rango se traslada al rango antes de decodificar; un tono que ya está dentro no se modifica.</string>
</resources>
@@ -298,4 +298,6 @@
\n* BA7OPF (fitur pencocokan lintasan)
\n* BG7NTA</string>
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
<string name="prefs_other_switch_cw_tone_shift">Geser nada CW di luar rentang</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW hanya menganalisis 400-1200 Hz. Saat aktif, nada di luar rentang itu dipindahkan ke dalamnya sebelum decoding; nada yang sudah di dalam tidak diubah.</string>
</resources>
@@ -298,4 +298,6 @@
\n* BA7OPF (fitur pencocokan lintasan)
\n* BG7NTA</string>
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
<string name="prefs_other_switch_cw_tone_shift">Geser nada CW di luar rentang</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW hanya menganalisis 400-1200 Hz. Saat aktif, nada di luar rentang itu dipindahkan ke dalamnya sebelum decoding; nada yang sudah di dalam tidak diubah.</string>
</resources>
@@ -169,4 +169,6 @@
<string name="prefs_outro_title">Я хотел бы сказать спасибо:</string>
<string name="prefs_outro_license">Это ПО поставляется без гарантий.</string>
<string name="prefs_other_switch_cw_tone_shift">Сдвигать CW-тоны вне диапазона</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW анализирует только 400-1200 Гц. Если включено, тон вне этого диапазона переносится внутрь перед декодированием; тон внутри диапазона не изменяется.</string>
</resources>
@@ -169,4 +169,6 @@
<string name="prefs_outro_title">මම ස්තුති කිරීමට කැමති:</string>
<string name="prefs_outro_license">මෘදුකාංගය වගකීමක් සමග නොලැබේ</string>
<string name="prefs_other_switch_cw_tone_shift">පරාසයෙන් පිටත CW ස්වර මාරු කරන්න</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW විශ්ලේෂණය කරන්නේ 400-1200 Hz පමණි. සක්‍රීය විට, එම පරාසයෙන් පිටත ස්වරයක් විකේතනයට පෙර පරාසය තුළට ගෙන එයි; දැනටමත් පරාසය තුළ ඇති ස්වරයක් වෙනස් නොකරයි.</string>
</resources>
@@ -310,4 +310,6 @@
<string name="radar_cw_tone">Ton %1$d Hz</string>
<string name="radar_cw_waiting">Sinyal bekleniyor…</string>
<string name="prefs_other_switch_cw_tone_shift">Aralık dışı CW tonlarını kaydır</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW yalnızca 400-1200 Hz analiz eder. Açıkken bu aralığın dışındaki bir ton çözülmeden önce aralığa taşınır; aralıkta olan ton değiştirilmez.</string>
</resources>
@@ -169,4 +169,6 @@
<string name="prefs_outro_title">Я хотів би подякувати:</string>
<string name="prefs_outro_license">Ця програма поставляється без жодних гарантій</string>
<string name="prefs_other_switch_cw_tone_shift">Зсувати CW-тони поза діапазоном</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW аналізує лише 400-1200 Гц. Якщо увімкнено, тон поза цим діапазоном переноситься в нього перед декодуванням; тон, що вже в діапазоні, не змінюється.</string>
</resources>
@@ -300,4 +300,6 @@
<string name="prefs_net_frequency_offset_help" translatable="false">范围: -50000 到 50000 Hz。正值提高上报频率; 负值降低。</string>
<string name="prefs_other_compass_offset">雷达罗盘偏移</string>
<string name="prefs_other_compass_offset_elev">雷达罗盘偏移 (仰角)</string>
<string name="prefs_other_switch_cw_tone_shift">搬移超出范围的 CW 音调</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW 仅分析 400-1200 Hz。开启后,超出该范围的音调会先搬移到范围内再解码;已在范围内的音调不作处理。</string>
</resources>
@@ -333,4 +333,6 @@
<string name="prefs_net_frequency_offset_help" translatable="false">Range: -50000 to 50000 Hz. Positive values increase reported frequency; negative values decrease it.</string>
<string name="prefs_other_compass_offset">Radar compass offset</string>
<string name="prefs_other_compass_offset_elev">Radar compass offset (elev)</string>
<string name="prefs_other_switch_cw_tone_shift">Shift out-of-range CW tones</string>
<string name="prefs_other_cw_tone_shift_help">DeepCW only analyses 400-1200 Hz. When on, a tone outside that range is moved into it before decoding; a tone already inside is untouched.</string>
</resources>