Latest commit

History

68 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Ham Radio Net Speech-to-Text

Live transcription for an amateur radio net. Audio comes in from an SDR, a radio's line output, or a microphone; each transmission is transcribed, the speaker is matched against a roster of known callsigns, and everything lands in a browser dashboard that net control can read from across the table.

Built for a high-traffic event or race net — the kind run from a trailer with several people talking at once, where the question is "what was that last transmission?" and the answer has to be on screen rather than in somebody's memory. On an event net the callsign is also a location: operators are posted around the course, so knowing who transmitted is knowing where it came from — which is why this app would rather log a line as unmatched than attach a plausible wrong callsign to it. It works for an orderly weekly check-in too, but that is the easy case.

Fully offline — no cloud services, nothing leaves the machine. Whisper runs locally.

SDR / radio ──▶ loopback, line in, or mic ──▶ capture ──▶ VAD ──▶ Whisper ──▶ roster match ──▶ dashboard

The dashboard during an event net: each line shows the callsign, the position that station is posted at, and the operator's name, with traffic declarations badged, lines re-transcribed by the second pass marked, and a roster sidebar acting as a who-is-where board

Deliberately an unflattering run: tiny live with base on escalation, so the 2nd pass marks are visible and a couple of lines are left as tiny produced them. base on its own transcribes this recording cleanly — see docs/HARDWARE.md.

Each line carries the callsign, where that station is posted, and the operator's name. On an event net the position is the actionable half: "need medical at my location" is only useful once the line says Mile 8. The sidebar doubles as a who-is-where board and lights up as stations are heard from.

Lines that declared traffic are badged, and the stations holding it are marked in the sidebar — the header count is also a filter. Stations the roster cannot match are flagged amber rather than attached to the nearest plausible callsign — a wrong callsign is a wrong location. Clicking one sets the right station, and the app learns the correction for next time. (Screenshot uses the example roster and synthesised audio from tools/make_test_audio.py.)

Status

Everything downstream of the audio device works and is tested offline: segmentation, transcription, callsign matching, corrections and learning, voice suggestions, multiple receivers, buffering, crash-safe transcripts, the watchdog, export, the container image.

No part of it has run against a real radio yet, and every tuning constant is a reasoned guess rather than a measurement. docs/STATUS.md is the honest account of what is proven, what is not, and what to do next; docs/FIELD-BRINGUP.md is the checklist for the first session at the hardware.

What it does

  • Listens to an SDR loopback, a radio's line output, a microphone, or a linked system over the internet (EchoLink/AllStar) — one receiver or several, each with its own level, VAD settings and health.
  • Transcribes each transmission with Whisper, conditioning the audio and biasing decoding toward the stations most likely to speak. Parakeet TDT is also supported and measured better on real net audio, without needing the bias at all — see HARDWARE.md.
  • Identifies the station: phonetic normalisation, fuzzy roster matching, aliases learned from corrections, and voice recognition for transmissions that carry no callsign at all. It refuses to guess rather than log a wrong callsign.
  • Shows the transcript with each station's position on the course, marks traffic and lets you clear it, and reports its own health continuously.
  • Keeps everything: transcripts written as the net runs and survive a power cut, a session that can be resumed, and exports for the net report.
  • Learns between events — aliases, voices, attendance and thresholds all improve from nets nobody was watching.

Documentation

The README is deliberately short. Each of these is the full account of one thing:

docs/AUDIO-INPUT.mdWhat to plug in — SDR loopback, line in, or mic — and running several receivers at once
docs/ACCURACY.mdChoosing a model, the four things that buy accuracy, and tuning against your own recordings
docs/MATCHING.mdThe roster, how a spoken callsign becomes a match, corrections, and voice identification
docs/OPERATIONS.mdDashboard, settings panel, transcripts on disk, watchdog and alerting, config reference, container
docs/FIELD-BRINGUP.mdFirst run against real hardware, in order, with the failure modes at each step
docs/HARDWARE.mdEngine and model-size benchmarks, and whether anything needs buying (usually not)
docs/ARCHITECTURE.mdHow the pieces fit, the threading model, and why things are the way they are
docs/TESTING.mdTest suites, generating audio without an SDR, and adding a regression
docs/STATUS.mdWhat is proven, what is guessed, and the work worth doing next

Deployment files live in deploy/ (a systemd unit) and at the repo root (Containerfile, docker-compose.yml).

Quick start

python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
cp config.yaml.example config.yaml && cp roster.example.csv roster.csv

Edit roster.csv with the stations you expect, then find the audio device SDR++/GQRX is feeding:

python app.py --list-devices

Put that device name (a substring is enough) in config.yaml under audio.device, and run:

python app.py

Open http://localhost:8080. The first run downloads the Whisper model; after that it works with no network at all.

Where to go next

Three paths, depending on what you have in front of you:

  • No radio yetdocs/ACCURACY.md covers replaying a recording through --file, which exercises everything except audio capture.
  • A radio and an afternoondocs/FIELD-BRINGUP.md starts at getting the receiver producing audio and ends at a real net.
  • Wondering what to run it ondocs/HARDWARE.md. The short answer is that the machine you already have is probably enough.

Before the first real net, the two things worth setting deliberately are the roster (docs/MATCHING.md) and the squelch on the receiver (docs/FIELD-BRINGUP.md) — an open squelch is the single most common way to get a stream of junk lines.

Tests

.venv/bin/python -m pytest

442 tests, all offline, no audio hardware needed — CI runs them on every push across Python 3.11–3.13, plus a job with the optional libraries removed so the Raspberry Pi fallback paths are exercised too. test_callsign_match.py covers the normalizer and matcher, including verbatim Whisper output; test_vad_segmenter.py pins the clip boundaries with scripted speech patterns.

See docs/TESTING.md for the workflow when a net turns up a mis-transcription the matcher does not handle — it is a two-line change plus a test.

What to run it on

The short version, with the benchmarks, specific machines and the buying argument in docs/HARDWARE.md. Start by assuming you need nothing — on the synthetic test net base matches medium on callsign recovery, and runs at a fraction of realtime on an ordinary CPU.

RunsNotes
Used RTX laptopanything, on CUDAThe battery is a UPS, which matters more in a trailer than the extra speed
Jetson Orin Nanoanything, on CUDA7–25 W, for a permanent install. Needs an aarch64 CUDA build of CTranslate2
x86 mini PCbase, small on CPUCheapest reliable path with no GPU
Raspberry Pi 5tiny, baseWorks, no headroom. small only on a quiet net
Raspberry Pi 4tiny, baseDo not target medium

INT8 is already the default on CPU (whisper.compute_type: null).

If SDR++/GQRX runs on the same box, remember it has real CPU cost of its own — watch the status strip during an actual net before assuming one machine covers both. And escalation lowers the bar a long way: the big model only handles the lines the fast one was unsure about, so base live with large-v3 on escalation is a comfortable target for hardware that could not run large-v3 on every transmission.

License

GPL-3.0. Copyright (C) 2026 Michelle Michaels. See LICENSE.

The same license as GQRX, GNU Radio, and fldigi, so this composes with the rest of the SDR stack it sits alongside. If you modify it and distribute your version, your changes have to be available under the GPL too.

Limitations

See docs/STATUS.md for the full account, including which settings are still guesses. In short:

  • Voice only (FM/SSB). No CW, no digital modes.
  • One transmission is assumed to be one speaker, which holds for a half-duplex net and not much else.
  • The confidence figure comes from Whisper's avg_logprob. It is a useful relative cue and not a calibrated probability.
  • One Whisper model is shared across receivers, so two busy frequencies serialise rather than transcribe in parallel.
  • Traffic is detected from what was said; whether it was passed is your click, not something the app infers from later transmissions.
  • Voice suggestions and attendance both need history, so the first event with a new roster offers neither. Both improve every time the app runs.
  • Settings changed from the dashboard apply to the running process; saving to config.yaml is a separate click, so an unsaved change is gone at the next restart.

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Latest commit

