Zum Inhalt
Anmelden

/docs · General · Practical

Einen Datensatz mit Gesprächen prüfen: woher das Audio wirklich stammt

Miss einen Ordner mit Anruf-, Voice-Agent- oder Text-to-Speech-Aufnahmen auf verlustbehaftete Quellen, Upsampling, Schnittstellen, getarntes Mono, Clipping und Bittiefe, 20 Dateien auf einmal, und wisse, wann das Audio hochgeladen wird und wann nicht.

Aktualisiert 12.10.2026


Für wen das ist

Du trainierst, feintunst oder evaluierst auf Gesprächsaudio: Anrufaufnahmen, Voice-Agent-Logs, synthetische Sprache, ein Korpus, den du gescrapt oder gekauft hast. Bevor du den Labels, den Scores oder einem darauf gebauten Modell traust, willst du wissen, was die Dateien wirklich sind. Eine als 16 kHz ausgewiesene Datei kann auf mehr Bandbreite gedehntes 8-kHz-Telefonaudio sein. Eine WAV kann eine dekodierte MP3 sein. Eine Stereodatei kann ein Kanal zweimal sein. Eine Aufnahme kann geclippt sein, und zwei Quellen können innerhalb einer Datei zusammengefügt sein. Nichts davon steht im Header.

Gedacht ist es für Teams, die selbst entscheiden, was sie mit einem Befund tun. Es beurteilt keine Labels oder Transkripte, es liest die Wörter nicht, identifiziert keine Sprecher und unterscheidet keine synthetische Stimme von einer echten. Siehe was AudioLab nicht tut.

Was du bekommst

  • Ein Skript, audit_dataset.py, das jede Audiodatei in einem Ordner misst, 20 auf einmal, und pro Datei eine Zeile in eine CSV schreibt.
  • Eine Zusammenfassung dessen, was einen Blick wert ist, mit den Dateinamen, und die Bandbreite für jede Abtastrate im Ordner.
  • Jeder Befund stammt aus einer einzigen Analyse, der Signalanalyse (POST /v1/signallab), und jede Zahl, die sie dort liest, ist validiert: die Codec-Wand (lossySource), die ursprüngliche Abtastrate hinter einem Resampler (upsampledFromHz), Übergänge zwischen Quellen (bandwidthSplices), die Kanäle und wie ähnlich sie sich sind (channels, monophonicProbability), Clipping (hasClipping, clippingRegions), die genutzten Bits (effectiveBitDepth) und die Bandbreite (bandwidthHz).

Bevor du anfängst

  1. Wisse, wann das Audio hochgeladen wird. Das Skript lädt es hoch: Jede Datei geht an die AudioLab-API, die in der EU gehostet wird, und es kommen nur Zahlen zurück. Die Werkbank ist der andere Weg: Sie lässt die Engine in deinem Browser laufen und lädt nichts hoch. Ihr Batch liefert dir die zehn Zahlen des Vergleichsleitfadens über viele Dateien, von Hand, mit der Bandbreite und den geclippten Samples; er benennt keine Codec-Wand, kein Resampling, keine Schnittstelle und kein Kanallayout. Wenn deine Aufnahmen deine eigenen Systeme nicht verlassen dürfen, nimm die Werkbank und akzeptiere, dass sie weniger abdeckt.
  2. Wisse, was die Aufnahmen enthalten. Aufnahmen von Anrufen enthalten meist personenbezogene Daten. Sie an die API zu schicken, macht uns zu deinem Auftragsverarbeiter: Lies die Vereinbarung zur Auftragsverarbeitung und /privacy, bevor du das Skript auf echten Aufnahmen laufen lässt, und fang mit Dateien an, die du frei weitergeben darfst.
  3. Hol dir einen Schlüssel. Er ist kostenlos und braucht keine Karte: Melde dich auf /account an und erzeuge einen. Das Skript braucht ihn, weil es zuerst fragt, was dein Tarif noch hergibt (GET /v1/usage, das kostenlos ist und nicht als Aufruf zählt).
  4. Wisse, was es kostet. Jede Datei ist ein Aufruf, und ihre Größe zählt gegen dein Datenvolumen im Monat. Im Free-Kontingent (1.000 Aufrufe im Monat, 2 GB im Monat und 60 Anfragen pro Minute) prüfst du also eine Stichprobe, keinen Korpus mit zehntausend Dateien. Eine lokale Datei braucht zwei Anfragen: eine zum Hochladen und eine zum Messen. Das Skript taktet sich selbst auf die Anfragen pro Minute deines Tarifs, also brauchen 300 Dateien bei 60 pro Minute mindestens zehn Minuten. Fang mit --sample 300 an: Eine zufällige Stichprobe, jedes Mal dieselbe, reicht, um zu sehen, ob es ein Problem gibt.
  5. Python 3.9 oder neuer und das Paket:
