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
"""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.
Tuning builds a GNU Radio flowgraph programmatically using the same GRC
Platform API that gr-mcp uses, compiles it with grcc, and controls it at
runtime via XML-RPC for live frequency changes.
"""
import argparse
import csv
import io
import json
import re
import shutil
import signal
import subprocess
@ -255,40 +254,116 @@ def pick_station(stations: list[dict]) -> float | None:
XMLRPC_PORT = 8090
GRC_TEMPLATE = Path(__file__).parent / "fm_receiver.grc"
def prepare_flowgraph(freq_mhz: float, gain: int = 10) -> Path:
"""Create a tuned FM receiver GRC file from the template.
def build_fm_receiver(freq_mhz: float, gain: int = 10) -> Path:
"""Build an FM receiver flowgraph programmatically — no GRC template needed.
Patches the template with the requested frequency and gain, then
compiles it with grcc. Returns the path to the compiled .py file.
Creates all blocks, sets parameters, connects the signal chain, saves to
.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)
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,
# Initialize platform (same as gr-mcp main.py)
platform = Platform(
version=gr.version(),
version_parts=(gr.major_version(), gr.api_version(), gr.minor_version()),
prefs=gr.prefs(),
)
# 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)
platform.build_library()
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.write_text(grc_text)
platform.save_flow_graph(str(grc_path), fg)
# Compile GRC → Python
result = subprocess.run(
["grcc", "-o", str(work_dir), str(grc_path)],
capture_output=True, text=True,
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)
@ -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):
"""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.
Builds a flowgraph programmatically using the GRC Platform API (the same
approach gr-mcp uses), compiles it with grcc, launches the Python flowgraph
as a subprocess, and connects to its XML-RPC server for live frequency
control.
"""
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}"
print(f" Launching flowgraph ({py_path.name})...")