History

68 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Ham Radio Net Speech-to-Text

Live transcription for an amateur radio net. Audio comes in from an SDR, a radio's line output, or a microphone; each transmission is transcribed, the speaker is matched against a roster of known callsigns, and everything lands in a browser dashboard that net control can read from across the table.

Built for a high-traffic event or race net — the kind run from a trailer with several people talking at once, where the question is "what was that last transmission?" and the answer has to be on screen rather than in somebody's memory. On an event net the callsign is also a location: operators are posted around the course, so knowing who transmitted is knowing where it came from — which is why this app would rather log a line as unmatched than attach a plausible wrong callsign to it. It works for an orderly weekly check-in too, but that is the easy case.

Fully offline — no cloud services, nothing leaves the machine. Whisper runs locally.

SDR / radio ──▶ loopback, line in, or mic ──▶ capture ──▶ VAD ──▶ Whisper ──▶ roster match ──▶ dashboard

The dashboard during an event net: each line shows the callsign, the position that station is posted at, and the operator's name, with traffic declarations badged, lines re-transcribed by the second pass marked, and a roster sidebar acting as a who-is-where board

Deliberately an unflattering run: tiny live with base on escalation, so the 2nd pass marks are visible and a couple of lines are left as tiny produced them. base on its own transcribes this recording cleanly — see docs/HARDWARE.md.

Each line carries the callsign, where that station is posted, and the operator's name. On an event net the position is the actionable half: "need medical at my location" is only useful once the line says Mile 8. The sidebar doubles as a who-is-where board and lights up as stations are heard from.

Lines that declared traffic are badged, and the stations holding it are marked in the sidebar — the header count is also a filter. Stations the roster cannot match are flagged amber rather than attached to the nearest plausible callsign — a wrong callsign is a wrong location. Clicking one sets the right station, and the app learns the correction for next time. (Screenshot uses the example roster and synthesised audio from tools/make_test_audio.py.)

Status

Everything downstream of the audio device works and is tested offline: segmentation, transcription, callsign matching, corrections and learning, voice suggestions, multiple receivers, buffering, crash-safe transcripts, the watchdog, export, the container image.

No part of it has run against a real radio yet, and every tuning constant is a reasoned guess rather than a measurement. docs/STATUS.md is the honest account of what is proven, what is not, and what to do next; docs/FIELD-BRINGUP.md is the checklist for the first session at the hardware.

What it does

  • Listens to an SDR loopback, a radio's line output, a microphone, or a linked system over the internet (EchoLink/AllStar) — one receiver or several, each with its own level, VAD settings and health.
  • Transcribes each transmission with Whisper, conditioning the audio and biasing decoding toward the stations most likely to speak. Parakeet TDT is also supported and measured better on real net audio, without needing the bias at all — see HARDWARE.md.
  • Identifies the station: phonetic normalisation, fuzzy roster matching, aliases learned from corrections, and voice recognition for transmissions that carry no callsign at all. It refuses to guess rather than log a wrong callsign.
  • Shows the transcript with each station's position on the course, marks traffic and lets you clear it, and reports its own health continuously.
  • Keeps everything: transcripts written as the net runs and survive a power cut, a session that can be resumed, and exports for the net report.
  • Learns between events — aliases, voices, attendance and thresholds all improve from nets nobody was watching.

Documentation

The README is deliberately short. Each of these is the full account of one thing:

docs/AUDIO-INPUT.mdWhat to plug in — SDR loopback, line in, or mic — and running several receivers at once
docs/ACCURACY.mdChoosing a model, the four things that buy accuracy, and tuning against your own recordings
docs/MATCHING.mdThe roster, how a spoken callsign becomes a match, corrections, and voice identification
docs/OPERATIONS.mdDashboard, settings panel, transcripts on disk, watchdog and alerting, config reference, container
docs/FIELD-BRINGUP.mdFirst run against real hardware, in order, with the failure modes at each step
docs/HARDWARE.mdEngine and model-size benchmarks, and whether anything needs buying (usually not)
docs/ARCHITECTURE.mdHow the pieces fit, the threading model, and why things are the way they are
docs/TESTING.mdTest suites, generating audio without an SDR, and adding a regression
docs/STATUS.mdWhat is proven, what is guessed, and the work worth doing next

Deployment files live in deploy/ (a systemd unit) and at the repo root (Containerfile, docker-compose.yml).

Quick start

python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
cp config.yaml.example config.yaml && cp roster.example.csv roster.csv

Edit roster.csv with the stations you expect, then find the audio device SDR++/GQRX is feeding:

python app.py --list-devices

Put that device name (a substring is enough) in config.yaml under audio.device, and run:

python app.py

Open http://localhost:8080. The first run downloads the Whisper model; after that it works with no network at all.

Where to go next

Three paths, depending on what you have in front of you:

  • No radio yetdocs/ACCURACY.md covers replaying a recording through --file, which exercises everything except audio capture.
  • A radio and an afternoondocs/FIELD-BRINGUP.md starts at getting the receiver producing audio and ends at a real net.
  • Wondering what to run it ondocs/HARDWARE.md. The short answer is that the machine you already have is probably enough.

Before the first real net, the two things worth setting deliberately are the roster (docs/MATCHING.md) and the squelch on the receiver (docs/FIELD-BRINGUP.md) — an open squelch is the single most common way to get a stream of junk lines.

Tests

.venv/bin/python -m pytest

442 tests, all offline, no audio hardware needed — CI runs them on every push across Python 3.11–3.13, plus a job with the optional libraries removed so the Raspberry Pi fallback paths are exercised too. test_callsign_match.py covers the normalizer and matcher, including verbatim Whisper output; test_vad_segmenter.py pins the clip boundaries with scripted speech patterns.

See docs/TESTING.md for the workflow when a net turns up a mis-transcription the matcher does not handle — it is a two-line change plus a test.

What to run it on

The short version, with the benchmarks, specific machines and the buying argument in docs/HARDWARE.md. Start by assuming you need nothing — on the synthetic test net base matches medium on callsign recovery, and runs at a fraction of realtime on an ordinary CPU.

RunsNotes
Used RTX laptopanything, on CUDAThe battery is a UPS, which matters more in a trailer than the extra speed
Jetson Orin Nanoanything, on CUDA7–25 W, for a permanent install. Needs an aarch64 CUDA build of CTranslate2
x86 mini PCbase, small on CPUCheapest reliable path with no GPU
Raspberry Pi 5tiny, baseWorks, no headroom. small only on a quiet net
Raspberry Pi 4tiny, baseDo not target medium

INT8 is already the default on CPU (whisper.compute_type: null).

If SDR++/GQRX runs on the same box, remember it has real CPU cost of its own — watch the status strip during an actual net before assuming one machine covers both. And escalation lowers the bar a long way: the big model only handles the lines the fast one was unsure about, so base live with large-v3 on escalation is a comfortable target for hardware that could not run large-v3 on every transmission.

License

GPL-3.0. Copyright (C) 2026 Michelle Michaels. See LICENSE.

The same license as GQRX, GNU Radio, and fldigi, so this composes with the rest of the SDR stack it sits alongside. If you modify it and distribute your version, your changes have to be available under the GPL too.

Limitations

See docs/STATUS.md for the full account, including which settings are still guesses. In short:

  • Voice only (FM/SSB). No CW, no digital modes.
  • One transmission is assumed to be one speaker, which holds for a half-duplex net and not much else.
  • The confidence figure comes from Whisper's avg_logprob. It is a useful relative cue and not a calibrated probability.
  • One Whisper model is shared across receivers, so two busy frequencies serialise rather than transcribe in parallel.
  • Traffic is detected from what was said; whether it was passed is your click, not something the app infers from later transmissions.
  • Voice suggestions and attendance both need history, so the first event with a new roster offers neither. Both improve every time the app runs.
  • Settings changed from the dashboard apply to the running process; saving to config.yaml is a separate click, so an unsaved change is gone at the next restart.

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

