GNU Radio Companion
Testen mit Konserven (WAV-Aufnahmen)
Wie man reale WAV-Aufnahmen — mono/reellwertig oder stereo/IQ — als Testgrundlage nutzt, und wie man die tatsächlichen Signalparameter einer unbekannten Aufnahme selbst ermittelt, statt sie zu erraten.
Die bisherigen Beispiele (FSK/RTTY-Strecke, BCD-Decoder) nutzen synthetische Signale mit exakt bekannten Parametern — ideal für Regressionstests, aber sie enthalten keine reale Hardware-Unzulänglichkeiten (DC-Offset, LO-Leakage, Frequenzdrift, echtes Rauschen). Eine Konserve — eine aufgezeichnete WAV-Datei wie in DCF77-Signal demodulieren oder den DDK9-Projekten — ergänzt das um den Praxistest: Funktioniert die Verarbeitungskette auch mit einem echten, unperfekten Signal?
Zwei Aufnahmearten — und wie man sie unterscheidet
| Mono/reellwertig | Stereo/IQ | |
|---|---|---|
| Kanäle | 1 | 2 (I = links, Q = rechts) |
| Enthält | nur Realteil, kein Imaginärteil bekannt | vollständiges komplexes Basisbandsignal |
| Spektrum | zwangsläufig symmetrisch um 0 Hz | im Allgemeinen asymmetrisch |
| Vor der Analyse nötig | Hilbert-Transformation (Imaginärteil rekonstruieren) | keine — bereits komplex |
| Beispiel im Projekt | DCF77-/DDK9-Konserven | — |
Warum das wichtig ist: Ein reelles Signal hat laut den Grundlagen zur Hilbert-Transformation immer ein symmetrisches Spektrum (Real- ohne Imaginärteil erzwingt das mathematisch) — daraus lässt sich eine unbekannte Aufnahme nicht direkt korrekt demodulieren, ohne vorher den fehlenden Imaginärteil zu rekonstruieren. Eine echte IQ-Aufnahme braucht diesen Schritt nicht, hat aber im Gegenzug zwei Kanäle statt einem, die vor der weiteren Verarbeitung korrekt zu einem komplexen Signal kombiniert werden müssen (I + jQ).
Welche Art vorliegt, lässt sich rein aus der Datei selbst bestimmen — die Kanalanzahl steht im WAV-Header, kein Rätselraten nötig.
Programm zur Ermittlung der Signalparameter
Das folgende Werkzeug liest eine WAV-Datei (mono oder stereo), erkennt automatisch, ob es sich um ein reelles oder IQ-Signal handelt, und ermittelt ohne weiteres Vorwissen: Samplerate, Mittenfrequenz, Frequenzhub (bei zweitonigen Signalen wie FSK) und Baudrate.
import wave
import numpy as np
def wav_laden(pfad):
"""Liest eine 16-Bit-PCM-WAV-Datei. Bei Stereo wird Kanal 1 als Realteil (I)
und Kanal 2 als Imaginaerteil (Q) interpretiert - die uebliche Konvention
fuer IQ-Aufnahmen von SDR-Empfaengern."""
with wave.open(pfad, "rb") as f:
n_channels = f.getnchannels()
samp_rate = f.getframerate()
sampwidth = f.getsampwidth()
raw = f.readframes(f.getnframes())
if sampwidth != 2:
raise ValueError(f"Nur 16-Bit-WAV unterstuetzt, hier: {sampwidth * 8}-Bit")
data = np.frombuffer(raw, dtype=np.int16).astype(np.float64) / 32768.0
if n_channels == 1:
return samp_rate, data, False # reelles Mono-Signal
elif n_channels == 2:
i = data[0::2] # interleaved Stereo: L,R,L,R,...
q = data[1::2]
return samp_rate, (i + 1j * q), True # komplexes IQ-Signal
else:
raise ValueError(f"{n_channels} Kanaele nicht unterstuetzt")
import wave
import numpy as np
def wav_laden(pfad):
"""Liest eine 16-Bit-PCM-WAV-Datei. Bei Stereo wird Kanal 1 als Realteil (I)
und Kanal 2 als Imaginaerteil (Q) interpretiert - die uebliche Konvention
fuer IQ-Aufnahmen von SDR-Empfaengern."""
with wave.open(pfad, "rb") as f:
n_channels = f.getnchannels()
samp_rate = f.getframerate()
sampwidth = f.getsampwidth()
raw = f.readframes(f.getnframes())
if sampwidth != 2:
raise ValueError(f"Nur 16-Bit-WAV unterstuetzt, hier: {sampwidth * 8}-Bit")
data = np.frombuffer(raw, dtype=np.int16).astype(np.float64) / 32768.0
if n_channels == 1:
return samp_rate, data, False # reelles Mono-Signal
elif n_channels == 2:
i = data[0::2] # interleaved Stereo: L,R,L,R,...
q = data[1::2]
return samp_rate, (i + 1j * q), True # komplexes IQ-Signal
else:
raise ValueError(f"{n_channels} Kanaele nicht unterstuetzt")
Hilbert-Transformation ohne scipy (nur numpy.fft, für reelle Aufnahmen nötig):
def zu_analytischem_signal(real_signal):
"""Frequenzanteile bei negativen Frequenzen auf 0 setzen, positive
verdoppeln - Standardkonstruktion des analytischen Signals."""
n = len(real_signal)
spektrum = np.fft.fft(real_signal)
h = np.zeros(n)
if n % 2 == 0:
h[0] = 1
h[n // 2] = 1
h[1:n // 2] = 2
else:
h[0] = 1
h[1:(n + 1) // 2] = 2
return np.fft.ifft(spektrum * h)
def zu_analytischem_signal(real_signal):
"""Frequenzanteile bei negativen Frequenzen auf 0 setzen, positive
verdoppeln - Standardkonstruktion des analytischen Signals."""
n = len(real_signal)
spektrum = np.fft.fft(real_signal)
h = np.zeros(n)
if n % 2 == 0:
h[0] = 1
h[n // 2] = 1
h[1:n // 2] = 2
else:
h[0] = 1
h[1:(n + 1) // 2] = 2
return np.fft.ifft(spektrum * h)
Mittenfrequenz und Hub über die zwei stärksten Spektral-Peaks (bei einem einfachen Zweiton-FSK-Signal):
def spektrum_peaks(signal, samp_rate, n_peaks=2, min_abstand_hz=20):
spec = np.fft.fftshift(np.fft.fft(signal))
freqs = np.fft.fftshift(np.fft.fftfreq(len(signal), 1 / samp_rate))
mag = np.abs(spec)
gefunden = []
mag_kopie = mag.copy()
for _ in range(n_peaks):
idx = np.argmax(mag_kopie)
gefunden.append(freqs[idx])
# Umgebung ausblenden, damit derselbe Peak nicht mehrfach gezaehlt wird
mask = np.abs(freqs - freqs[idx]) < min_abstand_hz
mag_kopie[mask] = 0
return sorted(gefunden)
def spektrum_peaks(signal, samp_rate, n_peaks=2, min_abstand_hz=20):
spec = np.fft.fftshift(np.fft.fft(signal))
freqs = np.fft.fftshift(np.fft.fftfreq(len(signal), 1 / samp_rate))
mag = np.abs(spec)
gefunden = []
mag_kopie = mag.copy()
for _ in range(n_peaks):
idx = np.argmax(mag_kopie)
gefunden.append(freqs[idx])
# Umgebung ausblenden, damit derselbe Peak nicht mehrfach gezaehlt wird
mask = np.abs(freqs - freqs[idx]) < min_abstand_hz
mag_kopie[mask] = 0
return sorted(gefunden)
Baudrate über den kürzesten, häufig auftretenden Abstand zwischen Vorzeichenwechseln des demodulierten Signals:
def baud_schaetzen(demod_signal, samp_rate):
"""Bei zufaelligen Bitfolgen ist der kuerzeste haeufige Abstand zwischen
zwei Vorzeichenwechseln genau eine Symbolperiode (wenn zwei benachbarte
Bits sich unterscheiden) - laengere Abstaende sind ganzzahlige Vielfache
davon (mehrere gleiche Bits in Folge)."""
vorzeichen = np.sign(demod_signal)
wechsel_idx = np.where(np.diff(vorzeichen) != 0)[0]
if len(wechsel_idx) < 2:
return None
abstaende = np.diff(wechsel_idx)
werte, counts = np.unique(abstaende, return_counts=True)
gueltig = werte >= 3 # kleinere Abstaende sind meist Rauschartefakte
werte, counts = werte[gueltig], counts[gueltig]
if len(werte) == 0:
return None
sps_schaetzung = werte[np.argmax(counts)]
return samp_rate / sps_schaetzung, sps_schaetzung
def baud_schaetzen(demod_signal, samp_rate):
"""Bei zufaelligen Bitfolgen ist der kuerzeste haeufige Abstand zwischen
zwei Vorzeichenwechseln genau eine Symbolperiode (wenn zwei benachbarte
Bits sich unterscheiden) - laengere Abstaende sind ganzzahlige Vielfache
davon (mehrere gleiche Bits in Folge)."""
vorzeichen = np.sign(demod_signal)
wechsel_idx = np.where(np.diff(vorzeichen) != 0)[0]
if len(wechsel_idx) < 2:
return None
abstaende = np.diff(wechsel_idx)
werte, counts = np.unique(abstaende, return_counts=True)
gueltig = werte >= 3 # kleinere Abstaende sind meist Rauschartefakte
werte, counts = werte[gueltig], counts[gueltig]
if len(werte) == 0:
return None
sps_schaetzung = werte[np.argmax(counts)]
return samp_rate / sps_schaetzung, sps_schaetzung
Alles zusammen:
def analysiere_wav(pfad):
samp_rate, signal, ist_iq = wav_laden(pfad)
print(f"Samplerate: {samp_rate} Hz")
print(f"Kanaele: {'Stereo/IQ (komplex)' if ist_iq else 'Mono (reell)'}")
# Reelle Aufnahmen zuerst in ein analytisches Signal wandeln, damit
# dieselbe Spektralanalyse wie bei einer echten IQ-Aufnahme greift
analytisch = signal if ist_iq else zu_analytischem_signal(signal)
peaks = spektrum_peaks(analytisch, samp_rate, min_abstand_hz=samp_rate * 0.01)
print(f"Spektral-Peaks: {[f'{p:.1f} Hz' for p in peaks]}")
if len(peaks) == 2:
center = (peaks[0] + peaks[1]) / 2
hub = (peaks[1] - peaks[0]) / 2
print(f"Mittenfrequenz: {center:.1f} Hz, Hub: {hub:.1f} Hz")
t = np.arange(len(analytisch)) / samp_rate
basisband = analytisch * np.exp(-1j * 2 * np.pi * center * t)
demod = np.angle(basisband[1:] * np.conj(basisband[:-1]))
ergebnis = baud_schaetzen(demod, samp_rate)
if ergebnis:
baud, sps = ergebnis
print(f"Baudrate: {baud:.1f} Bd (sps={sps})")
def analysiere_wav(pfad):
samp_rate, signal, ist_iq = wav_laden(pfad)
print(f"Samplerate: {samp_rate} Hz")
print(f"Kanaele: {'Stereo/IQ (komplex)' if ist_iq else 'Mono (reell)'}")
# Reelle Aufnahmen zuerst in ein analytisches Signal wandeln, damit
# dieselbe Spektralanalyse wie bei einer echten IQ-Aufnahme greift
analytisch = signal if ist_iq else zu_analytischem_signal(signal)
peaks = spektrum_peaks(analytisch, samp_rate, min_abstand_hz=samp_rate * 0.01)
print(f"Spektral-Peaks: {[f'{p:.1f} Hz' for p in peaks]}")
if len(peaks) == 2:
center = (peaks[0] + peaks[1]) / 2
hub = (peaks[1] - peaks[0]) / 2
print(f"Mittenfrequenz: {center:.1f} Hz, Hub: {hub:.1f} Hz")
t = np.arange(len(analytisch)) / samp_rate
basisband = analytisch * np.exp(-1j * 2 * np.pi * center * t)
demod = np.angle(basisband[1:] * np.conj(basisband[:-1]))
ergebnis = baud_schaetzen(demod, samp_rate)
if ergebnis:
baud, sps = ergebnis
print(f"Baudrate: {baud:.1f} Bd (sps={sps})")
Verifikation mit bekannter Ground Truth
Bevor das Werkzeug auf eine echte, unbekannte Konserve angesetzt wird, muss es an einem Signal mit bekannten Parametern nachweisen, dass es überhaupt korrekt misst — dasselbe Ground-Truth-Prinzip wie in Testen mit GRC. Dafür wurden zwei synthetische Test-WAVs erzeugt (Samplerate 8000 Hz, Mittenfrequenz 1000 Hz, Hub 100 Hz, Baudrate 50 Bd) — einmal als Mono/reell, einmal als Stereo/IQ derselben Daten:
=== mono_real.wav ===
Samplerate: 8000 Hz
Kanaele: Mono (reell)
Spektral-Peaks: ['900.0 Hz', '1100.0 Hz']
Mittenfrequenz: 1000.0 Hz, Hub: 100.0 Hz
Baudrate: 50.0 Bd (sps=160)
=== stereo_iq.wav ===
Samplerate: 8000 Hz
Kanaele: Stereo/IQ (komplex)
Spektral-Peaks: ['900.0 Hz', '1100.0 Hz']
Mittenfrequenz: 1000.0 Hz, Hub: 100.0 Hz
Baudrate: 50.0 Bd (sps=160)
=== mono_real.wav ===
Samplerate: 8000 Hz
Kanaele: Mono (reell)
Spektral-Peaks: ['900.0 Hz', '1100.0 Hz']
Mittenfrequenz: 1000.0 Hz, Hub: 100.0 Hz
Baudrate: 50.0 Bd (sps=160)
=== stereo_iq.wav ===
Samplerate: 8000 Hz
Kanaele: Stereo/IQ (komplex)
Spektral-Peaks: ['900.0 Hz', '1100.0 Hz']
Mittenfrequenz: 1000.0 Hz, Hub: 100.0 Hz
Baudrate: 50.0 Bd (sps=160)
Beide Aufnahmearten liefern exakt dieselben, korrekten Werte — die Mono/Hilbert-Variante ist der Stereo/IQ-Variante also gleichwertig, sobald der Imaginärteil rekonstruiert wurde.
pip install -r requirements.txt
python3 gen_testwavs.py # erzeugt mono_real.wav und stereo_iq.wav
python3 signalparameter.py mono_real.wav stereo_iq.wav
# Mit einer eigenen Aufnahme:
python3 signalparameter.py meine_aufnahme.wav
pip install -r requirements.txt
python3 gen_testwavs.py # erzeugt mono_real.wav und stereo_iq.wav
python3 signalparameter.py mono_real.wav stereo_iq.wav
# Mit einer eigenen Aufnahme:
python3 signalparameter.py meine_aufnahme.wav
spektrum_peaks() zwei benachbarte, nahezu identische Frequenzen statt echter Mark-/Space-Töne — für diese Modulationsarten bräuchte es andere Kenngrößen (z. B. Symbolratenschätzung über die Hüllkurve statt über Frequenzsprünge). Auch eine reale Aufnahme mit starkem Rauschen oder mehreren überlagerten Signalen kann die automatische Peak-Erkennung in die Irre führen — das Ergebnis sollte immer gegen ein Spektrumsbild (visuell) gegengeprüft werden, nicht blind übernommen werden.Testen mit GRC
Die allgemeine Methodik: synthetische Signale, Ground Truth, headless-Tests.
Test-Beispiel: FSK/RTTY-Strecke
Dieselben Signalparameter (Hub, Baudrate) an einem vollständig synthetischen Signal verifiziert.
DCF77-Signal demodulieren
Die Hilbert-Transformation an einer echten Mono-Konserve im vollen Kontext.
Rechner
Aus den hier ermittelten Parametern direkt Filter- und Demod-Einstellungen berechnen.