examples: replace rtl_fm tuning with GNU Radio XML-RPC control

The --tune flag now builds an FM receiver from a GRC template,
compiles it with grcc, launches it as a subprocess, and tunes
via XML-RPC — the same mechanism gr-mcp uses for runtime
parameter changes. Includes an interactive freq> prompt for
live re-tuning without restarting the flowgraph.
This commit is contained in:
Ryan Malloy 2026-01-28 20:46:20 -07:00
parent c793208932
commit 2405bf5535
2 changed files with 598 additions and 40 deletions

483
examples/fm_receiver.grc Normal file
View File

@ -0,0 +1,483 @@
options:
parameters:
author: ''
catch_exceptions: 'True'
category: '[GRC Hier Blocks]'
cmake_opt: ''
comment: ''
copyright: ''
description: 'FM receiver template for fm_scanner.py'
gen_cmake: 'On'
gen_linking: dynamic
generate_options: no_gui
hier_block_src_path: '.:'
id: fm_receiver
max_nouts: '0'
output_language: python
placement: (0,0)
qt_qss_theme: ''
realtime_scheduling: ''
run: 'True'
run_command: '{python} -u {filename}'
run_options: run
sizing_mode: fixed
thread_safe_setters: ''
title: FM Receiver
window_size: (1000,1000)
states:
bus_sink: false
bus_source: false
bus_structure: null
coordinate: [8, 8]
rotation: 0
state: enabled
blocks:
- name: freq
id: variable
parameters:
comment: ''
value: 102.0e6
states:
bus_sink: false
bus_source: false
bus_structure: null
state: enabled
- name: samp_rate
id: variable
parameters:
comment: ''
value: int(2.4e6)
states:
bus_sink: false
bus_source: false
bus_structure: null
state: enabled
- name: analog_wfm_rcv_0
id: analog_wfm_rcv
parameters:
affinity: ''
alias: ''
audio_decimation: '10'
comment: ''
maxoutbuf: '0'
minoutbuf: '0'
quad_rate: 480e3
states:
bus_sink: false
bus_source: false
bus_structure: null
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
state: enabled
- name: low_pass_filter_0
id: low_pass_filter
parameters:
affinity: ''
alias: ''
beta: '6.76'
comment: ''
cutoff_freq: 100e3
decim: '5'
gain: '1'
interp: '1'
maxoutbuf: '0'
minoutbuf: '0'
samp_rate: 2.4e6
type: fir_filter_ccf
width: 10e3
win: window.WIN_HAMMING
states:
bus_sink: false
bus_source: false
bus_structure: null
state: enabled
- name: osmosdr_source_0
id: osmosdr_source
parameters:
affinity: ''
alias: ''
ant0: ''
ant1: ''
ant10: ''
ant11: ''
ant12: ''
ant13: ''
ant14: ''
ant15: ''
ant16: ''
ant17: ''
ant18: ''
ant19: ''
ant2: ''
ant20: ''
ant21: ''
ant22: ''
ant23: ''
ant24: ''
ant25: ''
ant26: ''
ant27: ''
ant28: ''
ant29: ''
ant3: ''
ant30: ''
ant31: ''
ant4: ''
ant5: ''
ant6: ''
ant7: ''
ant8: ''
ant9: ''
args: '"rtl=0"'
bb_gain0: '20'
bb_gain1: '20'
bb_gain10: '20'
bb_gain11: '20'
bb_gain12: '20'
bb_gain13: '20'
bb_gain14: '20'
bb_gain15: '20'
bb_gain16: '20'
bb_gain17: '20'
bb_gain18: '20'
bb_gain19: '20'
bb_gain2: '20'
bb_gain20: '20'
bb_gain21: '20'
bb_gain22: '20'
bb_gain23: '20'
bb_gain24: '20'
bb_gain25: '20'
bb_gain26: '20'
bb_gain27: '20'
bb_gain28: '20'
bb_gain29: '20'
bb_gain3: '20'
bb_gain30: '20'
bb_gain31: '20'
bb_gain4: '20'
bb_gain5: '20'
bb_gain6: '20'
bb_gain7: '20'
bb_gain8: '20'
bb_gain9: '20'
bw0: '0'
bw1: '0'
bw10: '0'
bw11: '0'
bw12: '0'
bw13: '0'
bw14: '0'
bw15: '0'
bw16: '0'
bw17: '0'
bw18: '0'
bw19: '0'
bw2: '0'
bw20: '0'
bw21: '0'
bw22: '0'
bw23: '0'
bw24: '0'
bw25: '0'
bw26: '0'
bw27: '0'
bw28: '0'
bw29: '0'
bw3: '0'
bw30: '0'
bw31: '0'
bw4: '0'
bw5: '0'
bw6: '0'
bw7: '0'
bw8: '0'
bw9: '0'
clock_source0: ''
clock_source1: ''
clock_source2: ''
clock_source3: ''
clock_source4: ''
clock_source5: ''
clock_source6: ''
clock_source7: ''
comment: ''
corr0: '0'
corr1: '0'
corr10: '0'
corr11: '0'
corr12: '0'
corr13: '0'
corr14: '0'
corr15: '0'
corr16: '0'
corr17: '0'
corr18: '0'
corr19: '0'
corr2: '0'
corr20: '0'
corr21: '0'
corr22: '0'
corr23: '0'
corr24: '0'
corr25: '0'
corr26: '0'
corr27: '0'
corr28: '0'
corr29: '0'
corr3: '0'
corr30: '0'
corr31: '0'
corr4: '0'
corr5: '0'
corr6: '0'
corr7: '0'
corr8: '0'
corr9: '0'
dc_offset_mode0: '0'
dc_offset_mode1: '0'
dc_offset_mode10: '0'
dc_offset_mode11: '0'
dc_offset_mode12: '0'
dc_offset_mode13: '0'
dc_offset_mode14: '0'
dc_offset_mode15: '0'
dc_offset_mode16: '0'
dc_offset_mode17: '0'
dc_offset_mode18: '0'
dc_offset_mode19: '0'
dc_offset_mode2: '0'
dc_offset_mode20: '0'
dc_offset_mode21: '0'
dc_offset_mode22: '0'
dc_offset_mode23: '0'
dc_offset_mode24: '0'
dc_offset_mode25: '0'
dc_offset_mode26: '0'
dc_offset_mode27: '0'
dc_offset_mode28: '0'
dc_offset_mode29: '0'
dc_offset_mode3: '0'
dc_offset_mode30: '0'
dc_offset_mode31: '0'
dc_offset_mode4: '0'
dc_offset_mode5: '0'
dc_offset_mode6: '0'
dc_offset_mode7: '0'
dc_offset_mode8: '0'
dc_offset_mode9: '0'
freq0: freq
freq1: 100e6
freq10: 100e6
freq11: 100e6
freq12: 100e6
freq13: 100e6
freq14: 100e6
freq15: 100e6
freq16: 100e6
freq17: 100e6
freq18: 100e6
freq19: 100e6
freq2: 100e6
freq20: 100e6
freq21: 100e6
freq22: 100e6
freq23: 100e6
freq24: 100e6
freq25: 100e6
freq26: 100e6
freq27: 100e6
freq28: 100e6
freq29: 100e6
freq3: 100e6
freq30: 100e6
freq31: 100e6
freq4: 100e6
freq5: 100e6
freq6: 100e6
freq7: 100e6
freq8: 100e6
freq9: 100e6
gain0: '10'
gain1: '10'
gain10: '10'
gain11: '10'
gain12: '10'
gain13: '10'
gain14: '10'
gain15: '10'
gain16: '10'
gain17: '10'
gain18: '10'
gain19: '10'
gain2: '10'
gain20: '10'
gain21: '10'
gain22: '10'
gain23: '10'
gain24: '10'
gain25: '10'
gain26: '10'
gain27: '10'
gain28: '10'
gain29: '10'
gain3: '10'
gain30: '10'
gain31: '10'
gain4: '10'
gain5: '10'
gain6: '10'
gain7: '10'
gain8: '10'
gain9: '10'
gain_mode0: 'False'
gain_mode1: 'False'
gain_mode10: 'False'
gain_mode11: 'False'
gain_mode12: 'False'
gain_mode13: 'False'
gain_mode14: 'False'
gain_mode15: 'False'
gain_mode16: 'False'
gain_mode17: 'False'
gain_mode18: 'False'
gain_mode19: 'False'
gain_mode2: 'False'
gain_mode20: 'False'
gain_mode21: 'False'
gain_mode22: 'False'
gain_mode23: 'False'
gain_mode24: 'False'
gain_mode25: 'False'
gain_mode26: 'False'
gain_mode27: 'False'
gain_mode28: 'False'
gain_mode29: 'False'
gain_mode3: 'False'
gain_mode30: 'False'
gain_mode31: 'False'
gain_mode4: 'False'
gain_mode5: 'False'
gain_mode6: 'False'
gain_mode7: 'False'
gain_mode8: 'False'
gain_mode9: 'False'
if_gain0: '20'
if_gain1: '20'
if_gain10: '20'
if_gain11: '20'
if_gain12: '20'
if_gain13: '20'
if_gain14: '20'
if_gain15: '20'
if_gain16: '20'
if_gain17: '20'
if_gain18: '20'
if_gain19: '20'
if_gain2: '20'
if_gain20: '20'
if_gain21: '20'
if_gain22: '20'
if_gain23: '20'
if_gain24: '20'
if_gain25: '20'
if_gain26: '20'
if_gain27: '20'
if_gain28: '20'
if_gain29: '20'
if_gain3: '20'
if_gain30: '20'
if_gain31: '20'
if_gain4: '20'
if_gain5: '20'
if_gain6: '20'
if_gain7: '20'
if_gain8: '20'
if_gain9: '20'
iq_balance_mode0: '0'
iq_balance_mode1: '0'
iq_balance_mode10: '0'
iq_balance_mode11: '0'
iq_balance_mode12: '0'
iq_balance_mode13: '0'
iq_balance_mode14: '0'
iq_balance_mode15: '0'
iq_balance_mode16: '0'
iq_balance_mode17: '0'
iq_balance_mode18: '0'
iq_balance_mode19: '0'
iq_balance_mode2: '0'
iq_balance_mode20: '0'
iq_balance_mode21: '0'
iq_balance_mode22: '0'
iq_balance_mode23: '0'
iq_balance_mode24: '0'
iq_balance_mode25: '0'
iq_balance_mode26: '0'
iq_balance_mode27: '0'
iq_balance_mode28: '0'
iq_balance_mode29: '0'
iq_balance_mode3: '0'
iq_balance_mode30: '0'
iq_balance_mode31: '0'
iq_balance_mode4: '0'
iq_balance_mode5: '0'
iq_balance_mode6: '0'
iq_balance_mode7: '0'
iq_balance_mode8: '0'
iq_balance_mode9: '0'
maxoutbuf: '0'
minoutbuf: '0'
nchan: '1'
num_mboards: '1'
sample_rate: 2.4e6
sync: sync
time_source0: ''
time_source1: ''
time_source2: ''
time_source3: ''
time_source4: ''
time_source5: ''
time_source6: ''
time_source7: ''
type: fc32
states:
bus_sink: false
bus_source: false
bus_structure: null
state: enabled
- name: xmlrpc_server_0
id: xmlrpc_server
parameters:
addr: 0.0.0.0
alias: ''
comment: ''
port: '8090'
states:
bus_sink: false
bus_source: false
bus_structure: null
state: enabled
connections:
- [osmosdr_source_0, '0', low_pass_filter_0, '0']
- [low_pass_filter_0, '0', analog_wfm_rcv_0, '0']
- [analog_wfm_rcv_0, '0', audio_sink_0, '0']
metadata:
file_format: 1
grc_version: 3.10.12.0