68 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Ham Radio Net Speech-to-Text

Live transcription for an amateur radio net. Audio comes in from an SDR, a radio's line output, or a microphone; each transmission is transcribed, the speaker is matched against a roster of known callsigns, and everything lands in a browser dashboard that net control can read from across the table.

Built for a high-traffic event or race net — the kind run from a trailer with several people talking at once, where the question is "what was that last transmission?" and the answer has to be on screen rather than in somebody's memory. On an event net the callsign is also a location: operators are posted around the course, so knowing who transmitted is knowing where it came from — which is why this app would rather log a line as unmatched than attach a plausible wrong callsign to it. It works for an orderly weekly check-in too, but that is the easy case.

Fully offline — no cloud services, nothing leaves the machine. Whisper runs locally.

SDR / radio ──▶ loopback, line in, or mic ──▶ capture ──▶ VAD ──▶ Whisper ──▶ roster match ──▶ dashboard

The dashboard during an event net: each line shows the callsign, the position that station is posted at, and the operator's name, with traffic declarations badged, lines re-transcribed by the second pass marked, and a roster sidebar acting as a who-is-where board

Deliberately an unflattering run: tiny live with base on escalation, so the 2nd pass marks are visible and a couple of lines are left as tiny produced them. base on its own transcribes this recording cleanly — see docs/HARDWARE.md.

Each line carries the callsign, where that station is posted, and the operator's name. On an event net the position is the actionable half: "need medical at my location" is only useful once the line says Mile 8. The sidebar doubles as a who-is-where board and lights up as stations are heard from.

Lines that declared traffic are badged, and the stations holding it are marked in the sidebar — the header count is also a filter. Stations the roster cannot match are flagged amber rather than attached to the nearest plausible callsign — a wrong callsign is a wrong location. Clicking one sets the right station, and the app learns the correction for next time. (Screenshot uses the example roster and synthesised audio from tools/make_test_audio.py.)

Status

Everything downstream of the audio device works and is tested offline: segmentation, transcription, callsign matching, corrections and learning, voice suggestions, multiple receivers, buffering, crash-safe transcripts, the watchdog, export, the container image.

No part of it has run against a real radio yet, and every tuning constant is a reasoned guess rather than a measurement. docs/STATUS.md is the honest account of what is proven, what is not, and what to do next; docs/FIELD-BRINGUP.md is the checklist for the first session at the hardware.

What it does

  • Listens to an SDR loopback, a radio's line output, a microphone, or a linked system over the internet (EchoLink/AllStar) — one receiver or several, each with its own level, VAD settings and health.
  • Transcribes each transmission with Whisper, conditioning the audio and biasing decoding toward the stations most likely to speak. Parakeet TDT is also supported and measured better on real net audio, without needing the bias at all — see HARDWARE.md.
  • Identifies the station: phonetic normalisation, fuzzy roster matching, aliases learned from corrections, and voice recognition for transmissions that carry no callsign at all. It refuses to guess rather than log a wrong callsign.
  • Shows the transcript with each station's position on the course, marks traffic and lets you clear it, and reports its own health continuously.
  • Keeps everything: transcripts written as the net runs and survive a power cut, a session that can be resumed, and exports for the net report.
  • Learns between events — aliases, voices, attendance and thresholds all improve from nets nobody was watching.

Documentation

The README is deliberately short. Each of these is the full account of one thing:

docs/AUDIO-INPUT.mdWhat to plug in — SDR loopback, line in, or mic — and running several receivers at once
docs/ACCURACY.mdChoosing a model, the four things that buy accuracy, and tuning against your own recordings
docs/MATCHING.mdThe roster, how a spoken callsign becomes a match, corrections, and voice identification
docs/OPERATIONS.mdDashboard, settings panel, transcripts on disk, watchdog and alerting, config reference, container
docs/FIELD-BRINGUP.mdFirst run against real hardware, in order, with the failure modes at each step
docs/HARDWARE.mdEngine and model-size benchmarks, and whether anything needs buying (usually not)
docs/ARCHITECTURE.mdHow the pieces fit, the threading model, and why things are the way they are
docs/TESTING.mdTest suites, generating audio without an SDR, and adding a regression
docs/STATUS.mdWhat is proven, what is guessed, and the work worth doing next

Deployment files live in deploy/ (a systemd unit) and at the repo root (Containerfile, docker-compose.yml).

Quick start

python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
cp config.yaml.example config.yaml && cp roster.example.csv roster.csv

Edit roster.csv with the stations you expect, then find the audio device SDR++/GQRX is feeding:

python app.py --list-devices

Put that device name (a substring is enough) in config.yaml under audio.device, and run:

python app.py

Open http://localhost:8080. The first run downloads the Whisper model; after that it works with no network at all.

Where to go next

Three paths, depending on what you have in front of you:

  • No radio yetdocs/ACCURACY.md covers replaying a recording through --file, which exercises everything except audio capture.
  • A radio and an afternoondocs/FIELD-BRINGUP.md starts at getting the receiver producing audio and ends at a real net.
  • Wondering what to run it ondocs/HARDWARE.md. The short answer is that the machine you already have is probably enough.

Before the first real net, the two things worth setting deliberately are the roster (docs/MATCHING.md) and the squelch on the receiver (docs/FIELD-BRINGUP.md) — an open squelch is the single most common way to get a stream of junk lines.

Tests

.venv/bin/python -m pytest

442 tests, all offline, no audio hardware needed — CI runs them on every push across Python 3.11–3.13, plus a job with the optional libraries removed so the Raspberry Pi fallback paths are exercised too. test_callsign_match.py covers the normalizer and matcher, including verbatim Whisper output; test_vad_segmenter.py pins the clip boundaries with scripted speech patterns.

See docs/TESTING.md for the workflow when a net turns up a mis-transcription the matcher does not handle — it is a two-line change plus a test.

What to run it on

The short version, with the benchmarks, specific machines and the buying argument in docs/HARDWARE.md. Start by assuming you need nothing — on the synthetic test net base matches medium on callsign recovery, and runs at a fraction of realtime on an ordinary CPU.

RunsNotes
Used RTX laptopanything, on CUDAThe battery is a UPS, which matters more in a trailer than the extra speed
Jetson Orin Nanoanything, on CUDA7–25 W, for a permanent install. Needs an aarch64 CUDA build of CTranslate2
x86 mini PCbase, small on CPUCheapest reliable path with no GPU
Raspberry Pi 5tiny, baseWorks, no headroom. small only on a quiet net
Raspberry Pi 4tiny, baseDo not target medium

INT8 is already the default on CPU (whisper.compute_type: null).

If SDR++/GQRX runs on the same box, remember it has real CPU cost of its own — watch the status strip during an actual net before assuming one machine covers both. And escalation lowers the bar a long way: the big model only handles the lines the fast one was unsure about, so base live with large-v3 on escalation is a comfortable target for hardware that could not run large-v3 on every transmission.

License

GPL-3.0. Copyright (C) 2026 Michelle Michaels. See LICENSE.

The same license as GQRX, GNU Radio, and fldigi, so this composes with the rest of the SDR stack it sits alongside. If you modify it and distribute your version, your changes have to be available under the GPL too.

Limitations

See docs/STATUS.md for the full account, including which settings are still guesses. In short:

  • Voice only (FM/SSB). No CW, no digital modes.
  • One transmission is assumed to be one speaker, which holds for a half-duplex net and not much else.
  • The confidence figure comes from Whisper's avg_logprob. It is a useful relative cue and not a calibrated probability.
  • One Whisper model is shared across receivers, so two busy frequencies serialise rather than transcribe in parallel.
  • Traffic is detected from what was said; whether it was passed is your click, not something the app infers from later transmissions.
  • Voice suggestions and attendance both need history, so the first event with a new roster offers neither. Both improve every time the app runs.
  • Settings changed from the dashboard apply to the running process; saving to config.yaml is a separate click, so an unsaved change is gone at the next restart.

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