pip install audiolabtools

Das Skript

"""Audit a folder of audio files for where the audio really comes from.

One row per file in a CSV, and a summary of what is worth a look.

    python audit_dataset.py audio/ audit.csv               every file in audio/, 20 at a time
    python audit_dataset.py audio/ audit.csv --sample 300  a random sample of 300, the same
                                                           300 every time: the cheap first look

https://audiolab.tools/docs/start/audit-a-conversation-dataset
Every file is uploaded to the AudioLab API (https://audiolab.tools/privacy) and counts as one
call, so this needs a key (AUDIOLAB_KEY). A file that is already in the CSV is skipped, so a
run that stopped can be started again.
Exit code: 0 when every file was measured, 2 when something could not be (the CSV says what).
"""

from __future__ import annotations

import argparse
import csv
import random
import statistics
import sys
import time
from pathlib import Path

from audiolabtools import AudioLab, AudioLabError

AUDIO = {".wav", ".mp3", ".flac", ".m4a", ".ogg", ".opus", ".aac", ".aif", ".aiff"}
FIELDS = [
    "sampleRate", "channels", "bandwidthHz", "upsampledFromHz", "lossySource",
    "bandwidthSplices", "effectiveBitDepth", "monophonicProbability",
    "hasClipping", "clippingRegions",
]
COLUMNS = ["file", "findings", *FIELDS, "error"]
BATCH = 20  # the most the API takes in one call
# On a stereo file a monophonicProbability at or above this counts as the same signal on both
# sides. That is our choice: the number says only that near 1 means mono in disguise.
SAME_BOTH_SIDES = 0.99
KINDS = [
    "upsampled", "lossy source", "source unclear", "bandwidth splices",
    "stereo, same both sides", "clipping", "fewer than 16 bits",
]


def findings(d):
    """What is worth a look in one file, as 'kind (detail)'.

    Only what the engine itself says. The limits that are ours: SAME_BOTH_SIDES and 16 bits.
    """
    found = []
    if d.get("upsampledFromHz") is not None:
        found.append(f"upsampled (from {d['upsampledFromHz']:g} Hz)")
    if str(d.get("lossySource")).startswith("lossy"):
        found.append(f"lossy source ({d['lossySource'][6:]})")
    elif d.get("lossySource") == "unknown":
        found.append("source unclear")
    if d.get("bandwidthSplices"):
        found.append(f"bandwidth splices ({d['bandwidthSplices']})")
    if d.get("channels") == 2 and (d.get("monophonicProbability") or 0) >= SAME_BOTH_SIDES:
        found.append("stereo, same both sides")
    if d.get("hasClipping"):
        regions = d.get("clippingRegions") or 0
        found.append(
            f"clipping ({regions} region{'' if regions == 1 else 's'})"
            if regions else "clipping (a short run at the peak)"
        )
    if d.get("effectiveBitDepth") is not None and d["effectiveBitDepth"] < 16:
        found.append(f"fewer than 16 bits ({d['effectiveBitDepth']})")
    return found


def write_csv(path, rows):
    with open(path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=COLUMNS)
        writer.writeheader()
        writer.writerows(rows.values())


def summary(rows, out):
    measured = [r for r in rows.values() if not r["error"]]
    plural = "" if len(measured) == 1 else "s"
    out(f"\n{len(measured)} file{plural} measured, {len(rows) - len(measured)} failed\n")
    out(f"{'what':25}{'files':>5}")
    for kind in KINDS:
        hit = [
            r["file"] for r in measured
            if any(x == kind or x.startswith(kind + " (") for x in r["findings"].split("; "))
        ]
        names = ", ".join(hit[:5]) + (f" and {len(hit) - 5} more" if len(hit) > 5 else "")
        out(f"{kind:25}{len(hit):>5}   {names}".rstrip())
    out("\nbandwidth by sample rate")
    for rate in sorted({int(float(r["sampleRate"])) for r in measured}):
        widths = sorted(float(r["bandwidthHz"]) for r in measured
                        if int(float(r["sampleRate"])) == rate)
        if len(widths) == 1:
            out(f"  {rate} Hz    1 file   {widths[0]:.0f} Hz")
        else:
            median = statistics.median(widths)
            out(f"  {rate} Hz  {len(widths):>3} files  {widths[0]:.0f} to {widths[-1]:.0f} Hz, "
                f"median {median:.0f}")


