From 2a5e73e9a73c82f214e5729ab5630548b35a3b7d Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Thu, 29 Jan 2026 02:18:58 -0700 Subject: [PATCH] examples: build FM receiver programmatically via GRC Platform API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- examples/fm_scanner.py | 129 ++++++++++++++++++++++++++++++++--------- 1 file changed, 102 insertions(+), 27 deletions(-) diff --git a/examples/fm_scanner.py b/examples/fm_scanner.py index 5169951..494d655 100755 --- a/examples/fm_scanner.py +++ b/examples/fm_scanner.py @@ -1,16 +1,15 @@ #!/usr/bin/env python3 """FM Band Scanner — scan 87.5–108.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"((?{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})...")