-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathflex_sim.py
More file actions
3481 lines (3201 loc) · 184 KB
/
Copy pathflex_sim.py
File metadata and controls
3481 lines (3201 loc) · 184 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
#
# flex-sim — synthetic FlexRadio-6000 emulator / test bench for AetherSDR.
# Copyright (C) 2026 Nigel Fenton (G0JKN)
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation, either version 3 of the License, or (at your option) any later
# version. Distributed WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
# License for more details. You should have received a copy of the GNU GPL along
# with this program; if not, see <https://www.gnu.org/licenses/>.
#
# Code generated by Claude (Anthropic) via Claude Code, directed and tested by
# the copyright holder.
"""flex-sim — a synthetic FlexRadio-6000 emulator for testing AetherSDR.
flex-sim impersonates a FlexRadio 6000's data plane: it answers SmartSDR
discovery + the FlexLib control handshake, then streams synthetic VITA-49
panadapter / waterfall / meter data so AetherSDR renders a waterfall, S-meter and
TX meters from a programmable signal engine — a deterministic, hardware-free test
bench for AE's spectrum / waterfall / CW code. (Wire format reverse-engineered
from AE's own decoder; see PROTOCOL.md.)
It serves a live browser control panel (default http://<ip>:8731/) with a pattern
picker, dBm/S-unit level controls, TX keying and CW/CWX — each pattern shows what
it exercises in AE.
NETWORKING: by default flex-sim binds UDP :4992 like a real radio, so give it its
own IP (second machine / VM / container / WSL2) — OR use --port to run it on the SAME
host as AE on a different control port. Pass --ae <AE-ip> to also unicast discovery to AE.
Pure Python 3.8+ stdlib — no dependencies.
Usage: python3 flex_sim.py [--ip IP] [--ae AE_IP] [--pattern NAME] [--port P]
[--bins N] [--fps N] [--width-khz K] [--ctl-port P]
--port lets flex-sim run on the SAME host as AE: it binds/advertises that port
(e.g. 5992) for control+data while still announcing to AE's fixed :4992 listener.
Patterns (each is a test signal; the control panel explains what to look for):
noise_floor flat floor (baseline; auto-black #3586)
ramp whole-span level swept min->max (auto-black / level mapping)
cal_tones fixed carriers at known dBm (verify the dB scale)
carrier steady tone on the VFO (S-meter / dBm calibration)
cw 20-wpm Morse 'CQ CQ CQ TST' (sim-keyed)
swept_carrier one tone sweeping the span (freq mapping / tile decode)
comb N evenly-spaced tones (dynamic range)
two_tone two equal tones spaced 2 kHz about the VFO (calibrated ruler / linearity / IMD)
step whole-span square wave in time (temporal response)
impulse brief full-span flash each period (transient response)
staircase center carrier stepping floor->max (colormap ladder)
test_card 17 carriers S1..S9 (1/2-S steps) on a steppable noise floor (auto-black)
noise random noise band, white->pink tilt
noise_cal flat noise at EXACT dBm for filter testing (output = filter response); width narrows -> band-limited
tx_blank periodic real TX gap -> repro #2126 / #1916
Also handles AE's own CWX keyer (cwx send/wpm/qsk_enabled) for authentic CW TX.
"""
import argparse, http.server, json, math, os, glob, random, socket, struct, subprocess, sys, threading, time, urllib.parse, uuid, wave
HERE = os.path.dirname(os.path.abspath(__file__))
FIXTURES_DIR = os.path.join(HERE, "fixtures")
REPORTS_DIR = os.path.join(HERE, "reports")
FLEX_SIM_VERSION = "0.2.0"
DISCOVERY_PORT = 4992 # AE always listens for discovery here (fixed); we broadcast TO it
DEFAULT_PORT = 4992 # flex-sim's own control (TCP) + VITA-prime (UDP) port; override with --port
def _default_serial_prefix():
"""Serial prefix defaults to THIS MACHINE'S hostname.
AE matches `connect local serial <s>`, so two sims sharing a prefix are
indistinguishable to that verb and AE silently takes whichever it
discovered first — a real wrong-radio bug on a network with more than one
sim. Deriving from the hostname makes the collision structurally impossible
rather than merely avoidable-if-you-remember-the-flag: every box is
distinct for free, including ones nobody has thought of yet.
Sanitised to A-Z0-9 (a serial travels in a discovery string and a
`chassis_serial=` reply; punctuation has no business in either) and capped
so the `:02d` suffix keeps rack serials readable. Falls back to the old
FLEXSIM constant if the hostname is unusable, so this can never leave a
radio without an identity.
"""
try:
raw = "".join(c for c in socket.gethostname().upper() if c.isalnum())
except Exception:
raw = ""
return raw[:12] or "FLEXSIM"
SERIAL_PREFIX = _default_serial_prefix() # serial = <prefix><radio_id:02d>; --serial overrides
# (pass --serial FLEXSIM for the pre-2026-07-31 default)
# -> set e.g. 5992 to run on the SAME host as AE without a :4992 clash
HANDLE = 0x1A2B3C4D
HANDLE_HEX = f"{HANDLE:08X}"
PAN_ID = 0x40000000 # FFT stream id (PCC 0x8003)
WF_ID = 0x42000000 # waterfall id (PCC 0x8004)
SERIAL, MODEL, VERSION = "EMULATE01", "FLEX-6600", "3.3.28.0"
MODELS = { # model -> caps; slice count drives the multi-slice axis later
"FLEX-6300": {"slices": 2, "scu": 1}, "FLEX-6400": {"slices": 2, "scu": 1},
"FLEX-6500": {"slices": 4, "scu": 1}, "FLEX-6600": {"slices": 4, "scu": 2},
"FLEX-6700": {"slices": 8, "scu": 2},
# FLEX-8000 series per flexradio.com/comparison: 8400 = 2 slice / 1 SCU, 8600 = 4 slice / 2 SCU
"FLEX-8400": {"slices": 2, "scu": 1}, "FLEX-8600": {"slices": 4, "scu": 2},
}
CENTER_MHZ, SPAN_MHZ, BINS, FPS = 14.100, 0.250, 1024, 20
# Band -> default panadapter centre (MHz). AE's band buttons send
# "display pan set <pan> band=N"; map the band id to a sensible centre so a pan can be
# retuned by band, and so stacked pans can sit on different bands (20m + 6m) at once.
BAND_CENTERS_MHZ = {
"160": 1.900, "80": 3.750, "60": 5.357, "40": 7.150, "30": 10.125,
"20": 14.150, "17": 18.118, "15": 21.225, "12": 24.940, "10": 28.850,
"6": 50.150, "4": 70.200, "2": 145.000, "wwv": 10.000, "gen": 14.150,
}
PCC_FFT, PCC_WF, PCC_METER = 0x8003, 0x8004, 0x8002
METER_SID = 0x46000000 # meter VITA stream id (AE routes meters by PCC, not by sid)
DAXTX_SID_BASE = 0x84000000 # dax_tx stream id base — matches the id a real 6x00
# hands out (0x84000000 observed in the #4510 report log)
DAXTX_AUDIO_PORT = int(os.environ.get("FLEXSIM_DAXTX_PORT", "4991"))
# AE hardcodes DAX TX audio to <radio>:4991
# (PanadapterStream.cpp: m_radioPort = 4991), so this is only
# overridable for TESTS: one process per box can bind it, and a
# running sim would otherwise make the txchain suite unrunnable.
_DAXTX_LISTENER_STARTED = False # one :4991 observer per process (see Radio.__init__)
SLC_LEVEL_ID = 1 # (legacy) single-slice S-meter id; superseded by SLICE_METER_BASE+index
FWDPWR_ID, SWR_ID = 2, 3 # TX meters: forward power (src=TX nam=FWDPWR, dBm->W) + SWR
# Radio-published AMPLIFIER meters (src=AMP). AE creates the Amp applet's
# gauges from THESE, not from the PGXL's own :9008 telemetry -- without them a
# connected PGXL has nowhere to land and the applet stays empty (found on the
# bench 2026-07-30). Routing rule in MeterModel::defineMeter(): src=AMP with
# nam=FWD (unit dBm) / RL / TEMP, and `num` is matched against the TGXL handle
# -- num != tgxlHandle => PGXL (Amp applet); num == tgxlHandle => TGXL (Tuner
# applet). We publish under the AMPLIFIER handle so they route to the Amp side.
# ⚠ nam=RL is RETURN LOSS in dB (AE converts to SWR), NOT an SWR ratio.
AMP_FWD_ID, AMP_RL_ID, AMP_TEMP_ID = 4, 5, 6
SLICE_METER_BASE = 10 # per-slice S-meter: slice k -> meter id SLICE_METER_BASE+k (def num=k)
# pattern timing/shape defaults (seconds / counts)
RAMP_PERIOD = 10.0
SWEEP_PERIOD = 8.0
STEP_DT = 1.0
IMPULSE_PERIOD, IMPULSE_WIDTH = 2.0, 0.15
COMB_TONES = 8
TWO_TONE_SPACING_HZ = 2000.0 # two-tone ruler: tone separation (700/1900 Hz pair = 2 kHz is the SSB IMD convention's close cousin; whole-span-independent)
NOISE_CAL_RIPPLE_DB = 1.5 # noise_cal: ± bounded ripple about the exact mean (small enough the level reads true)
TXBLANK_PERIOD, TXBLANK_GAP = 6.0, 2.0
STAIRCASE_STEPS, STAIRCASE_DWELL = 10, 0.6 # 0->max amplitude ladder
SIGNAL_WIDTH_KHZ = 10.0 # synthesized carrier/signal width (kHz)
CW_TEXT, CW_WPM, CW_TAIL_GAP = "CQ CQ CQ TST CQ CQ CQ TST", 20, 2.0 # CW keyer (Morse) pattern
SSB_LO_HZ, SSB_HI_HZ = 300.0, 2700.0 # SSB-lookalike occupied band, offset from the (suppressed) carrier
SSB_SYLLABLE_HZ = 4.0 # ~4 syllables/sec — the voice envelope swell/dip rate
SSB_WORD_GAP_S = 0.45 # brief inter-word pauses (signal drops toward the floor)
CWX_TAIL_S = 0.6 # hold TX this long after the last CWX element so AE's keyer drains first
WF_FLOOR_VAL, WF_PEAK_VAL = 70.0, 235.0 # AE int16/128 intensity for our [min_dbm,max_dbm] range:
# low dBm -> ~black, high dBm -> bright. (Waterfall colour
# still depends on AE's black_level/color_gain; panadapter
# + S-meter read the exact dBm regardless.)
AMP_GAIN_DB = 11.0 # exciter -> amp gain in OPERATE (100 W in => ~1.25 kW out)
S9_DBM = -73.0 # HF S-meter convention: S9 = -73 dBm, 6 dB per S-unit
# remote_audio_rx VITA-49 constants (sourced from AetherSDR PanadapterStream.cpp)
AUDIO_OUI = 0x00001C2D # FlexRadio Systems IEEE OUI
AUDIO_ICC = 0x534C # FlexRadio information class code ("SL")
AUDIO_PCC = 0x03E3 # PCC_IF_NARROW: float32 stereo @ 24 kHz (full BW)
AUDIO_PCC_MONO = 0x0123 # PCC_IF_NARROW_REDUCED: int16 mono @ 24 kHz (reduced BW)
AUDIO_FRAMES = 128 # frames per packet (stereo: 128×2 floats; mono: 128 int16)
AUDIO_RATE = 24000 # Hz
AUDIO_INTERVAL = AUDIO_FRAMES / AUDIO_RATE # ~5.333 ms / packet → ~187.5 Hz
AUDIO_SID_BASE = 0x48000010 # base stream id for remote_audio_rx (per-radio: + radio_id)
DAX_SID_BASE = 0x48000040 # base stream id for dax_rx (per-radio: + radio_id*4 + (ch-1))
# AE routes dax_rx packets to its RADE/digital decoder ONLY when the
# stream id was registered via a 'stream <id> type=dax_rx ...' status
# line (TciServer::statusReceived -> registerDaxStream). The PCC/format
# is the SAME as remote_audio_rx (PCC_IF_NARROW float32 stereo), so the
# existing audio_loop streams the golden clip unchanged on the DAX id.
AUDIO_SRC_SILENCE = "silence" # DEFAULT: send silence (no audio) — a spectrum bench shouldn't blast a tone
AUDIO_SRC_TONE = "tone" # sine tone at audio_tone_hz (opt-in)
AUDIO_SRC_NOISE = "noise" # gaussian band noise
AUDIO_SRC_WAV = "wav" # WAV file playback (looped)
def log(*a):
# Encoding-safe: on Windows the console is often cp1252, which raises
# UnicodeEncodeError on non-ASCII (→, —, …). A crash in log() from inside a
# worker thread (e.g. audio_loop) silently kills that thread and its stream,
# so NEVER let a log character take down a thread — fall back to ASCII.
try:
print(time.strftime("%H:%M:%S"), *a, flush=True)
except UnicodeEncodeError:
safe = [str(x).encode("ascii", "replace").decode("ascii") for x in a]
print(time.strftime("%H:%M:%S"), *safe, flush=True)
def local_ip():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80)); return s.getsockname()[0]
except OSError:
return "127.0.0.1"
finally:
s.close()
def parse_kvs(text):
return dict(tok.split("=", 1) for tok in text.split() if "=" in tok)
# ---- VITA-49 builders (PROTOCOL.md §5, big-endian, 28-byte header) ----
def vita_header(stream_id, pcc, seq, payload_len):
total_words = (28 + payload_len + 3) // 4
word0 = (0x3 << 28) | (1 << 27) | ((seq & 0xF) << 16) | (total_words & 0xFFFF)
return struct.pack(">IIIIIII", word0, stream_id, 0x001C2D00, pcc & 0xFFFF, 0, 0, 0)
def fft_packet(stream_id, seq, pixels, frame_index):
n = len(pixels)
sub = struct.pack(">HHHHI", 0, n, 2, n, frame_index)
payload = sub + struct.pack(">%dH" % n, *pixels)
return vita_header(stream_id, PCC_FFT, seq, len(payload)) + payload
def wf_packet(stream_id, seq, intens, low_hz, binbw_hz, timecode, auto_black=20):
# auto_black = the tile's AutoBlackLevel field (raw uint, same domain as the
# intensity samples). A real Flex puts the radio-measured noise-floor level
# here; AE's #3586 auto-black path uses it as the waterfall black/low point.
# Pass the frame's measured floor (min raw) for faithful emulation.
w = len(intens)
# FrameLowFreq/BinBandwidth are FlexLib "VitaFrequency" = Hz * 2^20. AE >= #4412
# decodes that format UNCONDITIONALLY (VitaTileFrequency.h) — the old magnitude
# heuristic that let plain Hz through is gone, so plain Hz now lands ~2^20 low
# and every row maps off-screen (waterfall black while the pan stays correct).
low_raw = int(round(low_hz * 1048576))
binbw_raw = int(round(binbw_hz * 1048576))
sub = struct.pack(">qqIHHIIHH", low_raw, binbw_raw, 100, w, 1, timecode, auto_black, w, 0)
payload = sub + struct.pack(">%dh" % w, *intens) # signed int16, AE reads /128.0
return vita_header(stream_id, PCC_WF, seq, len(payload)) + payload
def meter_packet(stream_id, seq, meter_id, value, scale=128.0):
# PCC 0x8002 payload: N x (uint16 meter_id, int16 raw).
# ⚠ THE SCALE IS PER-UNIT, not universal (MeterModel::convertRaw):
# dBm / dB / dBFS / SWR -> raw / 128
# degC / degF -> raw / 64 <-- half!
# Volts / Amps -> raw / 256
# Encoding a temperature at 128 makes AE read exactly DOUBLE: a 38 C
# heatsink displayed as 76 C, which is what caught this on the bench
# (2026-07-30). Pass the matching scale for the meter's declared unit.
raw = max(-32768, min(32767, int(round(value * scale))))
payload = struct.pack(">Hh", meter_id, raw)
return vita_header(stream_id, PCC_METER, seq, len(payload)) + payload
def audio_packet(stream_id, seq, samples, reduced_bw=False):
"""VITA-49 remote_audio_rx packet.
reduced_bw=False: PCC 0x03E3 — float32 stereo big-endian, 128 frames (1024 B payload).
reduced_bw=True: PCC 0x0123 — int16 mono big-endian, 128 samples (256 B payload).
AE selects the decoder by PCC; use reduced_bw to match what AE requested via
'client set send_reduced_bw_dax=1'. OUI/ICC sourced from AetherSDR PanadapterStream.cpp.
"""
if reduced_bw:
pcc = AUDIO_PCC_MONO
raw = [max(-32767, min(32767, int(v * 32767))) for v in samples[:AUDIO_FRAMES]]
payload = struct.pack(f">{AUDIO_FRAMES}h", *raw) # 256 bytes = 64 words
else:
pcc = AUDIO_PCC
payload = struct.pack(f">{AUDIO_FRAMES * 2}f", *samples) # 1024 bytes = 256 words
total_words = 7 + len(payload) // 4
word0 = (0x3 << 28) | (1 << 27) | ((seq & 0xF) << 16) | total_words
header = struct.pack(">IIIIIII",
word0, stream_id,
AUDIO_OUI, (AUDIO_ICC << 16) | pcc,
0, 0, 0)
return header + payload
class _FloatWavReader:
"""Minimal WAV reader for IEEE-float (fmt tag 3) files, exposing the slice of
the stdlib `wave.Wave_read` interface WavPlayer needs. Used as a fallback when
`wave.open` rejects a float WAV (e.g. RADE Tap-E clips: 24k stereo float32)."""
def __init__(self, path):
with open(path, 'rb') as f:
data = f.read()
if data[:4] != b'RIFF' or data[8:12] != b'WAVE':
raise wave.Error("not a RIFF/WAVE file")
# walk chunks to find 'fmt ' and 'data'
pos, fmt, dpos, dlen = 12, None, None, 0
while pos + 8 <= len(data):
cid = data[pos:pos+4]
clen = struct.unpack('<I', data[pos+4:pos+8])[0]
body = pos + 8
if cid == b'fmt ':
fmt = struct.unpack('<HHIIHH', data[body:body+16])
elif cid == b'data':
dpos, dlen = body, clen
pos = body + clen + (clen & 1) # chunks are word-aligned
if fmt is None or dpos is None:
raise wave.Error("missing fmt/data chunk")
tag, ch, sr, _byterate, _ba, bits = fmt
self.is_float = (tag == 3)
if not self.is_float:
raise wave.Error(f"_FloatWavReader only handles float (tag 3), got tag {tag}")
self._ch, self._sr, self._sw = ch, sr, bits // 8
self._frame_bytes = self._ch * self._sw
self._data = data[dpos:dpos+dlen]
self._cur = 0 # byte cursor into _data
def getnchannels(self): return self._ch
def getsampwidth(self): return self._sw
def getframerate(self): return self._sr
def readframes(self, n):
nb = n * self._frame_bytes
chunk = self._data[self._cur:self._cur + nb]
self._cur += len(chunk)
return chunk
def rewind(self): self._cur = 0
def close(self): pass
class WavPlayer:
"""Reads a WAV file and returns resampled mono float samples at AUDIO_RATE.
Supports 8/16/32-bit PCM **and 32-bit IEEE float** (e.g. RADE Tap-E golden
clips), any channel count (mixed to mono), any sample rate (linear-interp
resample to 24 kHz). Loops at EOF.
"""
def __init__(self, path):
# Python's wave module handles PCM but raises on IEEE-float (fmt tag 3) —
# which is exactly what RADE Tap E emits. Try wave first (keeps PCM
# behaviour identical); on failure, fall back to a manual float-aware reader.
self._is_float = False
try:
self._wf = wave.open(path, 'rb')
self.n_ch = self._wf.getnchannels()
self.sw = self._wf.getsampwidth()
self.rate = self._wf.getframerate()
except (wave.Error, EOFError):
self._wf = _FloatWavReader(path)
self.n_ch = self._wf.getnchannels()
self.sw = self._wf.getsampwidth()
self.rate = self._wf.getframerate()
self._is_float = self._wf.is_float
self._ratio = self.rate / AUDIO_RATE # src samples per output sample
self._buf = [] # decoded mono floats not yet consumed
self._phase = 0.0 # fractional carry-over into next call
log(f"[wav] opened: {self.rate}Hz {self.n_ch}ch {self.sw*8}bit"
f"{' float' if self._is_float else ''} -> {AUDIO_RATE}Hz mono "
f"(ratio {self._ratio:.4f})")
# ------------------------------------------------------------------
def _sample_to_float(self, raw):
if self._is_float: # 32-bit IEEE float
return struct.unpack('<f', raw)[0]
if self.sw == 2:
return int.from_bytes(raw, 'little', signed=True) / 32768.0
if self.sw == 4:
return int.from_bytes(raw, 'little', signed=True) / 2147483648.0
return (int.from_bytes(raw, 'little') - 128) / 128.0 # uint8
def _fill(self, n_src):
"""Append at least n_src decoded mono floats to self._buf (looping WAV)."""
frame_bytes = self.n_ch * self.sw
needed = n_src
while needed > 0:
raw = self._wf.readframes(needed)
if not raw:
self._wf.rewind(); continue
got = len(raw) // frame_bytes
for i in range(got):
frame = raw[i * frame_bytes:(i + 1) * frame_bytes]
s = sum(self._sample_to_float(frame[c * self.sw:(c + 1) * self.sw])
for c in range(self.n_ch)) / self.n_ch
self._buf.append(s)
needed -= got
def read(self, n_out):
"""Return n_out resampled mono float samples, continuing phase from last call."""
n_src_needed = int(math.ceil(self._phase + n_out * self._ratio)) + 1
self._fill(max(0, n_src_needed - len(self._buf)))
out = []
p = self._phase
for _ in range(n_out):
lo = int(p)
frac = p - lo
hi = min(lo + 1, len(self._buf) - 1)
out.append(self._buf[lo] * (1 - frac) + self._buf[hi] * frac)
p += self._ratio
consumed = int(p)
self._buf = self._buf[consumed:]
self._phase = p - consumed
return out
def close(self):
try: self._wf.close()
except Exception: pass
# ---- pattern engine: each pattern returns a per-bin dBm list (or None = TX gap) ----
class PatternCtx:
def __init__(self, n, center_bin, min_dbm, max_dbm):
self.n, self.center = n, center_bin
self.min_dbm, self.max_dbm = min_dbm, max_dbm
self.floor = min_dbm + 10.0 # visible noise floor (live: noise-floor slider)
self.sig_level = max_dbm - 5.0 # signal peak level (live: signal-level slider)
self.sig_half = 2 # half-width of a synthesized signal, in bins
self.two_tone_half_bins = 1 # two-tone ruler: half the tone spacing, in bins
self.noise_cal_full = True # noise_cal: full-span bed (the standard); False = band-limit by width
self.noise_color = 0.0 # noise tilt dB across band (0=white, >0=pink)
self.cw_keydown = False # CW keyer: element key-down this frame
self.cw_in_message = False # CW keyer: inside the message (TX over), not the tail gap
self.cw_qsk = False # CW/CWX: full break-in (RX peeks between elements)
def _flat(ctx, dbm):
return [dbm] * ctx.n
def _hash01(n):
# Deterministic [0,1) from an int (cheap integer hash). Lets noise_cal be
# reproducible per (frame, bin) so a golden capture repeats, while looking random.
n &= 0xFFFFFFFF
n = (n ^ 61) ^ (n >> 16)
n = (n + (n << 3)) & 0xFFFFFFFF
n ^= (n >> 4)
n = (n * 0x27d4eb2d) & 0xFFFFFFFF
n ^= (n >> 15)
return (n & 0xFFFFFFFF) / 4294967296.0
def pat_noise_floor(ctx, t):
return _flat(ctx, ctx.floor)
def pat_ramp(ctx, t):
frac = (t % RAMP_PERIOD) / RAMP_PERIOD
return _flat(ctx, ctx.min_dbm + frac * (ctx.max_dbm - ctx.min_dbm))
def pat_cal_tones(ctx, t):
out = _flat(ctx, ctx.floor)
for frac, dbm in [(0.2, -100), (0.4, -80), (0.6, -60), (0.8, -40)]:
b = int(frac * ctx.n)
for d in (-1, 0, 1):
if 0 <= b + d < ctx.n:
out[b + d] = max(out[b + d], dbm)
return out
def pat_swept_carrier(ctx, t):
out = _flat(ctx, ctx.floor)
c = int(((t % SWEEP_PERIOD) / SWEEP_PERIOD) * (ctx.n - 1))
for d in range(-ctx.sig_half, ctx.sig_half + 1):
b = c + d
if 0 <= b < ctx.n:
out[b] = ctx.sig_level
return out
def pat_comb(ctx, t):
out = _flat(ctx, ctx.floor)
step = max(1, ctx.n // COMB_TONES)
for b in range(step // 2, ctx.n, step):
out[b] = ctx.sig_level - 10
return out
def pat_two_tone(ctx, t):
# Calibrated two-tone "golden ruler": two EQUAL tones symmetric about the VFO,
# spaced TWO_TONE_SPACING_HZ apart, each at exactly ctx.sig_level dBm. Single-bin
# placement (no level-corrupting smear) so the level you measure IS the level set.
# Classic linearity/IMD card: in a pure sim path the band between/around the tones
# stays at the noise floor (no intermod products) — so it also proves AE isn't
# inventing spurs. Spacing is rounded to whole bins (ctx.two_tone_half_bins, set
# per-frame from the live span); the true delivered spacing + the expected per-tone
# dBm/S-unit are reported in the control-panel hint so the ruler reads exactly.
out = _flat(ctx, ctx.floor)
for sign in (-1, +1):
b = ctx.center + sign * ctx.two_tone_half_bins
if 0 <= b < ctx.n:
out[b] = ctx.sig_level
return out
def pat_step(ctx, t):
hi = int(t / STEP_DT) % 2 == 0
return _flat(ctx, ctx.sig_level if hi else ctx.floor)
def pat_impulse(ctx, t):
bright = (t % IMPULSE_PERIOD) < IMPULSE_WIDTH
return _flat(ctx, ctx.sig_level if bright else ctx.floor)
def pat_noise(ctx, t):
# A band of random noise, `sig_width_khz` wide, centered at the VFO. noise_color
# tilts the band: 0 = white (flat), higher = pink/red (more power on the low side).
out = [ctx.floor] * ctx.n
lo = max(0, ctx.center - ctx.sig_half)
hi = min(ctx.n - 1, ctx.center + ctx.sig_half)
span = max(1, hi - lo)
for b in range(lo, hi + 1):
frac = (b - lo) / span # 0 low edge -> 1 high edge
env = ctx.sig_level - ctx.noise_color * frac # spectral tilt
out[b] = env - random.uniform(0.0, 12.0) # per-bin random texture
return out
def pat_ssb(ctx, t):
# An SSB-VOICE LOOKALIKE — not a calibration ruler, just something that *reads*
# like real on-air USB on the waterfall: a band offset above the (suppressed)
# carrier, ~300-2700 Hz wide, with a syllabic envelope (swells + word gaps) and
# formant-like energy that drifts within the band. Deterministic-ish via t so it
# animates smoothly frame to frame; uses random for live texture (not for golden
# captures — this pattern is for looks, not measurement).
out = [ctx.floor] * ctx.n
# Map the SSB band (Hz offset) to bins. The stream loop sets ctx.sig_half from the
# width slider, but SSB has its OWN fixed ~2.4 kHz band, so derive bins from the
# span the caller encoded into sig_half's reference. We approximate using the same
# bins-per-Hz the caller used for two_tone (ctx carries two_tone_half_bins for 2 kHz).
hz_per_bin = (TWO_TONE_SPACING_HZ / 2.0) / max(1, ctx.two_tone_half_bins)
lo_bin = ctx.center + int(SSB_LO_HZ / hz_per_bin) # USB: band sits ABOVE the carrier
hi_bin = ctx.center + int(SSB_HI_HZ / hz_per_bin)
lo = max(0, min(ctx.n - 1, lo_bin))
hi = max(0, min(ctx.n - 1, hi_bin))
if hi <= lo:
return out
span = hi - lo
# --- voice envelope: syllabic swell, occasional word gaps (drops toward floor) ---
syl = 0.5 + 0.5 * math.sin(2 * math.pi * SSB_SYLLABLE_HZ * t) # 0..1 syllable swell
syl *= 0.6 + 0.4 * math.sin(2 * math.pi * 1.3 * t + 1.1) # slower amplitude drift
word_phase = (t % (1.0 / 0.7)) * 0.7 # ~0.7 "words"/s cycle
in_gap = (word_phase % 1.0) < (SSB_WORD_GAP_S * 0.7) # brief inter-word silence
env = 0.12 if in_gap else (0.55 + 0.45 * max(0.0, syl)) # overall loudness 0..1
# `peak_dbm` is where a loud syllable on a formant should sit (the signal-level slider).
peak_dbm = ctx.sig_level
# How far DOWN from the peak the quiet parts of the band sag (dB of speech dynamic range).
SSB_DR = 24.0
# --- two drifting "formant" centres so energy clumps + moves like speech ---
f1 = 0.30 + 0.18 * math.sin(2 * math.pi * 0.9 * t) # 0..1 within band
f2 = 0.62 + 0.20 * math.sin(2 * math.pi * 0.6 * t + 2.0)
for b in range(lo, hi + 1):
x = (b - lo) / span # 0..1 across the band
edge = max(0.0, min(x / 0.12, (1 - x) / 0.12, 1.0)) # taper first/last ~12% (filter skirts)
g1 = math.exp(-((x - f1) ** 2) / (2 * 0.06 ** 2)) # formant clumps
g2 = 0.8 * math.exp(-((x - f2) ** 2) / (2 * 0.08 ** 2))
shape = max(0.0, min(1.0, (0.35 + 0.85 * (g1 + g2)))) * edge # 0..1 spectral shape
# Level in dB BELOW the peak: loud-syllable * on-a-formant = at the peak; else sags.
below = SSB_DR * (1.0 - env * shape)
level = peak_dbm - below - random.uniform(0.0, 4.0) # live per-bin texture
out[b] = max(ctx.floor, level)
return out
def pat_noise_cal(ctx, t):
# Calibrated noise bed for FILTER testing. Unlike `noise` (textured ±12 dB random,
# for looks), this holds an EXACT mean of ctx.sig_level dBm across the band with only
# a small bounded ripple (±NOISE_CAL_RIPPLE_DB) so the level reads true. Feed it
# through AE's filter and the output IS the filter's frequency response: bins inside
# the passband sit at sig_level, bins outside drop to the floor, the edges trace the
# roll-off — the whole curve in one frame (a tone shows one point; flat noise the lot).
#
# Full-span by default (noise_cal_full=True) — the STANDARD bed: noise everywhere,
# so AE's filter carves the visible shape. Clear the flag (panel slider) to BAND-LIMIT
# to sig_width_khz for ad-hoc/unknown tests — itself a filter test signal (watch AE's
# filter chew the edges as you cross its width). noise_color tilts white->pink.
# Deterministic per (frame, bin) so a golden capture is reproducible, yet looks noisy.
if ctx.noise_cal_full: # standard: fill the whole span
lo, hi = 0, ctx.n - 1
else: # ad-hoc: band-limit to the width
lo = max(0, ctx.center - ctx.sig_half)
hi = min(ctx.n - 1, ctx.center + ctx.sig_half)
out = [ctx.floor] * ctx.n
span = max(1, hi - lo)
frame = int(t * 1000) # stable within a frame, varies across
for b in range(lo, hi + 1):
frac = (b - lo) / span # 0 low edge -> 1 high edge
mean = ctx.sig_level - ctx.noise_color * frac # exact mean (tilt if pink)
# bounded zero-mean ripple, seeded by (frame,bin) -> reproducible, noise-like
ripple = (_hash01(frame * 131071 + b) - 0.5) * 2.0 * NOISE_CAL_RIPPLE_DB
out[b] = mean + ripple
return out
def pat_tx_blank(ctx, t):
# Periodic TX gap. During the gap return None (no pan/wf data); stream_loop
# ALSO asserts real TX state (interlock TRANSMITTING + forward power) so AE
# runs its waterfall-during-TX path -> repro #2126 (blank/flicker during TX)
# and #1916 (waterfall gone after TX).
if (t % TXBLANK_PERIOD) < TXBLANK_GAP:
return None # TX gap: send nothing
out = _flat(ctx, ctx.floor)
for d in range(-ctx.sig_half, ctx.sig_half + 1): # honour the width slider, like carrier/staircase
b = ctx.center + d
if 0 <= b < ctx.n:
out[b] = ctx.sig_level
return out
def pat_staircase(ctx, t):
# A center carrier whose amplitude steps floor->max in STAIRCASE_STEPS discrete
# levels (each held STAIRCASE_DWELL s), then repeats. Watch the waterfall carrier
# climb out of black in even brightness steps -> shows the colormap/black threshold.
cycle = STAIRCASE_STEPS * STAIRCASE_DWELL
step = int((t % cycle) / STAIRCASE_DWELL) # 0 .. STEPS-1
frac = step / max(1, STAIRCASE_STEPS - 1)
level = ctx.floor + frac * (ctx.sig_level - ctx.floor) # floor (0) -> signal level
out = [ctx.floor] * ctx.n
for d in range(-ctx.sig_half, ctx.sig_half + 1):
b = ctx.center + d
if 0 <= b < ctx.n:
out[b] = level
return out
def pat_carrier(ctx, t):
# Steady carrier at the VFO at sig_level — for S-meter / dBm calibration.
out = [ctx.floor] * ctx.n
for d in range(-ctx.sig_half, ctx.sig_half + 1):
b = ctx.center + d
if 0 <= b < ctx.n:
out[b] = ctx.sig_level
return out
def pat_test_card(ctx, t):
# Auto-black test card: 17 carriers evenly spaced across the band at FIXED
# levels S1..S9 in 1/2-S (3 dB) steps (S9 = -73 dBm, 6 dB per S-unit), on a
# flat noise floor at ctx.floor. The floor is the swept variable (noise-floor
# slider / /set?floor=): as it rises, carriers below it submerge, and because
# flex-sim advertises auto_black = ctx.floor the receiver's black point tracks
# the floor up with it. One pattern that shows the level->colour gradient,
# auto-black tracking, and signal/noise contrast (PR #3586 / sblanchard).
out = [ctx.floor] * ctx.n
s9 = -73.0
levels = [s9 - k * 3.0 for k in range(16, -1, -1)] # -121 (S1) .. -73 (S9)
m = len(levels)
for i, dbm in enumerate(levels):
b = int((i + 0.5) / m * ctx.n)
for d in range(-ctx.sig_half, ctx.sig_half + 1):
bb = b + d
if 0 <= bb < ctx.n:
out[bb] = max(out[bb], dbm)
return out
# ---- CW keyer (Morse) ----
_MORSE = {
"A": ".-", "B": "-...", "C": "-.-.", "D": "-..", "E": ".", "F": "..-.",
"G": "--.", "H": "....", "I": "..", "J": ".---", "K": "-.-", "L": ".-..",
"M": "--", "N": "-.", "O": "---", "P": ".--.", "Q": "--.-", "R": ".-.",
"S": "...", "T": "-", "U": "..-", "V": "...-", "W": ".--", "X": "-..-",
"Y": "-.--", "Z": "--..", "0": "-----", "1": ".----", "2": "..---",
"3": "...--", "4": "....-", "5": ".....", "6": "-....", "7": "--...",
"8": "---..", "9": "----.", "/": "-..-.", "?": "..--..", ".": ".-.-.-",
",": "--..--", "=": "-...-",
}
def _schedule_keydown(schedule, tt):
acc = 0.0
for on, dur in schedule:
if tt < acc + dur:
return on
acc += dur
return False
def _morse_schedule(text, wpm):
# [(key_down, seconds), ...] for `text` at `wpm` (PARIS timing: dit = 1.2/wpm s).
dit = 1.2 / wpm
seg = []
for wi, word in enumerate(text.split(" ")):
if wi:
seg.append((False, 7 * dit)) # inter-word gap
for ci, ch in enumerate(word):
code = _MORSE.get(ch.upper())
if not code:
continue
if ci:
seg.append((False, 3 * dit)) # inter-character gap
for ei, el in enumerate(code):
if ei:
seg.append((False, dit)) # intra-character gap
seg.append((True, (3 if el == "-" else 1) * dit))
return seg
def _morse_char_ends(text, wpm):
# Cumulative time (s) at which each keyed character finishes — for 'cwx sent=N'.
dit = 1.2 / wpm
t, ends = 0.0, []
for wi, word in enumerate(text.split(" ")):
if wi:
t += 7 * dit
for ci, ch in enumerate(word):
code = _MORSE.get(ch.upper())
if not code:
continue
if ci:
t += 3 * dit
for ei, el in enumerate(code):
if ei:
t += dit
t += (3 if el == "-" else 1) * dit
ends.append(t)
return ends
CW_SCHEDULE = _morse_schedule(CW_TEXT, CW_WPM) + [(False, CW_TAIL_GAP)]
CW_CYCLE = sum(d for _, d in CW_SCHEDULE)
def pat_cw(ctx, t):
# Sim-driven Morse keyer at CW_WPM (the panel "Send CW" button — the sim fakes
# the keying). key-down = TX burst (RX muted -> None); key-up = brief RX (carrier);
# tail gap = RX; repeat. (For an AE-initiated over, use CWX — see handle_cwx.)
tt = t % CW_CYCLE
keydown = _schedule_keydown(CW_SCHEDULE, tt)
in_msg = tt < (CW_CYCLE - CW_TAIL_GAP)
ctx.cw_keydown = keydown
ctx.cw_in_message = in_msg
if keydown or (in_msg and not ctx.cw_qsk): # TX burst, or non-QSK over: RX muted
return None
out = [ctx.floor] * ctx.n # QSK key-up peek, or the tail RX gap
for d in range(-ctx.sig_half, ctx.sig_half + 1):
b = ctx.center + d
if 0 <= b < ctx.n:
out[b] = ctx.sig_level
return out
PATTERNS = {
"carrier": pat_carrier, "cw": pat_cw,
"noise_floor": pat_noise_floor, "ramp": pat_ramp, "cal_tones": pat_cal_tones,
"swept_carrier": pat_swept_carrier, "comb": pat_comb, "two_tone": pat_two_tone,
"step": pat_step, "impulse": pat_impulse, "tx_blank": pat_tx_blank,
"staircase": pat_staircase, "noise": pat_noise, "noise_cal": pat_noise_cal,
"test_card": pat_test_card, "ssb": pat_ssb,
}
AUTO_TX_PATTERNS = {"tx_blank", "cw"} # patterns that drive TX state themselves
# ===========================================================================
# HF NOISE BENCH — additive audio mixer for testing AE's noise reduction.
#
# AE's NR (RN2/NR2/NR4/DFNR/BNR/MacNR) runs on the remote_audio_rx audio path
# (AudioEngine.cpp processMixedRxAudioData -> NR block). This bench builds the
# audio it hears as a LINEAR SUM of independently-toggleable channels:
#
# frame = Σ enabled signal channels + Σ enabled noise channels (then clip)
#
# Everything is GENERATED ON THE SPOT — no recordings for the noise (its whole
# point is non-repeating randomness). Only the voice channel plays a WAV.
#
# Each generator has signature gen(st, ch, n) -> list[float] of length n:
# st = the Radio's NoiseMixer state (rate, phase counters, RNG helpers)
# ch = this channel's dict (level_db + any per-channel knobs)
# n = frames to produce (== AUDIO_FRAMES)
# It returns samples at UNITY reference; the mixer scales by 10**(level_db/20).
# level_db is dBFS-ish: 0 = full reference amplitude, -20 = /10, etc.
# ===========================================================================
NOISE_REF_AMP = 0.18 # unity-reference RMS-ish amplitude (headroom for summing)
_PINK_ROWS = 16 # Voss-McCartney octave rows
def _db2lin(db):
return 10.0 ** (db / 20.0)
def _tnf_width_hz(raw):
"""AE sends TNF width in MHz (TnfModel.cpp:86, width/1e6). Convert to Hz,
tolerating an already-Hz value defensively (matches AE's own parser)."""
v = float(raw)
return int(round(v * 1.0e6)) if 0.0 < v < 1.0 else max(10, int(round(v)))
# ---- signal channels (the WANTED signal NR must PRESERVE) -----------------
def sig_voice(st, ch, n):
"""Voice from a looped WAV (the only recorded source). Falls silent if no
WAV is loaded — the mixer just skips it then."""
wav = st.voice_wav
if wav is None:
return [0.0] * n
return wav.read(n) # WavPlayer already returns mono float at AUDIO_RATE
def sig_cw(st, ch, n):
"""A separate keyed CW tone at cw_hz, sending a repeating id in REAL Morse
so NR has a tonal signal to preserve and a decoder reads actual content.
Envelope-shaped keying (no clicks).
Was an element table [1,3,1,1,3,0,0] that conflated duration with key
state — every non-zero entry keyed, so the id rendered as one fused
~600 ms tone ('L' feel, not L). Found via the C++ port of this table in
AetherSDR demo mode (aethersdr#4593/#4594); now built from the same
_morse_schedule the panel keyer and CWX already use."""
hz = ch.get("hz", 700.0)
wpm = ch.get("wpm", 18.0)
text = ch.get("id", "L")
cache = ch.get("_sched")
if not cache or cache[0] != (text, wpm):
# schedule + 7-dit word gap before the loop restarts
sched = _morse_schedule(text, wpm) + [(False, 7 * 1.2 / wpm)]
cache = ((text, wpm), sched, sum(d for _, d in sched))
ch["_sched"] = cache
_, sched, total = cache
edge = 0.005 # raised-cosine, 5 ms
out = []
for i in range(n):
tt = (st.cw_phase + i) / st.rate
pos = tt % total
acc = 0.0; key = 0.0
for on, dur in sched:
if pos < acc + dur:
if on:
key = 1.0
into = pos - acc; left = acc + dur - pos
if into < edge: key = 0.5 - 0.5 * math.cos(math.pi * into / edge)
elif left < edge: key = 0.5 - 0.5 * math.cos(math.pi * left / edge)
break
acc += dur
out.append(key * math.sin(2 * math.pi * hz * tt))
st.cw_phase += n
return out
# ---- noise channels (all live DSP, non-repeating) -------------------------
def noi_white(st, ch, n):
"""Flat AWGN — the thermal baseline."""
g = st.rng.gauss
return [g(0.0, NOISE_REF_AMP) for _ in range(n)]
def noi_pink(st, ch, n):
"""1/f pink noise (Voss-McCartney) — the atmospheric band-hiss floor."""
rows = st._pink_rows
key = st._pink_key
out = []
for _ in range(n):
st._pink_ctr = (st._pink_ctr + 1) & ((1 << _PINK_ROWS) - 1)
diff = key ^ st._pink_ctr
for r in range(_PINK_ROWS):
if diff & (1 << r):
rows[r] = st.rng.uniform(-1.0, 1.0)
key = st._pink_ctr
out.append(sum(rows) / _PINK_ROWS * NOISE_REF_AMP * 3.0)
st._pink_key = key
return out
def noi_qrn(st, ch, n):
"""Atmospheric impulse crackle (lightning static) — Poisson-timed sharp
impulses with fast exponential decay. rate = impulses/sec."""
rate = ch.get("rate", 12.0)
p = rate / st.rate # per-sample trigger prob
out = []
for _ in range(n):
if st.rng.random() < p:
st._qrn_env = 1.0
st._qrn_sign = 1.0 if st.rng.random() < 0.5 else -1.0
v = st._qrn_sign * st._qrn_env * (0.6 + 0.4 * st.rng.random())
st._qrn_env *= 0.86 # ~ a few ms tail at 24 k
out.append(v * NOISE_REF_AMP * 5.0)
return out
def noi_powerline(st, ch, n):
"""Mains buzz — fundamental + odd harmonics (the classic power-line raster).
freq = 50 or 60 Hz."""
f0 = ch.get("freq", 60.0)
out = []
for i in range(n):
tt = (st.pl_phase + i) / st.rate
v = 0.0
for h in (1, 3, 5, 7, 9, 11):
v += (1.0 / h) * math.sin(2 * math.pi * f0 * h * tt)
out.append(v * NOISE_REF_AMP * 0.5)
st.pl_phase += n
return out
def noi_crashes(st, ch, n):
"""Static crashes — big correlated bursts of band-limited noise (storm
front). Rare, long-tailed. rate = crashes/sec."""
rate = ch.get("rate", 0.4)
p = rate / st.rate
out = []
for _ in range(n):
if st.rng.random() < p:
st._crash_env = 1.0
# low-passed noise (one-pole) for a rushing rather than hissing texture
st._crash_lp = 0.6 * st._crash_lp + 0.4 * st.rng.gauss(0.0, 1.0)
out.append(st._crash_lp * st._crash_env * NOISE_REF_AMP * 4.0)
st._crash_env *= 0.9995 # long decay (~150 ms)
return out
def noi_birdie(st, ch, n):
"""A steady carrier heterodyne — a pure tone birdie for notch/auto-notch
testing. hz = audio pitch of the het."""
hz = ch.get("hz", 1000.0)
out = []
for i in range(n):
tt = (st.birdie_phase + i) / st.rate
out.append(math.sin(2 * math.pi * hz * tt) * NOISE_REF_AMP * 1.2)
st.birdie_phase += n
return out
def noi_hash(st, ch, n):
"""Switching-supply / digital hash — broadband noise gated into clumps with
a fast repetition rate (SMPS whine + broadband splatter)."""
prf = ch.get("prf", 120.0) # clump repetition (Hz)
out = []
for i in range(n):
tt = (st.hash_phase + i) / st.rate
gate = 1.0 if (tt * prf) % 1.0 < 0.4 else 0.15
out.append(st.rng.gauss(0.0, 1.0) * gate * NOISE_REF_AMP * 2.0)
st.hash_phase += n
return out
def noi_woodpecker(st, ch, n):
"""Pulsed wideband rasp (over-the-horizon-radar flavour) — noise bursts at a
low PRF. prf = pulses/sec."""
prf = ch.get("prf", 10.0)
out = []
for i in range(n):
tt = (st.wood_phase + i) / st.rate
on = (tt * prf) % 1.0 < 0.5 # ~50% duty rasp
out.append((st.rng.gauss(0.0, 1.0) if on else 0.0) * NOISE_REF_AMP * 3.0)
st.wood_phase += n
return out
# Registry: name -> (generator, is_signal, default channel dict).
# default dict carries level_db (starts DISABLED via enabled=False) + knobs.
NOISE_CHANNELS = {
# signal channels (wanted)
"voice": (sig_voice, True, {"enabled": False, "level_db": -14.0}),
"cw": (sig_cw, True, {"enabled": False, "level_db": -16.0, "hz": 700.0, "wpm": 18.0}),
# noise channels
"white": (noi_white, False, {"enabled": False, "level_db": -26.0}),
"pink": (noi_pink, False, {"enabled": False, "level_db": -24.0}),
"qrn": (noi_qrn, False, {"enabled": False, "level_db": -18.0, "rate": 12.0}),
"powerline": (noi_powerline, False, {"enabled": False, "level_db": -22.0, "freq": 60.0}),
"crashes": (noi_crashes, False, {"enabled": False, "level_db": -16.0, "rate": 0.4}),
"birdie": (noi_birdie, False, {"enabled": False, "level_db": -28.0, "hz": 1000.0}),
"hash": (noi_hash, False, {"enabled": False, "level_db": -24.0, "prf": 120.0}),
"woodpecker": (noi_woodpecker, False, {"enabled": False, "level_db": -22.0, "prf": 10.0}),
}
class NoiseMixer:
"""Per-radio additive mixer state. Holds each channel's live dict, phase
counters for the deterministic tone/buzz generators, and RNG scratch for the
stochastic ones. mix(n) returns a summed, soft-clipped mono float block."""
def __init__(self, voice_wav=None):
import copy
self.rate = AUDIO_RATE
self.channels = {name: copy.deepcopy(defs)
for name, (_g, _s, defs) in NOISE_CHANNELS.items()}
self.voice_wav = voice_wav
self.rng = random.Random(0x5EED) # own stream; reproducible per launch
# phase counters
self.cw_phase = self.pl_phase = self.birdie_phase = 0
self.hash_phase = self.wood_phase = 0
# generator scratch
self._pink_rows = [0.0] * _PINK_ROWS
self._pink_ctr = 0; self._pink_key = 0
self._qrn_env = 0.0; self._qrn_sign = 1.0
self._crash_env = 0.0; self._crash_lp = 0.0
# TNF (tracking notch) — audio Hz offsets to remove + per-notch biquad state
self._notch_hz = []
self._notch_width_hz = {} # round(f_hz) -> width Hz (for the display dip)
self._notch_state = {}
def any_enabled(self):
return any(c["enabled"] for c in self.channels.values())
def mix(self, n):
acc = [0.0] * n
for name, (gen, _is_sig, _d) in NOISE_CHANNELS.items():
ch = self.channels[name]
if not ch["enabled"]:
continue
g = _db2lin(ch["level_db"])
block = gen(self, ch, n)
for i in range(n):