def main(argv, client=None, out=print, wait=time.sleep):
    ask = argparse.ArgumentParser(description="Audit a folder of audio files.")
    ask.add_argument("folder")
    ask.add_argument("csv")
    ask.add_argument("--sample", type=int, help="measure a random sample of this many files")
    args = ask.parse_args(argv[1:])
    folder, target = Path(args.folder), Path(args.csv)

    files = sorted(p for p in folder.rglob("*") if p.suffix.lower() in AUDIO)
    if args.sample and args.sample < len(files):
        files = sorted(random.Random(1).sample(files, args.sample))
    rows = {}
    if target.exists():
        with open(target, newline="", encoding="utf-8") as f:
            rows = {r["file"]: r for r in csv.DictReader(f)}

    def name(p):
        return p.relative_to(folder).as_posix()

    todo = [p for p in files if name(p) not in rows or rows[name(p)]["error"]]

    client = client or AudioLab()  # reads AUDIOLAB_KEY
    try:
        usage = client.usage()  # free, and it says what this run may cost before it costs it
        size = sum(p.stat().st_size for p in todo)
        calls_left, bytes_left = usage["calls"]["remaining"], usage["data"]["remainingBytes"]
        left = "no limit" if calls_left is None else f"{calls_left} calls"
        out(f"{len(todo)} files to measure ({size / 1e6:.0f} MB). Plan {usage['plan']}: "
            f"{left} left this month, {usage['requestsPerMinute']} requests a minute.")
        if (calls_left is not None and len(todo) > calls_left) or (
            bytes_left is not None and size > bytes_left
        ):
            out("That is more than your plan has left this month. "
                "Start with --sample, or wait for the new month.")
            return 2

        result = 0
        for i in range(0, len(todo), BATCH):
            chunk = todo[i : i + BATCH]
            started = time.monotonic()
            answer = client.batch([str(p) for p in chunk], endpoint="signallab", round=True)
            for p, r in zip(chunk, answer["results"]):
                d = r.get("data") or {}
                row = {
                    "file": name(p),
                    "findings": "; ".join(findings(d)) if r["ok"] else "",
                    "error": "" if r["ok"] else r.get("error", "failed"),
                }
                row.update({k: d.get(k, "") for k in FIELDS})
                rows[row["file"]] = row
                if not r["ok"]:
                    result = 2
            write_csv(target, rows)
            if len(answer["results"]) < len(chunk):
                # the API stopped inside the batch: a full minute, or a limit of your plan
                why = (answer["results"] or [{}])[-1].get("error", "no reason given")
                done = len(answer["results"])
                out(f"The API stopped after {done} of {len(chunk)} files: {why}")
                out("Run it again: the files already in the CSV are skipped.")
                return 2
            # A local file takes two requests: one to mint its signed upload, one for the
            # analysis. Stay under the requests a minute of the plan.
            pace = 2 * len(chunk) * 60 / usage["requestsPerMinute"]
            wait(max(0, pace - (time.monotonic() - started)))
    except AudioLabError as e:
        out(f"Stopped: {e}")
        return 2
    write_csv(target, rows)
    summary(rows, out)
    return result


if __name__ == "__main__":
    sys.exit(main(sys.argv))

Es listet die Audiodateien im Ordner auf, zieht die Stichprobe, wenn du eine willst, fragt, was dein Tarif noch hergibt, und verweigert einen Lauf, der nicht passt. Dann schickt es die Dateien an POST /v1/batch mit endpoint="signallab", 20 auf einmal, jede zuerst hochgeladen. Nach jedem Batch schreibt es die CSV. Stoppt die API mitten in einem Batch (eine volle Minute oder ein Limit deines Tarifs), sagt das Skript es und endet; starte es erneut, und die Dateien, die schon in der CSV stehen, werden übersprungen.

Ein Lauf mit sieben Aufnahmen

