DDK9 RTTY-Demodulation mit GNU Radio – Version V2

DDK9 ist ein Kurzwellensender des Deutschen Wetterdienstes und überträgt meteorologische Daten per FSK (F1B) mit 225 Hz Frequenzhub, 50 Baud Symbolrate und ITA2/Baudot-Kodierung. In Teil 1 haben wir einen ersten Ansatz mit verdoppelter Baudrate und Müller&Müller-Synchronisation beschrieben. Diese Version (DDK9_V3) ersetzt diesen Ansatz durch eine robustere Lösung: 50 Baud mit Zero-Crossing-Synchronisation, AGC2, Squelch und einem verbesserten Baudot-Decoder.

WAV-Quelle → Throttle → Freq-Xlating FIR → Squelch → AGC2
  → Quad-Demod → FIR-Tiefpass → Polarität → Symbol-Sync
  → Binary Slicer → Baudot-DecoderCode-Sprache: JavaScript (javascript)

Signalparameter DDK9

Ein RTTY-Zeichen besteht aus einem Startbit (Space = 0), fünf Datenbits (LSB zuerst) und mindestens einem Stoppbit (Mark = 1). DDK9 verwendet 1,5 Stoppbits, also 30 ms Marketon am Ende jedes Zeichens.

ModulationsartFSK (F1B)Frequenzumtastung, zwei Töne
Baudrate50 Baud50 Symbole pro Sekunde
Bitdauer20 ms1 / 50 Baud
FSK-Shift225 HzFrequenzabstand Mark–Space
Mark (logisch 1)höhere FrequenzTräger + 112,5 Hz
Space (logisch 0)niedrigere FrequenzTräger − 112,5 Hz
KodierungITA2 / Baudot5-Bit-Code, LSB zuerst
Rahmen1 + 5 + 1,5 BitsStart + Daten + Stop
Aufzeichnungmono WAV, 12000 HzUSB-Demodulation, Mittenfrequenz ~1000 Hz
Startbit:    1 × 20 ms =  20 ms   (Space = 0)
Datenbits:   5 × 20 ms = 100 ms   (LSB zuerst)
Stoppbit:  1,5 × 20 ms =  30 ms   (Mark = 1)
Gesamt:    min. 150 ms pro Zeichen

WAV-Quelle und Throttle

Die Aufzeichnung liegt als mono WAV-Datei mit 12000 Hz Samplerate vor. Ohne Geschwindigkeitsbegrenzung würde GNU Radio die Datei so schnell wie möglich verarbeiten. Der Throttle-Block begrenzt auf die reale Abtastrate.

Python
samp_rate = 12000   # Hz
baud      =    50   # Symbole/s
shift     =   225   # Hz – FSK-Hub
decim     =    10   # Dezimationsfaktor

blocks.wavfile_source('ddk9_2.wav', True)           # True = Loop
blocks.throttle(gr.sizeof_float*1, samp_rate, True)

Frequency Xlating FIR Filter

Dieser Block übernimmt drei Aufgaben gleichzeitig: Er mischt das Signal so, dass die gewählte Mittenfrequenz auf 0 Hz liegt, er filtert mit einem Tiefpass auf die relevante Bandbreite, und er wandelt das reale Signal in ein komplexes IQ-Signal um. DDK9 liegt in der USB-Demodulation typischerweise bei ~1000 Hz (über einen Schieberegler einstellbar). Nach der Mischung liegt das FSK-Signal symmetrisch um 0 Hz: Mark bei +112,5 Hz, Space bei −112,5 Hz.

Python
center_freq = 1000   # Hz, einstellbar 800–1200 Hz

filter.freq_xlating_fir_filter_fcc(
    1,                                             # Decimation (hier keine)
    firdes.low_pass(1.0, samp_rate, 300, 100),     # 300 Hz Grenzfreq, 100 Hz Übergang
    center_freq,                                   # Mischfrequenz
    samp_rate                                      # Eingabe-Samplerate
)

