Advanced Features

Waveform Generation

Every Probe call that decodes audio (WAV, AIFF, ALAC, FLAC, MP3, AAC, WMA, TTA, Opus, Vorbis, WavPack — not MOV/MP4, which is metadata-only unless a decodable audio track is found) populates ProbeResult.Waveform with a list of normalized peak values in [0, 1], one per window, via WaveformCalculator:

using EggEncoder.Waveform;

var calculator = new WaveformCalculator(totalSamplesPerChannel, channels, bitsPerSample, sampleRate: sampleRate);

int framesRead;
while ((framesRead = wavReader.ReadInterleavedSamples(buffer, 4096)) > 0)
{
    calculator.AddBlock(new ReadOnlySpan<int>(buffer, 0, framesRead * channels));
}

List<double> windows = calculator.GetNormalizedWindows(); // default 200 windows
double peak = calculator.GetNormalizedPeakAmplitude(); // loudest absolute sample seen so far, in [0, 1]
double rms = calculator.GetNormalizedRmsLevel();       // root-mean-square across every sample, in [0, 1]
double? lufs = calculator.GetIntegratedLoudness();     // ITU-R BS.1770-4 / EBU R128 integrated loudness, only if sampleRate was given

WaveformCalculator streams — call AddBlock as many times as you like across however many decode callbacks the codec produces; it does not need the full signal in memory at once. GetNormalizedPeakAmplitude/GetNormalizedRmsLevel need no flushing step the way GetNormalizedWindows does (its own trailing partial window) — both are fully up to date after every AddBlock call, and back ProbeResult.PeakAmplitude/RmsLevel the same way GetNormalizedWindows backs ProbeResult.Waveform. RmsLevel is a rough loudness indicator, not an ITU-R BS.1770 perceptual loudness measurement — for that, see Loudness Measurement below.

WAV Metadata Tags

Probe-ing a WAV file with a 'LIST'/'INFO' chunk populates ProbeResult.Tags, keyed by the raw four-character chunk ID exactly as RIFF stores it (e.g. "INAM" for title, "IART" for artist, "ICRD" for creation date) — not remapped to friendlier names, the same way CodecName exposes "pcm_s16le" rather than a human label:

var probeResult = await mediaEncoder.Probe("tagged.wav");
if (probeResult.Tags is { } tags && tags.TryGetValue("INAM", out var title))
{
    Console.WriteLine($"Title: {title}");
}

Tags is null for every non-WAV format, and for a WAV file that simply doesn't carry a 'LIST'/'INFO' chunk (most don't). A malformed or truncated tag list degrades gracefully — whatever sub-chunks parsed cleanly before the inconsistency are still returned — rather than failing the whole Probe call, since this metadata is decorative and unrelated to decoding the audio itself.

WavWriter.Create can write tags back out too, via an optional tags parameter — the exact inverse of the read side, written as a single trailing chunk after the audio data:

using var writer = WavWriter.Create("tagged.wav", channels: 2, sampleRate: 44100, bitsPerSample: 16,
    totalFrames: frameCount, tags: new Dictionary<string, string> { ["INAM"] = "Title", ["IART"] = "Artist" });