68 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Ham Radio Net Speech-to-Text

Live transcription for an amateur radio net. Audio comes in from an SDR, a radio's line output, or a microphone; each transmission is transcribed, the speaker is matched against a roster of known callsigns, and everything lands in a browser dashboard that net control can read from across the table.

Built for a high-traffic event or race net — the kind run from a trailer with several people talking at once, where the question is "what was that last transmission?" and the answer has to be on screen rather than in somebody's memory. On an event net the callsign is also a location: operators are posted around the course, so knowing who transmitted is knowing where it came from — which is why this app would rather log a line as unmatched than attach a plausible wrong callsign to it. It works for an orderly weekly check-in too, but that is the easy case.

Fully offline — no cloud services, nothing leaves the machine. Whisper runs locally.

SDR / radio ──▶ loopback, line in, or mic ──▶ capture ──▶ VAD ──▶ Whisper ──▶ roster match ──▶ dashboard

The dashboard during an event net: each line shows the callsign, the position that station is posted at, and the operator's name, with traffic declarations badged, lines re-transcribed by the second pass marked, and a roster sidebar acting as a who-is-where board

Deliberately an unflattering run: tiny live with base on escalation, so the 2nd pass marks are visible and a couple of lines are left as tiny produced them. base on its own transcribes this recording cleanly — see docs/HARDWARE.md.

Each line carries the callsign, where that station is posted, and the operator's name. On an event net the position is the actionable half: "need medical at my location" is only useful once the line says Mile 8. The sidebar doubles as a who-is-where board and lights up as stations are heard from.

Lines that declared traffic are badged, and the stations holding it are marked in the sidebar — the header count is also a filter. Stations the roster cannot match are flagged amber rather than attached to the nearest plausible callsign — a wrong callsign is a wrong location. Clicking one sets the right station, and the app learns the correction for next time. (Screenshot uses the example roster and synthesised audio from tools/make_test_audio.py.)

Status

Everything downstream of the audio device works and is tested offline: segmentation, transcription, callsign matching, corrections and learning, voice suggestions, multiple receivers, buffering, crash-safe transcripts, the watchdog, export, the container image.

No part of it has run against a real radio yet, and every tuning constant is a reasoned guess rather than a measurement. docs/STATUS.md is the honest account of what is proven, what is not, and what to do next; docs/FIELD-BRINGUP.md is the checklist for the first session at the hardware.

What it does

  • Listens to an SDR loopback, a radio's line output, a microphone, or a linked system over the internet (EchoLink/AllStar) — one receiver or several, each with its own level, VAD settings and health.
  • Transcribes each transmission with Whisper, conditioning the audio and biasing decoding toward the stations most likely to speak. Parakeet TDT is also supported and measured better on real net audio, without needing the bias at all — see HARDWARE.md.
  • Identifies the station: phonetic normalisation, fuzzy roster matching, aliases learned from corrections, and voice recognition for transmissions that carry no callsign at all. It refuses to guess rather than log a wrong callsign.
  • Shows the transcript with each station's position on the course, marks traffic and lets you clear it, and reports its own health continuously.
  • Keeps everything: transcripts written as the net runs and survive a power cut, a session that can be resumed, and exports for the net report.
  • Learns between events — aliases, voices, attendance and thresholds all improve from nets nobody was watching.

Documentation

The README is deliberately short. Each of these is the full account of one thing:

docs/AUDIO-INPUT.mdWhat to plug in — SDR loopback, line in, or mic — and running several receivers at once
docs/ACCURACY.mdChoosing a model, the four things that buy accuracy, and tuning against your own recordings
docs/MATCHING.mdThe roster, how a spoken callsign becomes a match, corrections, and voice identification
docs/OPERATIONS.mdDashboard, settings panel, transcripts on disk, watchdog and alerting, config reference, container
docs/FIELD-BRINGUP.mdFirst run against real hardware, in order, with the failure modes at each step
docs/HARDWARE.mdEngine and model-size benchmarks, and whether anything needs buying (usually not)
docs/ARCHITECTURE.mdHow the pieces fit, the threading model, and why things are the way they are
docs/TESTING.mdTest suites, generating audio without an SDR, and adding a regression
docs/STATUS.mdWhat is proven, what is guessed, and the work worth doing next

Deployment files live in deploy/ (a systemd unit) and at the repo root (Containerfile, docker-compose.yml).

Quick start

python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
cp config.yaml.example config.yaml && cp roster.example.csv roster.csv

Edit roster.csv with the stations you expect, then find the audio device SDR++/GQRX is feeding:

python app.py --list-devices

Put that device name (a substring is enough) in config.yaml under audio.device, and run:

python app.py

Open http://localhost:8080. The first run downloads the Whisper model; after that it works with no network at all.

Where to go next

Three paths, depending on what you have in front of you:

  • No radio yetdocs/ACCURACY.md covers replaying a recording through --file, which exercises everything except audio capture.
  • A radio and an afternoondocs/FIELD-BRINGUP.md starts at getting the receiver producing audio and ends at a real net.
  • Wondering what to run it ondocs/HARDWARE.md. The short answer is that the machine you already have is probably enough.

Before the first real net, the two things worth setting deliberately are the roster (docs/MATCHING.md) and the squelch on the receiver (docs/FIELD-BRINGUP.md) — an open squelch is the single most common way to get a stream of junk lines.

Tests

.venv/bin/python -m pytest

442 tests, all offline, no audio hardware needed — CI runs them on every push across Python 3.11–3.13, plus a job with the optional libraries removed so the Raspberry Pi fallback paths are exercised too. test_callsign_match.py covers the normalizer and matcher, including verbatim Whisper output; test_vad_segmenter.py pins the clip boundaries with scripted speech patterns.

See docs/TESTING.md for the workflow when a net turns up a mis-transcription the matcher does not handle — it is a two-line change plus a test.

What to run it on

The short version, with the benchmarks, specific machines and the buying argument in docs/HARDWARE.md. Start by assuming you need nothing — on the synthetic test net base matches medium on callsign recovery, and runs at a fraction of realtime on an ordinary CPU.

RunsNotes
Used RTX laptopanything, on CUDAThe battery is a UPS, which matters more in a trailer than the extra speed
Jetson Orin Nanoanything, on CUDA7–25 W, for a permanent install. Needs an aarch64 CUDA build of CTranslate2
x86 mini PCbase, small on CPUCheapest reliable path with no GPU
Raspberry Pi 5tiny, baseWorks, no headroom. small only on a quiet net
Raspberry Pi 4tiny, baseDo not target medium

INT8 is already the default on CPU (whisper.compute_type: null).

If SDR++/GQRX runs on the same box, remember it has real CPU cost of its own — watch the status strip during an actual net before assuming one machine covers both. And escalation lowers the bar a long way: the big model only handles the lines the fast one was unsure about, so base live with large-v3 on escalation is a comfortable target for hardware that could not run large-v3 on every transmission.

License

GPL-3.0. Copyright (C) 2026 Michelle Michaels. See LICENSE.

The same license as GQRX, GNU Radio, and fldigi, so this composes with the rest of the SDR stack it sits alongside. If you modify it and distribute your version, your changes have to be available under the GPL too.

Limitations

See docs/STATUS.md for the full account, including which settings are still guesses. In short:

  • Voice only (FM/SSB). No CW, no digital modes.
  • One transmission is assumed to be one speaker, which holds for a half-duplex net and not much else.
  • The confidence figure comes from Whisper's avg_logprob. It is a useful relative cue and not a calibrated probability.
  • One Whisper model is shared across receivers, so two busy frequencies serialise rather than transcribe in parallel.
  • Traffic is detected from what was said; whether it was passed is your click, not something the app infers from later transmissions.
  • Voice suggestions and attendance both need history, so the first event with a new roster offers neither. Both improve every time the app runs.
  • Settings changed from the dashboard apply to the running process; saving to config.yaml is a separate click, so an unsaved change is gone at the next restart.

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Latest commit

History