View File

@ -1,14 +1,23 @@
#!/usr/bin/env python3
"""FM Band Scanner — scan 87.5108.0 MHz using rtl_power, rank stations by signal strength."""
"""FM Band Scanner — scan 87.5108.0 MHz using rtl_power, rank stations by signal strength.
Tuning uses a GNU Radio flowgraph built from the included GRC template,
compiled with grcc, and controlled at runtime via XML-RPC the same
protocol that gr-mcp uses for live parameter control.
"""
import argparse
import csv
import io
import json
import signal
import re
import shutil
import signal
import subprocess
import sys
import tempfile
import time
import xmlrpc.client
from collections import defaultdict
from pathlib import Path
@ -245,50 +254,116 @@ def pick_station(stations: list[dict]) -> float | None:
return pick_station(stations)
def tune_station(freq_mhz: float, gain: int = 10):
"""Tune to an FM station using rtl_fm piped to aplay.
XMLRPC_PORT = 8090
GRC_TEMPLATE = Path(__file__).parent / "fm_receiver.grc"
rtl_fm demodulates wideband FM, resamples to 48 kHz mono,
and pipes raw PCM to ALSA's aplay for real-time audio output.
def prepare_flowgraph(freq_mhz: float, gain: int = 10) -> Path:
"""Create a tuned FM receiver GRC file from the template.
Patches the template with the requested frequency and gain, then
compiles it with grcc. Returns the path to the compiled .py file.
"""
freq_hz = int(freq_mhz * 1e6)
print(f"\n Tuning to {freq_mhz:.1f} MHz — Ctrl+C to stop\n")
grc_text = GRC_TEMPLATE.read_text()
rtl_cmd = [
"rtl_fm",
"-f", str(freq_hz),
"-M", "wbfm", # wideband FM demodulation
"-s", "200k", # sample rate (200 kHz captures full FM channel)
"-r", "48k", # resample output to 48 kHz
"-g", str(gain),
"-", # output to stdout
]
play_cmd = [
"aplay",
"-r", "48000", # 48 kHz sample rate
"-f", "S16_LE", # signed 16-bit little-endian PCM
"-t", "raw", # raw format (no WAV header)
"-c", "1", # mono
"-q", # quiet (no progress output)
]
# Patch frequency variable (value line under the freq block)
grc_text = re.sub(
r"(- name: freq\n id: variable\n parameters:\n comment: ''\n value: )[\d.eE+]+",
rf"\g<1>{freq_mhz}e6",
grc_text,
)
# Patch osmosdr RF gain (only the top-level gain0, not bb_gain0/if_gain0)
grc_text = re.sub(r"((?<![_a-z])gain0: ')(\d+)(')", rf"\g<1>{gain}\3", grc_text)
work_dir = Path(tempfile.mkdtemp(prefix="fm_scanner_"))
grc_path = work_dir / "fm_receiver.grc"
grc_path.write_text(grc_text)
# Compile GRC → Python
result = subprocess.run(
["grcc", "-o", str(work_dir), str(grc_path)],
capture_output=True, text=True,
)
if result.returncode != 0:
print(f"Error: grcc compilation failed:\n{result.stderr}", file=sys.stderr)
sys.exit(1)
# grcc uses the flowgraph's id as the filename (default.py for id: default)
py_files = list(work_dir.glob("*.py"))
if not py_files:
print("Error: grcc produced no Python output.", file=sys.stderr)
sys.exit(1)
return py_files[0]
def wait_for_xmlrpc(url: str, timeout: float = 10.0) -> xmlrpc.client.ServerProxy:
"""Wait for the XML-RPC server to become reachable."""
proxy = xmlrpc.client.ServerProxy(url)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
proxy.get_freq()
return proxy
except ConnectionRefusedError:
time.sleep(0.3)
except Exception:
# Fault from missing method is fine — server is up
return proxy
print("Error: flowgraph XML-RPC server did not start.", file=sys.stderr)
sys.exit(1)
def tune_station(freq_mhz: float, gain: int = 10):
"""Launch a GNU Radio FM receiver and tune via XML-RPC.
Builds a flowgraph from the GRC template, compiles it with grcc,
launches the Python flowgraph as a subprocess, and connects to its
XML-RPC server for live frequency control the same mechanism that
gr-mcp uses for runtime parameter changes.
"""
print(f"\n Building FM receiver for {freq_mhz:.1f} MHz...")
py_path = prepare_flowgraph(freq_mhz, gain)
url = f"http://localhost:{XMLRPC_PORT}"
print(f" Launching flowgraph ({py_path.name})...")
fg_proc = subprocess.Popen(
[sys.executable, str(py_path)],
stderr=subprocess.DEVNULL,
)
proxy = wait_for_xmlrpc(url)
current = proxy.get_freq()
print(f" Receiving {current / 1e6:.1f} MHz — enter frequency to retune, q to quit\n")
rtl_proc = None
play_proc = None
try:
rtl_proc = subprocess.Popen(rtl_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
play_proc = subprocess.Popen(play_cmd, stdin=rtl_proc.stdout, stderr=subprocess.DEVNULL)
# Allow rtl_proc to receive SIGPIPE if play_proc exits
rtl_proc.stdout.close()
play_proc.wait()
while fg_proc.poll() is None:
try:
cmd = input(" freq> ").strip()
except EOFError:
break
if cmd.lower() in ("q", "quit", ""):
break
try:
new_freq = float(cmd)
if 87.5 <= new_freq <= 108.0:
proxy.set_freq(new_freq * 1e6)
print(f" Tuned to {new_freq:.1f} MHz")
else:
print(" Frequency must be 87.5108.0 MHz.")
except ValueError:
print(" Enter a frequency (MHz) or q to quit.")
except KeyboardInterrupt:
print("\n Stopped.")
except FileNotFoundError as e:
print(f" Error: {e.filename} not found.", file=sys.stderr)
finally:
for proc in (play_proc, rtl_proc):
if proc and proc.poll() is None:
proc.send_signal(signal.SIGTERM)
proc.wait(timeout=3)
pass
print("\n Stopping flowgraph...")
if fg_proc.poll() is None:
fg_proc.send_signal(signal.SIGTERM)
try:
fg_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
fg_proc.kill()
def main():