Naar inhoud
Inloggen

/docs · General · Practical

Audiokwaliteit controleren in CI: laat een build falen als een bestand je regels breekt

Een kort Python-script en een GitHub-workflow die elk audiobestand met de AudioLab-API meten, de getallen vergelijken met limieten die je zelf instelt, en de build laten falen met een bericht dat zegt waarom.

Bijgewerkt 12 okt 2026


Voor wie dit is

Je levert audio uit een pipeline: text-to-speech-prompts, opnames van voice-agents, podcastafleveringen, game- of appgeluid. Een build moet stoppen als een bestand te zacht is, clipt, bandbegrensd is of ruis heeft, voordat een luisteraar het hoort.

Het is voor teams die hun eigen limieten instellen. AudioLab meet het bestand; of een getal slaagt, is jouw regel. Het beoordeelt niet hoe natuurlijk een stem klinkt en het leest de woorden niet. Zie wat AudioLab niet doet.

Wat je krijgt

  • Een script, audio_gate.py, dat elk bestand dat je het geeft meet, je limieten controleert en eindigt met een exitcode die je CI begrijpt. Zijn enige afhankelijkheid is het pakket audiolabtools.
  • Een limietenbestand, gate.json, met één vermelding per getal.
  • Een workflow, audio-gate.yml, die het script op pull requests draait en alleen de audiobestanden meet die de pull request toevoegt of wijzigt.
  • Elke falende regel noemt het getal, zijn waarde en de limiet, en zegt hoe ver je het getal kunt vertrouwen: zijn status en zijn marge. Wie een rode build leest, weet waar hij naar kijkt. Zie hoe je een respons leest.

Voordat je begint

  1. Haal een sleutel. Hij is gratis en vraagt geen kaart: log in op /account en genereer er een. Sla hem in GitHub op als repository-secret met de naam AUDIOLAB_KEY. Commit hem nooit.
  2. Weet dat de bestanden de runner verlaten. Elk bestand dat de gate meet, gaat naar de AudioLab-API, gehost in de EU, en alleen getallen komen terug. Zie “Privacy en limieten” hieronder. Als je audio je eigen systemen niet mag verlaten, is dit niet het gereedschap voor de build. De werkbank meet in je browser en je audio wordt niet geüpload, maar met de hand, niet in een build.
  3. Weet wat het kost. De tien getallen komen uit drie analyses: de loudness-getallen uit één aanroep, de voice-getallen uit een tweede, de bandbreedte uit een derde. Het script vraagt alleen de aanroepen die je limieten nodig hebben. Het limietenbestand hieronder gebruikt alle drie, dus een bestand kost drie aanroepen; een limietenbestand met alleen loudness-getallen kost één aanroep per bestand. Elke aanroep telt mee voor je aanroepen en je data van de maand. De gratis tier is 1.000 aanroepen per maand, 2 GB per maand en 60 verzoeken per minuut.
  4. Python 3.9 of nieuwer, en het pakket:
pip install audiolabtools

Je limieten

{
  "integratedLufs": { "min": -26, "max": -16 },
  "truePeakDb": { "max": -1 },
  "clipping": { "oneOf": ["None"] },
  "signalToNoiseDb": { "min": 30 },
  "bandwidthHz": { "min": 7000 }
}

Elke vermelding noemt een van de tien getallen en geeft een min, een max of beide. clipping is een woord, dus die krijgt een lijst met de woorden die je toestaat in oneOf. Deze limieten zijn voorbeelden: stel je eigen limieten in op basis van je eigen goede opnames. Neem er twintig die je goed vindt en gebruik hun 5e en 95e percentiel, zoals stap 7 van “Zo lees je ze” in de vergelijkingsgids zegt.

Het script

"""Fail a build when an audio file breaks your rules.

    python audio_gate.py gate.json audio/intro.wav audio/outro.wav

gate.json holds the limits, one entry per number:
https://audiolab.tools/docs/start/audio-quality-gate-in-ci
Every file is sent to the AudioLab API; only numbers come back.

Exit code: 0 every file passes, 1 a file breaks a rule, 2 a file could not be checked
(a key, a limit of your plan, no network). A build must not pass on 2.
"""

from __future__ import annotations

import json
import sys
import time

from audiolabtools import (
    ApiTimeout,
    ApiUnreachable,
    AudioLab,
    AudioLabError,
    AuthenticationError,
    MissingApiKey,
    PlanLimitError,
    RateLimitError,
)

PASSED, BROKEN, COULD_NOT_CHECK = 0, 1, 2

# The same for every file, so there is no point in going on: a key that is refused,
# a limit of your plan, a minute that is full, no network.
STOPS = (
    AuthenticationError, PlanLimitError, RateLimitError,
    MissingApiKey, ApiUnreachable, ApiTimeout,
)