Der Tiefpass mit 300 Hz Grenzfrequenz lässt das FSK-Signal (±112,5 Hz) vollständig durch und unterdrückt Nachbarsignale. Ein zu breites Filter (z. B. 600 Hz) würde Störsignale unnötig durchlassen.

Squelch

Ohne Squelch erzeugt Rauschen bei ausgefallenem Signal einen ununterbrochenen Bitstrom, der durch den Baudot-Decoder läuft. Dabei wird der interne shift_state (LETTERS/FIGURES) korrumpiert – sobald das eigentliche Signal einsetzt, werden Zeichen falsch interpretiert.

Python
analog.simple_squelch_cc(
    threshold_db = -30,    # Öffnet ab −30 dBFS (einstellbar −60..0 dB)
    alpha        = 0.001   # Glättungszeitkonstante der Leistungsmessung
)

AGC2 – Automatic Gain Control

Der Quadraturdemodulator erwartet ein Signal mit konstanter Amplitude. Kurze atmosphärische Störimpulse können die Amplitude kurzzeitig stark überschreiten. Ein einfacher AGC-Block mit einer Zeitkonstante überschwingt bei solchen Impulsen und vernichtet nachfolgende Symbole. agc2_cc trennt Attack und Decay in zwei unabhängige Zeitkonstanten

Python
analog.agc2_cc(
    attack_rate = 1e-1,   # schnelle Reaktion auf starke Signale
    decay_rate  = 1e-2,   # langsames Abklingen nach einem Störer
    reference   = 1.0,    # Zielamplitude
    gain        = 1.0     # Anfangsverstärkung
)
<strong>Parameter	Wert	Wirkung</strong>
attack_rate	10⁻¹	Dämpft Störimpulse innerhalb weniger Samples
decay_rate	10⁻²	Pegel erholt sich langsam → nachfolgende Symbole bleiben stabil
Code-Sprache: HTML, XML (xml)

Quadratur-Demodulator

Der Quadraturdemodulator berechnet die momentane Frequenzabweichung des komplexen Eingangssignals. Positive Abweichung (Mark) ergibt positive Ausgangsamplitude, negative Abweichung (Space) ergibt negative Amplitude.

Der Gain-Parameter skaliert so, dass ±1,0 am Ausgang einer Frequenzabweichung von ±shift/2 entspricht:

gain=fs2πshift=120002π225=120001413,7+8,49gain = \frac{f_s}{2\pi \cdot shift} = \frac{12000}{2\pi \cdot 225} = \frac{12000}{1413{,}7} \approx +8{,}49
Python
analog.quadrature_demod_cf(samp_rate / (2 * math.pi * shift))
# = 12000 / (2 * 3.14159 * 225) ≈ +8.49

Das Vorzeichen ist entscheidend: Ein negativer Gain vertauscht Mark und Space. Da ITA2 im Idle-Zustand alle Bits = 1 (Mark) erwartet, führt ein falsches Vorzeichen zu systematischen Dekodierfehlern.

Gain-VorzeichenMark (höhere Freq.)Space (niedrigere Freq.)Ergebnis
negativ (falsch)negative Amplitude → Slicer: 0positive Amplitude → Slicer: 1Mark/Space vertauscht ✗
positiv (korrekt)positive Amplitude → Slicer: 1negative Amplitude → Slicer: 0korrekte Polarität ✓

FIR-Tiefpass mit Dezimation

Nach dem Quadraturdemodulator enthält das Signal hochfrequente Artefakte. Ein Tiefpassfilter entfernt diese und reduziert gleichzeitig durch Dezimation die Samplerate auf das Minimum, das für die Symbolsynchronisation ausreicht.

Nyquist-Kriterium für 50-Baud-RTTY

Das schnellste Signal bei 50 Baud ist eine alternierende Folge 0101… mit 25 Hz. Ein Grenzwert von 50 Hz mit 25 Hz Übergangsband ist optimal.

