examples: build FM receiver programmatically via GRC Platform API

Replace template-based prepare_flowgraph() with build_fm_receiver() that
constructs the entire flowgraph in Python code:

- Initialize Platform (same as gr-mcp main.py)
- Create variables: samp_rate, freq
- Create blocks: osmosdr_source, low_pass_filter, analog_wfm_rcv, audio_sink
- Add xmlrpc_server for runtime frequency control
- Connect signal chain: source → LPF → demod → audio
- Save and compile with grcc

This proves gr-mcp's middleware approach works end-to-end: parameters
set via set_params() serialize correctly after the lru_cache fix.

No longer needs examples/fm_receiver.grc template file.
This commit is contained in:
Ryan Malloy 2026-01-29 02:18:58 -07:00
parent 27f651307e
commit 2a5e73e9a7

View File

@ -1,16 +1,15 @@
#!/usr/bin/env python3 #!/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, Tuning builds a GNU Radio flowgraph programmatically using the same GRC
compiled with grcc, and controlled at runtime via XML-RPC the same Platform API that gr-mcp uses, compiles it with grcc, and controls it at
protocol that gr-mcp uses for live parameter control. runtime via XML-RPC for live frequency changes.
""" """
import argparse import argparse
import csv import csv
import io import io
import json import json
import re
import shutil import shutil
import signal import signal
import subprocess import subprocess
@ -255,40 +254,116 @@ def pick_station(stations: list[dict]) -> float | None:
XMLRPC_PORT = 8090 XMLRPC_PORT = 8090
GRC_TEMPLATE = Path(__file__).parent / "fm_receiver.grc"
def prepare_flowgraph(freq_mhz: float, gain: int = 10) -> Path: def build_fm_receiver(freq_mhz: float, gain: int = 10) -> Path:
"""Create a tuned FM receiver GRC file from the template. """Build an FM receiver flowgraph programmatically — no GRC template needed.
Patches the template with the requested frequency and gain, then Creates all blocks, sets parameters, connects the signal chain, saves to
compiles it with grcc. Returns the path to the compiled .py file. .grc, and compiles with grcc. This uses the same middleware that gr-mcp's
MCP tools use, proving end-to-end programmatic flowgraph construction.
Signal chain:
RTL-SDR (2.4 MHz) LPF (decim 5) WBFM Demod (decim 10) Audio (48 kHz)
""" """
grc_text = GRC_TEMPLATE.read_text() # Late import to avoid dependency when just scanning (no --tune)
try:
from gnuradio import gr
from gnuradio.grc.core.platform import Platform
except ImportError:
print("Error: GNU Radio not found. Install gnuradio.", file=sys.stderr)
sys.exit(1)
# Patch frequency variable (value line under the freq block) # Initialize platform (same as gr-mcp main.py)
grc_text = re.sub( platform = Platform(
r"(- name: freq\n id: variable\n parameters:\n comment: ''\n value: )[\d.eE+]+", version=gr.version(),
rf"\g<1>{freq_mhz}e6", version_parts=(gr.major_version(), gr.api_version(), gr.minor_version()),
grc_text, prefs=gr.prefs(),
) )
# Patch osmosdr RF gain (only the top-level gain0, not bb_gain0/if_gain0) platform.build_library()
grc_text = re.sub(r"((?<![_a-z])gain0: ')(\d+)(')", rf"\g<1>{gain}\3", grc_text)
work_dir = Path(tempfile.mkdtemp(prefix="fm_scanner_")) # Create flowgraph
fg = platform.make_flow_graph()
# Configure options block (flowgraph metadata)
options = next(b for b in fg.blocks if b.key == "options")
options.params["id"].set_value("fm_receiver")
options.params["title"].set_value("FM Receiver")
options.params["generate_options"].set_value("no_gui")
options.params["run_options"].set_value("run")
# Add samp_rate variable (not included by default, unlike GRC GUI)
samp_rate = fg.new_block("variable")
samp_rate.params["id"].set_value("samp_rate")
samp_rate.params["value"].set_value("int(2.4e6)")
# Add freq variable
freq_block = fg.new_block("variable")
freq_block.params["id"].set_value("freq")
freq_block.params["value"].set_value(f"{freq_mhz}e6")
# RTL-SDR source
source = fg.new_block("osmosdr_source")
source.params["id"].set_value("osmosdr_source_0")
source.params["sample_rate"].set_value("samp_rate")
source.params["freq0"].set_value("freq") # Reference the variable
source.params["gain0"].set_value(str(gain))
source.params["if_gain0"].set_value("20")
source.params["bb_gain0"].set_value("20")
source.params["args"].set_value('"rtl=0"')
# Low-pass filter: 2.4 MHz → 480 kHz (decim 5)
lpf = fg.new_block("low_pass_filter")
lpf.params["id"].set_value("low_pass_filter_0")
lpf.params["type"].set_value("fir_filter_ccf")
lpf.params["decim"].set_value("5")
lpf.params["gain"].set_value("1")
lpf.params["samp_rate"].set_value("samp_rate")
lpf.params["cutoff_freq"].set_value("100e3")
lpf.params["width"].set_value("10e3")
lpf.params["win"].set_value("window.WIN_HAMMING")
lpf.params["beta"].set_value("6.76")
# WBFM demodulator: 480 kHz → 48 kHz (decim 10)
wfm = fg.new_block("analog_wfm_rcv")
wfm.params["id"].set_value("analog_wfm_rcv_0")
wfm.params["quad_rate"].set_value("480e3")
wfm.params["audio_decimation"].set_value("10")
# Audio sink
audio = fg.new_block("audio_sink")
audio.params["id"].set_value("audio_sink_0")
audio.params["samp_rate"].set_value("48000")
audio.params["ok_to_block"].set_value("True")
# XML-RPC server for runtime control
xmlrpc = fg.new_block("xmlrpc_server")
xmlrpc.params["id"].set_value("xmlrpc_server_0")
xmlrpc.params["addr"].set_value("0.0.0.0")
xmlrpc.params["port"].set_value(str(XMLRPC_PORT))
# Connect signal chain
# source:0 → lpf:0
fg.connect(source.sources[0], lpf.sinks[0])
# lpf:0 → wfm:0
fg.connect(lpf.sources[0], wfm.sinks[0])
# wfm:0 → audio:0
fg.connect(wfm.sources[0], audio.sinks[0])
# Save and compile
work_dir = Path(tempfile.mkdtemp(prefix="fm_receiver_"))
grc_path = work_dir / "fm_receiver.grc" grc_path = work_dir / "fm_receiver.grc"
grc_path.write_text(grc_text) platform.save_flow_graph(str(grc_path), fg)
# Compile GRC → Python
result = subprocess.run( result = subprocess.run(
["grcc", "-o", str(work_dir), str(grc_path)], ["grcc", "-o", str(work_dir), str(grc_path)],
capture_output=True, text=True, capture_output=True,
text=True,
) )
if result.returncode != 0: if result.returncode != 0:
print(f"Error: grcc compilation failed:\n{result.stderr}", file=sys.stderr) print(f"Error: grcc compilation failed:\n{result.stderr}", file=sys.stderr)
sys.exit(1) sys.exit(1)
# grcc uses the flowgraph's id as the filename (default.py for id: default)
py_files = list(work_dir.glob("*.py")) py_files = list(work_dir.glob("*.py"))
if not py_files: if not py_files:
print("Error: grcc produced no Python output.", file=sys.stderr) print("Error: grcc produced no Python output.", file=sys.stderr)
@ -317,13 +392,13 @@ def wait_for_xmlrpc(url: str, timeout: float = 10.0) -> xmlrpc.client.ServerProx
def tune_station(freq_mhz: float, gain: int = 10): def tune_station(freq_mhz: float, gain: int = 10):
"""Launch a GNU Radio FM receiver and tune via XML-RPC. """Launch a GNU Radio FM receiver and tune via XML-RPC.
Builds a flowgraph from the GRC template, compiles it with grcc, Builds a flowgraph programmatically using the GRC Platform API (the same
launches the Python flowgraph as a subprocess, and connects to its approach gr-mcp uses), compiles it with grcc, launches the Python flowgraph
XML-RPC server for live frequency control the same mechanism that as a subprocess, and connects to its XML-RPC server for live frequency
gr-mcp uses for runtime parameter changes. control.
""" """
print(f"\n Building FM receiver for {freq_mhz:.1f} MHz...") print(f"\n Building FM receiver for {freq_mhz:.1f} MHz...")
py_path = prepare_flowgraph(freq_mhz, gain) py_path = build_fm_receiver(freq_mhz, gain)
url = f"http://localhost:{XMLRPC_PORT}" url = f"http://localhost:{XMLRPC_PORT}"
print(f" Launching flowgraph ({py_path.name})...") print(f" Launching flowgraph ({py_path.name})...")