CW-Demodulator als GNU-Radio-Flowgraph

Von der Anforderungsliste zur lauffähigen Blockverschaltung — Hüllkurve, adaptiver Rauschflur, Element-Timing und Morsecode-Decodierung in GNU Radio Companion.

Dieser Artikel setzt die in Stufe 1 abgeleiteten Anforderungen als lauffähigen GNU-Radio-Flowgraph um: schmalbandige, an die Sendegeschwindigkeit gekoppelte Filterung, adaptiver Schwellwert statt Festwert, und eine Element-/Zeichenerkennung nach dem 1:3:7-Timing. Die Architektur der Rauschflur-Schätzung und Timing-Klassifikation orientiert sich an MorseDemod aus dem Open-Source-Projekt SDRangel (dort für VOR/ILS-Kennungen eingesetzt) — als GNU-Radio-Blockverschaltung neu umgesetzt und gegen eigene Testsignale verifiziert, nicht Zeile für Zeile portiert.

Testsignale

Da für dieses Projekt noch keine echte CW-Aufnahme vorliegt, erzeugt ein eigenes Python-Skript (gen_cw_testsignal.py) synthetische Testsignale mit bekanntem Klartext als Ground Truth. Das Skript ist generisch parametrisiert (Text, WPM, Tonhöhe, Flankenform, Rauschpegel) und nicht auf einen einzelnen Testfall zugeschnitten — für die Verifikation wurden drei unterschiedliche Signale erzeugt:

Testsignal Text WPM Tonhöhe SNR
cw_test_20wpm.wav CQ CQ DE DL1ABC TEST 20 700 Hz 40 dB
cw_test_35wpm.wav CQ CQ DE DL1ABC TEST 35 600 Hz 40 dB
cw_test_20wpm_noisy.wav VVV DE DL1ABC K 20 700 Hz 3 dB

Die Flanken sind, entsprechend Stufe 1, Abschnitt 3, mit einer 5-ms-Raised-Cosine-Rampe geformt statt rechteckig — sonst würde das Testsignal selbst schon unrealistische Keyclicks enthalten. Auch das „saubere" 40-dB-Signal hat einen kleinen, aber realen Rauschflur ungleich Null: Ein Testsignal mit exakt null Rauschen ist unrealistisch und bringt (wie sich beim Testen zeigte, siehe unten) den Rauschflur-Schätzer aus dem Tritt, weil er dann keinen von null verschiedenen Referenzwert mehr findet.

Diese Testsignale sind synthetisch und dienen der Verifikation der Blockverschaltung selbst. Mit einer echten Aufnahme (Empfänger-Audio, KiwiSDR o. ä.) müssten Bandbreite und Schwellwerte gegen das tatsächliche Rauschverhalten nachgemessen werden — die hier gezeigten Werte sind kein Versprechen für beliebige Live-Signale.

Verarbeitungskette

Blockschaltbild: WAV Source, Throttle, Bandpassfilter, Hilbert, Complex-to-Mag, Noise-Floor Normalizer, Threshold, Element Tagger, CW Decoder, Message Debug

Grün hinterlegte Blöcke sind eigene Python-Blöcke (epy_block), blau hinterlegte sind eingebaute GNU-Radio-Blöcke.

1. Band Pass Filter

Warum: Isoliert den BFO-Ton aus dem Audiosignal und unterdrückt Rauschen/Störer außerhalb der Nutzbandbreite — die in Stufe 1, Abschnitt 4 geforderte Kopplung von Filterbandbreite an die Sendegeschwindigkeit.

Dimensionierung: cw_bw = max(4·WPM, 100 Hz), zentriert auf pitch (Standard 700 Hz). Die dort zitierte QEX-Faustregel (≈1,5×WPM) ist für die Höreignung eines Menschen optimiert, nicht für die automatische Flankenerkennung: Ein derart schmales FIR-Filter hat eine Einschwingzeit, die bei 20 WPM in der Größenordnung der Punktlänge selbst liegt (gemessen: bei 1,5×WPM-Bandbreite verschmiert das Filter einzelne Punkte komplett ineinander, siehe Prüfweg unten) — die Elementgrenzen wären für den Tagger nicht mehr unterscheidbar. 4·WPM ist der in diesem Projekt empirisch ermittelte Kompromiss: schmal genug, um Störer zu unterdrücken, breit genug, um Flanken sauber zu erhalten.

Erwartung: Am Filterausgang sollte nur noch der CW-Ton (plus Restrauschen) übrig sein, mit einer Anstiegs-/Abfallzeit, die deutlich kürzer als die Punktlänge ist.

Prüfweg: Mit cw_bw = 1,5·WPM (30 Hz bei 20 WPM) decodierte die Kette ausschließlich Fragezeichen — die Hüllkurve erreichte zwischen zwei kurzen Punkten nie mehr die Nulllinie. Mit cw_bw = 4·WPM (100 Hz bei 20 WPM) ist die Hüllkurve klar erkennbar (siehe Plot unten).

2. Hilbert + Complex to Mag

