From 80ea31428c2e1e6b1e8c2927263cbd07d4bcdbcc Mon Sep 17 00:00:00 2001 From: StratPlans Date: Fri, 7 Aug 2026 19:21:28 -0400 Subject: [PATCH 1/7] Add Silent Disco receiver with channel selection The existing ZMQ NBFM flowgraph is not usable for the Silent Disco system: its analog.nbfm_rx audio low-pass is fixed at 2.7 kHz, so pointed at a silent disco channel it produces something audible but badly wrong. Silent disco is wideband FM carrying a broadcast-style stereo multiplex - L+R below 15 kHz, a 19 kHz pilot and L-R on a 38 kHz DSB-SC subcarrier. This adds a receiver built for that. gr_3.10_silent_disco_rx.grc QT flowgraph with a 10-channel colour chooser that retunes the radio. The colours match the LEDs on the headsets, so an operator reads the colour off a headset and picks it from the menu. make_silent_disco_iq.py generates synthetic multi-channel FM stereo IQ, so the receiver can be tested with no hardware and no live event Verified by running the shipped flowgraph headlessly against generated IQ: wanted tones at 103 dB, neighbouring channels 500 kHz away at about 5 dB, 19 kHz pilot suppressed to 2.7 dB. The 15 kHz audio passband is load-bearing - anything wider puts the pilot into the audio. Channel frequencies are the published Quiet Events plan and are irregularly spaced (gaps of 400 to 1200 kHz), which is why this uses a frequency-translating filter per channel rather than a uniform channelizer. All stock GNU Radio blocks, no custom DSP. --- Silent Disco Receivers/README.md | 111 ++++ .../gr_3.10_silent_disco_rx.grc | 567 ++++++++++++++++++ .../make_silent_disco_iq.py | 205 +++++++ 3 files changed, 883 insertions(+) create mode 100644 Silent Disco Receivers/README.md create mode 100644 Silent Disco Receivers/gr_3.10_silent_disco_rx.grc create mode 100755 Silent Disco Receivers/make_silent_disco_iq.py diff --git a/Silent Disco Receivers/README.md b/Silent Disco Receivers/README.md new file mode 100644 index 0000000..e66a85e --- /dev/null +++ b/Silent Disco Receivers/README.md @@ -0,0 +1,111 @@ +# Silent Disco Receivers + +Receive the DEF CON silent disco with an SDR and listen to any channel. + +The silent disco is a [Quiet Events](https://quietevents.com/) system. DEF CON +publishes this in the Hacker Tracker information documents: "We're using two +different solutions: Quiet Events and Listen WIFI. Quiet Events may also be +known as 'Silent Disco'... Headsets have LEDs on them; the color indicates the +audio channel that the headset is tuned to." + +Physically it is the simplest thing it could be: **analogue FM, one carrier per +audio channel, in the US 902-928 MHz ISM band, stereo**. A headset is an FM +receiver, and the channel button retunes it. Nothing stops an SDR from doing +the same thing, or from doing it to several channels at once. + +## Files + +| File | What it does | +| --- | --- | +| `gr_3.10_silent_disco_rx.grc` | GNU Radio Companion flowgraph. Pick a channel from the drop-down, hear it. | +| `make_silent_disco_iq.py` | Writes a synthetic silent disco capture so you can test the receiver without a rig. | + +## Quick start + +You need GNU Radio 3.10 and an RTL-SDR. `gr-soapy` ships with GNU Radio 3.10, +so nothing else has to be installed. An antenna cut for 900 MHz helps; the +stock whip works if you are in the room. + +``` +gnuradio-companion gr_3.10_silent_disco_rx.grc +``` + +Press run. Pick a channel from **Silent Disco Channel** at the top of the +window. The lower left plot is the whole 2.4 MHz capture, so you can see which +carriers are actually on the air; the lower right plot is the channel you +selected after filtering. + +## The channel plan + +The drop-down carries the ten Quiet Events Ultra 900 channels used on the main +and creator stages. The colour is the colour of the LED on the matching +headset, so a headset lying on a chair tells you the frequency. + +| Channel | Colour | MHz | | Channel | Colour | MHz | +| --- | --- | --- | --- | --- | --- | --- | +| 1 | red | 920.1 | | 6 | turquoise | 922.8 | +| 2 | yellow | 920.7 | | 7 | white | 923.4 | +| 3 | green | 921.2 | | 8 | orange | 924.2 | +| 4 | purple | 921.9 | | 9 | pink | 924.7 | +| 5 | blue | 922.3 | | 10 | mint | 925.9 | + +Village and community locations use the Quiet Events 45 Max transmitter +instead, which has 45 channels from 908.1 to 927.5 MHz. + +Two things worth noticing about this plan, because they drive the design: + +* **The spacing is not uniform.** It runs from 400 kHz to 1.2 MHz. Anything + that assumes a regular channel grid, such as a polyphase channelizer, does + not fit it. +* **The whole plan is wider than one cheap SDR can see.** 908 to 927.5 MHz is + 19.4 MHz; an RTL-SDR gets about 2.4 MHz. You will always be choosing a slice. + +## How it works + +``` +Soapy RTLSDR Source -> Frequency Xlating FIR Filter -> FM Demod -> AGC -> Volume -> Audio Sink +``` + +* The radio tunes 300 kHz **below** the channel you picked, and the translating + filter shifts by +300 kHz to bring it back to zero. That keeps the carrier off + the RTL-SDR's DC spike, which otherwise sits right in the middle of your + audio. +* The channel filter is 200 kHz wide with a Blackman-Harris window. Hamming's + 53 dB stopband is not enough when a loud transmitter is 400 kHz from a quiet + one, which the plan above allows. +* `FM Demod` is stock `analog.fm_demod_cf` with a 15 kHz audio passband. That + 15 kHz limit matters: these transmitters send a broadcast-style stereo + multiplex, and the 19 kHz pilot tone and the 38 kHz L-R subcarrier sit just + above the audio. Filter at 15 kHz and you get clean mono, which is the L+R + sum, and it works whether the transmitter is stereo or mono. Filter wider and + you get a 19 kHz whine and the AGC chasing it. +* The AGC is there because Quiet Events do not publish their deviation, and no + FCC grant exists under the brand name (the hardware is certified under the + factory's name). Rather than guess the number and get the level wrong, the + audio is levelled after demodulation. + +## Testing it without a silent disco + +`make_silent_disco_iq.py` writes a complex float32 capture containing as many +FM carriers as you ask for, each with a full stereo multiplex: pre-emphasis, +L+R, a 19 kHz pilot and the L-R subcarrier at 38 kHz. Each carrier gets a +different tone, so a correct decode is provable rather than a matter of +opinion. + +``` +./make_silent_disco_iq.py --out silent_disco.cf32 --offsets=-200e3,300e3,800e3 +``` + +Then in the flowgraph, right-click **File Source** and **Throttle** and enable +them, right-click **Soapy RTLSDR Source** and disable it, and run. The default +`if_offset` of 300 kHz lands on the middle carrier, which carries a 700 Hz tone +on the left and 1400 Hz on the right. + +This writes a file. It does not transmit, and neither does the flowgraph. + +## Only 3.10 + +The other receivers in this repository ship for 3.7 through 3.10. This one is +3.10 only: it uses `gr-soapy`, which does not exist before 3.9, and 3.10 is the +version it was written and tested against. A 3.9 port would be the same +flowgraph; earlier versions would need `gr-osmosdr` instead. diff --git a/Silent Disco Receivers/gr_3.10_silent_disco_rx.grc b/Silent Disco Receivers/gr_3.10_silent_disco_rx.grc new file mode 100644 index 0000000..fddf8d2 --- /dev/null +++ b/Silent Disco Receivers/gr_3.10_silent_disco_rx.grc @@ -0,0 +1,567 @@ +options: + parameters: + author: rfhs contributors + catch_exceptions: 'True' + category: '[GRC Hier Blocks]' + cmake_opt: '' + comment: "Silent disco receiver.\n\nThe DEF CON silent disco is a Quiet Events\ + \ system: analogue FM, stereo,\none carrier per audio channel, in the US 902-928\ + \ MHz ISM band. Pick a\nchannel with the chooser at the top of the window and\ + \ listen to it. The\nchooser carries the ten Quiet Events Ultra 900 colours,\ + \ which are the\ncolours printed on the headset LEDs.\n\nThe radio retunes to\ + \ follow the channel you pick and keeps the carrier\n300 kHz off centre, so\ + \ nothing lands under the receiver's DC spike.\n\nReceive only. This flowgraph\ + \ does not transmit." + copyright: '' + description: Silent disco channel receiver + gen_cmake: 'On' + gen_linking: dynamic + generate_options: qt_gui + hier_block_src_path: '.:' + id: silent_disco_rx + max_nouts: '0' + output_language: python + placement: (0,0) + qt_qss_theme: '' + realtime_scheduling: '' + run: 'True' + run_command: '{python} -u {filename}' + run_options: prompt + sizing_mode: fixed + thread_safe_setters: '' + title: Silent Disco Receiver + window_size: (1200,900) + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [8, 8] + rotation: 0 + state: enabled + +blocks: +- name: audio_rate + id: variable + parameters: + comment: '' + value: '48000' + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [352, 12.0] + rotation: 0 + state: enabled +- name: chan_bw + id: variable + parameters: + comment: 'Per channel bandwidth. The plan puts channels + + 400 kHz apart at the closest.' + value: 200e3 + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [440, 12.0] + rotation: 0 + state: enabled +- name: chan_taps + id: variable + parameters: + comment: 'Blackman-Harris, not Hamming: a loud + + neighbour 400 kHz away needs more than + + 53 dB of stopband to stay out.' + value: firdes.low_pass(1.0, samp_rate, chan_bw/2, chan_bw/4, window.WIN_BLACKMAN_HARRIS, + 6.76) + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [704, 12.0] + rotation: 0 + state: enabled +- name: channel + id: variable_qtgui_chooser + parameters: + comment: 'The channel selection feature. Ten Quiet Events + + Ultra 900 channels, named by the colour of the + + LED on the matching headset.' + gui_hint: 0,0,1,2 + label: Silent Disco Channel + label0: '' + label1: '' + label2: '' + label3: '' + label4: '' + labels: '["1 red 920.1", "2 yellow 920.7", "3 green 921.2", "4 purple 921.9", + "5 blue 922.3", "6 turquoise 922.8", "7 white 923.4", "8 orange 924.2", "9 pink + 924.7", "10 mint 925.9"]' + num_opts: '0' + option0: '0' + option1: '1' + option2: '2' + option3: '3' + option4: '4' + options: '[920.1e6, 920.7e6, 921.2e6, 921.9e6, 922.3e6, 922.8e6, 923.4e6, 924.2e6, + 924.7e6, 925.9e6]' + orient: Qt.QVBoxLayout + type: real + value: 922.3e6 + widget: combo_box + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [8, 108.0] + rotation: 0 + state: enabled +- name: center_freq + id: variable + parameters: + comment: 'Tune the radio if_offset below the channel so + + the carrier never sits on the DC spike.' + value: channel - if_offset + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [216, 108.0] + rotation: 0 + state: enabled +- name: if_offset + id: variable + parameters: + comment: '' + value: 300e3 + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [552, 12.0] + rotation: 0 + state: enabled +- name: iq_file + id: variable + parameters: + comment: 'Only used by the File Source, which is disabled. + + See README.md.' + value: '"silent_disco.cf32"' + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [216, 180.0] + rotation: 0 + state: enabled +- name: quad_rate + id: variable + parameters: + comment: 'samp_rate / 5. Decimates to 480 kHz, then + + the demodulator decimates by 10 to audio.' + value: '480000' + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [256, 12.0] + rotation: 0 + state: enabled +- name: rf_gain + id: variable + parameters: + comment: '' + value: '40' + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [632, 12.0] + rotation: 0 + state: enabled +- name: samp_rate + id: variable + parameters: + comment: '' + value: '2400000' + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [168, 12.0] + rotation: 0 + state: enabled +- name: tau + id: variable + parameters: + comment: 'De-emphasis. 75 us in the US, + + 50 us in Europe.' + value: 75e-6 + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [864, 12.0] + rotation: 0 + state: enabled +- name: volume + id: variable_qtgui_range + parameters: + comment: '' + gui_hint: 1,0,1,2 + label: Volume + min_len: '200' + orient: Qt.Horizontal + rangeType: float + start: '0' + step: '0.05' + stop: '4' + value: '1.0' + widget: counter_slider + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [8, 236.0] + rotation: 0 + state: enabled +- name: analog_agc2_xx_0 + id: analog_agc2_xx + parameters: + affinity: '' + alias: '' + attack_rate: '0.4' + comment: 'Quiet Events do not publish their deviation and + + there is no FCC grant under the brand, so level + + the audio rather than guess the number.' + decay_rate: 1e-3 + gain: '1.0' + max_gain: '4000' + maxoutbuf: '0' + minoutbuf: '0' + reference: '0.35' + type: float + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [768, 236.0] + rotation: 0 + state: enabled +- name: analog_fm_demod_cf_0 + id: analog_fm_demod_cf + parameters: + affinity: '' + alias: '' + audio_decim: '10' + audio_pass: '15000' + audio_stop: '17000' + chan_rate: quad_rate + comment: 'Mono recovers L+R, which is what a stereo + + multiplex carries below 15 kHz, so this works + + on mono and stereo transmitters alike. The + + 15 kHz audio filter is what keeps the 19 kHz + + pilot out of the sound.' + deviation: 75e3 + gain: '1.0' + maxoutbuf: '0' + minoutbuf: '0' + tau: tau + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [528, 220.0] + rotation: 0 + state: enabled +- name: audio_sink_0 + id: audio_sink + parameters: + affinity: '' + alias: '' + comment: '' + device_name: '' + num_inputs: '1' + ok_to_block: 'True' + samp_rate: '48000' + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [1120, 240.0] + rotation: 0 + state: enabled +- name: blocks_file_source_0 + id: blocks_file_source + parameters: + affinity: '' + alias: '' + begin_tag: pmt.PMT_NIL + comment: 'Offline testing without a radio. Right click to + + enable this and the Throttle, and disable the + + Soapy RTLSDR Source. Make a capture with + + make_silent_disco_iq.py.' + file: iq_file + length: '0' + maxoutbuf: '0' + minoutbuf: '0' + offset: '0' + repeat: 'True' + type: complex + vlen: '1' + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [8, 356.0] + rotation: 0 + state: disabled +- name: blocks_multiply_const_vxx_0 + id: blocks_multiply_const_vxx + parameters: + affinity: '' + alias: '' + comment: '' + const: volume + maxoutbuf: '0' + minoutbuf: '0' + type: float + vlen: '1' + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [944, 244.0] + rotation: 0 + state: enabled +- name: blocks_throttle_0 + id: blocks_throttle + parameters: + affinity: '' + alias: '' + comment: 'Paces a file to real time. Not needed with + + a radio, which paces itself.' + ignoretag: 'True' + maxoutbuf: '0' + minoutbuf: '0' + samples_per_second: samp_rate + type: complex + vlen: '1' + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [216, 364.0] + rotation: 0 + state: disabled +- name: blocks_wavfile_sink_0 + id: blocks_wavfile_sink + parameters: + affinity: '' + alias: '' + append: 'False' + bits_per_sample1: FORMAT_PCM_16 + bits_per_sample2: FORMAT_PCM_16 + bits_per_sample3: FORMAT_VORBIS + bits_per_sample4: FORMAT_PCM_16 + comment: 'Right click and enable to record the channel + + you are listening to.' + file: '"silent_disco.wav"' + format: FORMAT_WAV + nchan: '1' + samp_rate: audio_rate + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [1104, 348.0] + rotation: 0 + state: disabled +- name: freq_xlating_fir_filter_xxx_0 + id: freq_xlating_fir_filter_xxx + parameters: + affinity: '' + alias: '' + center_freq: if_offset + comment: 'Translate the wanted carrier down to zero and + + decimate to quad_rate in one block. Change the + + centre frequency here and you have picked a + + different channel out of the same capture, + + which is what the five channel version does + + five times over.' + decim: '5' + maxoutbuf: '0' + minoutbuf: '0' + samp_rate: samp_rate + taps: chan_taps + type: ccf + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [288, 220.0] + rotation: 0 + state: enabled +- name: qtgui_freq_sink_x_0 + id: qtgui_freq_sink_x + parameters: + affinity: '' + alias: '' + alpha1: '1.0' + autoscale: 'False' + average: '0.2' + axislabels: 'True' + bw: samp_rate + color1: '"blue"' + comment: 'The whole capture. The other silent disco + + carriers are the other humps.' + ctrlpanel: 'False' + fc: center_freq + fftsize: '2048' + freqhalf: 'True' + grid: 'True' + gui_hint: 2,0,2,1 + label: Relative Gain + label1: capture + legend: 'True' + maxoutbuf: '0' + minoutbuf: '0' + name: '"Capture"' + nconnections: '1' + norm_window: 'False' + showports: 'False' + tr_chan: '0' + tr_level: '0.0' + tr_mode: qtgui.TRIG_MODE_FREE + tr_tag: '""' + type: complex + units: dB + update_time: '0.10' + width1: '1' + wintype: window.WIN_BLACKMAN_hARRIS + ymax: '10' + ymin: '-140' + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [288, 452.0] + rotation: 0 + state: enabled +- name: qtgui_freq_sink_x_1 + id: qtgui_freq_sink_x + parameters: + affinity: '' + alias: '' + alpha1: '1.0' + autoscale: 'False' + average: '0.2' + axislabels: 'True' + bw: quad_rate + color1: '"blue"' + comment: 'The selected channel after filtering. One + + hump, centred, roughly 200 kHz wide.' + ctrlpanel: 'False' + fc: channel + fftsize: '1024' + freqhalf: 'True' + grid: 'True' + gui_hint: 2,1,2,1 + label: Relative Gain + label1: channel + legend: 'True' + maxoutbuf: '0' + minoutbuf: '0' + name: '"Selected Channel"' + nconnections: '1' + norm_window: 'False' + showports: 'False' + tr_chan: '0' + tr_level: '0.0' + tr_mode: qtgui.TRIG_MODE_FREE + tr_tag: '""' + type: complex + units: dB + update_time: '0.10' + width1: '1' + wintype: window.WIN_BLACKMAN_hARRIS + ymax: '10' + ymin: '-140' + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [536, 452.0] + rotation: 0 + state: enabled +- name: soapy_rtlsdr_source_0 + id: soapy_rtlsdr_source + parameters: + affinity: '' + agc: 'False' + alias: '' + bias: 'False' + bufflen: '16384' + center_freq: center_freq + comment: 'gr-soapy ships with GNU Radio 3.10, so this + + needs nothing installed beyond GNU Radio and + + the SoapySDR RTL-SDR module.' + dev_args: '' + freq_correction: '0' + gain: rf_gain + maxoutbuf: '0' + minoutbuf: '0' + samp_rate: samp_rate + type: fc32 + states: + bus_sink: false + bus_source: false + bus_structure: null + coordinate: [8, 268.0] + rotation: 0 + state: enabled + +connections: +- [analog_agc2_xx_0, '0', blocks_multiply_const_vxx_0, '0'] +- [analog_fm_demod_cf_0, '0', analog_agc2_xx_0, '0'] +- [blocks_file_source_0, '0', blocks_throttle_0, '0'] +- [blocks_multiply_const_vxx_0, '0', audio_sink_0, '0'] +- [blocks_multiply_const_vxx_0, '0', blocks_wavfile_sink_0, '0'] +- [blocks_throttle_0, '0', freq_xlating_fir_filter_xxx_0, '0'] +- [blocks_throttle_0, '0', qtgui_freq_sink_x_0, '0'] +- [freq_xlating_fir_filter_xxx_0, '0', analog_fm_demod_cf_0, '0'] +- [freq_xlating_fir_filter_xxx_0, '0', qtgui_freq_sink_x_1, '0'] +- [soapy_rtlsdr_source_0, '0', freq_xlating_fir_filter_xxx_0, '0'] +- [soapy_rtlsdr_source_0, '0', qtgui_freq_sink_x_0, '0'] + +metadata: + file_format: 1 + grc_version: 3.10.9.2 diff --git a/Silent Disco Receivers/make_silent_disco_iq.py b/Silent Disco Receivers/make_silent_disco_iq.py new file mode 100755 index 0000000..1ce62c1 --- /dev/null +++ b/Silent Disco Receivers/make_silent_disco_iq.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +# +# make_silent_disco_iq.py - generate a synthetic silent disco IQ capture. +# +# Silent disco transmitters are analogue FM, one carrier per audio channel, +# carrying a broadcast-style stereo multiplex (L+R baseband, 19 kHz pilot, +# L-R on a 38 kHz double-sideband suppressed-carrier subcarrier) with +# pre-emphasis. This script builds exactly that, for as many carriers as you +# ask for, and writes the sum to a complex float32 file. +# +# Purpose: let anyone verify silent_disco_rx.py and the .grc flowgraph +# without owning a silent disco rig. Each channel carries a different tone, so +# a correct decode is provable and a channel mix-up is visible: +# +# channel k -> left = tone_k Hz, right = 2 * tone_k Hz +# +# This writes a file. It does not transmit. +# +# SPDX-License-Identifier: BSD-3-Clause + +import argparse +import math +import sys + +from gnuradio import analog +from gnuradio import blocks +from gnuradio import gr + +# Tones are chosen to be mutually non-harmonic within a channel's own pair so +# that left (tone) and right (2 x tone) stay distinguishable across channels. +DEFAULT_TONES = [400.0, 700.0, 1100.0, 1700.0, 2300.0, 2900.0, 3700.0, 4300.0] + + +class silent_disco_source(gr.top_block): + def __init__(self, offsets, tones, samp_rate, deviation, tau, + stereo, levels, noise_amp, nsamples, out_path): + gr.top_block.__init__(self, "Silent Disco Test Signal") + + combiner = blocks.add_vcc(1) + + for idx, offset in enumerate(offsets): + tone = tones[idx % len(tones)] + level = levels[idx % len(levels)] + + # Audio is generated directly at the IQ rate. A 400 Hz tone at + # 2.4 Msps is exact and needs no interpolation filter, which keeps + # the reference signal free of anything the receiver could blame. + left = analog.sig_source_f(samp_rate, analog.GR_COS_WAVE, + tone, 0.5, 0, 0) + right = analog.sig_source_f(samp_rate, analog.GR_COS_WAVE, + 2.0 * tone, 0.5, 0, 0) + + pre_l = analog.fm_preemph(fs=samp_rate, tau=tau, fh=-1.0) + pre_r = analog.fm_preemph(fs=samp_rate, tau=tau, fh=-1.0) + self.connect(left, pre_l) + self.connect(right, pre_r) + + sum_lr = blocks.add_ff(1) + self.connect(pre_l, (sum_lr, 0)) + self.connect(pre_r, (sum_lr, 1)) + mono_gain = blocks.multiply_const_ff(0.45) + self.connect(sum_lr, mono_gain) + + if stereo: + diff_lr = blocks.sub_ff(1) + self.connect(pre_l, (diff_lr, 0)) + self.connect(pre_r, (diff_lr, 1)) + + # Broadcast convention: the pilot is the half-frequency of + # the subcarrier with their zero crossings aligned, so both + # are sines. Getting this wrong does not break the decode, it + # silently swaps left and right, which is worth knowing if you + # ever build one of these. + subcarrier = analog.sig_source_f(samp_rate, analog.GR_SIN_WAVE, + 38000.0, 1.0, 0, 0) + pilot = analog.sig_source_f(samp_rate, analog.GR_SIN_WAVE, + 19000.0, 0.10, 0, 0) + dsb = blocks.multiply_ff(1) + self.connect(diff_lr, (dsb, 0)) + self.connect(subcarrier, (dsb, 1)) + stereo_gain = blocks.multiply_const_ff(0.45) + self.connect(dsb, stereo_gain) + + mpx = blocks.add_ff(1) + self.connect(mono_gain, (mpx, 0)) + self.connect(stereo_gain, (mpx, 1)) + self.connect(pilot, (mpx, 2)) + else: + mpx = mono_gain + + modulator = analog.frequency_modulator_fc( + 2.0 * math.pi * deviation / samp_rate) + rotator = blocks.rotator_cc(2.0 * math.pi * offset / samp_rate) + amplitude = blocks.multiply_const_cc(level) + self.connect(mpx, modulator, rotator, amplitude, + (combiner, idx)) + + if noise_amp > 0.0: + noise = analog.noise_source_c(analog.GR_GAUSSIAN, noise_amp, 42) + noisy = blocks.add_vcc(1) + self.connect(combiner, (noisy, 0)) + self.connect(noise, (noisy, 1)) + tail = noisy + else: + tail = combiner + + head = blocks.head(gr.sizeof_gr_complex, nsamples) + sink = blocks.file_sink(gr.sizeof_gr_complex, out_path, False) + sink.set_unbuffered(False) + self.connect(tail, head, sink) + + +def parse_args(argv): + p = argparse.ArgumentParser( + description="Generate a synthetic silent disco IQ capture " + "(complex float32). Writes a file, does not transmit.") + p.add_argument("--out", required=True, + help="output file, complex float32 interleaved") + p.add_argument("--samp-rate", type=float, default=2.4e6, + help="IQ sample rate in Hz (default 2.4e6)") + p.add_argument("--offsets", + default="-1050e3,-550e3,150e3,550e3,1050e3", + help="comma separated carrier offsets in Hz from the " + "centre of the capture. The default reproduces Quiet " + "Events Ultra 900 channels 2 to 6 (920.7, 921.2, " + "921.9, 922.3, 922.8 MHz) as seen by a receiver " + "centred on 921.75 MHz, which is what " + "silent_disco_rx.py tunes to by default") + p.add_argument("--tones", default="", + help="comma separated left-channel tone per carrier in Hz; " + "default picks distinct tones automatically") + p.add_argument("--levels", default="1.0", + help="comma separated amplitude per carrier, recycled if " + "shorter than the carrier list") + p.add_argument("--deviation", type=float, default=50e3, + help="peak FM deviation in Hz (default 50e3)") + p.add_argument("--deemph", type=float, default=75.0, + help="pre-emphasis time constant in microseconds, " + "75 in the US and 50 in Europe (default 75)") + p.add_argument("--mono", action="store_true", + help="omit the pilot and the L-R subcarrier") + p.add_argument("--noise", type=float, default=0.0, + help="Gaussian noise amplitude added to the sum " + "(default 0.0)") + p.add_argument("--seconds", type=float, default=4.0, + help="capture length in seconds (default 4.0)") + return p.parse_args(argv) + + +def main(argv=None): + args = parse_args(argv) + + samp_rate = int(args.samp_rate) + offsets = [float(x) for x in args.offsets.split(",") if x.strip()] + levels = [float(x) for x in args.levels.split(",") if x.strip()] + if args.tones.strip(): + tones = [float(x) for x in args.tones.split(",") if x.strip()] + else: + tones = DEFAULT_TONES + + if len(offsets) > len(tones): + print("error: %d carriers but only %d distinct tones; pass --tones" + % (len(offsets), len(tones)), file=sys.stderr) + return 2 + + span = max(offsets) - min(offsets) + if span + 300e3 > samp_rate: + print("warning: carriers span %.3f MHz, which does not fit cleanly " + "in a %.3f MHz capture" % (span / 1e6, samp_rate / 1e6), + file=sys.stderr) + + nsamples = int(samp_rate * args.seconds) + tb = silent_disco_source( + offsets=offsets, + tones=tones, + samp_rate=samp_rate, + deviation=args.deviation, + tau=args.deemph * 1e-6, + stereo=not args.mono, + levels=levels, + noise_amp=args.noise, + nsamples=nsamples, + out_path=args.out, + ) + + print("writing %s" % args.out) + print(" sample rate %d Hz" % samp_rate) + print(" length %.2f s (%d complex samples, %.1f MB)" + % (args.seconds, nsamples, nsamples * 8 / 1e6)) + print(" deviation %.0f Hz peak" % args.deviation) + print(" multiplex %s" % ("mono" if args.mono else + "stereo (19 kHz pilot, 38 kHz L-R)")) + for idx, offset in enumerate(offsets): + print(" channel %d offset %+9.1f kHz left %.0f Hz " + "right %.0f Hz level %.2f" + % (idx, offset / 1e3, tones[idx], 2 * tones[idx], + levels[idx % len(levels)])) + + tb.run() + print("done") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 22d9ca0ceaa29e2c0ae4b78f63cf7a3932c1b5f9 Mon Sep 17 00:00:00 2001 From: StratPlans Date: Fri, 7 Aug 2026 19:22:29 -0400 Subject: [PATCH 2/7] Add multi-channel Silent Disco receiver: five or more channels at once Extends the single-channel receiver to demodulate several channels concurrently and write each to its own WAV, so a whole venue can be recorded in one pass. silent_disco_rx.py N channels concurrently, one WAV per channel. Auto-selects the five Ultra 900 channels that fit an RTL-SDR at 2.4 Msps; 8 Msps reaches all ten. selftest.py runs the whole chain against generated IQ with no radio attached One frequency-translating filter per channel rather than a polyphase channelizer. The published channel plans are irregular - Ultra 900 gaps run 400 to 1200 kHz and Pulse Mobile needs a 25 kHz grid to contain its carriers - so a uniform channelizer would spend most of its bins on nothing and still need 2-3 bins stitched together per 200 kHz channel. Measured 12x real time mono and 6x stereo for five channels on eight cores, so the efficiency argument does not bite at this scale. Tested against generated multi-channel IQ: five channels concurrent, wanted tones at 103 dB mono and 92 dB stereo, both negative controls at 16-17 dB against a 30 dB threshold, stereo separation 47-53 dB. Two of the bugs found during development came from the negative controls rather than the positive tests, which is the argument for shipping selftest.py alongside. --- Silent Disco Receivers/README.md | 120 ++++++ Silent Disco Receivers/selftest.py | 197 +++++++++ Silent Disco Receivers/silent_disco_rx.py | 489 ++++++++++++++++++++++ 3 files changed, 806 insertions(+) create mode 100755 Silent Disco Receivers/selftest.py create mode 100755 Silent Disco Receivers/silent_disco_rx.py diff --git a/Silent Disco Receivers/README.md b/Silent Disco Receivers/README.md index e66a85e..4f076a0 100644 --- a/Silent Disco Receivers/README.md +++ b/Silent Disco Receivers/README.md @@ -109,3 +109,123 @@ The other receivers in this repository ship for 3.7 through 3.10. This one is 3.10 only: it uses `gr-soapy`, which does not exist before 3.9, and 3.10 is the version it was written and tested against. A 3.9 port would be the same flowgraph; earlier versions would need `gr-osmosdr` instead. +## Listening to every channel at once + +`silent_disco_rx.py` does what the flowgraph does, from the command line, and +does it to every channel in the capture simultaneously. + +``` +./silent_disco_rx.py --plan ultra900 --record --record-dir wav +``` + +That tunes one radio, works out which of the ten Ultra 900 channels fit inside +a 2.4 MHz capture, and demodulates all of them concurrently into one WAV file +each: + +``` +centre 921.7500 MHz +sample rate 2.400 Msps +demod rate 480.0 kHz (decimate 5) +audio rate 48000 Hz (decimate 10) +channel taps 201, 200 kHz wide +demodulator mono (L+R), de-emphasis 75 us, audio AGC +channels 5 demodulated concurrently + 0 920.7000 MHz -1050.0 kHz 2 yellow record + 1 921.2000 MHz -550.0 kHz 3 green record + 2 921.9000 MHz +150.0 kHz 4 purple record + 3 922.3000 MHz +550.0 kHz 5 blue record + 4 922.8000 MHz +1050.0 kHz 6 turquoise record + 920.1000 MHz 1 red outside the capture + 923.4000 MHz 7 white outside the capture +``` + +It also plays a single channel, by colour, by number, or by frequency: + +``` +./silent_disco_rx.py --channel blue +./silent_disco_rx.py --channel ch5 +./silent_disco_rx.py --channel 922.3 +``` + +and will do both at once, so you can listen to one channel while recording all +of them: + +``` +./silent_disco_rx.py --plan ultra900 --record --record-dir wav --channel blue +``` + +Useful options: + +| Option | Why | +| --- | --- | +| `--source hackrf` | any SoapySDR driver: `rtlsdr`, `hackrf`, `airspy`, `bladerf`, `lime`, `uhd`, `plutosdr` | +| `--source file:capture.cf32` | work offline against a recorded capture | +| `--samp-rate 8e6` | a wider radio reaches more channels at once | +| `--stereo` | decode the stereo multiplex into left and right instead of mono L+R | +| `--plan max45` | the 45-channel village plan instead of the main-stage one | +| `--freqs 920.7,921.2,921.9` | ignore the plans, use these frequencies | +| `--seconds 60` | stop after a minute | +| `--list-plans` | print every built-in plan and exit | + +### Why N filters and not a channelizer + +The obvious alternative is a polyphase channelizer: capture wide, split into N +uniform bins, demodulate each bin. It is much cheaper per channel, and for many +channels on a regular grid it is the right answer. It is the wrong answer here, +for one reason and one reason only: **the channel plan is not on a regular +grid.** + +The Ultra 900 channels are at 920.1, 920.7, 921.2, 921.9, 922.3, 922.8, 923.4, +924.2, 924.7 and 925.9 MHz. The gaps are 600, 500, 700, 400, 500, 600, 800, 500 +and 1200 kHz. The finest uniform grid that contains all of them has 100 kHz +bins, so a channelizer would have to produce 24 bins to cover the range and +then stitch two or three adjacent bins back together for every channel, because +one channel is 200 kHz wide. That is more work than the thing it was supposed to +save, and it constrains the tuner's centre frequency to the bin grid as well. + +`freq_xlating_fir_filter_ccf` has no such constraint. Each channel gets its own +translation, so irregular spacing costs nothing, and each channel can have its +own filter width, gain and output file. + +The efficiency argument that favours a channelizer does not bite at this scale. +Five channels at 200 kHz with a 201-tap decimating filter is about 0.5 GMAC/s, +which is nothing. On an eight-core desktop this runs at roughly **12 times real +time in mono and 6 times real time in stereo**, so the constraint is USB +bandwidth, not arithmetic. + +[RTLSDR-Airband](https://github.com/rtl-airband/RTLSDR-Airband) does use an FFT +channelizer and is right to: it demodulates tens of narrow AM and NFM voice +channels on a Raspberry Pi, where constant-cost-per-channel is the whole game, +and it decimates to 8 or 16 kHz audio. Neither of those applies to five 200 kHz +stereo music channels. + +## Proving it works + +`selftest.py` needs no radio. It generates a synthetic five-carrier silent +disco signal, runs the receiver over it, and checks that each WAV file holds +the tones that were put on that carrier and nothing else. + +``` +./selftest.py +``` + +It runs two **negative controls** as well, because a test that only ever +passes is not a test: + +* **noise only** - no carriers at all. Must fail. +* **mistuned 500 kHz** - real carriers, receiver pointed into the gaps between + them. Must fail. + +Measured on GNU Radio 3.10.9.2: + +| Case | Wanted tone over noise floor | Best other channel | Verdict | +| --- | --- | --- | --- | +| mono, five channels | 103 dB | 9 dB | pass | +| stereo, five channels | 92 dB | 23 dB | pass | +| noise only | 17 dB | 17 dB | correctly fails | +| mistuned 500 kHz | 16 dB | 16 dB | correctly fails | + +The pass threshold is 30 dB. It sits 13 dB above everything the negative +controls produce and 60 dB below everything the positive controls produce. +Stereo separation measures 47 to 53 dB, which matches what this class of +hardware is specified at. diff --git a/Silent Disco Receivers/selftest.py b/Silent Disco Receivers/selftest.py new file mode 100755 index 0000000..8b34623 --- /dev/null +++ b/Silent Disco Receivers/selftest.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +# +# selftest.py - prove silent_disco_rx.py really demodulates, with no radio. +# +# Generates a synthetic multi-carrier silent disco signal, runs the receiver +# over it, and checks that each WAV file holds the tones that were put on that +# carrier and nothing else. +# +# It also runs two negative controls, because a test that only ever passes is +# not a test: +# +# noise only no carriers at all, just AWGN. Must FAIL. +# mistuned real carriers, receiver pointed 500 kHz off. Must FAIL. +# +# If the negative controls pass, the pass threshold is sitting in the noise +# and the positive results mean nothing. +# +# SPDX-License-Identifier: BSD-3-Clause + +import glob +import os +import shutil +import subprocess +import sys +import tempfile + +import numpy as np +from scipy.io import wavfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +GEN = os.path.join(HERE, "make_silent_disco_iq.py") +RX = os.path.join(HERE, "silent_disco_rx.py") + +# Must match make_silent_disco_iq.py. +TONES = [400.0, 700.0, 1100.0, 1700.0, 2300.0, 2900.0, 3700.0, 4300.0] + +# A real carrier lands 60 dB or more over the in-band noise floor. The two +# negative controls land under 15 dB. 30 dB sits clear of both. +MIN_SNR_DB = 30.0 +MIN_ISOLATION_DB = 30.0 + + +def run(cmd): + proc = subprocess.run(cmd, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True) + if proc.returncode != 0: + print(proc.stdout) + raise SystemExit("command failed: %s" % " ".join(cmd)) + return proc.stdout + + +def tone_snr(path): + """Return (sample_rate, [(track_name, {tone_hz: snr_db})]) for a WAV. + + Every carrier's left tone and its right tone (twice the left) is measured, + so a channel that ends up in the wrong file, or a left/right swap, shows + up as a level in a slot that should be empty. + """ + fs, data = wavfile.read(path) + if data.ndim == 1: + tracks = [("mono", data.astype(np.float64) / 32768.0)] + else: + tracks = [("left", data[:, 0].astype(np.float64) / 32768.0), + ("right", data[:, 1].astype(np.float64) / 32768.0)] + + probes = sorted(set(TONES) | set(2 * t for t in TONES)) + out = [] + for name, x in tracks: + # Skip the first half: filter transients and AGC settling live there. + x = x[len(x) // 2:] + n = 1 << int(np.floor(np.log2(max(len(x), 2)))) + if n < 4096: + out.append((name, {})) + continue + spec = np.abs(np.fft.rfft(x[:n] * np.hanning(n))) + faxis = np.fft.rfftfreq(n, 1.0 / fs) + band = (faxis > 100) & (faxis < 15000) + floor = np.median(spec[band]) + levels = {} + for t in probes: + m = (faxis > t - 30) & (faxis < t + 30) + if m.any(): + levels[t] = 20 * np.log10(spec[m].max() / max(floor, 1e-12)) + out.append((name, levels)) + return fs, out + + +def check(directory, label, expect_pass, nchan=None): + paths = sorted(glob.glob(os.path.join(directory, "*.wav"))) + if not paths: + print(" %s: no WAV files produced" % label) + return not expect_pass + + count = nchan if nchan is not None else len(paths) + print(" %s: %d files" % (label, len(paths))) + all_good = True + for idx, path in enumerate(paths): + own = TONES[idx] + pair = 2 * own + fs, tracks = tone_snr(path) + for name, levels in tracks: + if not levels: + all_good = False + continue + # In stereo, left should hold the tone and right should hold + # twice it. In mono both arrive together as L+R. + if name == "left": + wanted, unwanted_own = [own], [pair] + elif name == "right": + wanted, unwanted_own = [pair], [own] + else: + wanted, unwanted_own = [own, pair], [] + + mine = max(levels.get(t, -300.0) for t in wanted) + foreign = [] + for j in range(count): + if j == idx: + continue + foreign.append(levels.get(TONES[j], -300.0)) + foreign.append(levels.get(2 * TONES[j], -300.0)) + worst_other = max(foreign) if foreign else -300.0 + good = (mine >= MIN_SNR_DB + and mine - worst_other >= MIN_ISOLATION_DB) + all_good &= good + extra = "" + if unwanted_own: + extra = " opposite track %6.1f dB" % levels.get( + unwanted_own[0], -300.0) + print(" ch%d %-5s own %6.1f dB next best other %6.1f dB%s %s" + % (idx, name, mine, worst_other, extra, + "ok" if good else "no")) + + verdict = (all_good == expect_pass) + print(" %s: %s (expected %s) -> %s" + % (label, "PASS" if all_good else "FAIL", + "PASS" if expect_pass else "FAIL", + "correct" if verdict else "WRONG")) + return verdict + + +def main(): + tmp = tempfile.mkdtemp(prefix="silent_disco_selftest_") + seconds = "3.0" + ok = True + try: + iq = os.path.join(tmp, "five.cf32") + noise_iq = os.path.join(tmp, "noise.cf32") + sparse_iq = os.path.join(tmp, "sparse.cf32") + + print("generating test signals") + run([sys.executable, GEN, "--out", iq, "--seconds", seconds, + "--noise", "0.05"]) + # Noise only: one carrier at zero amplitude leaves pure AWGN. + run([sys.executable, GEN, "--out", noise_iq, "--seconds", seconds, + "--offsets", "0", "--levels", "0.0", "--noise", "0.3"]) + # Three carriers 1 MHz apart, so that a receiver asked for the gaps + # between them is a genuine 500 kHz mistune with nothing to find. + run([sys.executable, GEN, "--out", sparse_iq, "--seconds", seconds, + "--offsets=-1000e3,0,1000e3", "--noise", "0.05"]) + + print("positive control: mono decode of five channels") + d = os.path.join(tmp, "mono") + run([sys.executable, RX, "--source", "file:" + iq, "--plan", + "ultra900", "--record", "--record-dir", d]) + ok &= check(d, "mono five channels", True) + + print("positive control: stereo decode of five channels") + d = os.path.join(tmp, "stereo") + run([sys.executable, RX, "--source", "file:" + iq, "--plan", + "ultra900", "--record", "--record-dir", d, "--stereo"]) + ok &= check(d, "stereo five channels", True) + + print("negative control: noise only, no carriers") + d = os.path.join(tmp, "noise") + run([sys.executable, RX, "--source", "file:" + noise_iq, "--plan", + "ultra900", "--record", "--record-dir", d]) + ok &= check(d, "noise only", False) + + # Carriers sit at -1000, 0 and +1000 kHz; ask for the two gaps at + # -500 and +500 kHz. That is a 500 kHz mistune, wider than the + # tightest real channel spacing in the Ultra 900 plan. + print("negative control: receiver mistuned 500 kHz") + d = os.path.join(tmp, "mistuned") + run([sys.executable, RX, "--source", "file:" + sparse_iq, "--freqs", + "921.25,922.25", "--center", "921.75e6", + "--record", "--record-dir", d]) + ok &= check(d, "mistuned 500 kHz", False, nchan=3) + + finally: + shutil.rmtree(tmp, ignore_errors=True) + + print("selftest: %s" % ("PASS" if ok else "FAIL")) + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Silent Disco Receivers/silent_disco_rx.py b/Silent Disco Receivers/silent_disco_rx.py new file mode 100755 index 0000000..a2be43d --- /dev/null +++ b/Silent Disco Receivers/silent_disco_rx.py @@ -0,0 +1,489 @@ +#!/usr/bin/env python3 +# +# silent_disco_rx.py - demodulate several silent disco channels at once. +# +# The DEF CON silent disco is a Quiet Events system: analogue FM, stereo, one +# carrier per audio channel, in the US 902-928 MHz ISM band. A headset is just +# an FM receiver that retunes when you press the channel button, and the LED +# colour tells you which carrier it is on. Nothing stops one SDR from +# demodulating every carrier inside its capture at the same time. +# +# silent_disco_rx.py --plan ultra900 --channel blue +# play one channel through the sound card +# +# silent_disco_rx.py --plan ultra900 --record --record-dir wav +# demodulate every channel that fits in the capture, concurrently, +# one WAV file each +# +# silent_disco_rx.py --source file:capture.cf32 --plan ultra900 --record +# the same thing offline, against a recorded capture +# +# Receive only. Nothing in this file transmits. +# +# SPDX-License-Identifier: BSD-3-Clause + +import argparse +import os +import sys +from fractions import Fraction + +from gnuradio import analog +from gnuradio import audio +from gnuradio import blocks +from gnuradio import filter as gr_filter +from gnuradio import gr +from gnuradio.fft import window +from gnuradio.filter import firdes + +AUDIO_RATE = 48000 + +# Quiet Events "Ultra 900" plan, used on the main and creator stages. The LED +# colour on a headset is the channel, so a headset lying on a chair tells you +# the frequency without scanning for it. +# Source: quietevents.com Ultra1 900 (10CH) headphone and Ultra 900 Mobile +# Transmitter (13CH) product pages. +ULTRA900 = [ + ("1", "red", 920.1e6), + ("2", "yellow", 920.7e6), + ("3", "green", 921.2e6), + ("4", "purple", 921.9e6), + ("5", "blue", 922.3e6), + ("6", "turquoise", 922.8e6), + ("7", "white", 923.4e6), + ("8", "orange", 924.2e6), + ("9", "pink", 924.7e6), + ("10", "mint", 925.9e6), + ("A1", "", 920.5e6), + ("A2", "", 922.4e6), + ("A3", "", 926.7e6), +] + +# Quiet Events "45 Max" plan, used at village and community locations. +# Source: quietevents.com 45 Max Transmitter (45CH) product page. +MAX45 = [f * 1e6 for f in ( + 908.1, 908.4, 908.7, 909.2, 909.5, 909.9, 910.3, 910.8, 911.1, 911.4, + 911.8, 912.2, 912.7, 913.0, 913.3, 913.6, 913.9, 914.2, 914.5, 914.8, + 915.1, 915.5, 915.8, 920.1, 920.4, 920.7, 921.2, 921.5, 921.9, 922.3, + 922.8, 923.1, 923.4, 923.8, 924.2, 924.7, 925.0, 925.3, 925.6, 925.9, + 926.2, 926.5, 926.8, 927.1, 927.5)] + +CHANNEL_PLANS = { + # The ten colour channels. Spacing runs 400 kHz to 1.2 MHz, never uniform. + "ultra900": [f for _, c, f in ULTRA900 if c], + # Including the three auxiliary channels. A1 sits 200 kHz from yellow and + # A2 sits 100 kHz from blue, closer than one FM channel is wide, so these + # are alternatives to the colours rather than additions to them. + "ultra900-all": [f for _, _, f in ULTRA900], + "max45": MAX45, +} + +# Every name a channel can be called by, for --channel. +CHANNEL_ALIASES = {} +for _ch, _colour, _f in ULTRA900: + CHANNEL_ALIASES[_ch.lower()] = _f + CHANNEL_ALIASES["ch" + _ch.lower()] = _f + if _colour: + CHANNEL_ALIASES[_colour] = _f + + +def channel_label(freq): + for ch, colour, f in ULTRA900: + if abs(f - freq) < 1e3: + return "%s %s" % (ch, colour) if colour else ch + return "" + + +def choose_rates(samp_rate, chan_bw, audio_rate): + """Pick channel and audio decimations for this capture. + + Returns (chan_decim, demod_rate, audio_decim, resamp), where resamp is a + Fraction to apply after demodulation or None if the rates already land on + audio_rate exactly. + """ + # Demodulate at roughly 2.4x the channel bandwidth: wide enough to keep the + # channel filter's transition band away from the FM sidebands, narrow + # enough that the audio filter stays cheap. + chan_decim = max(1, int(round(samp_rate / (2.4 * chan_bw)))) + while chan_decim > 1 and samp_rate / chan_decim < 1.5 * chan_bw: + chan_decim -= 1 + demod_rate = samp_rate / chan_decim + + audio_decim = max(1, int(round(demod_rate / audio_rate))) + intermediate = demod_rate / audio_decim + resamp = Fraction(audio_rate / intermediate).limit_denominator(2000) + return chan_decim, demod_rate, audio_decim, (None if resamp == 1 else resamp) + + +def choose_center(freqs, usable, dc_guard): + """Pick the tuner centre that catches the most channels. + + A silent disco plan can be wider than any one SDR: the Quiet Events 45 Max + plan spans 908 to 927.5 MHz, eight times an RTL-SDR's usable bandwidth. + Cover the largest group rather than failing, and keep the centre off any + carrier so nothing lands under the receiver's DC spike. + """ + best = None + for lo in freqs: + center = lo + usable + caught = [f for f in freqs if abs(f - center) <= usable + 1.0] + if not caught: + continue + center = (min(caught) + max(caught)) / 2.0 + caught = [f for f in freqs if abs(f - center) <= usable + 1.0] + for center in _dc_clear(center, caught, dc_guard): + caught2 = [f for f in freqs if abs(f - center) <= usable + 1.0] + score = (len(caught2), -abs(center - (min(caught2) + max(caught2)) / 2.0)) + if best is None or score > best[0]: + best = (score, center) + return best[1] if best else freqs[0] + + +def _dc_clear(center, caught, dc_guard): + """Candidate centres that keep every carrier clear of DC.""" + if not caught or min(abs(f - center) for f in caught) >= dc_guard: + return [center] + nearest = min(caught, key=lambda f: abs(f - center)) + return [nearest + dc_guard, nearest - dc_guard] + + +class silent_disco_rx(gr.top_block): + def __init__(self, args, freqs, center): + gr.top_block.__init__(self, "Silent Disco Receiver") + + samp_rate = args.samp_rate + chan_bw = args.channel_bw + chan_decim, demod_rate, audio_decim, resamp = choose_rates( + samp_rate, chan_bw, AUDIO_RATE) + + self.demod_rate = demod_rate + self.audio_decim = audio_decim + self.chan_decim = chan_decim + self.resamp = resamp + + source = self._build_source(args, center) + + # One channel filter, shared by every branch. Blackman-Harris rather + # than Hamming: the plan puts strong and weak transmitters 400 kHz + # apart, and Hamming's 53 dB stopband is not enough to keep a loud + # neighbour out of a quiet channel. + chan_taps = firdes.low_pass(1.0, samp_rate, chan_bw / 2.0, + chan_bw / 4.0, + window.WIN_BLACKMAN_HARRIS, 6.76) + self.chan_taps = chan_taps + + tau = args.deemph * 1e-6 + self.branches = [] + + for idx, freq in enumerate(freqs): + # Only build a branch that something is listening to. Playing one + # channel should not cost the CPU of demodulating all ten. + if not args.record and idx != args.play_index: + continue + + xlate = gr_filter.freq_xlating_fir_filter_ccf( + chan_decim, chan_taps, freq - center, samp_rate) + + if args.stereo: + demod = analog.wfm_rcv_pll(demod_rate, audio_decim, tau) + nchan = 2 + else: + # Mono recovers L+R, which is what a stereo multiplex carries + # below 15 kHz, so this works on mono and stereo transmitters + # alike. The 15 kHz audio filter is what keeps the 19 kHz + # pilot and the 38 kHz subcarrier out of the recording. + demod = analog.fm_demod_cf( + channel_rate=demod_rate, audio_decim=audio_decim, + deviation=args.deviation, audio_pass=15000, + audio_stop=17000, gain=1.0, tau=tau) + nchan = 1 + + self.connect(source, xlate, demod) + + tails = [] + for port in range(nchan): + node = (demod, port) + if resamp is not None: + rr = gr_filter.rational_resampler_fff( + interpolation=resamp.numerator, + decimation=resamp.denominator, + taps=[], fractional_bw=0.0) + self.connect(node, rr) + node = rr + if args.agc: + # Quiet Events do not publish their deviation and there is + # no FCC grant under the brand, so level the audio instead + # of guessing the number. + agc = analog.agc2_ff(0.4, 1e-3, args.level, 1.0) + agc.set_max_gain(4000.0) + self.connect(node, agc) + node = agc + vol = blocks.multiply_const_ff(args.volume) + self.connect(node, vol) + tails.append(vol) + + if args.record: + label = channel_label(freq).replace(" ", "_") + name = "%s_%02d_%.4fMHz%s.wav" % ( + args.prefix, idx, freq / 1e6, + "_" + label if label else "") + wav = blocks.wavfile_sink( + os.path.join(args.record_dir, name), nchan, AUDIO_RATE, + blocks.FORMAT_WAV, blocks.FORMAT_PCM_16, False) + for port, tail in enumerate(tails): + self.connect(tail, (wav, port)) + + if args.play_index is not None and idx == args.play_index: + sink = audio.sink(AUDIO_RATE, args.audio_device, True) + for port, tail in enumerate(tails): + self.connect(tail, (sink, port)) + + self.branches.append(tails) + + def _build_source(self, args, center): + if args.source.startswith("file:"): + src = blocks.file_source(gr.sizeof_gr_complex, args.source[5:], + args.repeat, 0, 0) + node = src + if args.seconds > 0: + head = blocks.head(gr.sizeof_gr_complex, + int(args.samp_rate * args.seconds)) + self.connect(node, head) + node = head + if args.play_index is not None: + # Recording wants the file to run as fast as the CPU allows. + # Playing it needs the stream paced to real time. + thr = blocks.throttle(gr.sizeof_gr_complex, args.samp_rate, + True) + self.connect(node, thr) + node = thr + return node + + from gnuradio import soapy + src = soapy.source("driver=%s" % args.source, "fc32", 1, + args.device_args, "bufflen=16384", [""], [""]) + src.set_sample_rate(0, args.samp_rate) + src.set_frequency(0, center) + src.set_frequency_correction(0, args.ppm) + src.set_gain_mode(0, args.hw_agc) + if not args.hw_agc: + try: + src.set_gain(0, "TUNER", args.gain) + except Exception: + src.set_gain(0, args.gain) + if args.seconds > 0: + head = blocks.head(gr.sizeof_gr_complex, + int(args.samp_rate * args.seconds)) + self.connect(src, head) + return head + return src + + +def parse_args(argv): + p = argparse.ArgumentParser( + description="Demodulate silent disco channels, one or all at once.", + epilog="Receive only. This program does not transmit.") + + g = p.add_argument_group("what to listen to") + g.add_argument("--plan", default="ultra900", + help="named channel plan: %s (default ultra900)" + % ", ".join(sorted(CHANNEL_PLANS))) + g.add_argument("--freqs", default=None, + help="comma separated channel frequencies in MHz or Hz, " + "overrides --plan") + g.add_argument("--list-plans", action="store_true", + help="print the built in channel plans and exit") + g.add_argument("--center", type=float, default=None, + help="tuner centre in Hz, default catches the most channels") + + g = p.add_argument_group("what to do with it") + g.add_argument("--channel", default=None, + help="play one channel through the sound card, named by " + "colour (blue), by number (5, ch5, A2) or in MHz") + g.add_argument("--record", action="store_true", + help="demodulate every channel in the capture at once and " + "write one WAV file each") + g.add_argument("--record-dir", default=".", + help="directory for the WAV files (default .)") + g.add_argument("--prefix", default="silent_disco", + help="WAV filename prefix (default silent_disco)") + g.add_argument("--audio-device", default="", + help="ALSA device for playback (default system default)") + + g = p.add_argument_group("radio") + g.add_argument("--source", default="rtlsdr", + help="SoapySDR driver (rtlsdr, hackrf, airspy, bladerf, " + "lime, uhd, plutosdr) or file:PATH for a complex " + "float32 capture (default rtlsdr)") + g.add_argument("--device-args", default="", + help="extra SoapySDR device arguments") + g.add_argument("--samp-rate", type=float, default=2.4e6, + help="capture sample rate in Hz (default 2.4e6)") + g.add_argument("--gain", type=float, default=40.0, + help="RF gain in dB (default 40)") + g.add_argument("--hw-agc", action="store_true", + help="enable the tuner's own AGC") + g.add_argument("--ppm", type=float, default=0.0, + help="frequency correction in PPM") + g.add_argument("--dc-guard", type=float, default=60e3, + help="keep every carrier at least this far from the tuner " + "centre, dodging the DC spike (default 60e3)") + g.add_argument("--repeat", action="store_true", help="loop a file source") + g.add_argument("--seconds", type=float, default=0.0, + help="stop after this many seconds, 0 runs until " + "interrupted (default 0)") + + g = p.add_argument_group("demodulator") + g.add_argument("--channel-bw", type=float, default=200e3, + help="per channel bandwidth in Hz (default 200e3)") + g.add_argument("--deviation", type=float, default=75e3, + help="peak FM deviation in Hz, used only when --no-agc is " + "given (default 75e3)") + g.add_argument("--deemph", type=float, default=75.0, + help="de-emphasis time constant in microseconds, 75 in the " + "US and 50 in Europe (default 75)") + g.add_argument("--stereo", action="store_true", + help="decode the stereo multiplex into L and R. The " + "default mono decode recovers L+R and works on both " + "mono and stereo transmitters") + g.add_argument("--no-agc", dest="agc", action="store_false", + help="skip the audio AGC and scale by --deviation instead") + g.add_argument("--level", type=float, default=0.35, + help="audio AGC target level (default 0.35)") + g.add_argument("--volume", type=float, default=1.0, + help="output gain applied last (default 1)") + + return p.parse_args(argv) + + +def resolve_freqs(args): + if args.freqs: + out = [] + for tok in args.freqs.split(","): + tok = tok.strip() + if tok: + val = float(tok) + out.append(val if val > 1e6 else val * 1e6) + return sorted(out) + if args.plan not in CHANNEL_PLANS: + print("error: unknown plan %r, try --list-plans" % args.plan, + file=sys.stderr) + return None + return sorted(CHANNEL_PLANS[args.plan]) + + +def resolve_channel(text): + key = text.strip().lower() + if key in CHANNEL_ALIASES: + return CHANNEL_ALIASES[key] + try: + val = float(key) + except ValueError: + return None + return val if val > 1e6 else val * 1e6 + + +def main(argv=None): + args = parse_args(argv) + + if args.list_plans: + for name in sorted(CHANNEL_PLANS): + freqs = sorted(CHANNEL_PLANS[name]) + print("%-14s %2d channels %.1f - %.1f MHz" + % (name, len(freqs), freqs[0] / 1e6, freqs[-1] / 1e6)) + for f in freqs: + label = channel_label(f) + print(" %9.4f MHz %s" % (f / 1e6, label)) + return 0 + + all_freqs = resolve_freqs(args) + if all_freqs is None: + return 2 + + if not args.record and args.channel is None: + print("error: nothing to do. Give --channel NAME to play one channel " + "or --record to write every channel to WAV.", file=sys.stderr) + return 2 + + usable = (args.samp_rate - args.channel_bw) / 2.0 + + play_freq = None + if args.channel is not None: + play_freq = resolve_channel(args.channel) + if play_freq is None: + print("error: cannot make sense of --channel %r. Use a colour " + "(blue), a number (5 or ch5), or MHz." % args.channel, + file=sys.stderr) + return 2 + if play_freq not in all_freqs: + all_freqs = sorted(set(all_freqs + [play_freq])) + + center = args.center + if center is None: + if play_freq is not None and not args.record: + # Playing one channel: put it comfortably off DC and ignore the + # rest of the plan. + center = play_freq + args.dc_guard * 5 + else: + center = choose_center(all_freqs, usable, args.dc_guard) + + freqs = [f for f in all_freqs if abs(f - center) <= usable + 1.0] + dropped = [f for f in all_freqs if f not in freqs] + if not freqs: + print("error: no channel falls inside a %.3f MHz capture centred on " + "%.4f MHz" % (args.samp_rate / 1e6, center / 1e6), + file=sys.stderr) + return 2 + + args.play_index = None + if play_freq is not None: + if play_freq not in freqs: + print("error: the channel you asked to play is outside the " + "capture", file=sys.stderr) + return 2 + args.play_index = freqs.index(play_freq) + + if args.record: + os.makedirs(args.record_dir, exist_ok=True) + + tb = silent_disco_rx(args, freqs, center) + + print("centre %.4f MHz" % (center / 1e6)) + print("sample rate %.3f Msps" % (args.samp_rate / 1e6)) + print("demod rate %.1f kHz (decimate %d)" + % (tb.demod_rate / 1e3, tb.chan_decim)) + print("audio rate %d Hz (decimate %d%s)" + % (AUDIO_RATE, tb.audio_decim, + "" if tb.resamp is None else ", resample %d/%d" + % (tb.resamp.numerator, tb.resamp.denominator))) + print("channel taps %d, %.0f kHz wide" + % (len(tb.chan_taps), args.channel_bw / 1e3)) + print("demodulator %s, de-emphasis %.0f us, %s" + % ("stereo multiplex" if args.stereo else "mono (L+R)", + args.deemph, + "audio AGC" if args.agc else + "deviation %.0f kHz" % (args.deviation / 1e3))) + print("channels %d demodulated concurrently" % len(tb.branches)) + for idx, freq in enumerate(freqs): + marks = [] + if args.record: + marks.append("record") + if idx == args.play_index: + marks.append("PLAY") + print(" %2d %9.4f MHz %+8.1f kHz %-12s %s" + % (idx, freq / 1e6, (freq - center) / 1e3, + channel_label(freq), " ".join(marks) or "idle")) + for freq in dropped: + print(" %9.4f MHz %-12s outside the capture" + % (freq / 1e6, channel_label(freq))) + + try: + tb.start() + tb.wait() + except KeyboardInterrupt: + tb.stop() + tb.wait() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 1ea7b68e3ec26a982c1b915c61b9a028fceeb8f5 Mon Sep 17 00:00:00 2001 From: StratPlans Date: Fri, 7 Aug 2026 19:31:36 -0400 Subject: [PATCH 3/7] Validate --channel-bw instead of failing inside the filter designer Passing a channel bandwidth too narrow for the 15 kHz audio passband produced a raw GNU Radio traceback from optfir.low_pass - pm_remez: band edges must be nondecreasing - which says nothing about the cause. That is the first thing anyone hits, because --channel-bw is exposed with no stated lower bound. Two guards in main(), before the flowgraph is built: - channel bandwidth too narrow for the audio filter, naming the minimum - channel bandwidth wider than the capture itself Found by running the receiver against a real off-air 96 kHz IQ capture rather than generated input. The narrow-channel case does not arise at the default 200 kHz, so synthetic testing at the design bandwidth never reached it. Also lifted the 15 kHz passband and 17 kHz stop out of the call site into named constants, since the guard and the demodulator have to agree on them. --- Silent Disco Receivers/silent_disco_rx.py | 30 +++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/Silent Disco Receivers/silent_disco_rx.py b/Silent Disco Receivers/silent_disco_rx.py index a2be43d..9fd33bc 100755 --- a/Silent Disco Receivers/silent_disco_rx.py +++ b/Silent Disco Receivers/silent_disco_rx.py @@ -37,6 +37,11 @@ AUDIO_RATE = 48000 +# The stereo multiplex puts L+R below 15 kHz, the pilot at 19 kHz and L-R on a +# 38 kHz subcarrier. Cutting at 15 kHz is what keeps the pilot out of the audio. +AUDIO_PASS_HZ = 15000 +AUDIO_STOP_HZ = 17000 + # Quiet Events "Ultra 900" plan, used on the main and creator stages. The LED # colour on a headset is the channel, so a headset lying on a chair tells you # the frequency without scanning for it. @@ -193,8 +198,8 @@ def __init__(self, args, freqs, center): # pilot and the 38 kHz subcarrier out of the recording. demod = analog.fm_demod_cf( channel_rate=demod_rate, audio_decim=audio_decim, - deviation=args.deviation, audio_pass=15000, - audio_stop=17000, gain=1.0, tau=tau) + deviation=args.deviation, audio_pass=AUDIO_PASS_HZ, + audio_stop=AUDIO_STOP_HZ, gain=1.0, tau=tau) nchan = 1 self.connect(source, xlate, demod) @@ -404,6 +409,27 @@ def main(argv=None): "or --record to write every channel to WAV.", file=sys.stderr) return 2 + # The FM demodulator builds an audio low-pass with a 15 kHz passband and a + # 17 kHz stop. If the channel rate is too low to hold that, the filter + # designer fails deep inside GNU Radio with "band edges must be + # nondecreasing", which says nothing useful about the cause. Catch it here. + _, _demod_rate, _, _ = choose_rates(args.samp_rate, args.channel_bw, AUDIO_RATE) + if _demod_rate <= 2 * AUDIO_STOP_HZ: + print("error: --channel-bw %.1f kHz is too narrow. It leaves a channel " + "rate of %.1f kHz, and the audio filter needs more than %.1f kHz " + "to fit its %.0f kHz passband. Use at least %.0f kHz." + % (args.channel_bw / 1e3, _demod_rate / 1e3, + 2 * AUDIO_STOP_HZ / 1e3, AUDIO_PASS_HZ / 1e3, + 2 * AUDIO_STOP_HZ / 1e3), + file=sys.stderr) + return 2 + + if args.channel_bw >= args.samp_rate: + print("error: --channel-bw %.1f kHz does not fit inside a %.1f kHz " + "capture. Lower it or raise --samp-rate." + % (args.channel_bw / 1e3, args.samp_rate / 1e3), file=sys.stderr) + return 2 + usable = (args.samp_rate - args.channel_bw) / 2.0 play_freq = None From bba634706e24d340d66bdc6116c5eeaeeeced4f2 Mon Sep 17 00:00:00 2001 From: StratPlans Date: Fri, 7 Aug 2026 19:51:20 -0400 Subject: [PATCH 4/7] Name the generated flowgraph output silent_disco_rx_gui GRC writes its generated top block next to the .grc, named from the flowgraph id. With id silent_disco_rx that file is silent_disco_rx.py, which collides with the command line receiver added in the follow-up. Opening the flowgraph in GNU Radio Companion - the first step in the README - would overwrite it in the user's working copy. Renaming the id to silent_disco_rx_gui keeps the two apart. Verified: compiling the flowgraph in a directory holding both now emits silent_disco_rx_gui.py and leaves the receiver byte identical. --- Silent Disco Receivers/gr_3.10_silent_disco_rx.grc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Silent Disco Receivers/gr_3.10_silent_disco_rx.grc b/Silent Disco Receivers/gr_3.10_silent_disco_rx.grc index fddf8d2..6086ca9 100644 --- a/Silent Disco Receivers/gr_3.10_silent_disco_rx.grc +++ b/Silent Disco Receivers/gr_3.10_silent_disco_rx.grc @@ -18,7 +18,7 @@ options: gen_linking: dynamic generate_options: qt_gui hier_block_src_path: '.:' - id: silent_disco_rx + id: silent_disco_rx_gui max_nouts: '0' output_language: python placement: (0,0) From fd62eeab8e3184dd5eea5fd5ec74fa3915566e44 Mon Sep 17 00:00:00 2001 From: StratPlans Date: Fri, 7 Aug 2026 19:51:23 -0400 Subject: [PATCH 5/7] Assert stereo separation, warn on assumed file centre, correct --deviation help Four fixes from an independent review of both branches. The stereo separation figure was printed but never checked, so a regression collapsing left and right into the same audio would still have passed the stereo test. It is now asserted at 20 dB. Real hardware manages far more; the threshold only has to catch a collapse to mono. --channel with a file source guessed a tuner centre from the requested channel. That is right for a radio and meaningless for a capture, whose 0 Hz is wherever the recording was made - the receiver would translate to an empty part of the spectrum and play noise with nothing on screen to say why. It now warns and names the assumption. --deviation said it applied whenever --no-agc was given. In stereo that is untrue: wfm_rcv_pll fixes deviation at 75 kHz internally and never consults the flag. The help now says so. Also dropped a line claiming the measured stereo separation matches this class of hardware. It was measured through our own generator and demodulator, so it bounds this code and says nothing about hardware. --- Silent Disco Receivers/selftest.py | 11 ++++++++++- Silent Disco Receivers/silent_disco_rx.py | 17 +++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/Silent Disco Receivers/selftest.py b/Silent Disco Receivers/selftest.py index 8b34623..8d2b2ff 100755 --- a/Silent Disco Receivers/selftest.py +++ b/Silent Disco Receivers/selftest.py @@ -35,9 +35,12 @@ TONES = [400.0, 700.0, 1100.0, 1700.0, 2300.0, 2900.0, 3700.0, 4300.0] # A real carrier lands 60 dB or more over the in-band noise floor. The two -# negative controls land under 15 dB. 30 dB sits clear of both. +# negative controls land under 18 dB. 30 dB sits clear of both. MIN_SNR_DB = 30.0 MIN_ISOLATION_DB = 30.0 +# Left and right must actually differ. Real hardware manages far more than +# this; the threshold only has to catch a collapse to mono. +MIN_SEPARATION_DB = 20.0 def run(cmd): @@ -121,6 +124,12 @@ def check(directory, label, expect_pass, nchan=None): worst_other = max(foreign) if foreign else -300.0 good = (mine >= MIN_SNR_DB and mine - worst_other >= MIN_ISOLATION_DB) + # The opposite track was previously printed but never checked, so a + # regression collapsing L and R into the same audio would still + # have passed. Require real separation when decoding stereo. + if unwanted_own: + sep = mine - levels.get(unwanted_own[0], -300.0) + good &= sep >= MIN_SEPARATION_DB all_good &= good extra = "" if unwanted_own: diff --git a/Silent Disco Receivers/silent_disco_rx.py b/Silent Disco Receivers/silent_disco_rx.py index 9fd33bc..1a77a09 100755 --- a/Silent Disco Receivers/silent_disco_rx.py +++ b/Silent Disco Receivers/silent_disco_rx.py @@ -341,8 +341,10 @@ def parse_args(argv): g.add_argument("--channel-bw", type=float, default=200e3, help="per channel bandwidth in Hz (default 200e3)") g.add_argument("--deviation", type=float, default=75e3, - help="peak FM deviation in Hz, used only when --no-agc is " - "given (default 75e3)") + help="peak FM deviation in Hz. Used only with --no-agc, " + "and only in mono: the stereo path uses GNU Radio's " + "wfm_rcv_pll, which fixes deviation at 75 kHz " + "internally and ignores this (default 75e3)") g.add_argument("--deemph", type=float, default=75.0, help="de-emphasis time constant in microseconds, 75 in the " "US and 50 in Europe (default 75)") @@ -449,6 +451,17 @@ def main(argv=None): # Playing one channel: put it comfortably off DC and ignore the # rest of the plan. center = play_freq + args.dc_guard * 5 + # A file has no idea what it was tuned to. Guessing a centre from + # the requested channel is right for a radio and meaningless for a + # capture, where 0 Hz is wherever the recording was made. Without + # this warning the receiver translates to an empty part of the + # spectrum and plays noise, with nothing on screen to say why. + if str(args.source).startswith("file:"): + print("warning: --channel with a file source assumes the " + "capture is centred on %.4f MHz. If it is not, you will " + "hear noise. Give --center with the capture's true " + "centre frequency in Hz." + % (center / 1e6), file=sys.stderr) else: center = choose_center(all_freqs, usable, args.dc_guard) From 245d7b0b4a83cb2469ee8e3c2115f251197db810 Mon Sep 17 00:00:00 2001 From: StratPlans Date: Fri, 7 Aug 2026 20:42:14 -0400 Subject: [PATCH 6/7] Open radios other than the RTL-SDR, and add a carrier survey Testing against a USRP B210 found two faults that a file source could never have exposed. The receiver passed "bufflen=16384" as a stream argument to every driver. bufflen is RTL-SDR specific; UHD rejects it outright with "Unsupported stream argument bufflen for channel 0" before the radio is ever tuned, so no USRP could be opened at all and there was no way around it from the command line. It now asks for the larger buffer and falls back to the driver's default when that is not understood. No change for an RTL-SDR. --samp-rate was handed straight to the driver. A USRP derives its rate from a master clock and takes only discrete values, so an unsupported rate produced a ValueError listing all 250 legal rates. It now names the nearest rate at or above the request, and the nearest below with a warning that a lower rate may not span every channel. It deliberately does not substitute one automatically: the channel plan has to fit inside the sample rate, and silently lowering it would push the outermost channels out of the captured band and produce silence with no explanation. survey.py is new. The channel plans are reconstructed from vendor documentation rather than measured, so if the transmitters sit anywhere else the receiver plays silence and says nothing about why. The survey sweeps the band, reports every carrier it finds with its level above the noise floor, names the nearest plan channel, and prints the --freqs string that matches what is actually on the air. Run it first. --- Silent Disco Receivers/silent_disco_rx.py | 57 ++++- Silent Disco Receivers/survey.py | 256 ++++++++++++++++++++++ 2 files changed, 310 insertions(+), 3 deletions(-) create mode 100755 Silent Disco Receivers/survey.py diff --git a/Silent Disco Receivers/silent_disco_rx.py b/Silent Disco Receivers/silent_disco_rx.py index 1a77a09..8dfcd18 100755 --- a/Silent Disco Receivers/silent_disco_rx.py +++ b/Silent Disco Receivers/silent_disco_rx.py @@ -24,6 +24,7 @@ import argparse import os +import re import sys from fractions import Fraction @@ -151,6 +152,57 @@ def _dc_clear(center, caught, dc_guard): return [nearest + dc_guard, nearest - dc_guard] +def _set_sample_rate(src, rate): + """Set the sample rate, or explain the radio's fixed set of legal rates. + + A USRP derives its rate from a master clock, so it takes only discrete + values and rejects anything else with a ValueError that lists every legal + rate - roughly 250 of them. Handing a user that wall of text at a demo is + useless, so catch it and name the two nearest usable rates instead. + + This deliberately does NOT pick a rate automatically. The channel plan's + span has to fit inside the sample rate, and quietly substituting a lower + one would push the outermost channels out of the captured band and produce + silence with no explanation. + """ + try: + src.set_sample_rate(0, rate) + return + except Exception as exc: + legal = sorted({float(m) for m in re.findall(r"\d+\.\d+", str(exc)) + if abs(float(m) - rate) > 0.5}) + if not legal: + raise + below = [c for c in legal if c < rate] + above = [c for c in legal if c > rate] + print("error: this radio does not support --samp-rate %.6g." + % rate, file=sys.stderr) + if above: + print(" nearest at or above: --samp-rate %.6g" % above[0], + file=sys.stderr) + if below: + print(" nearest below: --samp-rate %.6g (a lower " + "rate may not span every channel)" % below[-1], + file=sys.stderr) + raise SystemExit(2) + + +def _soapy_source(soapy, driver, device_args): + """Open a SoapySDR source, tolerating drivers that reject bufflen. + + bufflen is an RTL-SDR stream argument. UHD rejects it outright with + "Unsupported stream argument bufflen for channel 0", so a USRP could not + be opened at all. Ask for the larger buffer, and fall back to the driver's + own default when it is not understood. + """ + try: + return soapy.source("driver=%s" % driver, "fc32", 1, + device_args, "bufflen=16384", [""], [""]) + except Exception: + return soapy.source("driver=%s" % driver, "fc32", 1, + device_args, "", [""], [""]) + + class silent_disco_rx(gr.top_block): def __init__(self, args, freqs, center): gr.top_block.__init__(self, "Silent Disco Receiver") @@ -264,9 +316,8 @@ def _build_source(self, args, center): return node from gnuradio import soapy - src = soapy.source("driver=%s" % args.source, "fc32", 1, - args.device_args, "bufflen=16384", [""], [""]) - src.set_sample_rate(0, args.samp_rate) + src = _soapy_source(soapy, args.source, args.device_args) + _set_sample_rate(src, args.samp_rate) src.set_frequency(0, center) src.set_frequency_correction(0, args.ppm) src.set_gain_mode(0, args.hw_agc) diff --git a/Silent Disco Receivers/survey.py b/Silent Disco Receivers/survey.py new file mode 100755 index 0000000..0528e8f --- /dev/null +++ b/Silent Disco Receivers/survey.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Find the silent disco carriers on the air, before trying to demodulate them. + +The receiver takes a channel plan on faith. Our plans are reconstructed from +vendor documentation, not measured off the DEF CON system, so if the real +transmitters sit anywhere else `--plan ultra900` produces silence and says +nothing about why. This sweeps the band, reports every carrier it actually +finds, and prints the `--freqs` string that matches them. + +Run this FIRST at the table. It answers "is the system even on, and where". + + ./survey.py --source hackrf --samp-rate 8e6 + ./survey.py --source hackrf --start 920e6 --stop 925e6 # colours only + ./survey.py --source file:capture.cf32 --center 922.4e6 # a recording + +Receive only. This program does not transmit. +""" + +import argparse +import sys + +import numpy as np + +# --- the plans the receiver ships, duplicated deliberately ------------------- +# Importing silent_disco_rx would drag in the whole GNU Radio demodulator chain +# and couple this tool to it. The point of a survey is to work when the +# receiver does not. +COLOURS = { + 920.1e6: "1 red", 920.7e6: "2 yellow", 921.2e6: "3 green", + 921.9e6: "4 purple", 922.3e6: "5 blue", 922.8e6: "6 turquoise", + 923.4e6: "7 white", 924.2e6: "8 orange", 924.7e6: "9 pink", +} + + +def _soapy_source(soapy, driver, device_args): + """Open a SoapySDR source, tolerating drivers that reject bufflen. + + bufflen is an RTL-SDR stream argument. UHD rejects it outright with + "Unsupported stream argument bufflen for channel 0", so a USRP could not + be opened at all. Ask for the larger buffer, and fall back to the driver's + own default when it is not understood. + """ + try: + return soapy.source("driver=%s" % driver, "fc32", 1, + device_args, "bufflen=16384", [""], [""]) + except Exception: + return soapy.source("driver=%s" % driver, "fc32", 1, + device_args, "", [""], [""]) + + +def capture(source, device_args, samp_rate, center, gain, hw_agc, ppm, nsamp): + """Grab nsamp complex samples at one tuning. Returns a complex64 array.""" + from gnuradio import gr, blocks + + tb = gr.top_block() + if str(source).startswith("file:"): + src = blocks.file_source(gr.sizeof_gr_complex, source[5:], False) + else: + from gnuradio import soapy + src = _soapy_source(soapy, source, device_args) + src.set_sample_rate(0, samp_rate) + src.set_frequency(0, center) + src.set_frequency_correction(0, ppm) + src.set_gain_mode(0, hw_agc) + if not hw_agc: + try: + src.set_gain(0, "TUNER", gain) + except Exception: + src.set_gain(0, gain) + + head = blocks.head(gr.sizeof_gr_complex, int(nsamp)) + sink = blocks.vector_sink_c() + tb.connect(src, head, sink) + tb.run() + return np.array(sink.data(), dtype=np.complex64) + + +def spectrum(x, samp_rate, nfft): + """Welch PSD in dB, DC-centred, with the matching frequency offsets.""" + if len(x) < nfft: + return None, None + win = np.hanning(nfft) + nseg = len(x) // nfft + acc = np.zeros(nfft) + for i in range(nseg): + seg = x[i * nfft:(i + 1) * nfft] * win + acc += np.abs(np.fft.fft(seg)) ** 2 + acc /= nseg + psd = 10.0 * np.log10(np.fft.fftshift(acc) + 1e-20) + off = np.fft.fftshift(np.fft.fftfreq(nfft, 1.0 / samp_rate)) + return off, psd + + +def find_carriers(off, psd, floor, threshold_db, min_sep_hz): + """Local maxima standing threshold_db above the noise floor. + + An FM carrier is a broad hump, not a spike, so this takes the strongest + bin in each contiguous run above the threshold rather than every bin. + """ + hot = psd > (floor + threshold_db) + out = [] + i = 0 + while i < len(hot): + if not hot[i]: + i += 1 + continue + j = i + while j < len(hot) and hot[j]: + j += 1 + run = slice(i, j) + k = i + int(np.argmax(psd[run])) + out.append((off[k], psd[k] - floor)) + i = j + # Merge anything closer together than a channel can be. + out.sort(key=lambda t: t[0]) + merged = [] + for f, snr in out: + if merged and abs(f - merged[-1][0]) < min_sep_hz: + if snr > merged[-1][1]: + merged[-1] = (f, snr) + else: + merged.append((f, snr)) + return merged + + +def main(argv=None): + p = argparse.ArgumentParser( + description="Sweep for silent disco carriers and report what is " + "actually transmitting.", + epilog="Receive only. This program does not transmit.") + p.add_argument("--source", default="hackrf", + help="SoapySDR driver, or file:PATH for a complex float32 " + "capture (default hackrf)") + p.add_argument("--device-args", default="", + help="extra SoapySDR device arguments") + p.add_argument("--samp-rate", type=float, default=8e6, + help="capture sample rate in Hz (default 8e6)") + p.add_argument("--start", type=float, default=902e6, + help="low edge of the sweep in Hz (default 902e6)") + p.add_argument("--stop", type=float, default=928e6, + help="high edge of the sweep in Hz (default 928e6)") + p.add_argument("--center", type=float, default=None, + help="single tuning in Hz; required for a file source, " + "and skips the sweep for a radio") + p.add_argument("--gain", type=float, default=40.0, help="RF gain in dB") + p.add_argument("--hw-agc", action="store_true", + help="enable the tuner's own AGC") + p.add_argument("--ppm", type=float, default=0.0, + help="frequency correction in PPM") + p.add_argument("--dwell", type=float, default=0.25, + help="seconds of samples per tuning (default 0.25)") + p.add_argument("--nfft", type=int, default=4096, help="FFT size") + p.add_argument("--threshold", type=float, default=8.0, + help="dB above the noise floor to call a carrier " + "(default 8)") + p.add_argument("--dc-guard", type=float, default=60e3, + help="ignore this much either side of the tuner centre, " + "where the DC spike lives (default 60e3)") + p.add_argument("--usable", type=float, default=0.75, + help="fraction of the sample rate to trust, the rest is " + "filter roll-off (default 0.75)") + args = p.parse_args(argv) + + is_file = str(args.source).startswith("file:") + if is_file and args.center is None: + print("error: a file has no idea what it was tuned to. Give --center " + "with the capture's true centre frequency in Hz.", + file=sys.stderr) + return 2 + + usable = args.samp_rate * args.usable + if args.center is not None: + tunings = [args.center] + else: + # Overlap the tiles so a carrier landing on a seam is not missed. + step = usable * 0.8 + n = max(1, int(np.ceil((args.stop - args.start) / step))) + tunings = [args.start + usable / 2 + i * step for i in range(n)] + + print("sweeping %.1f - %.1f MHz at %.1f Msps, %d tuning(s), %.2fs each" + % (args.start / 1e6, args.stop / 1e6, args.samp_rate / 1e6, + len(tunings), args.dwell)) + + found = [] + for center in tunings: + try: + x = capture(args.source, args.device_args, args.samp_rate, center, + args.gain, args.hw_agc, args.ppm, + args.samp_rate * args.dwell) + except Exception as exc: + print(" %.3f MHz: capture failed: %s" % (center / 1e6, exc), + file=sys.stderr) + continue + if len(x) == 0: + print(" %.3f MHz: no samples. Is the radio connected and free?" + % (center / 1e6), file=sys.stderr) + continue + + off, psd = spectrum(x, args.samp_rate, args.nfft) + if off is None: + print(" %.3f MHz: too few samples for a %d point FFT" + % (center / 1e6, args.nfft), file=sys.stderr) + continue + + keep = (np.abs(off) < usable / 2) & (np.abs(off) > args.dc_guard) + off, psd = off[keep], psd[keep] + floor = float(np.median(psd)) + for f_off, snr in find_carriers(off, psd, floor, args.threshold, 150e3): + f = center + f_off + if args.start - 1e6 <= f <= args.stop + 1e6: + found.append((f, snr)) + + # A carrier seen in two overlapping tiles is one carrier. + found.sort(key=lambda t: t[0]) + merged = [] + for f, snr in found: + if merged and abs(f - merged[-1][0]) < 150e3: + if snr > merged[-1][1]: + merged[-1] = (f, snr) + else: + merged.append((f, snr)) + + if not merged: + print("\nNo carriers found.") + print("The system may be off, out of range, or outside %.1f - %.1f MHz." + % (args.start / 1e6, args.stop / 1e6)) + print("Try --gain 60, --hw-agc, a wider --start/--stop, or " + "--threshold 5.") + return 1 + + print("\n%d carrier(s):\n" % len(merged)) + print(" %-14s %8s %s" % ("frequency", "dB>floor", "nearest plan channel")) + for f, snr in merged: + best = min(COLOURS, key=lambda c: abs(c - f)) + delta = (f - best) / 1e3 + note = ("%-14s %+7.1f kHz off" % (COLOURS[best], delta) + if abs(delta) < 200 else "no plan channel within 200 kHz") + print(" %10.4f MHz %8.1f %s" % (f / 1e6, snr, note)) + + freqs = ",".join("%.4f" % (f / 1e6) for f, _ in merged) + print("\nMeasured channel list, use it instead of --plan:\n") + print(" --freqs %s\n" % freqs) + if len(merged) >= 5: + span = (merged[-1][0] - merged[0][0]) / 1e6 + print(" %d channels spanning %.2f MHz. To take them all at once the " + "sample rate\n must exceed that span: --samp-rate %.1fe6 or " + "higher." % (len(merged), span, np.ceil(span / 0.75))) + else: + print(" Only %d carriers. Five are needed for the multichannel " + "challenge - widen\n the sweep or lower --threshold before " + "concluding the rest are absent." % len(merged)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 149d73246e2c5818c208947f25f2c3d25a460807 Mon Sep 17 00:00:00 2001 From: StratPlans Date: Fri, 7 Aug 2026 21:02:28 -0400 Subject: [PATCH 7/7] Expand the audio the transmitter compressed These systems compand: the transmitter squashes the audio about 2:1 before modulating and the headphone expands it 1:2. That pairing is where their quoted signal to noise figure comes from, because raw narrowband FM cannot deliver it. This receiver de-emphasised but never expanded, so the audio played and was intelligible while its dynamics stayed squashed - quiet passages sitting too loud, and pumping. --expander undoes it. For a 2:1 law the expander gain is the envelope itself, so this needs no arbitrary power function: rectify, smooth with a one pole IIR, multiply. It sits before the AGC deliberately, because after it the AGC would have already flattened the dynamics being restored. It is an option rather than the default. The exact compander law is not published and is not standardised between vendors, so applying it blindly would be trading one wrong assumption for another. The generator gained the matching compressor, plus a --dynamics gate, because a constant tone has a constant envelope and a compander does nothing measurable to one. With audio gated between 1.0 and 0.1 the source carries 20 dB of range, and selftest.py now asserts the round trip: uncompanded, no expander 20.0 dB companded, no expander 10.2 dB <- halved, as a 2:1 law must companded, with expander 20.3 dB <- restored The middle line is the measurement of what was wrong: without this, companded audio arrives with half its dynamic range. --- .../make_silent_disco_iq.py | 63 ++++++++++++++++++- Silent Disco Receivers/selftest.py | 58 +++++++++++++++++ Silent Disco Receivers/silent_disco_rx.py | 32 ++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) diff --git a/Silent Disco Receivers/make_silent_disco_iq.py b/Silent Disco Receivers/make_silent_disco_iq.py index 1ce62c1..87dc706 100755 --- a/Silent Disco Receivers/make_silent_disco_iq.py +++ b/Silent Disco Receivers/make_silent_disco_iq.py @@ -24,6 +24,7 @@ from gnuradio import analog from gnuradio import blocks +from gnuradio import filter as gr_filter from gnuradio import gr # Tones are chosen to be mutually non-harmonic within a channel's own pair so @@ -32,8 +33,32 @@ class silent_disco_source(gr.top_block): + def _compress(self, node, samp_rate, tau): + """2:1 compressor, the transmit half of the compander. + + These systems squash the audio about 2:1 before modulating and expand + it 1:2 in the headphone. That pairing is where their quoted signal to + noise figure comes from; raw narrowband FM cannot deliver it. + + For a 2:1 law the gain is the inverse square root of the envelope, so + this is rectify, smooth, sqrt, divide. The constant added to the + envelope keeps silence from dividing by zero. + """ + alpha = 1.0 - math.exp(-1.0 / (tau * samp_rate)) + rect = blocks.abs_ff() + env = gr_filter.single_pole_iir_filter_ff(alpha) + floor_ = blocks.add_const_ff(1e-3) + root = blocks.transcendental("sqrt", "float") + div = blocks.divide_ff(1) + self.connect(node, rect, env, floor_, root) + self.connect(root, (div, 1)) + self.connect(node, (div, 0)) + return div + def __init__(self, offsets, tones, samp_rate, deviation, tau, - stereo, levels, noise_amp, nsamples, out_path): + stereo, levels, noise_amp, nsamples, out_path, + compand=False, compand_tau=0.01, dynamics=0.0, + quiet_level=0.1): gr.top_block.__init__(self, "Silent Disco Test Signal") combiner = blocks.add_vcc(1) @@ -50,6 +75,25 @@ def __init__(self, offsets, tones, samp_rate, deviation, tau, right = analog.sig_source_f(samp_rate, analog.GR_COS_WAVE, 2.0 * tone, 0.5, 0, 0) + if dynamics > 0.0: + # A constant tone has a constant envelope, so a compander does + # nothing measurable to it. Gate the level between loud and + # quiet so there are real dynamics to squash and restore. + gate = analog.sig_source_f(samp_rate, analog.GR_SQR_WAVE, + dynamics, 1.0 - quiet_level, + quiet_level, 0) + gl = blocks.multiply_ff(1) + gr_r = blocks.multiply_ff(1) + self.connect(left, (gl, 0)) + self.connect(gate, (gl, 1)) + self.connect(right, (gr_r, 0)) + self.connect(gate, (gr_r, 1)) + left, right = gl, gr_r + + if compand: + left = self._compress(left, samp_rate, compand_tau) + right = self._compress(right, samp_rate, compand_tau) + pre_l = analog.fm_preemph(fs=samp_rate, tau=tau, fh=-1.0) pre_r = analog.fm_preemph(fs=samp_rate, tau=tau, fh=-1.0) self.connect(left, pre_l) @@ -139,6 +183,19 @@ def parse_args(argv): "75 in the US and 50 in Europe (default 75)") p.add_argument("--mono", action="store_true", help="omit the pilot and the L-R subcarrier") + p.add_argument("--compand", action="store_true", + help="apply the 2:1 compressor these systems use before " + "modulating, so a receiver's expander can be tested") + p.add_argument("--compand-tau", type=float, default=0.01, + help="compressor envelope time constant in seconds " + "(default 0.01)") + p.add_argument("--dynamics", type=float, default=0.0, + help="gate the audio between loud and quiet at this rate " + "in Hz, giving the compander something to act on. " + "0 disables (default 0)") + p.add_argument("--quiet-level", type=float, default=0.1, + help="amplitude of the quiet half of the gate, so the " + "default 0.1 is 20 dB of dynamic range (default 0.1)") p.add_argument("--noise", type=float, default=0.0, help="Gaussian noise amplitude added to the sum " "(default 0.0)") @@ -181,6 +238,10 @@ def main(argv=None): noise_amp=args.noise, nsamples=nsamples, out_path=args.out, + compand=args.compand, + compand_tau=args.compand_tau, + dynamics=args.dynamics, + quiet_level=args.quiet_level, ) print("writing %s" % args.out) diff --git a/Silent Disco Receivers/selftest.py b/Silent Disco Receivers/selftest.py index 8d2b2ff..6da6f69 100755 --- a/Silent Disco Receivers/selftest.py +++ b/Silent Disco Receivers/selftest.py @@ -42,6 +42,13 @@ # this; the threshold only has to catch a collapse to mono. MIN_SEPARATION_DB = 20.0 +# The generator gates the audio between 1.0 and 0.1, so the source has +# 20 dB of dynamic range. A 2:1 compressor must halve that and the +# expander must put it back. Tolerances are loose because the envelope +# follower has lag and the estimate is a percentile, not an oracle. +COMPAND_SOURCE_DB = 20.0 +COMPAND_TOL_DB = 3.0 + def run(cmd): proc = subprocess.run(cmd, stdout=subprocess.PIPE, @@ -147,6 +154,54 @@ def check(directory, label, expect_pass, nchan=None): return verdict +def dynamic_range_db(path): + """Loud-to-quiet ratio in dB, from short-window RMS percentiles.""" + fs, data = wavfile.read(path) + a = (data if data.ndim == 1 else data[:, 0]).astype(np.float64) / 32768.0 + a = a[len(a) // 6:] # drop the envelope settling + win = max(1, int(0.05 * fs)) + nw = len(a) // win + if nw < 8: + return 0.0 + r = np.sqrt(np.mean(a[:nw * win].reshape(nw, win) ** 2, axis=1)) + 1e-12 + return 20.0 * np.log10(np.percentile(r, 90) / np.percentile(r, 10)) + + +def check_compander(tmp): + """The expander must invert the compressor, and matter when absent.""" + plain = os.path.join(tmp, "compand_plain.cf32") + comp = os.path.join(tmp, "compand_comp.cf32") + gen = [sys.executable, GEN, "--samp-rate", "2e6", "--offsets", "-400000", + "--tones", "400", "--seconds", "8.0", "--mono", "--noise", "0.001", + "--dynamics", "2", "--quiet-level", "0.1"] + run(gen + ["--out", plain]) + run(gen + ["--compand", "--out", comp]) + + rx = [sys.executable, RX, "--samp-rate", "2e6", "--center", "922.4e6", + "--freqs", "922.0", "--record", "--no-agc", "--record-dir"] + out = {} + for label, src, extra in (("uncompanded, no expander", plain, []), + ("companded, no expander", comp, []), + ("companded, with expander", comp, ["--expander"])): + d = os.path.join(tmp, "cp_" + label.split(",")[0].replace(" ", "_") + + ("_exp" if extra else "")) + run(rx + [d, "--source", "file:" + src] + extra) + wavs = sorted(glob.glob(os.path.join(d, "*.wav"))) + out[label] = dynamic_range_db(wavs[0]) if wavs else 0.0 + + good = True + for label, want in (("uncompanded, no expander", COMPAND_SOURCE_DB), + ("companded, no expander", COMPAND_SOURCE_DB / 2.0), + ("companded, with expander", COMPAND_SOURCE_DB)): + got = out[label] + hit = abs(got - want) <= COMPAND_TOL_DB + good &= hit + print(" %-26s %6.1f dB want %4.1f %s" + % (label, got, want, "yes" if hit else "NO")) + print(" compander: %s" % ("PASS" if good else "FAIL")) + return good + + def main(): tmp = tempfile.mkdtemp(prefix="silent_disco_selftest_") seconds = "3.0" @@ -195,6 +250,9 @@ def main(): "--record", "--record-dir", d]) ok &= check(d, "mistuned 500 kHz", False, nchan=3) + print("compander: expander must invert the 2:1 compressor") + ok &= check_compander(tmp) + finally: shutil.rmtree(tmp, ignore_errors=True) diff --git a/Silent Disco Receivers/silent_disco_rx.py b/Silent Disco Receivers/silent_disco_rx.py index 8dfcd18..1b51db3 100755 --- a/Silent Disco Receivers/silent_disco_rx.py +++ b/Silent Disco Receivers/silent_disco_rx.py @@ -23,6 +23,7 @@ # SPDX-License-Identifier: BSD-3-Clause import argparse +import math import os import re import sys @@ -266,6 +267,29 @@ def __init__(self, args, freqs, center): taps=[], fractional_bw=0.0) self.connect(node, rr) node = rr + if args.expander: + # The transmitter compresses about 2:1 and the headphone + # expands 1:2; demodulating without expanding gives + # intelligible audio with the dynamics still squashed, so + # quiet passages sit too loud and the result pumps. + # + # For a 2:1 law the expander gain is the envelope itself, + # so this needs no arbitrary power: rectify, smooth, + # multiply. The compressor's reference level is not + # published, so this restores relative dynamics up to a + # constant scale, which the AGC below then takes out. + # + # It sits before the AGC deliberately. After it, the AGC + # would have already flattened the dynamics being restored. + alpha = 1.0 - math.exp( + -1.0 / (args.expander_tau * AUDIO_RATE)) + rect = blocks.abs_ff() + env = gr_filter.single_pole_iir_filter_ff(alpha) + mul = blocks.multiply_ff(1) + self.connect(node, rect, env) + self.connect(env, (mul, 1)) + self.connect(node, (mul, 0)) + node = mul if args.agc: # Quiet Events do not publish their deviation and there is # no FCC grant under the brand, so level the audio instead @@ -396,6 +420,14 @@ def parse_args(argv): "and only in mono: the stereo path uses GNU Radio's " "wfm_rcv_pll, which fixes deviation at 75 kHz " "internally and ignores this (default 75e3)") + g.add_argument("--expander", action="store_true", + help="undo the transmitter's 2:1 compressor. These systems " + "compand, and without this the audio plays but its " + "dynamics stay squashed. The exact law is not " + "published, so this is offered rather than assumed") + g.add_argument("--expander-tau", type=float, default=0.01, + help="expander envelope time constant in seconds " + "(default 0.01)") g.add_argument("--deemph", type=float, default=75.0, help="de-emphasis time constant in microseconds, 75 in the " "US and 50 in Europe (default 75)")