fout=fsdecim=1200010=1200Hzf_{out} = \frac{f_s}{decim} = \frac{12000}{10} = 1200\,\text{Hz}
sps=foutbaud=120050=24sps = \frac{f_{out}}{baud} = \frac{1200}{50} = 24
Python
filter.fir_filter_fff(
    10,                                                           # Dezimation 12000→1200 Hz
    firdes.low_pass(1.0, samp_rate, 50, 25, window.WIN_HAMMING)  # 50 Hz, 25 Hz Übergang
)
ParameterWertBedeutung
Dezimationsfaktor1012000 Hz → 1200 Hz
Grenzfrequenz50 HzNyquist für 50 Baud
Übergangsbreite25 HzSteilheit des Filters
FensterfunktionHamming~53 dB Sperrdämpfung
Samples pro Symbol (sps)241200 Hz / 50 Baud

Polaritäts-Block

Der Multiply-Const-Block multipliziert das Signal mit +1 oder −1. Dies ist nötig, wenn die Aufzeichnung in LSB statt USB gemacht wurde – in diesem Fall sind Mark und Space frequenzmäßig gespiegelt. Der Wert ist live über einen Schieberegler umschaltbar.

Das Signal nach diesem Block wird auch an das Eye-Diagramm weitergeleitet.

Python
blocks.multiply_const_ff(polarity)   # polarity = +1 oder −1

Symbol-Synchronisation (Zero-Crossing TED)

RTTY ist ein asynchrones Protokoll ohne gemeinsamen Takt zwischen Sender und Empfänger. Zwischen Zeichen verharrt DDK9 im Mark-Idle-Zustand – der Sender schickt nur logische 1er. Der Symbol-Sync-Block muss den lokalen Abtastzeitpunkt laufend nachführen.

Problem mit Müller&Müller TED: Dieser Algorithmus leitet Taktkorrektur aus Übergängen (0→1, 1→0) ab. Bei langen Mark-Phasen (Idle) gibt es keine Übergänge – der Takt erhält kein Korrektursignal und driftet. Im Eye-Diagramm sieht man ein „Wandern” des Abtastzeitpunkts.

Der Zero-Crossing TED erkennt Nulldurchgänge im kontinuierlichen Signal zwischen zwei Symbolen. Auch bei Mark-Idle erzeugen die Flanken zwischen Symbolen verwertbare Nulldurchgangsinformation:

Python
# sps = samp_rate / decim / baud = 12000 / 10 / 50 = 24

digital.symbol_sync_ff(
    digital.TED_ZERO_CROSSING,           # stabil auch bei Idle-Phasen
    samp_rate / decim / baud,            # sps = 24
    0.05,                                # loop_bw – schnelles Einrasten
    1.0,                                 # damping
    1.0,                                 # gain_mu
    0.1,                                 # max_dev = ±10 %
    1,                                   # osps = 1 Sample pro Symbol
    digital.constellation_bpsk().base(),
    digital.IR_MMSE_8TAP,
    128, []
)
ParameterWertWirkung
TED-TypZERO_CROSSINGFunktioniert ohne Übergänge im Signal
sps24Muss exakt samp_rate / decim / baud sein
loop_bw0,05Schnelles Einrasten auf neues Signal
max_dev0,1Bis zu 10 % Symboltaktabweichung toleriert

Häufiger Fehler: In älteren Versionen wurde fälschlicherweise

sps=fs/decim/(2shift)=12000/10/4502,67sps = f_s / decim / (2 \cdot shift) = 12000 / 10 / 450 \approx 2{,}67

verwendet. Die korrekte Formel ist immer

sps=fs/decim/baudsps = f_s / decim / baud

Binary Slicer

Der Binary Slicer wandelt das kontinuierliche Float-Signal in einzelne Bits um. Schwellwert ist fest bei 0: Werte > 0 werden zu Bit 1 (Mark), Werte ≤ 0 werden zu Bit 0 (Space).