Warum: Erzeugt aus dem gefilterten reellen Audiosignal ein analytisches (komplexes) Signal, dessen Betrag die Hüllkurve liefert — ripple-frei, im Gegensatz zur reinen Gleichrichtung eines reellen Tons (siehe dieselbe Problematik im DCF77-Projekt).

Dimensionierung: 65-Tap-Hilbert-Transformator mit Hamming-Fenster (Standardwerte des GRC-Blocks).

Erwartung: Eine glatte Hüllkurve zwischen 0 (Pause) und einem stabilen Plateau (Mark), ohne die doppelte Trägerfrequenz-Welligkeit einer einfachen Gleichrichtung.

Prüfweg: Siehe Envelope-Plot unten — das Plateau liegt stabil bei ≈0,88–0,92, die Pausen fallen auf ≈0,0005.

3. CW Noise-Floor Normalizer (epy_block)

Warum: Ein fester Schwellwert versagt, sobald sich der Signalpegel ändert (Fading, unterschiedliche Sendeleistungen — Anforderung aus Stufe 1, Abschnitt 6). Dieser Block bildet stattdessen Ratio = geglättete Hüllkurve / adaptiver Rauschflur.

Dimensionierung: Die Hüllkurve wird über ein gleitendes Mittel der Länge dot_samples/3 geglättet (kantenerhaltend, im Gegensatz zu einem Tiefpass). Der Rauschflur ist das Minimum der Maxima von 20 aufeinanderfolgenden Zeitfenstern (dot_samples/4 lang) — dieselbe Idee wie in SDRangels MorseDemod: Solange innerhalb der Fensterhistorie mindestens eine echte Pause vorkommt, bleibt der Rauschflur realistisch, auch während längerer Mark-Phasen.

Erwartung: Ratio ≈ 1 während echten Pausen, Ratio ≫ 1 während eines Marks.

Prüfweg: Gemessen (20 WPM, sauberes Signal): Ratio-Maximum 1745, Median über das gesamte Signal 92 (dominiert von den Mark-Phasen, die deutlich länger als 1 sind). Mit einem Testsignal ganz ohne Rauschen (SNR = ∞) brach das Verfahren zusammen: Ohne jedes Hintergrundrauschen wird der Rauschflur numerisch exakt Null, und die Ratio schoss auf über 9·10⁸ — ein Divide-by-Near-Zero-Artefakt, der mit realen (immer verrauschten) Empfängersignalen nicht auftritt, aber bei der Testsignalerzeugung zu einem künstlichen Mindest-SNR von 40 dB als Default geführt hat (siehe oben).

Envelope (oben) und Ratio-Signal (unten) für einen Ausschnitt des 20-WPM-Testsignals ("CQ C…"). Gestrichelte Linien markieren die Hysterese-Schwellwerte des nächsten Blocks. Deutlich sichtbar: Die Ratio bleibt während der kurzen Element-Pausen bei ≈1–2, schießt während jedes Marks weit über die Schwellwerte hinaus.

Hüllkurve und Ratio-Signal mit Hysterese-Schwellwerten

4. Threshold (Hysterese)

Warum: Wandelt das analoge Ratio-Signal in ein sauberes binäres Mark/Space-Signal um — ein Schmitt-Trigger mit Hysterese verhindert, dass Rauschen um einen einzelnen Schwellwert herum mehrfach triggert (dieselbe Technik wie der Schmitt-Trigger im DCF77-Projekt, hier aber als eingebauter blocks_threshold_ff statt in einem eigenen Block).

Dimensionierung: low = 3.0, high = 6.0. Diese Werte wurden nicht theoretisch hergeleitet, sondern durch systematisches Durchprobieren gegen das 20-WPM-Testsignal gefunden (siehe Prüfweg).

Erwartung: Stabiles Binärsignal ohne Chattern (schnelles Hin-und-Her-Springen) an den Flanken.

Prüfweg: Mit einer engen Hysterese (low=1.3, high=2.2, angelehnt an die reine Verdopplung des Rauschflurs) chatterte das Binärsignal während echter Pausen sichtbar zwischen 0 und 1 (Ratio schwankte dort natürlich zwischen ≈0,4 und ≈4 — reines Rauschen im geglätteten Signal), was der Element Tagger fälschlich als zusätzliche kurze Marks interpretierte. Ein systematischer Test über low ∈ {2, 2.5, 3, 4}, high ∈ {4, 5, 6, 8} ergab mehrere funktionierende Kombinationen; low=3.0/high=6.0 liegt mittig im funktionierenden Bereich und wurde übernommen.

5. CW Element Tagger (epy_block)

Warum: Klassifiziert Mark- und Pausendauern relativ zur aus WPM abgeleiteten Punktlänge — direkte Umsetzung des 1:3:7-Timings aus Stufe 1, Abschnitt 2.