NEAR = "  within the margin: the number cannot say if the file is past it"


def broken_rules(numbers, rules):
    """The rules one file breaks, one line each.

    A line carries the status and the margin of the number: how far to trust it.
    """
    lines = []
    for field, rule in rules.items():
        m = numbers[field]
        trust = (m.status or "no status") + (f", margin {m.margin}" if m.margin else "")
        if m.value is None:
            lines.append(f"{field} could not be measured")
        elif "oneOf" in rule:
            if m.value not in rule["oneOf"]:
                allowed = ", ".join(rule["oneOf"])
                lines.append(f"{field} is {m.value}, allowed: {allowed} ({trust})")
        else:
            mg = m.margin
            slack = max(mg.plus_minus or 0, mg.plus or 0, mg.minus or 0) if mg else 0
            low, high = rule.get("min"), rule.get("max")
            past = []
            if low is not None and m.value < low:
                past.append(("below the minimum", low))
            if high is not None and m.value > high:
                past.append(("above the maximum", high))
            for word, limit in past:
                lines.append(f"{field} is {m.value} {m.unit}, {word} {limit} ({trust})")
                if abs(m.value - limit) <= slack + 1e-9:
                    lines.append(NEAR)
    return lines


def measure(client, path, fields, wait=time.sleep):
    """The numbers of one file. On a full minute the API says how long to wait: do that once."""
    try:
        return client.ten_numbers(path, only=fields)
    except RateLimitError as e:
        wait(e.retry_after or 60)
        return client.ten_numbers(path, only=fields)


def main(argv, client=None, out=print, wait=time.sleep):
    if len(argv) < 3:
        out("usage: python audio_gate.py gate.json file [file ...]")
        return COULD_NOT_CHECK
    with open(argv[1], encoding="utf-8") as f:
        rules = json.load(f)
    client = client or AudioLab()  # reads AUDIOLAB_KEY
    result, broken, unchecked = PASSED, 0, 0
    for path in argv[2:]:
        try:
            lines = broken_rules(measure(client, path, list(rules), wait), rules)
        except STOPS as e:
            out(f"STOP   {path}\n         {e}")
            return COULD_NOT_CHECK
        except AudioLabError as e:  # a problem with this file only: say so, check the others
            out(f"ERROR  {path}\n         {e}")
            unchecked += 1
            result = COULD_NOT_CHECK
            continue
        out(f"{'FAIL' if lines else 'ok  '}   {path}")
        for line in lines:
            out(f"         {line}")
        if lines:
            broken += 1
            result = max(result, BROKEN)
    checked = len(argv) - 2
    out(f"{checked} file{'' if checked == 1 else 's'} checked, {broken} break a rule, "
        f"{unchecked} could not be checked")
    return result


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

Het leest de limieten, vraagt ten_numbers om de getallen die de limieten noemen (only=, dus er wordt niets anders aangeroepen), en print één regel per gebroken regel. Twee dingen beslist het voor je. Een volle minuut wordt eenmaal uitgewacht, zo lang als de API in zijn Retry-After vraagt, omdat dat is wat de API zegt te doen. Een geweigerde sleutel, een limiet van je abonnement of geen netwerk stopt de run bij het eerste bestand, omdat hetzelfde met elk bestand zou gebeuren; een probleem met één bestand wordt gemeld en de andere bestanden worden nog steeds gecontroleerd.

De workflow

name: audio quality gate

on:
  pull_request:
    paths: ["audio/**"]

jobs:
  gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install audiolabtools
      - name: Measure the audio files this pull request adds or changes
        env:
          AUDIOLAB_KEY: ${{ secrets.AUDIOLAB_KEY }}
          BASE: ${{ github.base_ref }}
        run: |
          mapfile -d '' files < <(git diff -z --name-only --diff-filter=AM \
            "origin/$BASE...HEAD" -- 'audio/*.wav' 'audio/*.mp3' 'audio/*.flac')
          if [ "${#files[@]}" -eq 0 ]; then echo "No audio files changed."; exit 0; fi
          python tools/audio_gate.py tools/gate.json "${files[@]}"

Zet audio_gate.py en gate.json in tools/, en de audio in audio/. De laatste stap geeft het script alleen de bestanden die deze pull request toevoegt of wijzigt, zodat een oud bestand niet bij elke push opnieuw wordt gemeten. Een pull request vanuit een fork krijgt geen van je secrets. Zonder sleutel draait het script als de proefperiode zonder sleutel, die maar 20 aanroepen per maand toestaat, dus de gate stopt al snel met een limietfout en exitcode 2. Beslis of je de job voor forks overslaat.