68 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Ham Radio Net Speech-to-Text

Live transcription for an amateur radio net. Audio comes in from an SDR, a radio's line output, or a microphone; each transmission is transcribed, the speaker is matched against a roster of known callsigns, and everything lands in a browser dashboard that net control can read from across the table.

Built for a high-traffic event or race net — the kind run from a trailer with several people talking at once, where the question is "what was that last transmission?" and the answer has to be on screen rather than in somebody's memory. On an event net the callsign is also a location: operators are posted around the course, so knowing who transmitted is knowing where it came from — which is why this app would rather log a line as unmatched than attach a plausible wrong callsign to it. It works for an orderly weekly check-in too, but that is the easy case.

Fully offline — no cloud services, nothing leaves the machine. Whisper runs locally.

SDR / radio ──▶ loopback, line in, or mic ──▶ capture ──▶ VAD ──▶ Whisper ──▶ roster match ──▶ dashboard

The dashboard during an event net: each line shows the callsign, the position that station is posted at, and the operator's name, with traffic declarations badged, lines re-transcribed by the second pass marked, and a roster sidebar acting as a who-is-where board

Deliberately an unflattering run: tiny live with base on escalation, so the 2nd pass marks are visible and a couple of lines are left as tiny produced them. base on its own transcribes this recording cleanly — see docs/HARDWARE.md.

Each line carries the callsign, where that station is posted, and the operator's name. On an event net the position is the actionable half: "need medical at my location" is only useful once the line says Mile 8. The sidebar doubles as a who-is-where board and lights up as stations are heard from.

Lines that declared traffic are badged, and the stations holding it are marked in the sidebar — the header count is also a filter. Stations the roster cannot match are flagged amber rather than attached to the nearest plausible callsign — a wrong callsign is a wrong location. Clicking one sets the right station, and the app learns the correction for next time. (Screenshot uses the example roster and synthesised audio from tools/make_test_audio.py.)

Status

Everything downstream of the audio device works and is tested offline: segmentation, transcription, callsign matching, corrections and learning, voice suggestions, multiple receivers, buffering, crash-safe transcripts, the watchdog, export, the container image.

No part of it has run against a real radio yet, and every tuning constant is a reasoned guess rather than a measurement. docs/STATUS.md is the honest account of what is proven, what is not, and what to do next; docs/FIELD-BRINGUP.md is the checklist for the first session at the hardware.

What it does

  • Listens to an SDR loopback, a radio's line output, a microphone, or a linked system over the internet (EchoLink/AllStar) — one receiver or several, each with its own level, VAD settings and health.
  • Transcribes each transmission with Whisper, conditioning the audio and biasing decoding toward the stations most likely to speak. Parakeet TDT is also supported and measured better on real net audio, without needing the bias at all — see HARDWARE.md.
  • Identifies the station: phonetic normalisation, fuzzy roster matching, aliases learned from corrections, and voice recognition for transmissions that carry no callsign at all. It refuses to guess rather than log a wrong callsign.
  • Shows the transcript with each station's position on the course, marks traffic and lets you clear it, and reports its own health continuously.
  • Keeps everything: transcripts written as the net runs and survive a power cut, a session that can be resumed, and exports for the net report.
  • Learns between events — aliases, voices, attendance and thresholds all improve from nets nobody was watching.

Documentation

The README is deliberately short. Each of these is the full account of one thing:

docs/AUDIO-INPUT.mdWhat to plug in — SDR loopback, line in, or mic — and running several receivers at once
docs/ACCURACY.mdChoosing a model, the four things that buy accuracy, and tuning against your own recordings
docs/MATCHING.mdThe roster, how a spoken callsign becomes a match, corrections, and voice identification
docs/OPERATIONS.mdDashboard, settings panel, transcripts on disk, watchdog and alerting, config reference, container
docs/FIELD-BRINGUP.mdFirst run against real hardware, in order, with the failure modes at each step
docs/HARDWARE.mdEngine and model-size benchmarks, and whether anything needs buying (usually not)
docs/ARCHITECTURE.mdHow the pieces fit, the threading model, and why things are the way they are
docs/TESTING.mdTest suites, generating audio without an SDR, and adding a regression
docs/STATUS.mdWhat is proven, what is guessed, and the work worth doing next

Deployment files live in deploy/ (a systemd unit) and at the repo root (Containerfile, docker-compose.yml).

Quick start

python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
cp config.yaml.example config.yaml && cp roster.example.csv roster.csv

Edit roster.csv with the stations you expect, then find the audio device SDR++/GQRX is feeding:

python app.py --list-devices

Put that device name (a substring is enough) in config.yaml under audio.device, and run:

python app.py

Open http://localhost:8080. The first run downloads the Whisper model; after that it works with no network at all.

Where to go next

Three paths, depending on what you have in front of you:

  • No radio yetdocs/ACCURACY.md covers replaying a recording through --file, which exercises everything except audio capture.
  • A radio and an afternoondocs/FIELD-BRINGUP.md starts at getting the receiver producing audio and ends at a real net.
  • Wondering what to run it ondocs/HARDWARE.md. The short answer is that the machine you already have is probably enough.

Before the first real net, the two things worth setting deliberately are the roster (docs/MATCHING.md) and the squelch on the receiver (docs/FIELD-BRINGUP.md) — an open squelch is the single most common way to get a stream of junk lines.

Tests

.venv/bin/python -m pytest

442 tests, all offline, no audio hardware needed — CI runs them on every push across Python 3.11–3.13, plus a job with the optional libraries removed so the Raspberry Pi fallback paths are exercised too. test_callsign_match.py covers the normalizer and matcher, including verbatim Whisper output; test_vad_segmenter.py pins the clip boundaries with scripted speech patterns.

See docs/TESTING.md for the workflow when a net turns up a mis-transcription the matcher does not handle — it is a two-line change plus a test.

What to run it on

The short version, with the benchmarks, specific machines and the buying argument in docs/HARDWARE.md. Start by assuming you need nothing — on the synthetic test net base matches medium on callsign recovery, and runs at a fraction of realtime on an ordinary CPU.

RunsNotes
Used RTX laptopanything, on CUDAThe battery is a UPS, which matters more in a trailer than the extra speed
Jetson Orin Nanoanything, on CUDA7–25 W, for a permanent install. Needs an aarch64 CUDA build of CTranslate2
x86 mini PCbase, small on CPUCheapest reliable path with no GPU
Raspberry Pi 5tiny, baseWorks, no headroom. small only on a quiet net
Raspberry Pi 4tiny, baseDo not target medium

INT8 is already the default on CPU (whisper.compute_type: null).

If SDR++/GQRX runs on the same box, remember it has real CPU cost of its own — watch the status strip during an actual net before assuming one machine covers both. And escalation lowers the bar a long way: the big model only handles the lines the fast one was unsure about, so base live with large-v3 on escalation is a comfortable target for hardware that could not run large-v3 on every transmission.

License

GPL-3.0. Copyright (C) 2026 Michelle Michaels. See LICENSE.

The same license as GQRX, GNU Radio, and fldigi, so this composes with the rest of the SDR stack it sits alongside. If you modify it and distribute your version, your changes have to be available under the GPL too.

Limitations

See docs/STATUS.md for the full account, including which settings are still guesses. In short:

  • Voice only (FM/SSB). No CW, no digital modes.
  • One transmission is assumed to be one speaker, which holds for a half-duplex net and not much else.
  • The confidence figure comes from Whisper's avg_logprob. It is a useful relative cue and not a calibrated probability.
  • One Whisper model is shared across receivers, so two busy frequencies serialise rather than transcribe in parallel.
  • Traffic is detected from what was said; whether it was passed is your click, not something the app infers from later transmissions.
  • Voice suggestions and attendance both need history, so the first event with a new roster offers neither. Both improve every time the app runs.
  • Settings changed from the dashboard apply to the running process; saving to config.yaml is a separate click, so an unsaved change is gone at the next restart.

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

