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.
| Modulationsart | FSK (F1B) | Frequenzumtastung, zwei Töne |
| Baudrate | 50 Baud | 50 Symbole pro Sekunde |
| Bitdauer | 20 ms | 1 / 50 Baud |
| FSK-Shift | 225 Hz | Frequenzabstand Mark–Space |
| Mark (logisch 1) | höhere Frequenz | Träger + 112,5 Hz |
| Space (logisch 0) | niedrigere Frequenz | Träger − 112,5 Hz |
| Kodierung | ITA2 / Baudot | 5-Bit-Code, LSB zuerst |
| Rahmen | 1 + 5 + 1,5 Bits | Start + Daten + Stop |
| Aufzeichnung | mono WAV, 12000 Hz | USB-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.
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.
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.
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
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:
analog.quadrature_demod_cf(samp_rate / (2 * math.pi * shift))
# = 12000 / (2 * 3.14159 * 225) ≈ +8.49Das 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-Vorzeichen | Mark (höhere Freq.) | Space (niedrigere Freq.) | Ergebnis |
|---|---|---|---|
| negativ (falsch) | negative Amplitude → Slicer: 0 | positive Amplitude → Slicer: 1 | Mark/Space vertauscht ✗ |
| positiv (korrekt) | positive Amplitude → Slicer: 1 | negative Amplitude → Slicer: 0 | korrekte 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.
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
)| Parameter | Wert | Bedeutung |
|---|---|---|
| Dezimationsfaktor | 10 | 12000 Hz → 1200 Hz |
| Grenzfrequenz | 50 Hz | Nyquist für 50 Baud |
| Übergangsbreite | 25 Hz | Steilheit des Filters |
| Fensterfunktion | Hamming | ~53 dB Sperrdämpfung |
| Samples pro Symbol (sps) | 24 | 1200 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.
blocks.multiply_const_ff(polarity) # polarity = +1 oder −1Symbol-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:
# 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, []
)| Parameter | Wert | Wirkung |
|---|---|---|
| TED-Typ | ZERO_CROSSING | Funktioniert ohne Übergänge im Signal |
| sps | 24 | Muss exakt samp_rate / decim / baud sein |
| loop_bw | 0,05 | Schnelles Einrasten auf neues Signal |
| max_dev | 0,1 | Bis zu 10 % Symboltaktabweichung toleriert |
Häufiger Fehler: In älteren Versionen wurde fälschlicherweise
verwendet. Die korrekte Formel ist immer
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).
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
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
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.
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:
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
| Regler | Bereich | Standard | Wirkung |
|---|---|---|---|
| Mittenfrequenz | 800–1200 Hz | 1000 Hz | Kompensiert Frequenzdrift; steuert Freq-Xlating-Block live |
| Squelch | −60 bis 0 dB | −30 dB | Unterdrückt Rausch-Dekodierung bei fehlendem Signal |
| Polarität | −1 / +1 | +1 | Tauscht Mark/Space; nötig bei LSB-Aufnahmen |
Alle Variablen und ihre Beziehungen
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ärkungAusgabe

Vollständiger GRC Flow Graph
