summaryrefslogtreecommitdiff
path: root/stacks/crdop/tools/max25-signal-sniffer.py
diff options
context:
space:
mode:
authorinfo@mode42.com <info@mode42.com>2026-08-07 18:25:13 +0000
committerinfo@mode42.com <info@mode42.com>2026-08-07 18:25:13 +0000
commit04d965d67a7264a1c7c211494aebda1953df7603 (patch)
tree0ebd700a6e219f84a26a656f4bee8778bc75da7b /stacks/crdop/tools/max25-signal-sniffer.py
Initial push
Diffstat (limited to 'stacks/crdop/tools/max25-signal-sniffer.py')
-rw-r--r--stacks/crdop/tools/max25-signal-sniffer.py78
1 files changed, 78 insertions, 0 deletions
diff --git a/stacks/crdop/tools/max25-signal-sniffer.py b/stacks/crdop/tools/max25-signal-sniffer.py
new file mode 100644
index 0000000..fed95f7
--- /dev/null
+++ b/stacks/crdop/tools/max25-signal-sniffer.py
@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""MAX25 signal sniffer — analyze Bell 202 AFSK on ALSA capture or PCM file."""
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[3]
+LIB = ROOT / "stacks" / "crdop" / "lib"
+sys.path.insert(0, str(LIB))
+
+from acoustic_engine import AcousticEngine # noqa: E402
+from sound_proxy import SoundConfig, SoundProxy # noqa: E402
+
+
+def read_wav_pcm(path: Path) -> bytes:
+ import wave
+
+ with wave.open(str(path), "rb") as wf:
+ if wf.getsampwidth() != 2 or wf.getnchannels() != 1:
+ raise SystemExit("WAV must be mono S16_LE")
+ return wf.readframes(wf.getnframes())
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="MAX25 acoustic signal sniffer (Bell 202 1200 bd)")
+ ap.add_argument("-D", "--device", default="", help="ALSA capture device (hw: or plughw:)")
+ ap.add_argument("-r", "--rate", type=int, default=48000)
+ ap.add_argument("-f", "--file", type=Path, help="Read mono S16 WAV instead of ALSA")
+ ap.add_argument("-t", "--seconds", type=float, default=2.0, help="ALSA capture duration")
+ ap.add_argument("--loopback", action="store_true", help="Internal DSP loopback (no audio hw)")
+ ap.add_argument("--mark", action="store_true", help="Play mark calibration tone (1200 Hz)")
+ ap.add_argument("--space", action="store_true", help="Play space calibration tone (2200 Hz)")
+ args = ap.parse_args()
+
+ cfg = SoundConfig(capture=args.device or "default", sample_rate=args.rate)
+ eng = AcousticEngine(sample_rate=args.rate, sound=cfg)
+
+ if args.loopback:
+ rep = eng.loopback_self_test()
+ print(f"loopback samples={rep.samples} symbols={rep.symbols} transitions={rep.transitions}")
+ print(f"mark={rep.mark_ratio:.2%} space={rep.space_ratio:.2%}")
+ for line in rep.decode_lines:
+ print(line)
+ return 0 if rep.decode_lines else 1
+
+ if args.mark:
+ eng.play_mark_calibration(args.seconds)
+ print(f"played mark tone {args.seconds}s")
+ return 0
+ if args.space:
+ eng.play_space_calibration(args.seconds)
+ print(f"played space tone {args.seconds}s")
+ return 0
+
+ if args.file:
+ pcm = read_wav_pcm(args.file)
+ else:
+ if not args.device:
+ print("error: -D hw:... or --file or --loopback required", file=sys.stderr)
+ return 2
+ proxy = SoundProxy(cfg)
+ proxy.start_capture()
+ nbytes = int(args.rate * args.seconds) * 2
+ pcm = proxy.read_capture(nbytes)
+ proxy.close()
+
+ rep = eng.analyze_pcm(pcm)
+ print(f"sniff samples={rep.samples} symbols={rep.symbols} transitions={rep.transitions}")
+ print(f"mark={rep.mark_ratio:.2%} space={rep.space_ratio:.2%} frames={len(rep.frames)}")
+ for line in rep.decode_lines:
+ print(line)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com