Hoe een mislukte check eruitziet

FAIL   audio/prompt-welcome.wav
         integratedLufs is -31.5 LUFS, below the minimum -26 (validated, margin ±0.1 LU)
1 file checked, 1 break a rule, 0 could not be checked

Dit is de uitvoer van het script op de voorbeeldlimieten, voor een bestand dat veel te zacht is. De exitcode is 1, dus de build faalt, en de log zegt welk bestand, welk getal, welke limiet en hoe ver je het getal kunt vertrouwen.

Als een getal dichter bij de limiet ligt dan zijn marge, zegt de regel dat. Hetzelfde bestand tegen een minimum van -31,4 in plaats van -26:

FAIL   audio/prompt-welcome.wav
         integratedLufs is -31.5 LUFS, below the minimum -31.4 (validated, margin ±0.1 LU)
           within the margin: the number cannot say if the file is past it
1 file checked, 1 break a rule, 0 could not be checked

Zo lees je het

  1. Exit 1 betekent dat het bestand een regel brak. Exit 2 betekent dat de gate het niet kon zeggen. De regel bevat de foutcode en de pagina die hem uitlegt, bijvoorbeeld https://audiolab.tools/docs/errors#quota_exceeded. Een build mag niet slagen op exit 2: een gate die de API niet kan bereiken, heeft niets gecontroleerd.
  2. Lees de marge voordat je met een falen in discussie gaat. Een regel gevolgd door “within the margin” is een getal dat je niet kan vertellen of het bestand voorbij de limiet is, omdat het getal zelf er verder naast kan zitten dan de afstand. Als de marge niet symmetrisch is, gebruikt het script de grootste kant. Versoepel de limiet met de marge of repareer het bestand. Het script beslist niet voor je.
  3. Een limiet op een proxy vergelijkt, hij stelt geen feit vast. speechRatio en longSilences zijn proxy’s, en de regel zegt dat. Ze zijn goed in het vangen van een grote verandering en slecht in het bewaken van een decimaal.
  4. Begin ruim, scherp dan aan. Draai de gate een tijdje met ruime limieten en lees wat hij print, en maak de limieten dan smaller tot wat je goede opnames laten zien. Een gate die elke build laat falen, wordt uitgezet.
  5. Een run meet wat hij krijgt. Als je gate.json wijzigt, worden de bestanden die al in de repository staan niet gemeten. Draai het script na het wijzigen van een limiet eenmaal over de hele map.

Wat het niet kan

  • Het meet het bestand, niet wat er wordt gezegd. Een bestand binnen alle limieten kan nog steeds de verkeerde zin, een slechte stem of de verkeerde taal zijn.
  • Het heeft geen geheugen. Het vergelijkt een bestand niet met een eerdere versie of met een referentie. Om vendors of versies over veel zinnen te vergelijken, gebruik je de vergelijkingsgids.
  • Het draait niet offline. De engine is niet als pakket gepubliceerd, dus elk bestand gaat naar de API.
  • Zijn getallen hebben de status en de marge die ze hebben. Zie wat AudioLab niet doet.

Privacy en limieten

  • Je audio wordt niet bewaard. Ze wordt in het geheugen geanalyseerd in Frankfurt. Een gecomprimeerd bestand (MP3, FLAC, M4A) gaat via een tijdelijk bestand dat direct na het decoderen wordt verwijderd. Een bestand dat je via de signed-upload-route uploadt, wordt binnen het uur verwijderd. Wij bewaren de getallen en, als je dat toestaat, de bestandsnaam. Zie /privacy.
  • Limieten. Gratis: 1.000 aanroepen en 2 GB per maand, 60 verzoeken per minuut. Plus: 10.000 aanroepen, 20 GB, 120 per minuut. Een lokaal bestand van meer dan 4 MB wordt eenmaal via de signed-upload-route geüpload voordat het wordt gemeten: dat telt als een verzoek maar niet als een aanroep. Elk geaccepteerd verzoek telt mee, ook als het daarna faalt. Een fout zegt wat je moet doen: /docs/errors.
  • Waar je staat. AudioLab().usage() geeft het abonnement en de deze maand gebruikte aanroepen en data terug, tegen de limieten. Het lezen is gratis en telt niet als aanroep.

Volgende in General docs

Een dataset met gesprekken auditen: waar de audio echt vandaan komt →

Meet een map met opnames van gesprekken, voice-agents of text-to-speech op een lossy bron, upsampling, plakplekken, mono vermomd als stereo, clipping en bitdiepte, 20 bestanden per keer, en weet wanneer de audio wordt geüpload en wanneer niet.

More in General docs