Python
digital.binary_slicer_fb()
# Eingang: float (−1 … +1)
# Ausgang: unsigned byte (0 oder 1)

Baudot-Decoder (Embedded Python Block)

ITA2 / Baudot-Kodierung

ITA2 ist ein 5-Bit-Code mit zwei Zeichensätzen. Umgeschaltet wird durch zwei Steuerworte: LTRS (0b11111) für Buchstaben und FIGS (0b11011) für Ziffern und Sonderzeichen. Die Bits werden LSB zuerst übertragen – Bit 0 hat Gewicht

20=12^0 = 1

Die State Machine erkennt den RTTY-Rahmen ohne externe Synchronisation:

  • IDLE: wartet auf fallende Flanke (Startbit = 0)
  • DATA: sammelt 5 Datenbits (LSB zuerst), errechnet 5-Bit-Code, schlägt im LTRS- oder FIGS-Table nach
  • WAIT_STOP: erwartet Stoppbit = 1. Fehlt es, liegt ein Framing Error vor. Nach 3 aufeinanderfolgenden Fehlern vollständiger Reset.

Der DDK9 Baudot Decoder ist ken Standard Block. Er ist ein embedded Python Block der die eingehenden Bits in einen lesbaren Textoutput umwandelt.

Vollständiger Decoder-Code

Python
import numpy as np
from gnuradio import gr

class ddk9_baudot_decoder(gr.sync_block):
    def __init__(self, text_queue=None):
        gr.sync_block.__init__(
            self, name='DDK9 Baudot Decoder',
            in_sig=[np.int8], out_sig=None
        )
        self.text_queue  = text_queue
        self.state       = 'IDLE'
        self.bit_buffer  = []
        self.shift_state = 'LETTERS'
        self.framing_errors = 0

        # ITA2-Tabelle Buchstaben (P=0b10110, L=0b10010 – nicht vertauschen!)
        self.LETS = {
            0b00100: ' ',  0b10111: 'Q',  0b10011: 'W',  0b00001: 'E',
            0b01010: 'R',  0b10000: 'T',  0b10101: 'Y',  0b00111: 'U',
            0b00110: 'I',  0b11000: 'O',  0b10110: 'P',  0b00011: 'A',
            0b00101: 'S',  0b01001: 'D',  0b01101: 'F',  0b11010: 'G',
            0b10100: 'H',  0b01011: 'J',  0b01111: 'K',  0b10010: 'L',
            0b10001: 'Z',  0b11101: 'X',  0b01110: 'C',  0b11110: 'V',
            0b11001: 'B',  0b01100: 'N',  0b11100: 'M',
            0b01000: '\r', 0b00010: '\n'
        }
        self.FIGS = {
            0b00100: ' ',  0b10111: '1',  0b10011: '2',  0b00001: '3',
            0b01010: '4',  0b10000: '5',  0b10101: '6',  0b00111: '7',
            0b00110: '8',  0b11000: '9',  0b10110: '0',  0b00011: '-',
            0b00101: "'",  0b01001: '$',  0b01101: '!',  0b11010: '&',
            0b10100: '#',  0b01011: '\a', 0b01111: '(',  0b10010: ')',
            0b10001: '+',  0b11101: '/',  0b01110: ':',  0b11110: ';',
            0b11001: '?',  0b01100: ',',  0b11100: '.',
            0b01000: '\r', 0b00010: '\n'
        }

    def _emit(self, char):
        if self.text_queue is not None:
            self.text_queue.put(char)
        else:
            print(char, end='', flush=True)

    def _reset(self):
        self.state = 'IDLE'
        self.bit_buffer = []
        self.shift_state = 'LETTERS'
        self.framing_errors = 0

    def work(self, input_items, output_items):
        in0 = input_items[0]
        for bit in in0:

            if self.state == 'IDLE':
                if bit == 0:                    # Startbit (Space = 0)
                    self.state = 'DATA'
                    self.bit_buffer = []

            elif self.state == 'DATA':
                self.bit_buffer.append(bit)
                if len(self.bit_buffer) == 5:
                    # LSB zuerst: Bit 0 hat Gewicht 2^0
                    code = sum(b << i for i, b in enumerate(self.bit_buffer))

                    if code == 0b11111:         # LTRS – Buchstabenmodus
                        self.shift_state = 'LETTERS'
                        self.framing_errors = 0
                    elif code == 0b11011:       # FIGS – Ziffermode
                        self.shift_state = 'FIGURES'
                        self.framing_errors = 0
                    elif code == 0b00000:
                        pass                    # Null / Idle
                    else:
                        table = self.LETS if self.shift_state == 'LETTERS' else self.FIGS
                        char = table.get(code)
                        if char is not None:
                            self.framing_errors = max(0, self.framing_errors - 1)
                            self._emit('<BEL>' if char == '\a' else char)

                    self.state = 'WAIT_STOP'

            elif self.state == 'WAIT_STOP':
                if bit == 1:                    # Stoppbit korrekt (Mark = 1)
                    self.state = 'IDLE'
                else:                           # fehlendes Stoppbit
                    self.framing_errors += 1
                    if self.framing_errors >= 3:
                        self._reset()           # Reset nach 3 Fehlern
                    else:
                        self.state = 'IDLE'

        return len(in0)

