Audio File Processor (AFP)

The Audio File Processor (AFP) is decibri's offline audio source. It runs a recording through the same conditioning chain the live microphone uses, and, because a recording has an end, it can also score the whole file for speech in one pass. The class is called File, in both Python and Node.js.

Where a Microphone hands you conditioned audio as it arrives, a File hands you conditioned audio from something you already have: a recording on disk, or samples already in memory. It also writes what it produced, so a conditioned recording can go straight back out to a file. The options are the same options, with the same names and the same ranges, and the chunks come out in the same shape. Like the rest of the engine it is keyless and local: no API key, no account, no network call.

What a File reads and writes

The path constructors read four containers: WAV, AIFF, AIFF-C and FLAC. Every encoding below opens through the same File(path) call, so nothing about the call changes with the format. Every conditioning option works on every one of them, and analyze() returns the same scores and the same segments for the same recording whichever container carried it.

Container Encodings
WAV 8-bit unsigned, 16-bit, 24-bit and 32-bit integer PCM, 32-bit and 64-bit IEEE float, mu-law and A-law. Also WAVE_FORMAT_EXTENSIBLE, which several common encoders emit by default, and RF64.
AIFF and AIFF-C 8-bit, 16-bit, 24-bit and 32-bit integer PCM, 32-bit and 64-bit IEEE float, mu-law and A-law, plus AIFF-C's little-endian sowt alongside the big-endian forms.
FLAC Bit depths 4 through 32.

save() writes three of those four: WAV, AIFF and FLAC, always as 16-bit PCM mono. See Saving the result.

The container is identified from the file's first twelve bytes rather than from its name, so a FLAC called clip.wav opens as a FLAC. Writing goes the other way: save() takes the container from the extension you chose. The asymmetry is deliberate, and it is worth holding both halves in mind at once. On a read there is a file to inspect; on a write there is only the name the caller supplied.

MP3, AAC, m4a, Ogg Vorbis, Opus, WMA and every ADPCM variant are not supported, and support for them is not planned: decoding them needs codecs decibri does not carry. For audio in one of those, decode it yourself and pass the samples to the buffer constructor; the conditioning behaves identically either way.

Multi-channel recordings are accepted and downmixed to mono, where the Microphone rejects multi-channel input outright.

Two structural cases are refused rather than tolerated. A WAV carrying a fmt chunk and no data chunk raises AudioFileMalformed, because it promises audio it does not contain; a data chunk of length zero is a legitimate empty recording and still opens, reports its rates, yields no chunks and analyses to an empty report. And a WAV whose declared data length is not a whole number of frames raises AudioFileTruncated instead of quietly delivering audio a fraction of a frame short of its own declaration. That second case is not the interrupted download, which declares more bytes than it holds and already failed to open; it is what a repair tool or an unfinalised writer leaves behind.

The three things a File does

A File does exactly three things, and each one reads the source from beginning to end:

  1. Stream. Deliver conditioned mono audio at your target rate, chunk by chunk. This is the same conditioning the live capture path applies, run over a recording instead of a microphone.
  2. Analyse. Score the whole recording for voice activity in one pass and return the per-window scores plus the merged speech segments. A live stream cannot do this, because it has no end.
  3. Save. Write the conditioned recording to a file: the same single pass, the same conditioning, ending at a WAV, AIFF or FLAC on disk instead of in your own buffers. See Saving the result.
One File, one operation. The three are alternatives, not a sequence. Each consumes the source once, so beginning one forecloses the others on that instance. save() consumes the source exactly as analyze() does, and raises the same engaged and consumed errors when it cannot. To do two of them over the same recording, construct a second File. See the complete example.

Quick example

Open a recording, turn on the conditioning stages you want, and read conditioned chunks:

import decibri

with decibri.File("clip.wav", denoise="fastenhancer-t", agc=-18) as file:
    for chunk in file:
        handle(chunk)  # conditioned int16 PCM bytes

Leave an option unset to keep that stage off. With every stage off, a File simply reads the recording, downmixes it to mono, and resamples it to your target rate.

Node.js also has a global File. The web File API defines one. Import decibri's explicitly with const { File } = require('decibri'), or reference it as decibri.File, so the two do not shadow each other.