Um zu zeigen, wie die Befunde aussehen, haben wir aus einer unserer eigenen Stimmproben sieben Aufnahmen gebaut, jede mit einem bekannten Defekt, und das Skript darauf laufen lassen. Die Aufnahmen wurden von der Engine gemessen, und das Skript lief gegen einen Ersatz für die API mit diesen Antworten, sodass das Folgende das ist, was das Skript ausgibt.

  • clean.wav: die Stimme und ein leiser Grundrauschpegel, als 16-Bit-WAV mit 44,1 kHz.
  • telephone.wav: auf 8 kHz heruntergerechnet und wieder auf 16 kHz hochgerechnet.
  • from-mp3-64k.wav: als MP3 mit 64 kbit/s kodiert und zu WAV dekodiert.
  • clipped.wav: 24 dB lauter, als das Format erlaubt.
  • stereo-twice.wav: dieselbe Stimme in beiden Kanälen einer Stereodatei.
  • spliced.wav: acht Sekunden unverändert, dann vier Sekunden derselben Stimme in Telefonbandbreite.
  • eight-bit.wav: als 8-Bit-Samples gespeichert.
7 files to measure (14 MB). Plan plus: 9988 calls left this month, 120 requests a minute.

7 files measured, 0 failed

what                     files
upsampled                    1   from-mp3-64k.wav
lossy source                 0
source unclear               2   from-mp3-64k.wav, spliced.wav
bandwidth splices            1   spliced.wav
stereo, same both sides      1   stereo-twice.wav
clipping                     2   clipped.wav, eight-bit.wav
fewer than 16 bits           1   eight-bit.wav

bandwidth by sample rate
  16000 Hz    1 file   4297 Hz
  44100 Hz    6 files  16516 to 22028 Hz, median 22028

Die CSV hat pro Datei eine Zeile mit den Befunden und den Zahlen dahinter. Die Befundspalte dieser sieben:

clean.wav         -
clipped.wav       clipping (22 regions)
eight-bit.wav     clipping (a short run at the peak); fewer than 16 bits (8)
from-mp3-64k.wav  upsampled (from 32000 Hz); source unclear
spliced.wav       source unclear; bandwidth splices (1)
stereo-twice.wav  stereo, same both sides
telephone.wav     -

So liest du es

  1. Ein Befund ist ein Grund zum Hinhören, kein Urteil. Öffne die CSV, sortiere nach der Spalte, die dich interessiert, und höre dir die Dateien ganz oben an.
  2. Lies die Bandbreite neben der Abtastrate. telephone.wav hat gar keinen Befund, und sie ist die, auf die es am meisten ankommt: Ihre Bandbreite beträgt 4.297 Hz in einer Datei mit 16.000 Hz, also war nie etwas oberhalb von etwa 4 kHz darin. Die Engine nennt eine ursprüngliche Rate (upsampledFromHz) nur, wenn der Abfall dort beginnt, wo eine Standardrate ihn hinlegen würde, und sagt sonst nichts. Hier zeigt es also die Zusammenfassung der Bandbreite pro Abtastrate. Achte auf eine Abtastrate mit großer Streuung oder auf eine Datei weit unter den anderen.
  3. Zwei Erklärungen für eine Wand sind ein Befund. from-mp3-64k.wav sagt „upsampled from 32,000 Hz“ und „source unclear“. Ihre Bandbreite beträgt 16.516 Hz in einer Datei mit 44,1 kHz. Eine MP3 mit niedriger Bitrate und eine hochgerechnete 32-kHz-Quelle hinterlassen eine ähnliche Wand: lossySource bleibt unknown, statt zu raten, und upsampledFromHz nennt eine 32-kHz-Quelle. Die Ursache war hier eine MP3, also ist diese zweite Zeile eine falsche Erklärung einer richtigen Beobachtung, und die Seite zur Messgröße sagt, dass ein Codec mit sehr niedriger Bitrate einen Resampler nachahmen kann. Für deinen Zweck sagen beide Zeilen dasselbe: nichts oberhalb von etwa 16,5 kHz, diese Datei trägt also nicht, was ihre Abtastrate verspricht.
  4. Ein Zusammenfügen zeigt sich als „bandwidth splice“. spliced.wav hat eine: Die Bandbreite sprang um mehr als 3 kHz und blieb dort mindestens zwei Sekunden lang, dort, wo der Teil in Telefonband beginnt. In Musik kann ein gezählter Übergang auch ein Wechsel im Arrangement sein. In einem Gespräch ist es häufiger eine andere Quelle, ein anderer Codec oder ein eingefügter Clip.
  5. Stereo doppelt sind zwei Zahlen. stereo-twice.wav hat zwei Kanäle und eine monophonicProbability von 1. Der Name führt in die Irre: Es ist die Korrelation zwischen links und rechts, nie unter 0, und nahe 1 heißt getarntes Mono. Das Skript nennt eine Stereodatei mit 0,99 oder mehr „same both sides“; dieses Limit ist unseres. Wenn deine Aufnahmen jeden Sprecher auf einen eigenen Kanal legen, heißt ein Wert nahe 1, dass sie zusammengemischt wurden.
  6. Clipping hat zwei Spalten. hasClipping ist wahr bei jeder kurzen Folge von Samples am eigenen Spitzenwert der Datei. clippingRegions zählt nur die Abschnitte, die lang genug sind, um zu zählen. eight-bit.wav hat das erste und keine Regionen, clipped.wav hat 22. Schau dir die Regionen an, wenn du es hören willst.
  7. Genutzte Bits. effectiveBitDepth ist die Zahl der Bits, die die Samples wirklich nutzen. Eine als 16 Bit gespeicherte Datei, die 8 liest, hatte ihre Auflösung gekürzt, bevor sie gespeichert wurde. Eine verlustbehaftete Datei, die nebenbei dekodiert wurde, liest 24, weshalb diese Zahl keine verlustbehafteten Quellen findet: Dafür ist lossySource da.

