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.

Download: Werkzeug, Testdaten-Generator und Anforderungen als ZIP — wav-signalparameter.zip herunterladen, entpacken, dann:
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
Grenzen dieses Werkzeugs: Die Peak- und Baud-Schätzung ist auf einfache Zweiton-Signale (FSK) zugeschnitten, wie sie bei DDK9/RTTY vorliegen. Bei einem ASK- oder PSK-Signal (nur ein Träger, keine zwei getrennten Tonlagen) liefert 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.