Opening a source

There are three ways in, and the right one depends on where the audio is and whether you are on an event loop.

Situation Python Node.js
A recording on disk, blocking is fine decibri.File(path) new File(path)
A recording on disk, inside async code await decibri.AsyncFile.open(path) await File.open(path)
Samples already in memory decibri.File.buffer(samples, input_rate=...) File.buffer(samples, { inputRate: ... })

Two spellings are easy to mix up. In Python, File.open(path) is a plain alternate spelling of File(path) and returns the same object; the async form is a separate class, AsyncFile. In Node.js, File.open() is the async factory and reads the file off the event loop, while File.buffer() is synchronous because no I/O is involved.

The buffer constructor takes mono samples in the range -1.0 to 1.0: a list of floats or a one-channel NumPy array with a floating dtype in Python, a Float32Array in Node.js. Raw samples carry no header, so their native rate has to be passed explicitly on input_rate (inputRate in Node.js). It is required, and range-checked from 1,000 to 384,000 exactly as the target rate is.

import decibri

# samples: mono floats in -1.0 to 1.0, at their own native rate
file = decibri.File.buffer(samples, input_rate=48000, sample_rate=16000)

for chunk in file:
    handle(chunk)

The conditioning chain

A File takes the same five conditioning options as a Microphone, with the same names, the same ranges, and the same fixed order: DC removal → denoise → high-pass → AGC → limiter. Every stage is off by default. The Audio Capture Engine page documents what each stage does and how to choose its value; everything there applies to a File unchanged.

file = decibri.File(
    "clip.wav",
    sample_rate=16000,
    dc_removal=True,
    denoise="fastenhancer-t",
    highpass=100,
    agc=-18,
    limiter=-1.0,
)

As on the microphone, voice activity detection reads the signal before the conditioning chain, so turning on enhancement does not change what counts as speech. It only changes the audio you receive.

Chunks and rates

A full chunk is 1,600 samples at the target rate, in both bindings. That is 3,200 bytes as int16 and 6,400 bytes as float32. The final chunk is shorter whenever the total does not divide evenly.

The count is fixed and the rate is not, so the chunk duration moves with the target rate: 100 ms at 16 kHz, 200 ms at 8 kHz, and about 33 ms at 48 kHz. Size buffers and reason about latency from the duration, not the count.

Two rates are visible on the instance. sample_rate (sampleRate) is the target rate every delivered chunk carries. input_rate (inputRate) is the source's own rate, read from the file's header or taken from the value you passed to the buffer constructor. They differ whenever the recording is resampled.

Rate conversion and denoise both lengthen the output. The resampler's internal state is flushed at the end of the stream and those samples are delivered rather than discarded, and the denoise stage does the same. A one-second recording resampled to a different rate therefore yields slightly more than one second of samples. Code that allocates an output buffer from duration × rate will come up short. The one case with no tail is the identity case: source rate equal to target rate, with denoise off, gives exactly the input sample count.

In Node.js, the total number of bytes is exact but the number of chunks depends on how you consume the stream: a for await loop and a 'data' listener over the same recording produce the same total in a different number of pieces, because Readable coalesces its internal buffer. Treat the total as exact and the chunk count as a property of the consumption style, not of decibri.

Voice activity detection

VAD is opt-in through the vad option, exactly as on the Microphone: pass "silero" or "energy" for the defaults, or a config object to tune the threshold and the holdoff. Without it, a File just conditions audio.

While you are streaming, Python exposes the speaking state as the is_speaking property and the raw score as vad_score. Node.js emits 'speech' and 'silence' events and exposes the raw score as vadScore.

import decibri

with decibri.File("clip.wav", vad="silero") as file:
    for chunk in file:
        if file.is_speaking:
            send_to_stt(chunk)  # file.vad_score holds the raw score

The holdoff on a File is measured in file time, not wall-clock time. A recording processes faster than real time, so a wall-clock timer would collapse the reported speech timing; measuring it in sample positions means a file reports the same state sequence a live stream of the identical audio would.

Three different things carry a speech signal here, and they are not interchangeable:

What you want What to use
The speech regions of a whole recording analyze().segments
Per-window scores across a whole recording analyze().scores
The speech score while streaming chunks vad_score (Python), vadScore (Node.js)
The debounced speech state while streaming is_speaking (Python), the 'speech' and 'silence' events (Node.js)

Whole-recording analysis

analyze() runs the recording once and returns a report of where the speech is. It requires vad="silero": a File built without a VAD configuration is refused with analysis requires VAD; construct the File with a vad configuration, and energy mode has no whole-recording analysis. analyse() is the same call under the other spelling.

The report has exactly two parts, both in seconds of file time:

import decibri

report = decibri.File("clip.wav", vad="silero").analyze()

for segment in report.segments:
    print(f"speech from {segment.start:.3f}s to {segment.end:.3f}s")

for window in report.scores:
    print(window.start, window.end, window.vad_score, window.is_speech)

The threshold and the holdoff shape the result: raising the threshold trims the segment edges, and a long holdoff merges neighbouring segments into one. Both are set on the vad config object at construction, so changing them means constructing a new File.

Saving the result

save() is the third pass. It runs the recording once through whatever conditioning the File was built with, whole, and writes the result to a path you name. Like the other two passes it consumes the source, so a File that has saved cannot then be streamed or analysed.

import decibri

file = decibri.File("noisy.wav", denoise="fastenhancer-t", agc=-18, limiter=-1.0)
report = file.save("clean.flac")

print(report.clipped_samples, report.non_finite_samples)

The container comes from the extension: .wav, .aiff, .aif, .aifc and .flac, matched without regard to case. An extension decibri does not recognise, and a path carrying no extension at all, are both refused with AudioFormatUnsupported rather than defaulted to something. One name is not quite what it looks like: .aifc writes a plain AIFF, not an AIFF-C. To decide the container yourself, set format to "wav", "aiff" or "flac", which overrides the extension entirely.

What comes out is 16-bit PCM, mono, at the File's target rate, in all three containers. No option changes that. compression sets the FLAC compression level, 0 through 8, default 5: higher levels search harder for a smaller file, and every level decodes to identical audio. It applies to FLAC only and is ignored for WAV and AIFF, and a value outside the range is refused.

The call returns a report of what the write did to the samples, carrying two counts. clipped_samples (clippedSamples in Node.js) counts finite samples that fell outside full scale and were clamped back into the -1.0 to 1.0 range. That count is the reason to read the report at all: AGC without a limiter can push a signal past full scale, and 16-bit PCM cannot hold it. Rather than clip silently, decibri clamps and tells you how often, which makes the fix visible: turn the limiter on and save again. non_finite_samples (nonFiniteSamples) counts samples that were NaN or infinite and were repaired on the way out, a NaN written as silence and an infinity as full scale.

Reading four containers and writing three does mean some conversions work. Open a FLAC, save a WAV, and you have converted it. That falls out of saving conditioned output rather than being a feature with a surface of its own, and the shape of the result says as much: 16-bit PCM mono at the File's target rate, through whatever conditioning the File carries, with no encoder to choose. What is not coming is a codec decibri does not carry. MP3, AAC and Ogg are not supported and are not planned.

Complete example

This is the two-pass shape the single-pass rule leads to: analyse one File to find the speech, then construct a second File over the same recording to stream the conditioned audio.

import decibri

# Pass one: where is the speech? This File is consumed by the analysis.
report = decibri.File("clip.wav", vad="silero").analyze()

# Pass two: a second File over the same recording, conditioned and streamed.
# chunk.timestamp is the chunk's position in seconds of file time, which is
# the same clock report.segments is measured on.
with decibri.File(
    "clip.wav",
    denoise="fastenhancer-t",
    agc=-18,
    limiter=-1.0,
) as file:
    for chunk in file.iter_with_metadata():
        if any(s.start <= chunk.timestamp <= s.end for s in report.segments):
            send_to_stt(chunk.data)

Constructing a second File over the same source is the supported way to do both. Neither pass waits on real time, so the second one costs a second read of the recording rather than a second playback of it.

Per-language detail

The full constructor surface, the metadata iteration, and the error tables live on the binding reference pages:

AFP is a native stage of the engine, so it is available in the Python and Node.js builds but not in the browser build. See the Browser API.