GUI-Elemente

Eye-Diagramm

Das Eye-Diagramm zeigt das Signal nach dem FIR-Tiefpass überlagert über genau eine Symbolperiode. Ein weit geöffnetes Auge zeigt saubere Symbolerkennung. Ein geschlossenes Auge deutet auf falsche Filterparameter, falschen sps-Wert oder starkes Rauschen hin.

Python
qtgui.eye_sink_f(
    size       = 1024,
    samp_rate  = samp_rate / decim,              # 1200 Hz
    num_inputs = 1
)
eye_sink.set_samp_per_symbol(int(samp_rate / decim / baud))   # 24

Thread-sichere Text-Ausgabe

GNU Radio Worker-Threads dürfen Qt-Widgets nicht direkt beschreiben. Der Decoder schreibt dekodierte Zeichen in eine queue.Queue, die ein QTimer alle 100 ms im GUI-Thread leert:

Python
self.text_queue = queue.Queue()
self._text_timer = Qt.QTimer()
self._text_timer.timeout.connect(self._update_text)
self._text_timer.start(100)   # alle 100 ms

def _update_text(self):
    chars = []
    while not self.text_queue.empty():
        try:
            chars.append(self.text_queue.get_nowait())
        except queue.Empty:
            break
    if chars:
        self.text_display.moveCursor(Qt.QTextCursor.End)
        self.text_display.insertPlainText(''.join(chars))

Bedienregler

ReglerBereichStandardWirkung
Mittenfrequenz800–1200 Hz1000 HzKompensiert Frequenzdrift; steuert Freq-Xlating-Block live
Squelch−60 bis 0 dB−30 dBUnterdrückt Rausch-Dekodierung bei fehlendem Signal
Polarität−1 / +1+1Tauscht Mark/Space; nötig bei LSB-Aufnahmen

Alle Variablen und ihre Beziehungen

Python
samp_rate = 12000   # Hz – Aufzeichnungs-Samplerate
decim     =    10   # Dezimationsfaktor im FIR-Tiefpass
baud      =    50   # Baudrate des DDK9-Signals
shift     =   225   # Hz – FSK-Frequenzhub

f_work    = samp_rate / decim           # 1200 Hz – Arbeitsrate nach Dezimation
sps       = samp_rate / decim / baud   #   24    – Samples pro Symbol
quad_gain = samp_rate / (2*pi * shift) #  ≈8,49  – Quadraturdemodulator-Verstärkung

Ausgabe

Vollständiger GRC Flow Graph

GRC Flowgraph RTTY Decoder