Was es nicht kann

  • Es sieht nicht jede verlustbehaftete Quelle. Die Engine sucht nach der Wand, die ein Encoder hinterlässt. Unsere eigenen Stimmproben sind MP3-Dateien, und die Engine liest sie als verlustfrei: Sie hat in ihnen keine Wand gefunden. lossySource: lossless heißt, es wurde keine Wand gefunden, nicht, dass die Datei nie verlustbehaftet war.
  • Es nennt nicht immer die ursprüngliche Rate einer hochgerechneten Datei. telephone.wav ist das Beispiel. Die Seite zur Messgröße listet die Fälle auf, die sie bewusst in Ruhe lässt.
  • Es beweist nicht, dass zwei Dateien dieselbe Quelle haben: Es gibt keinen Fingerprint. Ein Übergang zwischen zwei Aufnahmen derselben Art hinterlässt keine Spur im Band.
  • Es sagt nicht, ob eine Stimme synthetisch ist, identifiziert keinen Sprecher, prüft kein Label und liest kein Transkript.
  • Ein Batch ist ein Aufruf pro Datei nacheinander, ein großer Korpus ist also langsam und kostet Aufrufe. Nimm zuerst eine Stichprobe.

Datenschutz und Limits

  • Dein Audio wird nicht aufbewahrt. Es wird im Arbeitsspeicher in Frankfurt analysiert. Eine komprimierte Datei (MP3, FLAC, M4A) läuft über eine temporäre Datei, die gleich nach dem Dekodieren gelöscht wird. Eine Datei, die du über den Signed-Upload-Weg hochlädst, wird innerhalb einer Stunde entfernt. Wir behalten die Zahlen und, wenn du es erlaubst, den Dateinamen. Siehe /privacy.
  • Limits. Free: 1.000 Aufrufe und 2 GB im Monat, 60 Anfragen pro Minute. Plus: 10.000 Aufrufe, 20 GB, 120 pro Minute. Ein Batch nimmt bis zu 20 Dateien, eine Datei darf 150 MB groß sein, und jede Datei in einem Batch ist ein Aufruf. Jede akzeptierte Anfrage zählt, auch eine, die danach fehlschlägt. Eine Datei, die sich nicht messen lässt, ist eine Zeile mit ihrem Fehler in der CSV, und die anderen Dateien werden trotzdem gemessen.
  • Wo du stehst. AudioLab().usage() liefert den Tarif sowie die in diesem Monat verbrauchten Aufrufe und Daten gegenüber den Limits. Das Skript fragt es ab, bevor es etwas sendet. Das Lesen ist kostenlos und zählt nicht als Aufruf.

Weiter in General docs

Eine Antwort lesen: was jedes Feld bedeutet und wie weit du ihm trauen kannst →

Eine echte Antwort der API, Feld für Feld: was sie sagt, ihr Status und ihre Marge und was du daraus schließen darfst und was nicht.

More in General docs