Threads through every WavSampleFormat, including the block-structured ADPCM variants. Unlike the read side, write is strict: a tag id must be exactly 4 ASCII characters (RIFF's own FourCC convention, e.g. "INAM") or Create throws ArgumentException immediately — writing a well-formed file is a hard requirement here, unlike reading an already-on-disk file's possibly-malformed metadata.

MP3 ID3v2 Tags

Probe-ing an MP3 file with an ID3v2.3/2.4 tag populates the same ProbeResult.Tags, keyed by the raw four-character frame ID exactly as ID3v2 stores it (e.g. "TIT2" for title, "TPE1" for artist, "TALB" for album) rather than remapped to friendlier names — scoped to text information frames (any frame ID starting with T, except TXXX's own user-defined description+value structure):

var probeResult = await mediaEncoder.Probe("tagged.mp3");
if (probeResult.Tags is { } tags && tags.TryGetValue("TIT2", out var title))
{
    Console.WriteLine($"Title: {title}");
}

Tags is null for every non-WAV/MP3 format, and for an MP3 that simply doesn't carry an ID3v2 tag. A real but rare tag feature this reader doesn't decode (the extended header, whole-tag unsynchronization, a major version other than 2.3/2.4, or a per-frame compression/encryption/grouping/unsynchronization flag) degrades gracefully to an empty or partial tag rather than throwing — the same "decorative metadata" convention WAV's own tag parsing established above.

Mp3Encoder.Encode/OpenSession can write a tag back out too, via an optional tags parameter — always emits a fresh ID3v2.4 tag (UTF-8 text) prepended before the audio frames:

Mp3Encoder.Encode("source.wav", "tagged.mp3",
    tags: new Dictionary<string, string> { ["TIT2"] = "Title", ["TPE1"] = "Artist" });

Unlike the read side, write is strict: a tag id must be exactly 4 ASCII characters identifying a text information frame (starts with T, not TXXX) or OpenSession throws ArgumentException before the destination file is even created.

FLAC/Opus/Vorbis Comment Tags

Probe-ing a FLAC, Opus, or Vorbis file with comment tags populates the same ProbeResult.Tags, keyed by the raw field name exactly as stored (e.g. "TITLE", "ARTIST", "ALBUM") rather than remapped to friendlier names or normalized to a particular case — all three formats share the same Vorbis-spec vendor+comment-list convention, just embedded differently per container (FLAC's own VORBIS_COMMENT metadata block, Opus's OpusTags packet, or Vorbis's own mandatory comment header packet):

var probeResult = await mediaEncoder.Probe("tagged.flac"); // or .opus / .ogg
if (probeResult.Tags is { } tags && tags.TryGetValue("TITLE", out var title))
{
    Console.WriteLine($"Title: {title}");
}

Tags is null for every non-WAV/MP3/FLAC/Opus/Vorbis format, and for a file with no comment tags at all. A malformed or out-of-bounds field degrades gracefully (returns whatever parsed cleanly before that) rather than throwing, the same convention WAV's and MP3's own tag parsing established above. FlacEncoder/OpusEncoder/VorbisEncoder's own Encode/OpenSession can write tags back out too, via an optional tags parameter:

FlacEncoder.Encode("source.wav", "tagged.flac",
    tags: new Dictionary<string, string> { ["TITLE"] = "Title", ["ARTIST"] = "Artist" });

Unlike the read side, write is strict: a field name must be non-empty, printable ASCII, and must not contain =, or OpenSession throws ArgumentException before the destination file is even created — for all three formats, even though Vorbis itself delegates the actual write to OggVorbisEncoder's own Comments.AddTag, which validates nothing on its own.

AIFF Metadata Tags

Probe-ing an AIFF file populates the same ProbeResult.Tags from two possible sources, merged into one dictionary: the simple 'NAME'/'AUTH'/'(c) ' text chunks (keyed by the raw chunk id, e.g. "NAME" for title) and an embedded 'ID3 ' chunk if present (keyed by the raw ID3v2 frame id, e.g. "TIT2" — some real-world AIFF files carry one even though it's not AIFF's own native convention):

var probeResult = await mediaEncoder.Probe("tagged.aiff");
if (probeResult.Tags is { } tags && tags.TryGetValue("NAME", out var title))
{
    Console.WriteLine($"Title: {title}");
}

Tags is null if the file carries none of these chunks. AiffWriter.Create can write a tag back out too, via an optional tags parameter — writes AIFF's own native text chunks only, never an embedded ID3v2 chunk:

AiffWriter.Create("tagged.aiff", channels: 2, sampleRate: 44100, bitsPerSample: 16, totalFrames: frameCount,
    tags: new Dictionary<string, string> { ["NAME"] = "Title", ["AUTH"] = "Artist" });

Unlike the read side, write is strict: a tag id must be exactly "NAME", "AUTH", or "(c) " or Create throws ArgumentException before the destination file is even created.

Loudness Measurement (EBU R128)

WaveformCalculator takes an optional sampleRate constructor parameter; when given, Probe also measures real perceptual loudness — ITU-R BS.1770-4 / EBU R128 integrated loudness, in LUFS — alongside its existing peak/RMS tracking, surfaced as ProbeResult.IntegratedLoudness. This is a genuinely different measurement from RmsLevel (a flat sample-level average): the signal is K-weighted first, then gated 400ms-block energy averaging is applied per the standard:

var probeResult = await mediaEncoder.Probe("track.wav");
double? integratedLoudnessLufs = probeResult.IntegratedLoudness;

IntegratedLoudness is null if the audio is shorter than one 400ms gating block (or, for a format where Probe can't decode audio at all, the same case Waveform/PeakAmplitude/RmsLevel are null for). It can legitimately be double.NegativeInfinity for audio entirely below the standard's own -70 LUFS absolute silence gate — that's a real measurement, not a "couldn't measure" sentinel. Every Probe/Probe(Stream, AudioFormat) entry point populates it whenever audio decodes at all.

Internally, each sample is run through Waveform.KWeightingFilter — two cascaded biquads using ITU-R BS.1770-4's own tan-prewarped bilinear-transform coefficients, a different parameterization from Pcm.BiquadTransform's RBJ-cookbook formulas, so it isn't reused from that type — before 400ms/100ms-step gated block energies are accumulated (absolute gate at -70 LUFS, relative gate 10 LU below the mean energy of the absolute-gated blocks). The whole pipeline (filter and gating together, not just the coefficients in isolation) was verified against the standard's own published conformance signal: a 997Hz sine wave at -23dBFS peak must measure -23.0 LUFS.

Pcm.LoudnessTransform normalizes to a target LUFS using this same measurement, the way PeakNormalizationTransform normalizes to a target peak dBFS — see PCM Transform Pipeline below for the two-pass measure-then-apply usage, and feature-roadmap.md item 3 for status (done, both halves).

Stream-based Probing

IMediaEncoder.Probe(Stream source, AudioFormat format) probes a source that isn't a local file — an HTTP response body, blob storage, an in-memory buffer — without ever disposing source:

using var response = await httpClient.GetStreamAsync(audioUrl);
var probeResult = await mediaEncoder.Probe(response, AudioFormat.Flac);

Implemented for every AudioFormat: AudioFormat.Wav/Aiff/Au/Caf/Vorbis/Mov and AudioFormat.Flac/Aac/WavPack/Tta/Opus/Wma/Mp3, across four seek-requirement shapes: WavReader/AiffReader/AuReader/CafReader/MovProbe/MovDecoder's own Open(Stream)/Decode(Stream, ...) all require source.CanSeek, since each one's own chunk/atom-tree parse relies on Stream.Position/Seek throughout; FlacDecoder/AacDecoder/WavPackDecoder/OpusDecoder/WmaDecoder/Mp3Decoder's own Decode(Stream, ...) need no seek support at all (each reads the whole source into memory up front); TtaReader/TtaDecoder need no seek either, for a different reason — TTA's own header/seek-table/frame layout is read strictly sequentially, with zero Seek/Position calls anywhere in the reader; and VorbisDecoder's own Decode(Stream, ...) is the one exception that does require source.CanSeek — confirmed directly (not assumed), by feeding NVorbis's own VorbisReader a non-seekable stream and observing it throw — though it needed no "widen"/"read fully into memory" adaptation of its own at all, since VorbisReader's own constructor already supports not disposing the caller's stream directly (closeOnDispose: false). AudioFormat.Mp3 is covered too, via Mp3Decoder.Decode(Stream, ...)/Mp3Probe.Probe(Stream): the whole source is read into memory up front (no CanSeek needed, like FLAC/AAC), and NLayer is handed its own MemoryStream over those bytes — NLayer's MpegFile(Stream) always disposes the stream it's given, so it never sees the caller's. Because the bytes are buffered, a non-seekable MP3 source still reports a real SizeBytes rather than the usual 0. Unlike the path-based overload, AudioFormat.Mov has no file extension to distinguish a MOV container from an MP4 one, so Probe(Stream, AudioFormat.Mov) always reports FormatName/FormatLongName as "mov"/"QuickTime / MOV". See feature-roadmap.md item 1 for current status.

Cancellation

IMediaEncoder.ConvertFile/CutFile (and Probe, including its Stream overload) each have a CancellationToken-accepting overload:

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await mediaEncoder.ConvertFile(sourcePath, destPath, cts.Token);

ConvertFile/CutFile check the token once per decoded block — inside the single shared decode callback every codec's own decode loop already funnels through — giving real, sub-second cancellation for every supported format without needing to touch each codec's own individual decode loop. Probe's own check is deliberately shallow instead: once, up front, before any decode starts, not per-block. That's because Probe's internal structure has no equivalent single shared callback the way Convert/Cut do — it's split across more than twenty separate per-format methods, each driving its own WaveformCalculator directly. A cancelled call throws OperationCanceledException and produces no destination file. See feature-roadmap.md item 5.

Stream-based ConvertFile/CutFile

IMediaEncoder.ConvertFile(Stream source, AudioFormat sourceFormat, Stream dest, AudioFormat destFormat)/CutFile(Stream source, AudioFormat sourceFormat, int startInSeconds, int endInSeconds, Stream dest, AudioFormat destFormat) convert/cut between a source Stream and a destination Stream — for either side that isn't a local file. Neither stream is disposed by the call:

using var source = await httpClient.GetStreamAsync(audioUrl);
using var dest = new MemoryStream();
await mediaEncoder.ConvertFile(source, AudioFormat.Flac, dest, AudioFormat.Wav);

Backed by AudioCutter.Stream.cs's own Convert/Cut — the AudioFormat-keyed counterpart to the path-based overloads' own extension-keyed dispatch, reusing the exact same pipeline/fade/trim machinery (including a PcmTransformPipeline parameter, so ResamplingTransform/VolumeTransform/etc. work for a Stream destination exactly the same way they already do for a path-based one). Source: every format Probe(Stream, AudioFormat) already supports — every format, MP3 included. Destination: AudioFormat.Wav/Aiff/Au/Flac/Tta/Aac/Wma so far, via WavWriter.Create(Stream, ...)/AiffWriter.Create(Stream, ...)/AuWriter.Create(Stream, ...)/FlacEncoder.OpenSession(Stream, ...)/TtaEncoderSession.OpenSession(Stream, ...)/AacEncoderSession.OpenSession(Stream, ...)/WmaEncoderSession.OpenSession(Stream, ...) — WAV/AIFF/AU write a fixed-size header with every field already known from totalFrames up front, and TTA/AAC/WMA write strictly forward, so none of those need Stream.CanSeek; FLAC does, since FlacEncoderSession.Finish() seeks back to patch STREAMINFO. Every other destination codec's own Stream write side is still open, one format at a time, the same incremental way Stream-based Probe itself was rolled out. An unsupported AudioFormat on either side throws NotSupportedException. CancellationToken overloads of both check the token once per decoded block, the same as their path-based counterparts (see Cancellation above). See feature-roadmap.md item 1 for status.

Sample-Accurate Cutting

NativeEncoder.CutFile (backed by AudioCutter.Cut) decodes any supported source (WAV, AIFF, ALAC, FLAC, MP3, AAC, WMA, TTA, Opus, Vorbis, WavPack, or MOV/MP4 with a mono AAC-LC audio track) and writes any supported destination format without re-encoding the untouched region where the format allows frame-boundary slicing:

using EggEncoder.Codecs;

bool produced = AudioCutter.Cut(sourcePath, destPath, startInSeconds: 30, endInSeconds: 90);
// false means the requested range was entirely outside the source's duration — no file was written

Source and destination extensions no longer need to match — Cut can transcode while it trims (e.g. cut a WAV directly to MP3). Use AudioCutter.Convert (or NativeEncoder.ConvertFile) when you just need format conversion with no trimming.

PCM Transform Pipeline

A PcmTransformPipeline runs a list of IPcmTransforms over every decoded block between decode and the destination write. It's opt-in — the plain Convert/Cut above are untouched and still fully streaming. See API Reference for the full type list; this section is a usage walkthrough. Resolve IPcmTransformEncoder from DI the same way as IMediaEncoder (both come from AddEggEncoder()), or use AudioCutter's static methods directly.

Resampling, gain, remix, bit depth, in one pipeline

using EggEncoder.Codecs;
using EggEncoder.Pcm;

var pipeline = new PcmTransformPipeline(
    new ResamplingTransform(sourceRate: 44100, targetRate: 48000, channels: 2),
    new CompressorTransform(sampleRate: 48000, thresholdDb: -18, ratio: 4, attackMs: 10, releaseMs: 150),
    new NoiseGateTransform(sampleRate: 48000, thresholdDb: -40, ratio: 10, attackMs: 5, releaseMs: 100),
    new PanTransform(pan: -0.3, PanLaw.EqualPower),
    new ChannelRemixTransform(inputChannels: 2, outputChannels: 1),
    new EchoTransform(sampleRate: 48000, channels: 1, delaySeconds: 0.3, feedback: 0.4, wetGain: 0.5), // channels must match whatever's actually flowing at this point in the chain -- mono, after the remix above
    new VolumeTransform(gain: 1.5),
    new LimiterTransform(sampleRate: 48000, ceilingDb: -0.3, releaseMs: 50), // guarantee the final ceiling after everything above it, including any peak the echo added
    new BitDepthFormatTransform(fromBits: 16, toBits: 24));

AudioCutter.Convert(sourcePath, destPath, pipeline);

Transforms run in the order given; each receives the previous one's output. A pipeline instance carries state across blocks (a resampler's source history, a peak normalizer's measured gain) and is meant for exactly one Convert/Cut/Mix call — build a fresh PcmTransformPipeline per call rather than reusing an instance.

Resampling quality, and why Flush() exists

ResamplingTransform is a Kaiser-windowed-sinc polyphase filter: it anti-aliases on downsample and band-limit-reconstructs on upsample, rather than a naive linear interpolation between neighboring samples. To do that correctly across an arbitrarily block-split stream, it holds a destination frame back until the source frames on both sides of it have actually arrived — including a few frames past the end of the current block. That means the very last few output frames of a stream can't be produced until the transform is told there's no more input coming:

int[] channels = ...;
var pipeline = new PcmTransformPipeline(new ResamplingTransform(sourceRate: 44100, targetRate: 48000, channels: 2));

// AudioCutter.Convert(pipeline)/Cut(options) already call Flush for you -- only a driver working
// against PcmTransformPipeline directly needs to do this itself:
var (buffer, frameCount, ch, sampleRate, bitsPerSample) = pipeline.Apply(sourceBlock, frameCount, 2, 44100, 16);
destSink.WriteInterleavedSamples(buffer, frameCount);
// ... more Apply() calls for subsequent blocks ...
var (tailBuffer, tailFrameCount, _, _, _) = pipeline.Flush(2, 44100, 16); // drains the resampler's pending tail
if (tailFrameCount > 0) destSink.WriteInterleavedSamples(tailBuffer, tailFrameCount);

IPcmTransform.Flush() is a default interface method (existing custom transforms don't need to implement it; the default is a no-op) that cascades through every transform in a pipeline in order — a resampler's flushed tail is pushed through every later transform's own Apply, exactly like one final block, before that transform's own Flush drains whatever it, in turn, is left holding. Total output frame count converges exactly on round(totalSourceFrames * ratio) once flushed, regardless of how the source was split into blocks.

EchoTransform is the other built-in transform that needs Flush(), for a different reason than ResamplingTransform's withheld lookahead: its feedback delay line keeps producing progressively quieter repeats for a while after the real input ends, and that decaying tail is exactly what Flush() drains (a deterministic, bounded number of frames — see EchoTransform.MaxTailSeconds). Its CanChangeFrameCount reports true whenever feedback > 0, since the tail means total output genuinely exceeds total input — a caller sizing a destination sink from the input count alone (as AudioCutter.Convert/Cut does for a .wav destination) needs that signal to know it must wait and see the real total instead.

Fades on a cut

AudioCutter.Cut(sourcePath, destPath, startInSeconds: 30, endInSeconds: 90, new CutOptions
{
    FadeInSeconds = 0.5,
    FadeOutSeconds = 1.0,
    FadeCurve = FadeCurve.EqualPower,
    Pipeline = new PcmTransformPipeline(new VolumeTransform(1.2)) // optional; runs after the fade
});

The fade runs over the retained range — 0..FadeInSeconds at the start, the last FadeOutSeconds at the end — before anything in Pipeline.

True whole-file peak normalization

Dropping PeakNormalizationTransform straight into a pipeline self-measures from whatever block Apply sees first — one ~4096-frame decode block, not the whole file. For a true whole-file measurement, measure first and hand the result in:

var (peak, bitsPerSample) = AudioCutter.MeasurePeakAmplitude(sourcePath);

var normalize = new PeakNormalizationTransform(targetDb: -1.0);
normalize.MeasurePeak(peak, bitsPerSample);

AudioCutter.Convert(sourcePath, destPath, new PcmTransformPipeline(normalize));

True whole-file loudness normalization

LoudnessTransform is the real perceptual-loudness counterpart to PeakNormalizationTransform — it targets ITU-R BS.1770-4 / EBU R128 integrated loudness (LUFS), not peak dBFS. Unlike peak normalization, it has no self-measuring fallback at all: Apply throws InvalidOperationException unless MeasureLoudness was already called, since the standard's own gating algorithm cannot produce a meaningful number from one ~4096-frame decode block the way a peak scan can — measuring first is not just the recommended usage, it's required:

double? measuredLufs = AudioCutter.MeasureIntegratedLoudness(sourcePath);

var loudnessNormalize = new LoudnessTransform(targetLufs: -14.0); // a common streaming-platform target
loudnessNormalize.MeasureLoudness(measuredLufs);

AudioCutter.Convert(sourcePath, destPath, new PcmTransformPipeline(loudnessNormalize));

A null measurement (the source couldn't be measured at all — e.g. shorter than the standard's own 400ms minimum gating block) or double.NegativeInfinity (entirely below the standard's -70 LUFS absolute silence gate — a real measurement, not a "couldn't measure" sentinel) both make Apply pass samples through unchanged, since no finite gain reaches a finite target from either starting point.

Mixing and concatenating

AudioCutter.Mix(
    [new MixInput(voicePath, Gain: 1.0), new MixInput(musicPath, Gain: 0.3)],
    destPath);

AudioCutter.Concatenate([introPath, bodyPath, outroPath], destPath);

Both require every input to share the same channels, sample rate, and bit depth — mismatches throw NotSupportedException. Mix decodes every input fully into memory (silence-pads the shorter ones to the longest), so it's meant for clip-length material, not multi-hour streams. Concatenate streams for every destination format except WAV; a WAV destination buffers the combined output in memory, since WavWriter needs an exact frame count at creation time and the combined total across sources isn't known until they've all been decoded.

Parametric EQ and FIR filtering

BiquadTransform is a single second-order IIR filter section using the RBJ Audio EQ Cookbook formulas — low/high pass, band pass, notch, all pass, peaking EQ, and low/high shelf:

using EggEncoder.Codecs;
using EggEncoder.Pcm;

// A gentle low-pass at 8kHz, then a notch to pull out 60Hz mains hum
var pipeline = new PcmTransformPipeline(
    new BiquadTransform(BiquadFilterType.LowPass, channels: 2, sampleRate: 44100, frequencyHz: 8000),
    new BiquadTransform(BiquadFilterType.Notch, channels: 2, sampleRate: 44100, frequencyHz: 60, q: 4.0));

AudioCutter.Convert(sourcePath, destPath, pipeline);

A single biquad section rolls off at 12 dB/octave. For a steeper, maximally-flat-passband low/high pass, ButterworthTransform cascades order / 2 BiquadTransform stages sharing one cutoff frequency, each at its own Q from the standard Butterworth pole-pair formula — filterType must be LowPass or HighPass (the other BiquadFilterType values have no standard higher-order cascade construction), and order must be a positive even integer:

// A steep, maximally-flat 4th-order low-pass -- twice the biquad's own rolloff
var steepLowPass = new ButterworthTransform(BiquadFilterType.LowPass, order: 4, channels: 2, sampleRate: 44100, frequencyHz: 8000);
var pipeline = new PcmTransformPipeline(steepLowPass);

For a filter shape neither of those covers, FirFilterTransform applies a caller-supplied tap array identically to every channel — general FIR convolution, matching ffmpeg's afir/general-FIR filters. It has no filter-design code of its own; accepting arbitrary taps (e.g. designed by an external tool) is the whole point:

double[] taps = ...; // from an external filter-design tool, or a hand-rolled window-based design
var pipeline = new PcmTransformPipeline(new FirFilterTransform(channels: 2, taps: taps));

All three keep per-channel filter history across blocks (two state values per channel per biquad stage; the last taps.Length - 1 samples per channel for FirFilterTransform) — build a fresh instance per Convert/Cut/Mix call, same as every other stateful transform, or call Reset() to restart that state without discarding the instance.

Float PCM

The pipeline itself is exclusively int[]-based — a normalized float sample (-1.0..1.0) maps 1:1 onto the 32-bit int native range every other bit depth already uses, the same scale WavReader produces when decoding a float WAV. For samples that don't go through a WAV file at all (synthesized audio, another library's float[] buffer):

using EggEncoder.Pcm;

int[] intSamples = FloatSampleConverter.FromFloat(myFloatSamples);
float[] backToFloat = FloatSampleConverter.ToFloat(intSamples, bitsPerSample: 32);

// Or directly against a WAV file:
AudioCutter.WriteWavFromFloat(destPath, myFloatSamples, channels: 2, sampleRate: 44100);
var (samples, channels, sampleRate) = AudioCutter.ReadWavAsFloat(sourcePath); // any WAV bit depth, normalized

WavWriter.Create(..., sampleFormat: WavSampleFormat.Float32) writes a real 32-bit IEEE float WAV. A float WAV source already decodes transparently into int PCM through Convert/Cut/Mix/Concatenate — no special handling needed. For a float destination through those same entry points, pass WavSampleFormat.Float32:

using EggEncoder.Codecs;

AudioCutter.Convert(sourcePath, destPath, WavSampleFormat.Float32);

AudioCutter.Cut(sourcePath, destPath, startInSeconds: 0, endInSeconds: 30, new CutOptions
{
    DestinationWavFormat = WavSampleFormat.Float32
});

AudioCutter.Mix(inputs, destPath, WavSampleFormat.Float32);
AudioCutter.Concatenate(sourcePaths, destPath, WavSampleFormat.Float32);

WavSampleFormat.Float32 requires the destination's bit depth to already be 32 (widen a narrower source first with a BitDepthFormatTransform in the pipeline — Mix/Concatenate have no bit-depth conversion of their own, so their sources must already be 32-bit) and throws NotSupportedException against a non-WAV destination. The default, WavSampleFormat.Integer, is unchanged from before this option existed. Internally, processing always stays int[]-based at the usual native-range scale — this only selects how WavWriter encodes that data on disk. float.NaN samples convert to silence (0), not the unspecified result of a raw (int)double.NaN cast; float.PositiveInfinity/NegativeInfinity clamp to +/-1.0, same as any other out-of-range value.

WavSampleFormat.MuLaw/ALaw select G.711 companded PCM the same way — they require the destination's bit depth to be 16 instead of 32. WavReader also decodes IMA ADPCM, MS ADPCM, and Yamaha ADPCM WAV files (three genuinely different algorithms, not variants of each other, despite the shared 4-bit coded width) automatically, with no API to opt in or out of any of them on read. WavSampleFormat.ImaAdpcm/MsAdpcm additionally select either as a destination (16-bit input, mono/stereo only) — unlike every other value here, these are block-structured rather than one sample at a time: WavWriter buffers internally and only writes once a block fills or Finish() is called, which also flushes and pads a final short block. WavSampleFormat.YamahaAdpcm also selects a destination this way (16-bit input, mono/stereo only), but unlike the other two has no block structure at all — WavWriter writes two samples per byte continuously, only ever holding back the one trailing nibble an odd mono frame count can leave pending, which Finish() flushes zero-padded. See the README's format-coverage table for the full constraints on all five.

AIFF has the same float/G.711 destination-format story, via AiffSampleFormat instead of WavSampleFormat — Convert/Cut (via CutOptions.DestinationAiffFormat) take it the same way, and it's ignored for every destination that isn't .aiff/.aif/.aifc. Unlike WAV, AiffSampleFormat.Float32/Float64 are two separate values, not one — both decode/encode at the same int32-native-range scale (so both report/require bit depth 32), differing only in on-disk precision (4 vs 8 bytes per sample); LittleEndianInteger ('sowt') has no WAV equivalent at all, since WAV is already little-endian. Every non-Integer AiffSampleFormat value writes an AIFC container (FORM/AIFC) rather than plain AIFF — AiffReader reads AIFC automatically on the way in, the same no-opt-in-needed convention as WAV's own ADPCM decode.

AU (.au) follows the identical pattern once more, via AuSampleFormat/CutOptions.DestinationAuFormat — Float32/Float64 are the same two separate, same-scale values AIFC has, and MuLaw/ALaw work the same way too. The one difference from AIFF: AU has no LittleEndianInteger-equivalent value at all, since there's no AU convention for little-endian samples the way AIFC's 'sowt' exists for AIFF.

MOV/MP4 Probing and Audio Decode

MovProbe walks the ISO base media container's atom tree directly — no native dependency — to read duration, dimensions, and the video codec's four-character code. It's metadata-only and never decodes audio or video frames:

using EggEncoder.Codecs.Mov;

var result = MovProbe.Probe("clip.mp4");
Console.WriteLine($"{result.Width}x{result.Height}, {result.DurationInSeconds}s, {result.CodecFourCc}");

MovDecoder decodes an actual audio track when the file has one — a mono AAC-LC 'soun' track, specifically; it reads the MP4 sample table directly (no ADTS framing the way standalone .aac files have) and throws if the track is stereo or a different codec:

var streamInfo = MovDecoder.Decode("clip.mp4", (block, channels, sampleRate, bitsPerSample, totalSamples) =>
{
    // interleaved PCM samples for this chunk
});

NativeEncoder.Probe routes .mov/.mp4 files through both: ProbeResult.Width/Height/duration always come from MovProbe, and if MovDecoder successfully finds a decodable audio track, SampleRate/Channels/BitsPerSample/Waveform get populated too. If there's no audio track, or it's not mono AAC-LC, those fields stay null and the video metadata is still returned — audio decode failure never fails the whole probe. AudioCutter.Convert/Cut can also use a .mov/.mp4 file with a decodable audio track as a source, the same as any other format.

Cross-Platform, No Native Binaries

EggEncoder is 100% pure managed C# — every codec, including MP3, FLAC, and WavPack, is implemented directly in this project or via a pure managed NuGet dependency, with no native binary or P/Invoke binding anywhere. There's nothing to bundle, locate, or fail to find at runtime.

It runs anywhere .NET 8/9/10 runs — Windows, Linux, and macOS — and CI builds and tests on all three to prove it.