68 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Ham Radio Net Speech-to-Text

Live transcription for an amateur radio net. Audio comes in from an SDR, a radio's line output, or a microphone; each transmission is transcribed, the speaker is matched against a roster of known callsigns, and everything lands in a browser dashboard that net control can read from across the table.

Built for a high-traffic event or race net — the kind run from a trailer with several people talking at once, where the question is "what was that last transmission?" and the answer has to be on screen rather than in somebody's memory. On an event net the callsign is also a location: operators are posted around the course, so knowing who transmitted is knowing where it came from — which is why this app would rather log a line as unmatched than attach a plausible wrong callsign to it. It works for an orderly weekly check-in too, but that is the easy case.

Fully offline — no cloud services, nothing leaves the machine. Whisper runs locally.

SDR / radio ──▶ loopback, line in, or mic ──▶ capture ──▶ VAD ──▶ Whisper ──▶ roster match ──▶ dashboard

The dashboard during an event net: each line shows the callsign, the position that station is posted at, and the operator's name, with traffic declarations badged, lines re-transcribed by the second pass marked, and a roster sidebar acting as a who-is-where board

Deliberately an unflattering run: tiny live with base on escalation, so the 2nd pass marks are visible and a couple of lines are left as tiny produced them. base on its own transcribes this recording cleanly — see docs/HARDWARE.md.

Each line carries the callsign, where that station is posted, and the operator's name. On an event net the position is the actionable half: "need medical at my location" is only useful once the line says Mile 8. The sidebar doubles as a who-is-where board and lights up as stations are heard from.

Lines that declared traffic are badged, and the stations holding it are marked in the sidebar — the header count is also a filter. Stations the roster cannot match are flagged amber rather than attached to the nearest plausible callsign — a wrong callsign is a wrong location. Clicking one sets the right station, and the app learns the correction for next time. (Screenshot uses the example roster and synthesised audio from tools/make_test_audio.py.)

Status

Everything downstream of the audio device works and is tested offline: segmentation, transcription, callsign matching, corrections and learning, voice suggestions, multiple receivers, buffering, crash-safe transcripts, the watchdog, export, the container image.

No part of it has run against a real radio yet, and every tuning constant is a reasoned guess rather than a measurement. docs/STATUS.md is the honest account of what is proven, what is not, and what to do next; docs/FIELD-BRINGUP.md is the checklist for the first session at the hardware.

What it does

  • Listens to an SDR loopback, a radio's line output, a microphone, or a linked system over the internet (EchoLink/AllStar) — one receiver or several, each with its own level, VAD settings and health.
  • Transcribes each transmission with Whisper, conditioning the audio and biasing decoding toward the stations most likely to speak. Parakeet TDT is also supported and measured better on real net audio, without needing the bias at all — see HARDWARE.md.
  • Identifies the station: phonetic normalisation, fuzzy roster matching, aliases learned from corrections, and voice recognition for transmissions that carry no callsign at all. It refuses to guess rather than log a wrong callsign.
  • Shows the transcript with each station's position on the course, marks traffic and lets you clear it, and reports its own health continuously.
  • Keeps everything: transcripts written as the net runs and survive a power cut, a session that can be resumed, and exports for the net report.
  • Learns between events — aliases, voices, attendance and thresholds all improve from nets nobody was watching.

Documentation

The README is deliberately short. Each of these is the full account of one thing:

docs/AUDIO-INPUT.mdWhat to plug in — SDR loopback, line in, or mic — and running several receivers at once
docs/ACCURACY.mdChoosing a model, the four things that buy accuracy, and tuning against your own recordings
docs/MATCHING.mdThe roster, how a spoken callsign becomes a match, corrections, and voice identification
docs/OPERATIONS.mdDashboard, settings panel, transcripts on disk, watchdog and alerting, config reference, container
docs/FIELD-BRINGUP.mdFirst run against real hardware, in order, with the failure modes at each step
docs/HARDWARE.mdEngine and model-size benchmarks, and whether anything needs buying (usually not)
docs/ARCHITECTURE.mdHow the pieces fit, the threading model, and why things are the way they are
docs/TESTING.mdTest suites, generating audio without an SDR, and adding a regression
docs/STATUS.mdWhat is proven, what is guessed, and the work worth doing next

Deployment files live in deploy/ (a systemd unit) and at the repo root (Containerfile, docker-compose.yml).

Quick start

python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
cp config.yaml.example config.yaml && cp roster.example.csv roster.csv

Edit roster.csv with the stations you expect, then find the audio device SDR++/GQRX is feeding:

python app.py --list-devices

Put that device name (a substring is enough) in config.yaml under audio.device, and run:

python app.py

Open http://localhost:8080. The first run downloads the Whisper model; after that it works with no network at all.

Where to go next

Three paths, depending on what you have in front of you:

  • No radio yetdocs/ACCURACY.md covers replaying a recording through --file, which exercises everything except audio capture.
  • A radio and an afternoondocs/FIELD-BRINGUP.md starts at getting the receiver producing audio and ends at a real net.
  • Wondering what to run it ondocs/HARDWARE.md. The short answer is that the machine you already have is probably enough.

Before the first real net, the two things worth setting deliberately are the roster (docs/MATCHING.md) and the squelch on the receiver (docs/FIELD-BRINGUP.md) — an open squelch is the single most common way to get a stream of junk lines.

Tests

.venv/bin/python -m pytest

442 tests, all offline, no audio hardware needed — CI runs them on every push across Python 3.11–3.13, plus a job with the optional libraries removed so the Raspberry Pi fallback paths are exercised too. test_callsign_match.py covers the normalizer and matcher, including verbatim Whisper output; test_vad_segmenter.py pins the clip boundaries with scripted speech patterns.

See docs/TESTING.md for the workflow when a net turns up a mis-transcription the matcher does not handle — it is a two-line change plus a test.

What to run it on

The short version, with the benchmarks, specific machines and the buying argument in docs/HARDWARE.md. Start by assuming you need nothing — on the synthetic test net base matches medium on callsign recovery, and runs at a fraction of realtime on an ordinary CPU.

RunsNotes
Used RTX laptopanything, on CUDAThe battery is a UPS, which matters more in a trailer than the extra speed
Jetson Orin Nanoanything, on CUDA7–25 W, for a permanent install. Needs an aarch64 CUDA build of CTranslate2
x86 mini PCbase, small on CPUCheapest reliable path with no GPU
Raspberry Pi 5tiny, baseWorks, no headroom. small only on a quiet net
Raspberry Pi 4tiny, baseDo not target medium

INT8 is already the default on CPU (whisper.compute_type: null).

If SDR++/GQRX runs on the same box, remember it has real CPU cost of its own — watch the status strip during an actual net before assuming one machine covers both. And escalation lowers the bar a long way: the big model only handles the lines the fast one was unsure about, so base live with large-v3 on escalation is a comfortable target for hardware that could not run large-v3 on every transmission.

License

GPL-3.0. Copyright (C) 2026 Michelle Michaels. See LICENSE.

The same license as GQRX, GNU Radio, and fldigi, so this composes with the rest of the SDR stack it sits alongside. If you modify it and distribute your version, your changes have to be available under the GPL too.

Limitations

See docs/STATUS.md for the full account, including which settings are still guesses. In short:

  • Voice only (FM/SSB). No CW, no digital modes.
  • One transmission is assumed to be one speaker, which holds for a half-duplex net and not much else.
  • The confidence figure comes from Whisper's avg_logprob. It is a useful relative cue and not a calibrated probability.
  • One Whisper model is shared across receivers, so two busy frequencies serialise rather than transcribe in parallel.
  • Traffic is detected from what was said; whether it was passed is your click, not something the app infers from later transmissions.
  • Voice suggestions and attendance both need history, so the first event with a new roster offers neither. Both improve every time the app runs.
  • Settings changed from the dashboard apply to the running process; saving to config.yaml is a separate click, so an unsaved change is gone at the next restart.

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Latest commit

History