Dimensionierung: Mark ≥ 2 Punktlängen → Strich, sonst Punkt. Pause ≥ 6 Punktlängen → Wortgrenze, ≥ 2 Punktlängen → Zeichengrenze, sonst (Element-Innenabstand) keine Markierung. Die Schwellwerte 2/6 statt der „exakten" 2/6-Mitte zwischen 1-3-7-Referenzwerten wurden bewusst konservativ gewählt (näher an der kürzeren Nachbarklasse), um Verzögerungen durch das Bandpassfilter und die Glättung zu tolerieren.

Erwartung: Ein Tag-Strom aus cw_symbol ('.'/'-') und cw_gap ('char'/'word'), der bei Vergleich mit dem bekannten Klartext exakt übereinstimmt.

Prüfweg: Siehe Gesamtergebnis unten.

6. CW Decoder (epy_block)

Warum: Sammelt die Punkt-/Strich-Tags pro Zeichen und übersetzt über die internationale Morsecode-Tabelle in Klartext, analog zum DCF77-Decoder-Block.

Dimensionierung: Statische Lookup-Tabelle (A–Z, 0–9), unbekannte Zeichenfolgen werden als „?" ausgegeben statt verworfen — damit bleiben Decodierfehler im Ergebnis sichtbar statt stillschweigend zu verschwinden.

Erwartung: Fortlaufend wachsender decodierter Text, publiziert als PMT-Message nach jedem abgeschlossenen Zeichen.

Prüfweg: siehe folgende Tabelle.

Gesamtergebnis

Testsignal Erwartet Decodiert Ergebnis
20 WPM, 40 dB SNR CQ CQ DE DL1ABC TEST TCQ CQ DE DL1ABC TEST korrekt (bis auf Anlauf-Artefakt)
35 WPM, 40 dB SNR CQ CQ DE DL1ABC TEST TCQ CQ DE DL1ABC TENT 1 Zeichenfehler (S→N)
20 WPM, 3 dB SNR VVV DE DL1ABC K TVVV DE DL1ABC K korrekt (bis auf Anlauf-Artefakt)

Zwei reale, unverstellte Effekte zeigen sich hier:

Anlauf-Artefakt („T" am Anfang): Der Rauschflur-Schätzer braucht ca. 5 Fensterperioden (bei 20 WPM ≈ 300 ms), bis die 20 Bins der Historie einmal komplett durchlaufen sind und der Platzhalterwert vollständig durch echte Messwerte ersetzt wurde. Bis dahin ist die Ratio künstlich überhöht, was zuverlässig ein zusätzliches, falsches Zeichen am Anfang jeder Aufnahme erzeugt — sichtbar auch im Übersichtsplot als kurzer Ausschlag direkt am Anfang, noch vor dem eigentlichen Signal:

Übersicht über das gesamte 20-WPM-Testsignal mit Anlauf-Artefakt am Anfang

In einer echten Dauerbetrieb-Situation (Empfänger läuft durch, nicht neu gestartet pro Nachricht) tritt dieser Effekt nur einmal beim Programmstart auf, nicht bei jedem einzelnen Wort — hier fällt er nur auf, weil jedes Testsignal für sich neu gestartet wird.

Zeichenfehler bei 35 WPM: Die festen Hysterese-Schwellwerte wurden gegen das 20-WPM-Signal eingestellt. Bei 35 WPM ist die Punktlänge nur noch 34 statt 60 Samples lang, wodurch die gleiche Glättungs- und Hysteresedimensionierung anteilig mehr Zeit „frisst" — ein einzelnes „S" (drei kurze Punkte) wurde zu „N" (Strich-Punkt) fehlklassifiziert. Das bestätigt genau die in Stufe 1 formulierte Anforderung, dass Timing-Parameter an die tatsächliche Sendegeschwindigkeit gekoppelt sein müssen — hier sind sie das nur über den statischen wpm-Parameter, nicht über eine laufende Schätzung.

Grenzen und nächste Schritte

  • Keine automatische Geschwindigkeitserkennung: wpm ist ein fester Parameter, kein Schätzwert. Tools wie CW Skimmer/GGMorse (siehe SDRangel-morsedecoder-Plugin) schätzen Tonhöhe und Geschwindigkeit laufend aus dem Signal selbst — ein naheliegender nächster Ausbauschritt.
  • Anlaufverhalten des Rauschflur-Schätzers könnte durch eine kürzere Historie oder eine explizite Einschwing-Sperrzeit entschärft werden.
  • Frequenzdrift (Abschnitt 6 der Anforderungsliste) wird bislang nicht kompensiert — der Bandpass ist fest auf pitch zentriert.

Download

Download: Flowgraph (.grc), Testsignal-Generator, Validierungs-Skript und Testsignale als ZIP — cw-demodulator-gnuradio.zip herunterladen, entpacken, dann:
grcc -o build cw.grc
python3 build/cw.py
# oder headless zur reinen Verifikation:
python3 test_chain.py
grcc -o build cw.grc
python3 build/cw.py
# oder headless zur reinen Verifikation:
python3 test_chain.py

Neue Testsignale lassen sich mit gen_cw_testsignal.py --text "..." --wpm 20 --pitch 700 --out eigene.wav erzeugen.