diff --git a/Silent Disco Receivers/README.md b/Silent Disco Receivers/README.md new file mode 100644 index 0000000..4f076a0 --- /dev/null +++ b/Silent Disco Receivers/README.md @@ -0,0 +1,231 @@ +# 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. +## 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/gr_3.10_silent_disco_rx.grc b/Silent Disco Receivers/gr_3.10_silent_disco_rx.grc new file mode 100644 index 0000000..6086ca9 --- /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_gui + 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..87dc706 --- /dev/null +++ b/Silent Disco Receivers/make_silent_disco_iq.py @@ -0,0 +1,266 @@ +#!/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 filter as gr_filter +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 _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, + 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) + + 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) + + 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) + 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("--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)") + 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, + compand=args.compand, + compand_tau=args.compand_tau, + dynamics=args.dynamics, + quiet_level=args.quiet_level, + ) + + 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()) diff --git a/Silent Disco Receivers/selftest.py b/Silent Disco Receivers/selftest.py new file mode 100755 index 0000000..6da6f69 --- /dev/null +++ b/Silent Disco Receivers/selftest.py @@ -0,0 +1,264 @@ +#!/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 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 + +# 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, + 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) + # 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: + 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 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" + 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) + + print("compander: expander must invert the 2:1 compressor") + ok &= check_compander(tmp) + + 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..1b51db3 --- /dev/null +++ b/Silent Disco Receivers/silent_disco_rx.py @@ -0,0 +1,611 @@ +#!/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 math +import os +import re +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 + +# 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. +# 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] + + +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") + + 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=AUDIO_PASS_HZ, + audio_stop=AUDIO_STOP_HZ, 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.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 + # 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(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) + 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 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("--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)") + 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 + + # 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 + 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 + # 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) + + 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()) 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())