68 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Ham Radio Net Speech-to-Text

Live transcription for an amateur radio net. Audio comes in from an SDR, a radio's line output, or a microphone; each transmission is transcribed, the speaker is matched against a roster of known callsigns, and everything lands in a browser dashboard that net control can read from across the table.

Built for a high-traffic event or race net — the kind run from a trailer with several people talking at once, where the question is "what was that last transmission?" and the answer has to be on screen rather than in somebody's memory. On an event net the callsign is also a location: operators are posted around the course, so knowing who transmitted is knowing where it came from — which is why this app would rather log a line as unmatched than attach a plausible wrong callsign to it. It works for an orderly weekly check-in too, but that is the easy case.

Fully offline — no cloud services, nothing leaves the machine. Whisper runs locally.

SDR / radio ──▶ loopback, line in, or mic ──▶ capture ──▶ VAD ──▶ Whisper ──▶ roster match ──▶ dashboard

The dashboard during an event net: each line shows the callsign, the position that station is posted at, and the operator's name, with traffic declarations badged, lines re-transcribed by the second pass marked, and a roster sidebar acting as a who-is-where board

Deliberately an unflattering run: tiny live with base on escalation, so the 2nd pass marks are visible and a couple of lines are left as tiny produced them. base on its own transcribes this recording cleanly — see docs/HARDWARE.md.

Each line carries the callsign, where that station is posted, and the operator's name. On an event net the position is the actionable half: "need medical at my location" is only useful once the line says Mile 8. The sidebar doubles as a who-is-where board and lights up as stations are heard from.

Lines that declared traffic are badged, and the stations holding it are marked in the sidebar — the header count is also a filter. Stations the roster cannot match are flagged amber rather than attached to the nearest plausible callsign — a wrong callsign is a wrong location. Clicking one sets the right station, and the app learns the correction for next time. (Screenshot uses the example roster and synthesised audio from tools/make_test_audio.py.)

Status

Everything downstream of the audio device works and is tested offline: segmentation, transcription, callsign matching, corrections and learning, voice suggestions, multiple receivers, buffering, crash-safe transcripts, the watchdog, export, the container image.

No part of it has run against a real radio yet, and every tuning constant is a reasoned guess rather than a measurement. docs/STATUS.md is the honest account of what is proven, what is not, and what to do next; docs/FIELD-BRINGUP.md is the checklist for the first session at the hardware.

What it does

  • Listens to an SDR loopback, a radio's line output, a microphone, or a linked system over the internet (EchoLink/AllStar) — one receiver or several, each with its own level, VAD settings and health.
  • Transcribes each transmission with Whisper, conditioning the audio and biasing decoding toward the stations most likely to speak. Parakeet TDT is also supported and measured better on real net audio, without needing the bias at all — see HARDWARE.md.
  • Identifies the station: phonetic normalisation, fuzzy roster matching, aliases learned from corrections, and voice recognition for transmissions that carry no callsign at all. It refuses to guess rather than log a wrong callsign.
  • Shows the transcript with each station's position on the course, marks traffic and lets you clear it, and reports its own health continuously.
  • Keeps everything: transcripts written as the net runs and survive a power cut, a session that can be resumed, and exports for the net report.
  • Learns between events — aliases, voices, attendance and thresholds all improve from nets nobody was watching.

Documentation

The README is deliberately short. Each of these is the full account of one thing:

docs/AUDIO-INPUT.mdWhat to plug in — SDR loopback, line in, or mic — and running several receivers at once
docs/ACCURACY.mdChoosing a model, the four things that buy accuracy, and tuning against your own recordings
docs/MATCHING.mdThe roster, how a spoken callsign becomes a match, corrections, and voice identification
docs/OPERATIONS.mdDashboard, settings panel, transcripts on disk, watchdog and alerting, config reference, container
docs/FIELD-BRINGUP.mdFirst run against real hardware, in order, with the failure modes at each step
docs/HARDWARE.mdEngine and model-size benchmarks, and whether anything needs buying (usually not)
docs/ARCHITECTURE.mdHow the pieces fit, the threading model, and why things are the way they are
docs/TESTING.mdTest suites, generating audio without an SDR, and adding a regression
docs/STATUS.mdWhat is proven, what is guessed, and the work worth doing next

Deployment files live in deploy/ (a systemd unit) and at the repo root (Containerfile, docker-compose.yml).

Quick start

python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
cp config.yaml.example config.yaml && cp roster.example.csv roster.csv

Edit roster.csv with the stations you expect, then find the audio device SDR++/GQRX is feeding:

python app.py --list-devices

Put that device name (a substring is enough) in config.yaml under audio.device, and run:

python app.py

Open http://localhost:8080. The first run downloads the Whisper model; after that it works with no network at all.

Where to go next

Three paths, depending on what you have in front of you:

  • No radio yetdocs/ACCURACY.md covers replaying a recording through --file, which exercises everything except audio capture.
  • A radio and an afternoondocs/FIELD-BRINGUP.md starts at getting the receiver producing audio and ends at a real net.
  • Wondering what to run it ondocs/HARDWARE.md. The short answer is that the machine you already have is probably enough.

Before the first real net, the two things worth setting deliberately are the roster (docs/MATCHING.md) and the squelch on the receiver (docs/FIELD-BRINGUP.md) — an open squelch is the single most common way to get a stream of junk lines.

Tests

.venv/bin/python -m pytest

442 tests, all offline, no audio hardware needed — CI runs them on every push across Python 3.11–3.13, plus a job with the optional libraries removed so the Raspberry Pi fallback paths are exercised too. test_callsign_match.py covers the normalizer and matcher, including verbatim Whisper output; test_vad_segmenter.py pins the clip boundaries with scripted speech patterns.

See docs/TESTING.md for the workflow when a net turns up a mis-transcription the matcher does not handle — it is a two-line change plus a test.

What to run it on

The short version, with the benchmarks, specific machines and the buying argument in docs/HARDWARE.md. Start by assuming you need nothing — on the synthetic test net base matches medium on callsign recovery, and runs at a fraction of realtime on an ordinary CPU.

RunsNotes
Used RTX laptopanything, on CUDAThe battery is a UPS, which matters more in a trailer than the extra speed
Jetson Orin Nanoanything, on CUDA7–25 W, for a permanent install. Needs an aarch64 CUDA build of CTranslate2
x86 mini PCbase, small on CPUCheapest reliable path with no GPU
Raspberry Pi 5tiny, baseWorks, no headroom. small only on a quiet net
Raspberry Pi 4tiny, baseDo not target medium

INT8 is already the default on CPU (whisper.compute_type: null).

If SDR++/GQRX runs on the same box, remember it has real CPU cost of its own — watch the status strip during an actual net before assuming one machine covers both. And escalation lowers the bar a long way: the big model only handles the lines the fast one was unsure about, so base live with large-v3 on escalation is a comfortable target for hardware that could not run large-v3 on every transmission.

License

GPL-3.0. Copyright (C) 2026 Michelle Michaels. See LICENSE.

The same license as GQRX, GNU Radio, and fldigi, so this composes with the rest of the SDR stack it sits alongside. If you modify it and distribute your version, your changes have to be available under the GPL too.

Limitations

See docs/STATUS.md for the full account, including which settings are still guesses. In short:

  • Voice only (FM/SSB). No CW, no digital modes.
  • One transmission is assumed to be one speaker, which holds for a half-duplex net and not much else.
  • The confidence figure comes from Whisper's avg_logprob. It is a useful relative cue and not a calibrated probability.
  • One Whisper model is shared across receivers, so two busy frequencies serialise rather than transcribe in parallel.
  • Traffic is detected from what was said; whether it was passed is your click, not something the app infers from later transmissions.
  • Voice suggestions and attendance both need history, so the first event with a new roster offers neither. Both improve every time the app runs.
  • Settings changed from the dashboard apply to the running process; saving to config.yaml is a separate click, so an unsaved change is gone at the next restart.

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Latest commit

History

68 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Ham Radio Net Speech-to-Text

Live transcription for an amateur radio net. Audio comes in from an SDR, a radio's line output, or a microphone; each transmission is transcribed, the speaker is matched against a roster of known callsigns, and everything lands in a browser dashboard that net control can read from across the table.

Built for a high-traffic event or race net — the kind run from a trailer with several people talking at once, where the question is "what was that last transmission?" and the answer has to be on screen rather than in somebody's memory. On an event net the callsign is also a location: operators are posted around the course, so knowing who transmitted is knowing where it came from — which is why this app would rather log a line as unmatched than attach a plausible wrong callsign to it. It works for an orderly weekly check-in too, but that is the easy case.

Fully offline — no cloud services, nothing leaves the machine. Whisper runs locally.

SDR / radio ──▶ loopback, line in, or mic ──▶ capture ──▶ VAD ──▶ Whisper ──▶ roster match ──▶ dashboard

The dashboard during an event net: each line shows the callsign, the position that station is posted at, and the operator's name, with traffic declarations badged, lines re-transcribed by the second pass marked, and a roster sidebar acting as a who-is-where board

Deliberately an unflattering run: tiny live with base on escalation, so the 2nd pass marks are visible and a couple of lines are left as tiny produced them. base on its own transcribes this recording cleanly — see docs/HARDWARE.md.

Each line carries the callsign, where that station is posted, and the operator's name. On an event net the position is the actionable half: "need medical at my location" is only useful once the line says Mile 8. The sidebar doubles as a who-is-where board and lights up as stations are heard from.

Lines that declared traffic are badged, and the stations holding it are marked in the sidebar — the header count is also a filter. Stations the roster cannot match are flagged amber rather than attached to the nearest plausible callsign — a wrong callsign is a wrong location. Clicking one sets the right station, and the app learns the correction for next time. (Screenshot uses the example roster and synthesised audio from tools/make_test_audio.py.)

Status

Everything downstream of the audio device works and is tested offline: segmentation, transcription, callsign matching, corrections and learning, voice suggestions, multiple receivers, buffering, crash-safe transcripts, the watchdog, export, the container image.

No part of it has run against a real radio yet, and every tuning constant is a reasoned guess rather than a measurement. docs/STATUS.md is the honest account of what is proven, what is not, and what to do next; docs/FIELD-BRINGUP.md is the checklist for the first session at the hardware.

What it does

  • Listens to an SDR loopback, a radio's line output, a microphone, or a linked system over the internet (EchoLink/AllStar) — one receiver or several, each with its own level, VAD settings and health.
  • Transcribes each transmission with Whisper, conditioning the audio and biasing decoding toward the stations most likely to speak. Parakeet TDT is also supported and measured better on real net audio, without needing the bias at all — see HARDWARE.md.
  • Identifies the station: phonetic normalisation, fuzzy roster matching, aliases learned from corrections, and voice recognition for transmissions that carry no callsign at all. It refuses to guess rather than log a wrong callsign.
  • Shows the transcript with each station's position on the course, marks traffic and lets you clear it, and reports its own health continuously.
  • Keeps everything: transcripts written as the net runs and survive a power cut, a session that can be resumed, and exports for the net report.
  • Learns between events — aliases, voices, attendance and thresholds all improve from nets nobody was watching.

Documentation

The README is deliberately short. Each of these is the full account of one thing:

docs/AUDIO-INPUT.mdWhat to plug in — SDR loopback, line in, or mic — and running several receivers at once
docs/ACCURACY.mdChoosing a model, the four things that buy accuracy, and tuning against your own recordings
docs/MATCHING.mdThe roster, how a spoken callsign becomes a match, corrections, and voice identification
docs/OPERATIONS.mdDashboard, settings panel, transcripts on disk, watchdog and alerting, config reference, container
docs/FIELD-BRINGUP.mdFirst run against real hardware, in order, with the failure modes at each step
docs/HARDWARE.mdEngine and model-size benchmarks, and whether anything needs buying (usually not)
docs/ARCHITECTURE.mdHow the pieces fit, the threading model, and why things are the way they are
docs/TESTING.mdTest suites, generating audio without an SDR, and adding a regression
docs/STATUS.mdWhat is proven, what is guessed, and the work worth doing next

Deployment files live in deploy/ (a systemd unit) and at the repo root (Containerfile, docker-compose.yml).

Quick start

python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
cp config.yaml.example config.yaml && cp roster.example.csv roster.csv

Edit roster.csv with the stations you expect, then find the audio device SDR++/GQRX is feeding:

python app.py --list-devices

Put that device name (a substring is enough) in config.yaml under audio.device, and run:

python app.py

Open http://localhost:8080. The first run downloads the Whisper model; after that it works with no network at all.

Where to go next

Three paths, depending on what you have in front of you:

  • No radio yetdocs/ACCURACY.md covers replaying a recording through --file, which exercises everything except audio capture.
  • A radio and an afternoondocs/FIELD-BRINGUP.md starts at getting the receiver producing audio and ends at a real net.
  • Wondering what to run it ondocs/HARDWARE.md. The short answer is that the machine you already have is probably enough.

Before the first real net, the two things worth setting deliberately are the roster (docs/MATCHING.md) and the squelch on the receiver (docs/FIELD-BRINGUP.md) — an open squelch is the single most common way to get a stream of junk lines.

Tests

.venv/bin/python -m pytest

442 tests, all offline, no audio hardware needed — CI runs them on every push across Python 3.11–3.13, plus a job with the optional libraries removed so the Raspberry Pi fallback paths are exercised too. test_callsign_match.py covers the normalizer and matcher, including verbatim Whisper output; test_vad_segmenter.py pins the clip boundaries with scripted speech patterns.

See docs/TESTING.md for the workflow when a net turns up a mis-transcription the matcher does not handle — it is a two-line change plus a test.

What to run it on

The short version, with the benchmarks, specific machines and the buying argument in docs/HARDWARE.md. Start by assuming you need nothing — on the synthetic test net base matches medium on callsign recovery, and runs at a fraction of realtime on an ordinary CPU.

RunsNotes
Used RTX laptopanything, on CUDAThe battery is a UPS, which matters more in a trailer than the extra speed
Jetson Orin Nanoanything, on CUDA7–25 W, for a permanent install. Needs an aarch64 CUDA build of CTranslate2
x86 mini PCbase, small on CPUCheapest reliable path with no GPU
Raspberry Pi 5tiny, baseWorks, no headroom. small only on a quiet net
Raspberry Pi 4tiny, baseDo not target medium

INT8 is already the default on CPU (whisper.compute_type: null).

If SDR++/GQRX runs on the same box, remember it has real CPU cost of its own — watch the status strip during an actual net before assuming one machine covers both. And escalation lowers the bar a long way: the big model only handles the lines the fast one was unsure about, so base live with large-v3 on escalation is a comfortable target for hardware that could not run large-v3 on every transmission.

License

GPL-3.0. Copyright (C) 2026 Michelle Michaels. See LICENSE.

The same license as GQRX, GNU Radio, and fldigi, so this composes with the rest of the SDR stack it sits alongside. If you modify it and distribute your version, your changes have to be available under the GPL too.

Limitations

See docs/STATUS.md for the full account, including which settings are still guesses. In short:

  • Voice only (FM/SSB). No CW, no digital modes.
  • One transmission is assumed to be one speaker, which holds for a half-duplex net and not much else.
  • The confidence figure comes from Whisper's avg_logprob. It is a useful relative cue and not a calibrated probability.
  • One Whisper model is shared across receivers, so two busy frequencies serialise rather than transcribe in parallel.
  • Traffic is detected from what was said; whether it was passed is your click, not something the app infers from later transmissions.
  • Voice suggestions and attendance both need history, so the first event with a new roster offers neither. Both improve every time the app runs.
  • Settings changed from the dashboard apply to the running process; saving to config.yaml is a separate click, so an unsaved change is gone at the next restart.

Releases

Packages

Contributors

Languages