API Reference
IMediaEncoder
The core abstraction, implemented by NativeEncoder.
public interface IMediaEncoder
{
Task<ProbeResult> Probe(string filePath);
Task<ProbeResult> Probe(string filePath, CancellationToken cancellationToken);
Task<ProbeResult> Probe(Stream source, AudioFormat format);
Task<ProbeResult> Probe(Stream source, AudioFormat format, CancellationToken cancellationToken);
Task ConvertFile(string sourceFilePath, string destFilePath);
Task ConvertFile(string sourceFilePath, string destFilePath, CancellationToken cancellationToken);
Task ConvertFile(Stream source, AudioFormat sourceFormat, Stream dest, AudioFormat destFormat);
Task ConvertFile(Stream source, AudioFormat sourceFormat, Stream dest, AudioFormat destFormat, CancellationToken cancellationToken);
Task CutFile(string sourceFilePath, string destFilePath, int startInSeconds, int endInSeconds);
Task CutFile(string sourceFilePath, string destFilePath, int startInSeconds, int endInSeconds, CancellationToken cancellationToken);
Task<bool> CutFile(Stream source, AudioFormat sourceFormat, int startInSeconds, int endInSeconds, Stream dest, AudioFormat destFormat);
Task<bool> CutFile(Stream source, AudioFormat sourceFormat, int startInSeconds, int endInSeconds, Stream dest, AudioFormat destFormat, CancellationToken cancellationToken);
}
public enum AudioFormat { Wav, Aiff, Au, Flac, Mp3, Aac, Wma, Caf, Tta, Opus, Vorbis, WavPack, Mov }
The Stream-to-Stream overloads of ConvertFile/CutFile convert/cut between a source Stream and a destination Stream -- neither is disposed by the call. Backed by AudioCutter.Stream.cs's own Convert/Cut (see AudioCutter below), which reuse the exact same pipeline/fade/trim machinery the path-based overloads use. Source: every format Probe(Stream, AudioFormat) already supports. Destination: 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, ...) -- every other destination codec's own Stream write side is still open, one format at a time. An unsupported AudioFormat on either side throws NotSupportedException. See feature-roadmap.md item 1 for status.
The Stream overload of Probe is for a source that isn't a local file (an HTTP response body, blob storage, an in-memory buffer) — it never disposes source. Implemented for every AudioFormat: AudioFormat.Wav/Aiff/Au/Caf/Vorbis/Mov (the stream must be seekable) and AudioFormat.Flac/Aac/WavPack/Tta/Opus/Wma/Mp3 (no seek requirement; read into memory up front); an undefined value throws NotSupportedException. Unlike the path-based Probe(string), AudioFormat.Mov has no file extension to distinguish a MOV container from an MP4 one, so it always reports FormatName/FormatLongName as "mov"/"QuickTime / MOV". See feature-roadmap.md item 1 for status.
The CancellationToken overloads of ConvertFile/CutFile check the token once per decoded block (real, sub-second cancellation for every format), via the single shared decode callback every codec's own decode loop already funnels through. Probe's own CancellationToken check is deliberately shallow instead — once, up front, before any decode starts — since Probe has no equivalent single shared callback across its own per-format methods. A cancelled call throws OperationCanceledException and produces no destination file. See feature-roadmap.md item 5.
ProbeResult
Only fields this library can genuinely compute are populated — no ffprobe-style placeholders (e.g. no probe_score, no disposition flags).
public class ProbeResult
{
public string FormatName { get; init; } // e.g. "wav", "flac", "mp3", "aac", "asf", "mov", "mp4"
public string FormatLongName { get; init; } // e.g. "WAV / WAVE (Waveform Audio)"
public long SizeBytes { get; init; }
public double DurationSeconds { get; init; }
public string CodecType { get; init; } // "audio" or "video"
public string? CodecName { get; init; } // e.g. "pcm_s16le", "flac", "mp3", "aac", "wmav2"
public string? CodecLongName { get; init; }
public int? SampleRate { get; init; }
public int? Channels { get; init; }
public string? ChannelLayout { get; init; } // "mono", "stereo", or "{n} channels"
public int? BitsPerSample { get; init; }
public int? BitRate { get; init; }
public bool? IsVariableBitRate { get; init; } // MP3 only
public long? DurationInSamples { get; init; }
public string? TimeBase { get; init; } // e.g. "1/44100"
public int? Width { get; init; } // set for video containers (MOV/MP4), null for audio
public int? Height { get; init; }
public IReadOnlyList<double>? Waveform { get; init; } // normalized peak windows in [0,1], or null
public double? PeakAmplitude { get; init; } // loudest absolute sample across the whole decode, normalized to [0,1]
public double? RmsLevel { get; init; } // root-mean-square level across every sample, normalized to [0,1] (not ITU-R BS.1770 loudness)
public double? IntegratedLoudness { get; init; } // real ITU-R BS.1770-4 / EBU R128 integrated loudness, in LUFS -- null if not measured or <400ms of audio; can legitimately be double.NegativeInfinity for audio below the standard's -70 LUFS absolute silence gate (a real measurement, not a "couldn't measure" sentinel)
public int? EncoderDelaySamples { get; init; } // MP3 only -- this file's own "LAME tag" encoder delay, in samples; null if absent. Already subtracted from DurationSeconds/DurationInSeconds above
public int? EncoderPaddingSamples { get; init; } // MP3 only -- same tag's own encoder padding, in samples; null under the same conditions EncoderDelaySamples is
public IReadOnlyDictionary<string, string>? Tags { get; init; } // WAV/AIFF 'LIST'/'INFO'/'NAME' etc. metadata, MP3/embedded-AIFF ID3v2 text frames, or FLAC/Opus/Vorbis comment fields (keyed by raw field name, e.g. "TITLE"); null for every other format, or a WAV/AIFF/MP3/FLAC/Opus/Vorbis with none
}
NativeEncoder
Namespace: EggEncoder. Dispatches to a codec under Codecs/ by file extension. No external process.
public class NativeEncoder : IMediaEncoder
{
public NativeEncoder(ILogger<NativeEncoder> logger);
}
Supported extensions for Probe: .wav, .aiff/.aif/.aifc, .au (Sun AU), .caf (ALAC), .flac, .mp3, .aac, .wma, .tta, .opus, .ogg (Vorbis), .wv (WavPack), .mov, .mp4. Unrecognized extensions throw NotSupportedException.
ServiceCollectionExtensions
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddEggEncoder(this IServiceCollection services, bool enableLogging = true);
// Registers one scoped NativeEncoder instance, then maps both IMediaEncoder and
// IPcmTransformEncoder to resolve that same instance -- resolving either interface in the
// same DI scope gives you the same underlying NativeEncoder.
}
AudioCutter
Namespace: EggEncoder.Codecs. Format-dispatching convert/cut used internally by NativeEncoder; also usable directly. The two-argument/four-argument Convert/Cut overloads are unchanged since the original release; the PcmTransformPipeline/CutOptions overloads and everything else below are new — see PCM Transform Pipeline.
public static class AudioCutter
{
public static void Convert(string sourceFilePath, string destFilePath);
public static bool Cut(string sourceFilePath, string destFilePath, int startInSeconds, int endInSeconds);
// Pipeline-aware overloads: run every decoded block through a PcmTransformPipeline first.
public static void Convert(string sourceFilePath, string destFilePath, PcmTransformPipeline pipeline);
public static bool Cut(string sourceFilePath, string destFilePath, int startInSeconds, int endInSeconds, CutOptions options);
// WavSampleFormat overloads: select a .wav destination's on-disk encoding (Integer, the default
// matching every overload above, or Float32); ignored for non-WAV destinations, which throw
// NotSupportedException if Float32 is requested against them.
public static void Convert(string sourceFilePath, string destFilePath, WavSampleFormat destinationWavFormat);
public static void Convert(string sourceFilePath, string destFilePath, PcmTransformPipeline pipeline, WavSampleFormat destinationWavFormat);
// AiffSampleFormat overloads: same idea, for a .aiff/.aif/.aifc destination's on-disk encoding
// (Integer, the default, or LittleEndianInteger/Float32/Float64/MuLaw/ALaw -- all of which write
// an AIFC container). Also available via CutOptions.DestinationAiffFormat for Cut; Mix/Concatenate
// don't have a dedicated AiffSampleFormat overload yet and always write Integer to an AIFF-family destination.
public static void Convert(string sourceFilePath, string destFilePath, AiffSampleFormat destinationAiffFormat);
public static void Convert(string sourceFilePath, string destFilePath, PcmTransformPipeline pipeline, AiffSampleFormat destinationAiffFormat);
// AuSampleFormat overloads: same idea again, for a .au destination's on-disk encoding (Integer,
// the default, or Float32/Float64/MuLaw/ALaw). Also available via CutOptions.DestinationAuFormat
// for Cut; Mix/Concatenate don't have a dedicated AuSampleFormat overload yet either.
public static void Convert(string sourceFilePath, string destFilePath, AuSampleFormat destinationAuFormat);
public static void Convert(string sourceFilePath, string destFilePath, PcmTransformPipeline pipeline, AuSampleFormat destinationAuFormat);
// Mixes 2+ same-format sources with per-input gain; decodes every input fully into memory, concurrently.
public static void Mix(IReadOnlyList<MixInput> inputs, string destFilePath);
public static void Mix(IReadOnlyList<MixInput> inputs, string destFilePath, WavSampleFormat destinationWavFormat);
// Concatenates 2+ same-format sources, in order. Fully streaming for non-WAV destinations;
// a WAV destination buffers the combined output in memory (see Advanced-Features.html).
public static void Concatenate(IReadOnlyList<string> sourceFilePaths, string destFilePath);
public static void Concatenate(IReadOnlyList<string> sourceFilePaths, string destFilePath, WavSampleFormat destinationWavFormat);
// Decodes sourceFilePath fully to find its peak absolute sample value, for a true whole-file
// PeakNormalizationTransform.MeasurePeak(long, int) measurement.
public static (long Peak, int BitsPerSample) MeasurePeakAmplitude(string sourceFilePath);
// Decodes sourceFilePath fully to measure its real ITU-R BS.1770-4/EBU R128 integrated
// loudness, for a true whole-file LoudnessTransform.MeasureLoudness(double?) measurement.
// Null if it couldn't be measured at all; can legitimately be double.NegativeInfinity.
public static double? MeasureIntegratedLoudness(string sourceFilePath);
// Convenience float[] entry points -- see FloatSampleConverter below.
public static (float[] Samples, int Channels, int SampleRate) ReadWavAsFloat(string sourceFilePath);
public static void WriteWavFromFloat(string destFilePath, ReadOnlySpan<float> interleavedSamples, int channels, int sampleRate);
}
Cut decodes any supported source (.wav, .aiff/.aif/.aifc, .au, .caf, .flac, .mp3, .aac, .wma, .tta, .opus, .ogg, .wv, or .mov/.mp4 with a mono AAC-LC audio track) and writes any supported destination extension — source and destination don't need to match, so it can transcode while it trims. Returns false (and writes no file) if the requested range is entirely outside the source's duration.
WaveformCalculator
Namespace: EggEncoder.Waveform.
public sealed class WaveformCalculator
{
public const int DefaultWindowCount = 200;
public WaveformCalculator(long totalSamplesPerChannel, int channels, int bitsPerSample, int windowCount = DefaultWindowCount, int? sampleRate = null);
public void AddBlock(ReadOnlySpan<int> interleavedSamples);
public List<double> GetNormalizedWindows();
public double GetNormalizedPeakAmplitude(); // loudest absolute sample seen so far, normalized to [0,1]
public double GetNormalizedRmsLevel(); // root-mean-square across every sample seen so far, normalized to [0,1]
public double? GetIntegratedLoudness(); // ITU-R BS.1770-4 / EBU R128 integrated loudness in LUFS; pass sampleRate to the constructor to enable; null if not enabled or <400ms of audio
}
GetIntegratedLoudness K-weights every sample via Waveform.KWeightingFilter (two cascaded biquads using the standard's own tan-prewarped coefficients, not Pcm.BiquadTransform's RBJ-cookbook formula) before gated 400ms-block energy averaging per ITU-R BS.1770-4. See Advanced Features for details.
PCM Transform Pipeline
See Advanced Features for a full walkthrough with usage examples and important limits. This section is the type reference.
IPcmTransformEncoder — EggEncoder
Implemented by NativeEncoder alongside IMediaEncoder; resolve it from DI the same way (see ServiceCollectionExtensions above), or construct NativeEncoder directly.
public interface IPcmTransformEncoder
{
Task ConvertFile(string sourceFilePath, string destFilePath, PcmTransformPipeline pipeline);
Task<bool> CutFile(string sourceFilePath, string destFilePath, int startInSeconds, int endInSeconds, CutOptions options);
Task MixFiles(IReadOnlyList<MixInput> inputs, string destFilePath);
Task ConcatenateFiles(IReadOnlyList<string> sourceFilePaths, string destFilePath);
}
PcmTransformPipeline & IPcmTransform — EggEncoder.Pcm
public interface IPcmTransform
{
int OutputSampleRate { get; } // 0 = passthrough
int OutputChannels { get; } // 0 = passthrough
int OutputBitsPerSample { get; } // 0 = passthrough
bool CanChangeFrameCount { get; }
(int[] buffer, int frameCount) Apply(int[] buffer, int frameCount, int channels, int sampleRate, int bitsPerSample);
// Default interface method -- existing implementers don't need to change. Drains output this
// transform is still holding back (e.g. a resampler's pending tail); call once, after the last
// Apply() call for a stream. Most transforms hold no such state and keep the default (no output).
(int[] buffer, int frameCount) Flush() => (Array.Empty<int>(), 0);
}
public sealed class PcmTransformPipeline
{
public PcmTransformPipeline(params IPcmTransform[] transforms);
public bool HasTransforms { get; }
public IReadOnlyList<IPcmTransform> Transforms { get; }
public bool CanChangeFrameCount { get; }
public (int channels, int sampleRate, int bitsPerSample) ComputeOutputFormat(int inputChannels, int inputSampleRate, int inputBitsPerSample);
public (int[] buffer, int frameCount, int channels, int sampleRate, int bitsPerSample) Apply(int[] inputBuffer, int frameCount, int channels, int sampleRate, int bitsPerSample);
public (int[] buffer, int frameCount, int channels, int sampleRate, int bitsPerSample) Flush(int channels, int sampleRate, int bitsPerSample);
}
Apply is what AudioCutter.Convert/Cut call per decoded block; most consumers won't call it directly, but it's there if you're driving the pipeline yourself. Likewise Flush, which those same AudioCutter overloads call automatically once after the last block — see Advanced Features for why it exists and a worked example of calling it directly.
A pipeline instance carries cross-block state (a resampler's source history, a fade's frame position, a peak normalizer's measured gain) and is meant for exactly one Convert/Cut/Mix call — build a fresh one per call rather than reusing an instance across multiple calls.
Transforms — EggEncoder.Pcm
public sealed class ResamplingTransform : IPcmTransform
{
public const int DefaultFilterHalfWidth = 32;
public ResamplingTransform(int sourceRate, int targetRate, int channels, int filterHalfWidth = DefaultFilterHalfWidth);
public int FilterHalfWidth { get; } // the effective half-width actually used, after any downsampling scale-up/cap
public void Reset(); // restart all cross-block state (history, stream position, flushed flag)
// Flush() drains the last few output frames a full lookahead window couldn't yet be gathered for; see above.
}
public sealed class VolumeTransform : IPcmTransform
{
public VolumeTransform(double gain); // 1.0 = no change, 0.0 = silence
}
public sealed class PeakNormalizationTransform : IPcmTransform
{
public PeakNormalizationTransform(double targetDb); // e.g. -1.0; must be <= 0
public void MeasurePeak(int[] buffer, int frameCount, int channels, int bitsPerSample);
public void MeasurePeak(long maxAbsoluteSample, int bitsPerSample);
public static long ComputeMaxAbsoluteSample(ReadOnlySpan<int> samples);
}
public sealed class LoudnessTransform : IPcmTransform
{
public LoudnessTransform(double targetLufs); // e.g. -14.0; must be finite
public void MeasureLoudness(double? measuredLufs); // MUST be called before Apply -- no self-measuring fallback
}
public sealed class CompressorTransform : IPcmTransform
{
public CompressorTransform(int sampleRate, double thresholdDb, double ratio, double attackMs, double releaseMs, double makeupGainDb = 0.0);
public void Reset(); // clear the envelope follower back to silence
}
public sealed class NoiseGateTransform : IPcmTransform
{
// The mirror image of CompressorTransform: gain reduction below threshold instead of above.
public NoiseGateTransform(int sampleRate, double thresholdDb, double ratio, double attackMs, double releaseMs);
public void Reset(); // clear the envelope follower back to silence
}
public sealed class LimiterTransform : IPcmTransform
{
// Infinite-ratio compression (the envelope is clamped AT the ceiling, not reduced toward it by
// a finite ratio) plus a final per-sample hard clip, so the output is GUARANTEED never to exceed
// the ceiling even during a nonzero attack time. attackMs defaults to 0 (classic brick-wall).
public LimiterTransform(int sampleRate, double ceilingDb, double releaseMs, double attackMs = 0.0);
public void Reset(); // clear the envelope follower back to silence
}
public sealed class EchoTransform : IPcmTransform
{
public const double MaxDelaySeconds = 60.0;
public const double MaxTailSeconds = 30.0;
// A feedback delay line (one per channel; channels/sampleRate fixed at construction, like
// BiquadTransform). Each output sample is the dry input plus wetGain * the delayed input;
// feedback controls how much of each repeat feeds the next, quieter one. Implements Flush() to
// drain the decaying tail (deterministic: enough repeats to decay below -60dB, capped at
// MaxTailSeconds) -- CanChangeFrameCount is true whenever feedback > 0.
public EchoTransform(int sampleRate, int channels, double delaySeconds, double feedback, double wetGain);
public void Reset(); // clear the delay line back to silence
}
public sealed class PanTransform : IPcmTransform
{
public PanTransform(double pan, PanLaw law = PanLaw.Linear); // pan: -1.0 (full left) .. +1.0 (full right)
public double Pan { get; }
public PanLaw Law { get; }
}
public enum PanLaw { Linear, EqualPower }
public sealed class ChannelRemixTransform : IPcmTransform
{
public ChannelRemixTransform(int inputChannels, int outputChannels, ChannelRemixMode mixMode = ChannelRemixMode.Auto);
}
public enum ChannelRemixMode { Auto, PassThrough, Downmix, UpMix }
public sealed class BitDepthFormatTransform : IPcmTransform
{
public BitDepthFormatTransform(int fromBits, int toBits); // each 8, 16, 24, or 32
public static (long Min, long Max) GetNativeRange(int bits);
}
public sealed class FadeTransform : IPcmTransform
{
public FadeTransform(long totalFrames, long fadeInFrames = 0, long fadeOutFrames = 0, FadeCurve curve = FadeCurve.Linear);
public void Reset(); // restart the cross-block frame position tracker
}
public enum FadeCurve { Linear, EqualPower }
public sealed class BiquadTransform : IPcmTransform
{
public BiquadTransform(BiquadFilterType filterType, int channels, int sampleRate, double frequencyHz, double q = 0.7071067811865476, double gainDb = 0.0);
public void Reset(); // restart the two-state-value-per-channel filter history
}
public enum BiquadFilterType { LowPass, HighPass, BandPass, Notch, AllPass, PeakingEq, LowShelf, HighShelf }
public sealed class ButterworthTransform : IPcmTransform
{
// filterType must be LowPass or HighPass (the other BiquadFilterType values have no standard
// higher-order Butterworth cascade construction); order must be a positive even integer --
// realized as order/2 cascaded BiquadTransform stages sharing the cutoff, each at its own Q
// from the standard Butterworth pole-pair formula (maximally flat passband).
public ButterworthTransform(BiquadFilterType filterType, int order, int channels, int sampleRate, double frequencyHz);
public void Reset(); // restart every cascaded stage's filter history
}
public sealed class FirFilterTransform : IPcmTransform
{
public FirFilterTransform(int channels, double[] taps); // applied identically to every channel; no filter-design code of its own
public void Reset(); // restart each channel's taps.Length - 1 sample history
}
Parametric EQ and FIR filtering: BiquadTransform is a single second-order IIR section using the RBJ Audio EQ Cookbook formulas (Direct Form II Transposed, two state values per channel) — low/high pass, band pass, notch, all pass, peaking EQ, and low/high shelf. ButterworthTransform cascades order / 2 BiquadTransform stages sharing one cutoff frequency, each at a different Q from the standard Butterworth pole-pair formula, for a steeper, maximally-flat-passband low/high pass than a single biquad section can provide. FirFilterTransform is general FIR convolution (parity with ffmpeg's afir/general-FIR filters) — it applies a caller-supplied tap array identically to every channel and keeps each channel's last taps.Length - 1 samples as history across blocks; accepting arbitrary taps is the whole point, so there's no filter-design helper bundled with it.
Peak normalization used naively: if PeakNormalizationTransform is dropped into a pipeline and never pre-measured, MeasurePeak self-measures from whatever buffer Apply first sees — which, driven by AudioCutter.Convert/Cut, is one ~4096-frame decode block, not the whole file. For a true whole-file measurement, call AudioCutter.MeasurePeakAmplitude first and pass its result to MeasurePeak(long, int) before handing the transform to a pipeline.
Loudness normalization has no such fallback at all: LoudnessTransform.Apply throws InvalidOperationException unless MeasureLoudness was already called — EBU R128's own gating algorithm cannot produce a meaningful number from one decode block the way a peak scan can, so there is no "measure from whatever block Apply sees first" shortcut to fall back to. Call AudioCutter.MeasureIntegratedLoudness first and pass its result to MeasureLoudness before handing the transform to a pipeline; a null or double.NegativeInfinity measurement both pass samples through unchanged.
Resampler quality: ResamplingTransform is a Kaiser-windowed-sinc polyphase filter — anti-aliasing on downsample, band-limited reconstruction on upsample — not linear interpolation. Output is held back until its full tap window (FilterHalfWidth source frames on each side) has genuinely arrived, rather than falling back to duplicating an edge sample at a block boundary; see Flush above for draining the last few frames this implies at the true end of a stream. Total output frame count is a cumulative floor(sourceFramesSoFar * ratio), so it's exact regardless of how a stream is split into blocks — no per-block rounding drift to account for. The continuous fractional source position is quantized to 1024 polyphase-table phases (a small, standard timing error for this class of resampler); downsampling scales the filter's half-width up by 1/cutoff to preserve anti-aliasing quality as the ratio shrinks, capped at 256 taps so an extreme ratio (e.g. 100:1) can't grow the table or per-sample cost unboundedly.
CutOptions & MixInput — EggEncoder.Codecs
public sealed class CutOptions
{
public PcmTransformPipeline? Pipeline { get; init; }
public double FadeInSeconds { get; init; }
public double FadeOutSeconds { get; init; }
public FadeCurve FadeCurve { get; init; } = FadeCurve.Linear;
public WavSampleFormat DestinationWavFormat { get; init; } = WavSampleFormat.Integer;
public AiffSampleFormat DestinationAiffFormat { get; init; } = AiffSampleFormat.Integer;
public AuSampleFormat DestinationAuFormat { get; init; } = AuSampleFormat.Integer;
}
public readonly record struct MixInput(string FilePath, double Gain = 1.0);
public enum WavSampleFormat { Integer, Float32, MuLaw, ALaw }
public enum AiffSampleFormat { Integer, LittleEndianInteger, Float32, Float64, MuLaw, ALaw }
public enum AuSampleFormat { Integer, Float32, Float64, MuLaw, ALaw }
A fade, if requested, runs over the retained range before any transform in Pipeline. AudioCutter.Mix requires every input to share the same channels/sample rate/bit depth, and decodes all of them fully into memory, concurrently — clip-length material, not multi-hour streams. AudioCutter.Concatenate has the same format-matching requirement. WavSampleFormat selects a .wav destination's on-disk encoding (see Advanced Features for the float-WAV walkthrough); it's ignored for every other destination format. Float32 requires the destination's bit depth to already be 32; MuLaw/ALaw (G.711) require it to already be 16. AiffSampleFormat is the same idea for a .aiff/.aif/.aifc destination — LittleEndianInteger/Float32/Float64/MuLaw/ALaw all write an AIFC container regardless of which of those three extensions is used; Float32/Float64 require bit depth 32, MuLaw/ALaw require 16. AuSampleFormat is the same idea again for a .au destination — it has no LittleEndianInteger-equivalent value at all (AU is always big-endian), otherwise the same bit-depth requirements as AiffSampleFormat.
FloatSampleConverter — EggEncoder.Pcm
public static class FloatSampleConverter
{
public static int[] FromFloat(ReadOnlySpan<float> floatSamples); // -1.0..1.0 -> 32-bit native range
public static float[] ToFloat(ReadOnlySpan<int> samples, int bitsPerSample); // any native range -> -1.0..1.0
}
The pipeline itself is exclusively int[]-based; a normalized float sample maps 1:1 onto the 32-bit int native range (the same scale WavReader/WavWriter use for IEEE float WAV). Use this type when you have (or want) float[] directly rather than a WAV file — AudioCutter.ReadWavAsFloat/WriteWavFromFloat wrap it for the WAV case.
Codecs
AAC — EggEncoder.Codecs.Aac
public static class AacDecoder
{
public static AacStreamInfo Decode(string aacFilePath, AudioBlockDecodedCallback onBlockDecoded);
}
public static class AacEncoder
{
public static void Encode(string destFilePath, short[] samples, int channels, int sampleRate);
}
public sealed class AacEncoderSession : IDisposable // for incremental/streaming encode instead of a whole-file Encode call
{
public static AacEncoderSession OpenSession(string destFilePath, int channels, int sampleRate); // 16-bit input
public static AacEncoderSession OpenSession(string destFilePath, int channels, int sampleRate, int bitsPerSample); // 8/16/24/32-bit input, rescaled to 16-bit
public static AacEncoderSession OpenSession(Stream destStream, int channels, int sampleRate); // does not dispose destStream; no CanSeek needed
public static AacEncoderSession OpenSession(Stream destStream, int channels, int sampleRate, int bitsPerSample);
public void WriteInterleavedSamples(int[] buffer, int frameCount);
}
The AAC codec core only accepts 16-bit-range samples. The bitsPerSample overloads take samples in their own bit depth's native range (8, 16, 24, or 32 — see BitDepthFormatTransform) and rescale them to 16-bit (exact left shift to widen, rounded and clamped division to narrow), so a 24-bit source isn't wrapped into noise; any other bit depth throws NotSupportedException. AudioCutter/NativeEncoder pass the source's bit depth through automatically.
FLAC — EggEncoder.Codecs.Flac
public static class FlacDecoder
{
public static FlacStreamInfo Decode(string flacFilePath, AudioBlockDecodedCallback onBlockDecoded);
}
public static class FlacEncoder
{
public const uint DefaultCompressionLevel = 5;
public static void Encode(string sourceWavFilePath, string destFlacFilePath, uint compressionLevel = DefaultCompressionLevel);
}
public sealed class FlacEncoderSession : IDisposable // for incremental/streaming encode instead of a whole-file Encode call
{
public static FlacEncoderSession OpenSession(string destFlacFilePath, int channels, int bitsPerSample, int sampleRate, uint compressionLevel = FlacEncoder.DefaultCompressionLevel);
public void WriteInterleavedSamples(int[] buffer, int frameCount);
}
MP3 — EggEncoder.Codecs.Mp3
public static class Mp3Decoder // backed by NLayer
{
public static Mp3StreamInfo Decode(string mp3FilePath, AudioBlockDecodedCallback onBlockDecoded);
public static Mp3StreamInfo Decode(Stream stream, AudioBlockDecodedCallback onBlockDecoded); // reads the whole stream into memory; no CanSeek needed; never disposes it
}
public static class Mp3Encoder // pure managed, clean-room from ISO/IEC 11172-3, no native call
{
public const int DefaultBitRateKbps = 320;
public static void Encode(string sourceWavFilePath, string destMp3FilePath, int bitRateKbps = DefaultBitRateKbps);
}
public sealed class Mp3EncoderSession : IDisposable // for incremental/streaming encode instead of a whole-file Encode call
{
public static Mp3EncoderSession OpenSession(string destMp3FilePath, int channels, int sampleRate, int bitsPerSample, int bitRateKbps = Mp3Encoder.DefaultBitRateKbps);
public void WriteInterleavedSamples(int[] buffer, int frameCount);
}
public static class Mp3Probe // manual frame-header parsing, no native call
{
public static Mp3ProbeResult Probe(string mp3FilePath);
public static Mp3ProbeResult Probe(Stream stream); // reads the whole stream (from its current position) into memory; no CanSeek needed; never disposes it
}
WAV — EggEncoder.Codecs.Wav
public sealed class WavReader : IDisposable
{
public static WavReader Open(string filePath);
public int Channels { get; }
public int SampleRate { get; }
public int BitsPerSample { get; }
public long TotalSamples { get; }
public bool IsFloatFormat { get; }
public bool IsALaw { get; }
public bool IsMuLaw { get; }
public bool IsImaAdpcm { get; }
public bool IsMsAdpcm { get; }
public bool IsYamahaAdpcm { get; }
public int ReadInterleavedSamples(int[] buffer, int maxSamplesPerChannel);
}
public sealed class WavWriter : IDisposable
{
public static WavWriter Create(string destFilePath, int channels, int sampleRate, int bitsPerSample, long totalFrames, WavSampleFormat sampleFormat = WavSampleFormat.Integer, IReadOnlyDictionary<string, string>? tags = null);
public static WavWriter Create(Stream destStream, int channels, int sampleRate, int bitsPerSample, long totalFrames, WavSampleFormat sampleFormat = WavSampleFormat.Integer, IReadOnlyDictionary<string, string>? tags = null);
public void WriteInterleavedSamples(int[] buffer, int frameCount);
}
The Stream overload of Create doesn't dispose destStream, and — unlike every Stream-based reader in this project — needs no Stream.CanSeek at all: a WAV header's every size field is already known from totalFrames up front, so it writes the complete header once and never seeks back. It's the destination-Stream codec primitive IMediaEncoder.ConvertFile/CutFile's own Stream-to-Stream overloads use for a Wav destination (see IMediaEncoder above). See feature-roadmap.md item 1 for status.
Supports 8-bit unsigned, 16/24/32-bit signed integer, and 32-bit IEEE float PCM (both read and write — WavSampleFormat.Float32 requires bitsPerSample: 32). Also supports G.711 mu-law/A-law companded PCM, both read and write (WavSampleFormat.MuLaw/ALaw require bitsPerSample: 16; any channel count, unlike every other codec here), and three ADPCM variants (IsImaAdpcm/IsMsAdpcm/IsYamahaAdpcm, picked up automatically from the file's own fmt chunk on read, no opt-in needed). IMA ADPCM, MS ADPCM, and Yamaha ADPCM are genuinely different algorithms despite the shared 4-bit coded width — IMA ADPCM uses one universal fixed step table, MS ADPCM carries its own linear-prediction coefficient table in the fmt chunk extension itself and groups its block header by field across channels rather than repeating it per channel, and Yamaha ADPCM has no block structure at all (no per-block header, no wSamplesPerBlock extension — each channel's predictor/step state just carries continuously for the whole stream). All three are mono/stereo only. WavWriter also encodes all three (WavSampleFormat.ImaAdpcm/MsAdpcm/YamahaAdpcm, bitsPerSample: 16 required). IMA ADPCM and MS ADPCM are block-structured, unlike every other WavSampleFormat value: samples are buffered internally into fixed-size blocks (1024-byte block align, matching FFmpeg's own real encoder defaults) and only physically written once a block fills or WriteInterleavedSamples's caller calls Finish(), which also flushes and pads any final short block. Yamaha ADPCM instead writes two samples per byte continuously as they arrive, only ever holding back the one trailing nibble an odd mono frame count can leave pending, which Finish() flushes zero-padded. IMA ADPCM's own nibble is chosen by an 8-candidate search that always agrees with how this project's own decoder would reconstruct it, so encode and decode can never drift out of sync; MS ADPCM's and Yamaha ADPCM's own nibbles are each computed via their own direct closed-form formula instead (confirmed FFmpeg's real encoders' internal state-tracking formulas structurally match their own decoders', unlike IMA ADPCM's, so no search is needed), and MS ADPCM's encoder always selects the standard coefficient table's simplest pair for every block, matching FFmpeg's own real encoder exactly. All three are verified bit-exact against a real, independent ffmpeg decode of files they produced. Samples are always exchanged as int[], at this codebase's native-range convention for whatever bit depth is in play (see PCM Transform Pipeline); for float format that range is the full 32-bit int range, matching what a normalized -1.0..1.0 float sample maps to.
WMA — EggEncoder.Codecs.Wma
public static class WmaDecoder
{
public static WmaStreamInfo Decode(string wmaFilePath, AudioBlockDecodedCallback onBlockDecoded);
}
public static class WmaEncoder
{
public static void Encode(string destFilePath, IReadOnlyList<short> interleavedSamples, int channels, int sampleRate);
}
public sealed class WmaEncoderSession : IDisposable // for incremental/streaming encode instead of a whole-file Encode call
{
public static WmaEncoderSession OpenSession(string destFilePath, int channels, int sampleRate); // 16-bit input
public static WmaEncoderSession OpenSession(string destFilePath, int channels, int sampleRate, int bitsPerSample); // 8/16/24/32-bit input, rescaled to 16-bit
public static WmaEncoderSession OpenSession(Stream destStream, int channels, int sampleRate); // does not dispose destStream; no CanSeek needed
public static WmaEncoderSession OpenSession(Stream destStream, int channels, int sampleRate, int bitsPerSample);
public void WriteInterleavedSamples(int[] buffer, int frameCount);
}
Like AAC, the WMA codec core only accepts 16-bit-range samples: the bitsPerSample overloads rescale 8/24/32-bit input to 16-bit (rounded, clamped) rather than letting it clip to full scale, and AudioCutter/NativeEncoder pass the source's bit depth through automatically.
Mono or independently-coded stereo only. Mid/side stereo coding (used by most real-world WMAv2 encoders, including ffmpeg's) is not supported for decode and throws NotSupportedException; WmaEncoder never produces mid/side output, so this library's own encoder/decoder round-trip always works.
AIFF / AIFC — EggEncoder.Codecs.Aiff
public sealed class AiffReader : IDisposable
{
public static AiffReader Open(string filePath);
public int Channels { get; }
public int SampleRate { get; }
public int BitsPerSample { get; }
public long TotalSamples { get; }
public bool IsFloatFormat { get; }
public bool IsFloat32 { get; }
public bool IsFloat64 { get; }
public bool IsALaw { get; }
public bool IsMuLaw { get; }
public bool IsLittleEndian { get; }
public int ReadInterleavedSamples(int[] buffer, int maxSamplesPerChannel);
}
public sealed class AiffWriter : IDisposable
{
public static AiffWriter Create(string filePath, int channels, int sampleRate, int bitsPerSample, long totalFrames);
public static AiffWriter Create(Stream destStream, int channels, int sampleRate, int bitsPerSample, long totalFrames);
public static AiffWriter Create(string filePath, int channels, int sampleRate, int bitsPerSample, long totalFrames, AiffSampleFormat sampleFormat);
public static AiffWriter Create(Stream destStream, int channels, int sampleRate, int bitsPerSample, long totalFrames, AiffSampleFormat sampleFormat);
public void WriteInterleavedSamples(int[] buffer, int frameCount);
}
public enum AiffSampleFormat { Integer, LittleEndianInteger, Float32, Float64, MuLaw, ALaw }
The Stream overloads of Create don't dispose destStream, and need no Stream.CanSeek at all: every FORM/COMM/SSND size field is already known from totalFrames up front, the same reasoning WavWriter.Create(Stream, ...) already established.
Plain AIFF (FORM/AIFF, 8/16/24/32-bit signed integer PCM — 8-bit is signed, unlike WAV's unsigned convention) both read and write under AiffSampleFormat.Integer, the default — byte-for-byte what this type has always produced, under the .aiff/.aif extensions. AiffReader additionally reads AIFC (FORM/AIFC) under the same extensions plus .aifc, with no separate dispatch beyond that extension list — just an extra compressionType field it understands in COMM. Every other AiffSampleFormat value writes AIFC instead: LittleEndianInteger ('sowt'), Float32/Float64 ('fl32'/'fl64', big-endian IEEE float decoded at this codebase's usual int32-native-range scale — both report BitsPerSample 32, since this project's int[] model has no 64-bit representation and 64 is purely fl64's on-disk coded width), and MuLaw/ALaw ('ulaw'/'alaw', G.711, via the same G711Codec the WAV side uses). AIFC's 'ima4' (QuickTime IMA4 ADPCM) compressionType is also decoded (decode only — AiffWriter never writes it) — its block framing is a materially different bitstream from WAV's own IMA ADPCM (no verbatim first sample per block, and stereo stores each channel's complete 34-byte sub-block consecutively rather than nibble-interleaving), though the underlying per-nibble math is identical and reused directly from Wav.ImaAdpcmDecoder. Any other, genuinely unsupported compressionType (e.g. 'MAC3'/MACE) still makes Open throw NotSupportedException naming it specifically. Samples are always exchanged as int[] at the native range for whatever bit depth is in play, the same convention WAV uses.
AU — EggEncoder.Codecs.Au
public sealed class AuReader : IDisposable
{
public static AuReader Open(string filePath);
public int Channels { get; }
public int SampleRate { get; }
public int BitsPerSample { get; }
public long TotalSamples { get; }
public bool IsFloatFormat { get; }
public bool IsFloat32 { get; }
public bool IsFloat64 { get; }
public bool IsALaw { get; }
public bool IsMuLaw { get; }
public int ReadInterleavedSamples(int[] buffer, int maxSamplesPerChannel);
}
public sealed class AuWriter : IDisposable
{
public static AuWriter Create(string filePath, int channels, int sampleRate, int bitsPerSample, long totalFrames);
public static AuWriter Create(Stream destStream, int channels, int sampleRate, int bitsPerSample, long totalFrames);
public static AuWriter Create(string filePath, int channels, int sampleRate, int bitsPerSample, long totalFrames, AuSampleFormat sampleFormat);
public static AuWriter Create(Stream destStream, int channels, int sampleRate, int bitsPerSample, long totalFrames, AuSampleFormat sampleFormat);
public void WriteInterleavedSamples(int[] buffer, int frameCount);
}
public enum AuSampleFormat { Integer, Float32, Float64, MuLaw, ALaw }
The Stream overloads of Create don't dispose destStream, and need no Stream.CanSeek at all, the same reasoning WavWriter.Create(Stream, ...)/AiffWriter.Create(Stream, ...) already established.
Sun/NeXT AU (magic .snd, mime audio/basic) — a much simpler container than WAV/AIFF, with no chunk structure at all: one fixed 24-byte header, optionally followed by an annotation string (never parsed), then raw big-endian samples with no byte-alignment padding requirement. AuSampleFormat.Integer (the default) covers 8/16/24/32-bit signed integer PCM — AU's 8-bit is signed, the same convention AIFF uses, not WAV's unsigned one. Float32/Float64 are big-endian IEEE float decoded at this codebase's usual int32-native-range scale — both report BitsPerSample 32, the same "no 64-bit representation in this project's int[] model" reasoning AiffSampleFormat.Float64 already established. MuLaw/ALaw are G.711, via the same G711Codec the WAV and AIFF sides use. Every other defined AU encoding (G.721/G.722/G.723 ADPCM, fragmented samples) is real but obscure enough in practice that it's out of scope the same way AIFC's 'MAC3'/'MAC6' (MACE) are — Open throws NotSupportedException naming the unrecognized encoding value. AU also has its own "unknown data size" sentinel a real streaming file can use; AuReader reads until end-of-file for it, though AuWriter never writes it (it always knows its exact totalFrames up front, like WavWriter/AiffWriter).
ALAC — EggEncoder.Codecs.Alac
public static class AlacDecoder
{
public static AlacStreamInfo Decode(string filePath, AudioBlockDecodedCallback onBlockDecoded);
}
public static class AlacEncoder
{
public static void Encode(string sourceWavFilePath, string destAlacFilePath);
}
public sealed class AlacEncoderSession : IDisposable
{
public static AlacEncoderSession OpenSession(string destFilePath, int channels, int sampleRate, int bitsPerSample);
public void WriteInterleavedSamples(int[] buffer, int frameCount);
}
Apple Lossless, in a CAF container (.caf). Mono and stereo, 16-bit or 24-bit integer PCM only — throws NotSupportedException for any other channel count or bit depth (20-bit and 32-bit are out of scope, matching ffmpeg's own ALAC encoder, which doesn't emit those depths either). For stereo, the encoder tries a fixed mid/side mix first (the classic 0.5-weight split, expressed through ALAC's general affine decorrelation), falling back to independent channels and then to a verbatim (uncompressed) frame only if the previous option's residuals would overflow the Rice coder's escape code.
TTA — EggEncoder.Codecs.Tta
public static class TtaDecoder
{
public static TtaStreamInfo Decode(string filePath, AudioBlockDecodedCallback onBlockDecoded);
}
public static class TtaEncoder
{
public static void Encode(string sourceWavFilePath, string destTtaFilePath);
}
public sealed class TtaEncoderSession : IDisposable
{
public static TtaEncoderSession OpenSession(string destFilePath, int channels, int sampleRate, int bitsPerSample);
public void WriteInterleavedSamples(int[] buffer, int frameCount);
}
True Audio, a lossless codec (.tta). Mono and stereo, 16-bit integer PCM only — throws NotSupportedException for any other channel count or bit depth. Unlike ALAC, TTA's stereo decorrelation is unconditional (no bitstream flag, no fallback), and its Rice/unary residual coder has no escape code — the unary code is fully unbounded, so there's no verbatim/overflow fallback path.
WavPack — EggEncoder.Codecs.WavPack
public static class WavPackDecoder
{
public static WavPackStreamInfo Decode(string filePath, AudioBlockDecodedCallback onBlockDecoded);
}
public static class WavPackEncoder
{
public static void Encode(string sourceWavFilePath, string destWvFilePath);
}
public sealed class WavPackEncoderSession : IDisposable
{
public static WavPackEncoderSession OpenSession(string destFilePath, int channels, int bitsPerSample, int sampleRate, long totalSamples);
public void WriteInterleavedSamples(int[] buffer, int frameCount);
}
Lossless, in its own .wv container. Mono and stereo, 16-bit or 24-bit integer PCM only (WavPack's own lossy/hybrid and floating-point modes are rejected) — throws NotSupportedException otherwise. Both decode and encode are pure managed implementations, clean-room from the WavPack 4/5 format spec (see THIRD-PARTY-NOTICES.md) — no native binary is involved for this codec at all. OpenSession requires an exact, positive totalSamples up front (like WavWriter/AiffWriter) — WavPack genuinely cannot represent an empty/zero-sample stream at all, so a zero or negative total throws NotSupportedException rather than producing a file that would only turn out to be unreadable later.
Opus — EggEncoder.Codecs.Opus
public static class OpusDecoder
{
public static OpusStreamInfo Decode(string filePath, AudioBlockDecodedCallback onBlockDecoded);
}
public static class OpusEncoder
{
public static void Encode(string sourceWavFilePath, string destOpusFilePath);
}
public sealed class OpusEncoderSession : IDisposable
{
public static OpusEncoderSession OpenSession(string destFilePath, int channels, int sampleRate, int bitsPerSample);
public void WriteInterleavedSamples(int[] buffer, int frameCount);
}
Lossy, in a from-scratch OggOpus container (.opus). Mono and stereo, 16-bit integer PCM, and fixed at 48kHz (Opus's native/highest internal rate) regardless of the source's own rate — encoding at any other rate throws NotSupportedException; resample first with ResamplingTransform. The actual Opus codec (SILK+CELT) is delegated to Concentus, a pure managed C# port (the same "lean on an established pure-managed implementation" precedent NLayer sets for MP3 decode); EggEncoder implements the OggOpus container itself (page framing, CRC-32, the mandatory OpusHead/OpusTags packets) from scratch. Channel mapping family 0 only.
Vorbis — EggEncoder.Codecs.Vorbis
public static class VorbisDecoder
{
public static VorbisStreamInfo Decode(string filePath, AudioBlockDecodedCallback onBlockDecoded);
}
public static class VorbisEncoder
{
public const float DefaultQuality = 0.5f;
public static void Encode(string sourceWavFilePath, string destOggFilePath, float quality = DefaultQuality);
}
public sealed class VorbisEncoderSession : IDisposable
{
public static VorbisEncoderSession OpenSession(string destFilePath, int channels, int sampleRate, int bitsPerSample, float quality = DefaultQuality);
public void WriteInterleavedSamples(int[] buffer, int frameCount);
}
Lossy, in an Ogg container (.ogg). Mono and stereo, 16-bit integer PCM — throws NotSupportedException otherwise; unlike Opus, any positive sample rate is accepted, no fixed-rate resampling requirement. The codec and the Ogg container are both delegated to third-party pure managed NuGet packages — NVorbis for decode, OggVorbisEncoder for encode — each with its own independent Ogg object model (EggEncoder's own Codecs/Opus/OggPageReader/OggPageWriter aren't involved here). Vorbis has no pre_skip-equivalent field the way Opus does, so there's no way to tell a decoder exactly how many samples of the encoder's own fixed MDCT lookahead to trim off the front — finding the true alignment (e.g. via cross-correlation) is left to the caller if it matters.
MOV/MP4 — EggEncoder.Codecs.Mov
public static class MovProbe
{
public static MovProbeResult Probe(string filePath);
}
public class MovProbeResult
{
public int DurationInSeconds { get; init; }
public double DurationSeconds { get; init; }
public int? Width { get; init; }
public int? Height { get; init; }
public string? CodecFourCc { get; init; }
}
public static class MovDecoder
{
public static MovStreamInfo Decode(string filePath, AudioBlockDecodedCallback onBlockDecoded);
}
Decodes a mono AAC-LC 'soun' track via the MP4 sample table (no ADTS framing, unlike standalone .aac). Throws InvalidDataException if there's no audio track, NotSupportedException for stereo or a non-AAC codec.
Decode Callback Signature
Every codec's Decode method streams blocks through the same callback shape rather than returning the full signal in memory. It's a plain delegate rather than a generic Action<> because ReadOnlySpan<int> can't be used as a generic type argument on net8.0:
namespace EggEncoder.Codecs;
public delegate void AudioBlockDecodedCallback(ReadOnlySpan<int> block, int channels, int sampleRate, int bitsPerSample, long totalSamplesPerChannel);
block is interleaved samples for whatever chunk the decoder just produced — accumulate it yourself (into a List<int>, a WaveformCalculator, an IAudioSink) as needed.
Media Packet Model
Namespace: EggEncoder.Media. The codec-agnostic, already-encoded packet model every live source (RTMP ingest, FLV/MP4/MPEG-TS files) and output (RTMP push, HLS with video, recordings) in the live ingest & restream plan shares, so H.264/H.265 is remuxed without ever being decoded. This is that plan's item 1, the foundation; sources and sinks that produce and consume it arrive with later items.
public enum MediaKind { Audio, Video, Data }
public enum MediaCodec { Aac, Mp3, Opus, H264, H265, Av1, Vp9 }
public sealed record MediaTrackInfo(
int trackId, MediaKind kind, MediaCodec codec,
int timescale, // ticks per second for this track's Pts/Dts; must be positive
ReadOnlyMemory<byte> codecConfiguration, // avcC / hvcC / AudioSpecificConfig
int configVersion, // bumps on a mid-stream configuration change
int sampleRate = 0, int channels = 0, int width = 0, int height = 0);
public sealed record MediaTrackSet(IReadOnlyList<MediaTrackInfo> tracks) // unique track ids
{
public MediaTrackInfo? FindTrack(int trackId);
}
public readonly record struct MediaPacket(int TrackId, long Pts, long Dts, bool IsKeyframe, int ConfigVersion, ReadOnlyMemory<byte> Payload)
{
public long CompositionOffset { get; } // Pts - Dts
}
public interface IMediaPacketSink : IAsyncDisposable
{
ValueTask OpenAsync(MediaTrackSet tracks, CancellationToken cancellationToken = default);
ValueTask WriteAsync(MediaPacket packet, CancellationToken cancellationToken = default);
ValueTask CompleteAsync(CancellationToken cancellationToken = default);
}
public static class MediaTimestamp
{
// value * to / from with a 128-bit intermediate, rounded half to even; OverflowException if the result doesn't fit.
public static long Rescale(long value, int fromTimescale, int toTimescale);
}
public static class AnnexBConverter // one H.264/H.265 access unit, NAL contents untouched
{
public static byte[] ToAnnexB(ReadOnlySpan<byte> lengthPrefixed, int nalLengthSize = 4);
public static byte[] ToLengthPrefixed(ReadOnlySpan<byte> annexB, int nalLengthSize = 4);
}
public sealed record AvcDecoderConfigurationRecord(...) // avcC: Parse(span) / ToArray(), exact inverses
public sealed record HevcDecoderConfigurationRecord(...) // hvcC: Parse(span) / ToArray() / GetNalUnits(type)
public static class H264SpsParser { public static H264SpsInfo Parse(ReadOnlySpan<byte> spsNalUnit); }
public static class H265SpsParser { public static H265SpsInfo Parse(ReadOnlySpan<byte> spsNalUnit); }
public static class MediaPacketFactory
{
public static MediaPacket FromEncodedAudioFrame(EncodedAudioFrame frame, int trackId, long pts, int configVersion = 0);
}
Timestamps stay in each track's own timescale (1000 for RTMP/FLV, 90000 for MPEG-TS, the sample rate for audio from this library's encoders) until a muxer needs another one; MediaTimestamp.Rescale is the only conversion, and doesn't drift the way a fixed per-frame delta would (1024 samples at 44.1 kHz is 2089.8 ticks at 90 kHz). H.264/H.265 payloads are length-prefixed (AVCC/HVCC, what FLV, RTMP and MP4 use); AnnexBConverter converts to and from the start-code framing MPEG-TS uses. H264SpsInfo/H265SpsInfo report profile, level, chroma format, bit depth and the display size with SPS cropping / conformance window applied, plus an RFC 6381 CodecsString for an HLS CODECS attribute (avc1.64001E, hvc1.1.6.L120.90). Only parameter-set headers are parsed; slice data is never read. Every parser throws InvalidDataException on malformed input: truncation, zero NAL counts, a length running past the end, or a field outside its spec range.
var avcC = AvcDecoderConfigurationRecord.Parse(avcCBytes);
var sps = H264SpsParser.Parse(avcC.SequenceParameterSets[0].Span);
var video = new MediaTrackInfo(1, MediaKind.Video, MediaCodec.H264, timescale: 1000, avcCBytes, configVersion: 0,
width: sps.Width, height: sps.Height);
// Feed any IStreamingEncoder into the packet model.
long pts = 0;
using var aac = AacStreamingEncoder.Create(channels: 1, sampleRate: 44100, frame =>
{
var packet = MediaPacketFactory.FromEncodedAudioFrame(frame, trackId: 2, pts);
pts += frame.SampleCount;
// ... hand packet to an IMediaPacketSink
});
FLV Demux and Mux
Namespace: EggEncoder.Containers.Flv. Reads and writes FLV (Adobe FLV specification v10.1 Annex E plus Enhanced FLV) at the packet level: H.264/H.265/AV1/VP9 video and AAC/MP3 audio are remuxed, never decoded. This is item 2 of the live ingest & restream plan; an RTMP audio/video message body has exactly the FLV tag-body layout, so the RTMP items reuse FlvTagBodyParser.
public static class FlvTagBodyParser
{
public static FlvTagBody ParseVideo(ReadOnlySpan<byte> body); // legacy AVC (+ HEVC CodecID 12), Enhanced avc1/hvc1/av01/vp09
public static FlvTagBody ParseAudio(ReadOnlySpan<byte> body); // AAC, MP3
}
public readonly ref struct FlvTagBody
{
public FlvPacketType PacketType { get; } // SequenceHeader, CodedFrame, SequenceEnd, Ignored, Unsupported
public MediaKind Kind { get; }
public MediaCodec? Codec { get; } // null when Unsupported
public bool IsKeyframe { get; }
public int CompositionTime { get; } // signed ms, PTS - DTS
public ReadOnlySpan<byte> Payload { get; } // avcC/hvcC/AudioSpecificConfig, or the coded frame
public int SoundRate { get; } // legacy audio header hints
public int SoundChannels { get; }
public string? UnsupportedReason { get; }
}
public sealed class FlvDemuxer
{
public const int VideoTrackId = 0, AudioTrackId = 1, Timescale = 1000, MaxPendingPackets = 1024;
public FlvDemuxer(Action<MediaTrackSet> onTracks, Action<MediaPacket> onPacket);
public void Feed(ReadOnlySpan<byte> data); // any chunk size
public void Complete();
public MediaTrackSet? Tracks { get; }
public FlvMetadata? Metadata { get; }
public bool HeaderDeclaresAudio { get; }
public bool HeaderDeclaresVideo { get; }
public int SkippedTagCount { get; }
}
public sealed class FlvFileSource : IAsyncDisposable, IDisposable
{
public FlvFileSource(Stream source, bool leaveOpen = false); // no CanSeek needed
public IAsyncEnumerable<MediaPacket> ReadPacketsAsync(CancellationToken cancellationToken = default);
public MediaTrackSet? Tracks { get; } // describes the packet just yielded, never a later configuration
public FlvMetadata? Metadata { get; }
}
public sealed class FlvWriter : IMediaPacketSink, IDisposable
{
public FlvWriter(Stream destination, bool leaveOpen = false, FlvMetadata? metadata = null);
public void Open(MediaTrackSet tracks);
public void UpdateTracks(MediaTrackSet tracks); // after a ConfigVersion change, or to add a late track
public void Write(MediaPacket packet);
public void Complete();
// plus OpenAsync / WriteAsync / CompleteAsync / DisposeAsync from IMediaPacketSink
}
public sealed class FlvMetadata // onMetaData
{
public FlvMetadata(IEnumerable<KeyValuePair<string, Amf0Value>> properties);
public IReadOnlyList<KeyValuePair<string, Amf0Value>> Properties { get; }
public double? Duration { get; } // also Width, Height, FrameRate, VideoDataRate, AudioDataRate, AudioSampleRate
public string? Encoder { get; }
public Amf0Value? Find(string name);
public static FlvMetadata FromTracks(MediaTrackSet tracks, string? encoder = "EggEncoder");
}
Demuxing. Video is track 0 and audio track 1, both in milliseconds. The track set is announced once every track the FLV header declares has its configuration (an AVC/HEVC/AV1/VP9 or AAC sequence header, or the first MP3 frame), holding at most 1,024 packets until then, and again whenever a track appears or a sequence header with different bytes bumps its ConfigVersion. avcC/hvcC are validated and the display size read from the SPS; AAC sample rate and channels come from the AudioSpecificConfig, MP3's from the first frame's MPEG header (FLV's own rate field can't say 48 kHz). FLV's 32-bit timestamps are unwrapped into a continuous long, and a frame's PTS is its DTS plus the signed composition time. A bad signature, version or DataOffset, a PreviousTagSize mismatch, a malformed sequence header, or input that ends mid-tag throws InvalidDataException; an unsupported codec, a frame before its sequence header, an encrypted or unknown tag, or an unreadable script tag is skipped and counted in SkippedTagCount. A missing final PreviousTagSize is tolerated.
Writing. At most one audio and one video track. H.264, AAC and MP3 use legacy tags; H.265, AV1 and VP9 use Enhanced-FLV FourCC tags (HEVC frames with no composition offset use CodedFramesX, as ffmpeg does). A sequence header goes before each track's first frame and again whenever its ConfigVersion changes: call UpdateTracks first, since a packet whose ConfigVersion isn't the track's current one throws. Timestamps are rescaled to milliseconds with MediaTimestamp.Rescale; a negative DTS, a composition offset outside FLV's signed 24 bits, or a nonzero offset on audio/AV1/VP9 throws. Writes are forward-only, so the onMetaData duration stays 0, the live-FLV convention. Opus throws NotSupportedException (that needs Enhanced RTMP v2 audio, not implemented).
// Remux an FLV: demux packets, write them back out unchanged.
await using var source = new FlvFileSource(File.OpenRead("in.flv"));
await using var writer = new FlvWriter(File.Create("out.flv"));
var opened = false;
await foreach (var packet in source.ReadPacketsAsync(cancellationToken))
{
if (!opened) { await writer.OpenAsync(source.Tracks!, cancellationToken); opened = true; }
await writer.WriteAsync(packet, cancellationToken);
}
await writer.CompleteAsync(cancellationToken);
Fragmented MP4 (fMP4/CMAF) from Packets
Namespace: EggEncoder.Streaming.Mp4. Writes fragmented MP4 init and media segments, the format HLS and DASH serve, from packets: H.264 and H.265 video is passed through without decoding, next to AAC audio. This is item 5a of the live ingest & restream plan; the same two types also keep their original single mono AAC track mode (BuildInitSegment(channels, sampleRate) and BuildMediaSegment(IReadOnlyList<Mp4EncodedFrame>)), which the HLS segmenter's audio-frame mode uses.
public static class FmpInitSegmentWriter
{
public static byte[] BuildInitSegment(MediaTrackSet tracks); // ftyp + moov: avc1/avcC, hvc1/hvcC, mp4a/esds
public static byte[] BuildInitSegment(int channels, int sampleRate); // frame mode: one mono AAC-LC track
}
public sealed class FmpMediaSegmentWriter // stateful: one instance per rendition
{
public FmpMediaSegmentWriter(MediaTrackSet tracks); // packet mode
public FmpMediaSegmentWriter(); // frame mode
public byte[] BuildMediaSegment(IReadOnlyList<MediaPacket> packets, IReadOnlyDictionary<int, long>? nextDecodeTimes = null);
public byte[] BuildMediaSegment(IReadOnlyList<Mp4EncodedFrame> frames);
}
Init segment. One trak per track, in the set's order; a track's MP4 track_ID is its position plus one (ffmpeg's default; packet track ids may be 0), and its mdhd timescale is its own Timescale, so timestamps are written exactly as given. The avcC/hvcC and AudioSpecificConfig bytes are copied as-is (avcC/hvcC are validated first). A video track with unknown dimensions gets them from its SPS; an AAC track with an unknown sample rate or channel count gets them from its AudioSpecificConfig. AAC may be mono or stereo. MP3, Opus, AV1 and VP9 tracks throw NotSupportedException; an empty set, a missing or invalid codec configuration, or a dimension or channel count past 16 bits throws ArgumentException. No edit list is written.
Media segments. One moof + mdat per call, with a traf for each track that has packets in it. Each tfdt is that track's first DTS in the segment, and each sample's duration is the next DTS of its track minus its own. The last sample of a track has no next DTS in the segment, so it takes, in order: the nextDecodeTimes entry for its track id (a segmenter that cuts when the next keyframe arrives knows the video one), the previous duration in the segment, the track's last duration from the previous segment, or a default of 1024 samples for AAC and 1/30 s for video. Because the next tfdt always comes from the real DTS, a guessed duration never accumulates drift. Video samples carry sync/non-sync flags and, when any PTS differs from its DTS, signed composition offsets (version 1 trun). Packets must belong to the set, carry their track's ConfigVersion, and have a non-negative DTS that strictly increases per track, across segments too; otherwise, or when an offset or duration doesn't fit 32 bits, the call throws ArgumentException and the writer is left unchanged. Timestamps are never rebased. Calling the other mode's BuildMediaSegment throws InvalidOperationException.
// Remux an FLV (or an RTMP ingest's packets) to fMP4: an init segment, then one segment per GOP.
await using var source = new FlvFileSource(File.OpenRead("in.flv"));
await using var output = File.Create("out.mp4");
FmpMediaSegmentWriter? writer = null;
var gop = new List<MediaPacket>();
await foreach (var packet in source.ReadPacketsAsync(cancellationToken))
{
if (writer is null)
{
output.Write(FmpInitSegmentWriter.BuildInitSegment(source.Tracks!));
writer = new FmpMediaSegmentWriter(source.Tracks!);
}
var isVideoKeyframe = packet.IsKeyframe && source.Tracks!.FindTrack(packet.TrackId)!.Kind == MediaKind.Video;
if (isVideoKeyframe && gop.Count > 0)
{
output.Write(writer.BuildMediaSegment(gop, new Dictionary<int, long> { [packet.TrackId] = packet.Dts }));
gop.Clear();
}
gop.Add(packet);
}
if (gop.Count > 0) output.Write(writer!.BuildMediaSegment(gop));
MPEG-TS from Packets
Namespace: EggEncoder.Streaming.MpegTs. Writes MPEG-TS segments (ISO/IEC 13818-1), the other segment format HLS serves, from packets: H.264 and H.265 video is passed through without decoding, next to AAC audio. This is item 5b of the live ingest & restream plan; the type also keeps its original single mono AAC track mode (new MpegTsMuxer(channels, sampleRate) and BuildSegment(IReadOnlyList<Mp4EncodedFrame>)), which the HLS segmenter's audio-frame mode uses.
public sealed class MpegTsMuxer // stateful: one instance per rendition
{
public MpegTsMuxer(MediaTrackSet tracks); // packet mode
public MpegTsMuxer(int channels, int sampleRate); // frame mode
public byte[] BuildSegment(IReadOnlyList<MediaPacket> packets);
public byte[] BuildSegment(IReadOnlyList<Mp4EncodedFrame> frames);
}
Tracks. H.264 is written with stream_type 0x1B, H.265 with 0x24 and AAC as ADTS with 0x0F. A track's elementary PID is 0x100 plus its position in the set; video tracks get stream ids 0xE0 upward and audio tracks 0xC0 upward, so a set may hold at most 16 video and 32 audio tracks. The PCR rides on the first video track, or on the first track when there is no video. AAC must fit an ADTS header: audio object type 1-4 (so AAC-LC, or HE-AAC signalled implicitly, but not HE-AAC with explicit SBR/PS signalling, which fMP4 does accept), an indexed sample rate and a channel configuration of 1-7; anything else, and MP3, Opus, AV1 and VP9 tracks, throw NotSupportedException. An empty set or a missing or invalid codec configuration throws ArgumentException.
Segments. Each call returns one standalone .ts file: PAT, PMT, then one PES per packet, in DTS order (a stable sort, so packets may come grouped by track). Timestamps are rescaled to 90 kHz and written as given, modulo 233; nothing is rebased. A video PES holds the access unit in Annex B form: an access unit delimiter first (the packet's own, if it starts with one), then, on a keyframe that carries no SPS of its own, the parameter sets from the track's avcC/hvcC, then the packet's NAL units. Its PES_packet_length is 0 and it carries a DTS only when it differs from the PTS. An AAC PES holds one ADTS frame. Keyframe PES (and every AAC one) set random_access_indicator. The PCR is max(0, DTS - 700 ms), written on every PES of the PCR PID, with a PCR-only packet on that PID before another PID's PES that opens a segment, and PCR-only packets 100 ms apart bridging any gap of more than 100 ms (up to 5 s; a longer gap is treated as a timestamp jump and left alone). The last TS packet of a PES is padded with adaptation-field stuffing. Continuity counters, PAT and PMT included, run on from one segment to the next, so a playlist of segments reads as one continuous stream. Packets must belong to the set, carry their track's ConfigVersion, have a PTS no earlier than a non-negative DTS that strictly increases per track (across segments too, and also once rounded to 90 kHz), a well-formed length-prefixed video payload, and AAC payloads of at most 8,184 bytes; otherwise the call throws ArgumentException and the muxer is left unchanged. Calling the other mode's BuildSegment throws InvalidOperationException.
// Remux an FLV (or an RTMP ingest's packets) to MPEG-TS segments, one per GOP.
await using var source = new FlvFileSource(File.OpenRead("in.flv"));
MpegTsMuxer? muxer = null;
var gop = new List<MediaPacket>();
var index = 0;
await foreach (var packet in source.ReadPacketsAsync(cancellationToken))
{
muxer ??= new MpegTsMuxer(source.Tracks!);
var isVideoKeyframe = packet.IsKeyframe && source.Tracks!.FindTrack(packet.TrackId)!.Kind == MediaKind.Video;
if (isVideoKeyframe && gop.Count > 0)
{
File.WriteAllBytes($"segment{index++}.ts", muxer.BuildSegment(gop));
gop.Clear();
}
gop.Add(packet);
}
if (gop.Count > 0) File.WriteAllBytes($"segment{index}.ts", muxer!.BuildSegment(gop));
HLS from Packets
Namespace: EggEncoder.Streaming.Hls. Packages a packet stream (an RTMP ingest, an FLV file) as HLS with video, without decoding: HlsSegmenter cuts segments at video keyframes and writes them through the fMP4 or MPEG-TS packet writers, and HlsMediaPacketSink wraps it as an IMediaPacketSink. These are items 5c and 5d of the live ingest & restream plan. The original audio-frame constructors and AddFrame are unchanged.
public sealed partial class HlsSegmenter
{
public HlsSegmenter(MediaTrackSet tracks, int targetDurationSeconds, IStreamingSink sink, SegmentFormat segmentFormat = SegmentFormat.Fmp4); // VOD
public HlsSegmenter(MediaTrackSet tracks, int targetDurationSeconds, IStreamingSink sink, int liveWindowSize, SegmentFormat segmentFormat = SegmentFormat.Fmp4); // live
public long DroppedLeadingPacketCount { get; }
public void AddPacket(MediaPacket packet);
public void UpdateTracks(MediaTrackSet tracks); // a mid-stream configuration change
public void Complete();
}
public sealed class HlsMediaPacketSink : IMediaPacketSink
{
public HlsMediaPacketSink(IStreamingSink sink, int targetDurationSeconds, SegmentFormat segmentFormat = SegmentFormat.Fmp4, int? liveWindowSize = null);
public HlsSegmenter? Segmenter { get; } // built by OpenAsync
public void UpdateTracks(MediaTrackSet tracks); // forwards to the segmenter
}
public readonly record struct HlsSegmentDescriptor(long SequenceNumber, double DurationSeconds, string Uri)
{
public bool Discontinuity { get; init; } // EXT-X-DISCONTINUITY before this segment
public string? InitSegmentUri { get; init; } // its EXT-X-MAP
}
public static class M3u8PlaylistWriter
{
public static string BuildMediaPlaylist(int targetDurationSeconds, long mediaSequence, long discontinuitySequence,
IReadOnlyList<HlsSegmentDescriptor> segments, string? initSegmentUri, bool endList);
}
public interface IStreamingSink
{
// ... the members described under HLS ...
void DeleteInitSegment(string uri) { } // live window: an init segment no listed segment needs
}
public static class M3u8MasterPlaylistWriter
{
public static string BuildCodecsString(MediaTrackSet tracks); // e.g. "avc1.64001F,mp4a.40.2"
}
Cutting. Cut points are the keyframes of the first video track, or every packet of the first track when there is no video (an audio-only rendition). A cut point whose PTS is at least targetDurationSeconds past the pending segment's first cut point ends that segment and starts the next one, so every segment starts with a keyframe and lasts at least the target and at most one GOP more. Packets go into segments in the order they are added, as ffmpeg's HLS muxer does. Packets before the first cut point can't be decoded (with video, that is everything before its first keyframe, audio included) and are dropped and counted in DroppedLeadingPacketCount. The fMP4 writer gets the next keyframe's DTS as the video track's next decode time, so the last frame of each segment has its exact duration.
Playlist. Each EXTINF is the PTS difference between the segment's cut point and the next one, measured on the cut track. The final segment, written by Complete, runs to the latest PTS in it plus one frame (the cut track's last DTS step, or 1/30 s or 1024 AAC samples when there is none). EXT-X-TARGETDURATION is the ceiling of the longest segment so far, never below the target. fMP4 writes the init segment when the segmenter is built and references it with EXT-X-MAP; MPEG-TS needs neither. VOD and live sliding window work as for the audio segmenter.
Validation. The constructors reject what the chosen container rejects (an empty set, MP3/Opus/AV1/VP9, AAC that ADTS can't describe for MPEG-TS). AddPacket throws ArgumentException, adding nothing, for a packet of a track not in the set, a ConfigVersion other than its track's (see configuration changes below), a negative DTS, a PTS before its DTS, or a DTS not greater than its track's previous one. When the container still rejects a pending packet at a cut (a malformed payload, a timestamp it can't hold), that segment is discarded, the exception is thrown from the AddPacket (or Complete) call that cut it, and the next segment starts at that cut point; the timeline then has a gap the playlist doesn't mark. An exception from the IStreamingSink during a cut (an IOException from a failed upload, say) gets the same recovery: the next segment still starts at that cut point, and a segment the sink failed to write is dropped without using up a sequence number. Complete ends the playlist before rethrowing. Only the first video track decides cut points. In live mode keep the target at least as long as the GOP, since a longer segment raises EXT-X-TARGETDURATION, which a live playlist must not change (RFC 8216 §6.2.1). AddFrame on a packet segmenter, and AddPacket on a frame segmenter, throw InvalidOperationException.
Configuration changes. UpdateTracks switches the stream to a new configuration of the same tracks, item 5d of the plan: a new SPS when the encoder changes resolution or level, a new AudioSpecificConfig. The new set must hold the same track ids in the same order, each with its kind, codec and timescale unchanged; anything else throws ArgumentException and needs a new rendition. A track whose codec configuration bytes changed needs a new ConfigVersion (the same version with new bytes throws), and a set whose versions all match the current ones changes nothing. The set is checked against the container straight away (an fMP4 init segment is built from it, or an MPEG-TS muxer for it), so a rejected update throws from UpdateTracks and is ignored. Nothing is written yet: the next AddPacket, checked against the new set, ends the pending segment with the old configuration (its duration measured as Complete measures the final one), then the segmenter waits for the next cut point as at the start of the stream, dropping and counting the packets before it. The segment that starts there is preceded by EXT-X-DISCONTINUITY (unless it is the stream's first segment) and, for fMP4, by a new init segment, init_2.mp4, init_3.mp4 and so on, written just before it and listed with EXT-X-MAP; if the sink fails to write it, that segment is discarded and the next cut retries the same name. Each track's DTS must keep increasing across the change, with no rebasing. In live mode, an init segment no segment left in the window needs is deleted through IStreamingSink.DeleteInitSegment (a no-op by default; FileSystemStreamingSink deletes the file, InMemoryStreamingSink keeps every named init in InitSegments, and DelegateStreamingSink's five-delegate constructor passes init file names and deletions through), and EXT-X-DISCONTINUITY-SEQUENCE counts the discontinuities that left the window. An update not yet applied when Complete is called is dropped, and the master playlist's CODECS isn't rewritten. FmpMediaSegmentWriter.UpdateTracks and MpegTsMuxer.UpdateTracks do the same swap for the writers used on their own: the sequence number, decode times, PIDs, the PMT, continuity counters and the PCR carry on.
HlsMediaPacketSink. OpenAsync builds the segmenter for the tracks it receives (writing the fMP4 init segment), WriteAsync adds a packet and CompleteAsync completes it, synchronously, each returning a completed ValueTask; the IStreamingSink does the I/O. A canceled token throws before anything happens; writing before OpenAsync, or opening twice, throws InvalidOperationException; disposing doesn't complete the playlist. UpdateTracks forwards to the segmenter (call it when the source reports new tracks, before writing their packets); IMediaPacketSink itself has no track-update member yet.
CODECS. BuildCodecsString gives a rendition's CODECS value for HlsRenditionDescriptor: video first, then audio, each distinct string once. H.264 is avc1.PPCCLL from the avcC record's first SPS, H.265 hvc1.… from the hvcC record's SPS, and AAC mp4a.40.N with N the AudioSpecificConfig's audio object type (2 for AAC-LC, 5 or 29 for HE-AAC signalled explicitly). Other codecs throw NotSupportedException; an unreadable configuration throws ArgumentException.
// An FLV file (or an RTMP ingest) to an fMP4 HLS playlist with video, in 4-second segments.
var sink = new FileSystemStreamingSink("out");
await using var hls = new HlsMediaPacketSink(sink, targetDurationSeconds: 4);
await using var source = new FlvFileSource(File.OpenRead("in.flv"));
MediaTrackSet? tracks = null;
await foreach (var packet in source.ReadPacketsAsync(cancellationToken))
{
if (tracks is null)
{
await hls.OpenAsync(source.Tracks!, cancellationToken);
}
else if (!ReferenceEquals(tracks, source.Tracks))
{
hls.UpdateTracks(source.Tracks!); // a new sequence header mid-stream
}
tracks = source.Tracks;
await hls.WriteAsync(packet, cancellationToken);
}
await hls.CompleteAsync(cancellationToken);
var codecs = M3u8MasterPlaylistWriter.BuildCodecsString(source.Tracks!);
AMF0
Namespace: EggEncoder.Live.Rtmp.Amf0. The Action Message Format 0 codec FLV onMetaData and RTMP command messages use, hand-written with no reflection (AOT-safe).
public enum Amf0Type : byte { Number, Boolean, String, Object, Null, Undefined, EcmaArray, StrictArray, Date, LongString, XmlDocument }
public sealed class Amf0Value
{
public static readonly Amf0Value Null, Undefined;
public static Amf0Value FromNumber(double value);
public static Amf0Value FromBoolean(bool value);
public static Amf0Value FromString(string value); // a LongString past 65,535 UTF-8 bytes
public static Amf0Value FromLongString(string value);
public static Amf0Value FromXmlDocument(string value);
public static Amf0Value FromDate(double millisecondsSinceEpoch, short timeZone = 0);
public static Amf0Value FromObject(IEnumerable<KeyValuePair<string, Amf0Value>> properties);
public static Amf0Value FromEcmaArray(IEnumerable<KeyValuePair<string, Amf0Value>> properties);
public static Amf0Value FromStrictArray(IEnumerable<Amf0Value> items);
public Amf0Type Type { get; }
public double Number { get; } // also a Date's milliseconds
public bool Boolean { get; }
public string? String { get; }
public short TimeZone { get; }
public IReadOnlyList<KeyValuePair<string, Amf0Value>> Properties { get; }
public IReadOnlyList<Amf0Value> Items { get; }
public Amf0Value? GetProperty(string name);
}
public ref struct Amf0Reader
{
public const int MaxNestingDepth = 32, MaxStringBytes = 65536, MaxProperties = 1024;
public Amf0Reader(ReadOnlySpan<byte> data);
public Amf0Value ReadValue();
public int Position { get; }
public bool AtEnd { get; }
}
public static class Amf0Writer
{
public static byte[] Encode(params Amf0Value[] values);
public static void Write(IBufferWriter<byte> destination, Amf0Value value);
}
The reader bounds every length before allocating: nesting past 32 levels, a string over 64 KiB, more than 1,024 properties or items, a count larger than the bytes left, truncation, and the Reference, Typed Object and AMF3-switch markers all throw InvalidDataException. An ECMA array's count is treated as a hint; its object-end marker terminates it. The writer is the reader's inverse and refuses to nest deeper than the reader accepts.
RTMP Protocol Core
Namespace: EggEncoder.Live.Rtmp. Everything RTMP needs below "publish a stream" (Adobe RTMP specification 1.0): the handshake, chunk stream reassembly and splitting, protocol control and user control messages, and the AMF0 command and data messages publishers and servers exchange. This is item 3 of the live ingest & restream plan: every type here is pure and synchronous over bytes and never touches a socket, so the coming RTMP server (item 4) and client (item 6) share one implementation, and every parser can be tested and fuzzed without networking. An audio/video message body is an FLV tag body: parse it with FlvTagBodyParser.
public sealed class RtmpHandshake
{
public const byte Version = 3;
public const int PacketLength = 1536;
public static RtmpHandshake CreateServer(); // expects C0+C1, sends S0+S1+S2, expects C2
public static RtmpHandshake CreateClient(); // sends C0+C1, expects S0+S1+S2, sends C2
public bool IsComplete { get; }
public int BytesExpected { get; } // how many peer bytes Process needs next
public byte[] Start(); // bytes to send first (empty for a server)
public byte[] Process(ReadOnlySpan<byte> peerBytes); // bytes to send in reply
}
public readonly record struct RtmpMessage(RtmpMessageType Type, uint StreamId, uint Timestamp, ReadOnlyMemory<byte> Payload)
{
public const int MaxPayloadLength = 0xFFFFFF;
}
public sealed class RtmpChunkReader
{
public const int DefaultChunkSize = 128;
public RtmpChunkReader(Action<RtmpMessage> onMessage, RtmpChunkReaderOptions? options = null);
public void Feed(ReadOnlySpan<byte> data); // any piece size, straight from a socket
public void Complete(); // the peer closed: a chunk or message cut short throws
public int ChunkSize { get; } // the peer's, after its Set Chunk Size
public long BytesReceived { get; }
public uint AcknowledgementWindowSize { get; }
public bool TryTakeAcknowledgement(out uint sequenceNumber);
}
public sealed class RtmpChunkReaderOptions
{
public int MaxInboundChunkSize { get; init; } // default 65,536; hard limit 16 MiB - 1
public int MaxMessageLength { get; init; } // default 8 MiB; hard limit 16 MiB - 1
public int MaxChunkStreams { get; init; } // default 64; hard limit 1,024
public int MaxBufferedBytes { get; init; } // default 16 MiB; hard limit 256 MiB
}
public sealed class RtmpChunkWriter
{
public const int MaxChunkStreamId = 65599;
public int ChunkSize { get; }
public void WriteSetChunkSize(IBufferWriter<byte> destination, int chunkSize);
public void Write(IBufferWriter<byte> destination, int chunkStreamId, RtmpMessage message); // not Set Chunk Size: use WriteSetChunkSize
}
public static class RtmpProtocolControl // message types 1, 2, 3, 5, 6 on chunk stream 2
{
public static RtmpMessage CreateSetChunkSize(int chunkSize); // and ReadSetChunkSize
public static RtmpMessage CreateAbort(int chunkStreamId); // and ReadAbort
public static RtmpMessage CreateAcknowledgement(uint sequenceNumber); // and ReadAcknowledgement
public static RtmpMessage CreateWindowAcknowledgementSize(uint windowSize); // and ReadWindowAcknowledgementSize
public static RtmpMessage CreateSetPeerBandwidth(uint windowSize, RtmpPeerBandwidthLimitType limitType); // and ReadSetPeerBandwidth
}
public readonly record struct RtmpUserControl(RtmpUserControlEventType EventType, uint Value, uint BufferLength = 0)
{
public static RtmpUserControl StreamBegin(uint streamId); // also StreamEof, SetBufferLength, PingRequest, PingResponse
public RtmpMessage ToMessage(uint timestamp = 0);
public static RtmpUserControl Read(ReadOnlySpan<byte> payload);
}
public sealed class RtmpCommand // AMF0 command message, type 20
{
public RtmpCommand(string name, double transactionId, Amf0Value? commandObject, params Amf0Value[] arguments);
public string Name { get; }
public double TransactionId { get; }
public Amf0Value CommandObject { get; }
public IReadOnlyList<Amf0Value> Arguments { get; }
public string? StatusCode { get; } // the info object's code, e.g. NetStream.Publish.Start
public string? GetStringArgument(int index);
public double? GetNumberArgument(int index);
public static RtmpCommand Decode(ReadOnlySpan<byte> payload);
public byte[] Encode();
public RtmpMessage ToMessage(uint streamId = 0, uint timestamp = 0);
// Connect, ConnectResult, Result, Error, CreateStream, CreateStreamResult, ReleaseStream,
// FCPublish, FCUnpublish, Publish, Play, DeleteStream, OnStatus
}
public sealed class RtmpDataMessage // AMF0 data message, type 18
{
public static RtmpDataMessage SetDataFrame(FlvMetadata metadata); // @setDataFrame, onMetaData, properties
public static RtmpDataMessage Decode(ReadOnlySpan<byte> payload);
public FlvMetadata? TryGetMetadata(); // with or without the @setDataFrame wrapper
public byte[] Encode();
public RtmpMessage ToMessage(uint streamId, uint timestamp = 0);
}
public static class RtmpStatusCodes // ConnectSuccess, ConnectRejected, PublishStart, PublishBadName, ...
public sealed class RtmpProtocolException : IOException
Handshake. The simple handshake from the specification: version 3, then 1536-byte packets of time, zero and random bytes, each side echoing the other's. Versions 6 and 8 (RTMPE, Adobe's encrypted RTMP, out of scope) and every other version throw RtmpProtocolException. The echoes are not compared byte for byte, since digest-handshake publishers don't echo exactly; a real ffmpeg 9 publish completes against the simple handshake (checked in as a wire capture).
Chunk stream. The reader handles every message header type (0 absolute, 1 and 2 deltas, 3 repeating the previous delta, which right after a type 0 is that type 0's timestamp, as the specification describes), chunk stream ids 2 to 65,599 in one-, two- and three-byte basic headers, 32-bit extended timestamps (repeated on continuation chunks as the specification requires; a peer that leaves the repeat out, as some older publishers did, is recognised on its first continuation and read that way from then on), messages interleaved chunk by chunk across chunk streams, and zero-length messages. It applies Set Chunk Size, Abort and Window Acknowledgement Size itself before reporting them. Every bound is checked before anything is allocated, and buffers grow only as payload arrives, so a header that merely claims 8 MiB costs nothing. A chunk size, message length, chunk stream count or amount of partial data over its limit, a malformed control message, a type 1-3 header on a chunk stream that never had a type 0 one, and a new message header before the previous message on that chunk stream finished all throw RtmpProtocolException, after which the reader refuses further input. Because the reader waits for more bytes rather than guessing, truncation shows only when the caller says the input has ended: Complete() throws RtmpProtocolException if a chunk (header, extended timestamp or payload) or a message was cut short. The writer picks the most compact header the previous message on the chunk stream allows, switches to type 0 when the timestamp goes backwards, always repeats the extended timestamp, and never writes a type 3 directly after a type 0 (peers disagree on how to read that).
Commands. RtmpCommand.Decode wraps every AMF0 error (including its nesting, string and count bounds) in an RtmpProtocolException; a payload that stops after the name reads as transaction id 0 with a null command object. The factories build what publishers and servers exchange: connect (with app, type, flashVer, tcUrl and any extra properties such as Enhanced RTMP's fourCcList), the connect _result, createStream and its result, releaseStream/FCPublish/FCUnpublish, publish, play, deleteStream, _error and onStatus.
// One side of a connection (network is the accepted connection's NetworkStream); the I/O stays with the caller.
var handshake = RtmpHandshake.CreateServer();
await network.WriteAsync(handshake.Start());
while (!handshake.IsComplete)
{
var peer = new byte[handshake.BytesExpected];
await network.ReadExactlyAsync(peer);
await network.WriteAsync(handshake.Process(peer));
}
var writer = new RtmpChunkWriter();
var output = new ArrayBufferWriter<byte>();
var reader = new RtmpChunkReader(message =>
{
if (message.Type == RtmpMessageType.CommandAmf0 && RtmpCommand.Decode(message.Payload.Span) is { Name: "connect" } connect)
{
writer.Write(output, RtmpCommand.ChunkStreamId, RtmpCommand.ConnectResult(connect.TransactionId).ToMessage());
}
});
reader.Feed(receivedBytes); // then send output.WrittenSpan
RTMP Server (Ingest)
Namespace: EggEncoder.Live.Rtmp. An ingest server OBS, Streamlabs, vMix, ffmpeg or a hardware encoder can publish into at rtmp://host:port/app/streamKey (or rtmps:// with a certificate), built on the protocol core. Each authorized publish arrives as an RtmpIngestStream of packets: H.264/H.265/AV1/VP9 stay length-prefixed and AAC a raw access unit, exactly as the publisher sent them, so they can be remuxed (FLV recording, and HLS and restreaming as later plan items land) without decoding. This is item 4 of the live ingest & restream plan. Ingest only: play is refused.
public sealed class RtmpServer : IAsyncDisposable
{
public RtmpServer(RtmpServerOptions? options = null);
public event EventHandler<RtmpIngestStream>? PublishStarted; // authorized, before any media
public event EventHandler<RtmpIngestStream>? PublishEnded; // ended, packet stream complete
public RtmpServerOptions Options { get; }
public IPEndPoint? LocalEndPoint { get; } // the real port when EndPoint asked for 0
public int ConnectionCount { get; }
public Task StartAsync(CancellationToken cancellationToken = default);
public Task StopAsync(); // disconnects everyone, ends their publishes normally
}
public sealed class RtmpServerOptions
{
public IPEndPoint EndPoint { get; init; } // default 127.0.0.1:1935
public X509Certificate2? Certificate { get; init; } // set: RTMPS
public Func<RtmpPublishRequest, CancellationToken, ValueTask<bool>>? AuthorizePublishAsync { get; init; } // null: reject every publish
public RtmpChunkReaderOptions ChunkReader { get; init; }
public TimeProvider TimeProvider { get; init; }
public int MaxConnections { get; init; } // default 64; hard limit 10,000
public int MaxNameLength { get; init; } // default 256; hard limit 4,096 (app and stream key)
public int OutboundChunkSize { get; init; } // default 4,096
public uint AcknowledgementWindowSize { get; init; } // default 2,500,000
public int PacketQueueCapacity { get; init; } // default 1,024 packets per publish
public int PacketQueueMaxBytes { get; init; } // default 64 MiB per publish
public TimeSpan HandshakeTimeout { get; init; } // default 10 s (TLS + RTMP handshake)
public TimeSpan PublishTimeout { get; init; } // default 10 s (handshake done to publish accepted)
public TimeSpan IdleTimeout { get; init; } // default 30 s (a publisher sending nothing)
public TimeSpan ConsumerStallTimeout { get; init; } // default 10 s (a full queue nobody reads)
}
public sealed class RtmpPublishRequest
{
public string AppName { get; }
public string StreamKey { get; } // a secret: ToString leaves it out
public string PublishingType { get; } // live, record, append
public EndPoint? RemoteEndPoint { get; }
public IReadOnlyList<KeyValuePair<string, Amf0Value>> ConnectProperties { get; }
public string? FlashVersion { get; }
public string? TcUrl { get; }
}
public sealed class RtmpIngestStream
{
public string AppName { get; }
public string StreamKey { get; }
public EndPoint? RemoteEndPoint { get; }
public IReadOnlyList<KeyValuePair<string, Amf0Value>> ConnectProperties { get; }
public RtmpPublishRequest Request { get; }
public MediaTrackSet? Tracks { get; } // describes the packet last yielded
public FlvMetadata? Metadata { get; } // the publisher's latest onMetaData
public Task Completion { get; } // faults only on a protocol error
public IAsyncEnumerable<MediaPacket> ReadPacketsAsync(CancellationToken cancellationToken = default);
public void Disconnect(); // kick the publisher, ending the publish
}
Security defaults. The server listens on 127.0.0.1 only until EndPoint says otherwise, and with no AuthorizePublishAsync every publish gets NetStream.Publish.BadName. Application names and stream keys are checked (non-empty, at most MaxNameLength characters, no control characters) before the callback sees them; a key already being published under the same application is refused until that publish ends (compared as an exact string, ?query included). Those checks make a name safe to compare, not to use as a path: /, \ and .. pass them, so never build a file name from one unmapped. Every chunk-level bound is ChunkReader's, the AMF0 bounds are Amf0Reader's, and each connection is timed out through every phase: the TLS and RTMP handshakes, getting to an accepted publish (the authorization callback's token is cancelled when that runs out, and a callback that ignores it is abandoned), a publisher that sends no media (pings don't count), and a consumer that leaves the packet queue full. A connection closes once its publish ends. A connection over MaxConnections is closed as soon as it is accepted, and a connection that breaks a bound is closed while every other connection carries on.
Packets and tracks. The server turns each RTMP audio, video and @setDataFrame/onMetaData message into packets with the same code FlvDemuxer uses for FLV tags, so the two give identical packets for the same tags (track ids 0 for video and 1 for audio, timescale 1000). It waits for the tracks the publisher's onMetaData names (videocodecid/audiocodecid), when that arrives before the first media message as it does from OBS and ffmpeg, and for both otherwise. Tracks changes, just before the first packet that uses it, when a track appears or a new sequence header changes its configuration (compare by reference to notice).
Backpressure and the end of a publish. Packets wait for the consumer in a bounded queue (PacketQueueCapacity, PacketQueueMaxBytes). When it is full the server stops reading from the publisher, which slows it down through TCP, and if the consumer stays away longer than ConsumerStallTimeout the publisher is disconnected; nothing is dropped. A publish ends when the publisher unpublishes or disconnects, on a timeout, when the server stops, or when the consumer calls Disconnect(): the enumeration then finishes normally after the last queued packet. Only a protocol violation or a malformed sequence header ends it with an RtmpProtocolException (an exception from a PublishStarted handler also ends it, faulted with that exception). Handlers run on the connection's task, so hand the stream off rather than blocking.
await using var server = new RtmpServer(new RtmpServerOptions
{
EndPoint = new IPEndPoint(IPAddress.Any, 1935), // opt in to every interface
AuthorizePublishAsync = (request, _) => ValueTask.FromResult(request.AppName == "live" && request.StreamKey == secretKey)
});
server.PublishStarted += (_, stream) => _ = Task.Run(async () =>
{
await using var recording = new FlvWriter(File.Create("recording.flv"));
MediaTrackSet? tracks = null;
await foreach (var packet in stream.ReadPacketsAsync())
{
if (!ReferenceEquals(tracks, stream.Tracks))
{
if (tracks is null) await recording.OpenAsync(stream.Tracks!);
else recording.UpdateTracks(stream.Tracks!); // a new sequence header mid-stream
tracks = stream.Tracks;
}
await recording.WriteAsync(packet);
}
if (tracks is not null) await recording.CompleteAsync();
});
await server.StartAsync(); // OBS: Server rtmp://this-host/live, Stream Key secretKey
RTMP Publish Client (Push)
Namespace: EggEncoder.Live.Rtmp. Pushes a packet stream to YouTube, Twitch, Facebook, any RTMP ingest or this library's own RtmpServer, over rtmp:// or rtmps://, without decoding: what OBS or ffmpeg -c copy -f flv rtmp://... does. It is an IMediaPacketSink, so an FLV file or an RTMP ingest can be forwarded packet by packet. This is item 6a of the live ingest & restream plan; pulling a stream (play) is item 6b.
public sealed class RtmpPublishClient : IMediaPacketSink
{
public const int DefaultRtmpPort = 1935;
public const int DefaultRtmpsPort = 443;
public RtmpPublishClient(Uri serverUrl, string streamKey, RtmpPublishClientOptions? options = null);
public string AppName { get; } // the URL's path, without leading and trailing '/'
public string TcUrl { get; } // scheme://host[:port]/app, never the stream key
public ValueTask OpenAsync(MediaTrackSet tracks, CancellationToken cancellationToken = default);
public ValueTask WriteAsync(MediaPacket packet, CancellationToken cancellationToken = default);
public void UpdateTracks(MediaTrackSet tracks); // a mid-stream configuration change
public ValueTask CompleteAsync(CancellationToken cancellationToken = default);
public ValueTask DisposeAsync();
}
public sealed class RtmpPublishClientOptions
{
public RemoteCertificateValidationCallback? RemoteCertificateValidation { get; init; } // null: the system's validation
public FlvMetadata? Metadata { get; init; } // null: FlvMetadata.FromTracks(tracks)
public RtmpChunkReaderOptions ChunkReader { get; init; }
public TimeProvider TimeProvider { get; init; }
public int OutboundChunkSize { get; init; } // default 4,096
public TimeSpan ConnectTimeout { get; init; } // default 10 s (TCP + TLS + RTMP handshake)
public TimeSpan PublishTimeout { get; init; } // default 10 s (handshake done to publish accepted)
public TimeSpan WriteTimeout { get; init; } // default 30 s (one write or the unpublish)
public TimeSpan CloseTimeout { get; init; } // default 2 s (waiting for the server to close)
}
public sealed class RtmpClientException : IOException
{
public RtmpClientFailureReason Reason { get; } // ConnectFailed, TlsFailed, Rejected, Timeout, ConnectionLost, ProtocolError
public string? StatusCode { get; } // the server's code when Rejected, e.g. NetStream.Publish.BadName
}
URLs and keys. The server URL and the stream key are separate, as ingest services hand them out (rtmp://a.rtmp.youtube.com/live2 plus the key). The application is the URL's path, which may contain / (rtmp://host/app/instance); the port defaults to 1935 for rtmp:// and 443 for rtmps://, and tcUrl keeps the port only when the URL names one. A URL with user info, a query or a fragment is refused, so secrets stay in the key, which is sent exactly as given (a Twitch key?bandwidthtest=true included). The key never appears in an exception message.
The publish flow. OpenAsync checks the tracks first (what FlvWriter accepts: at most one audio track, AAC or MP3, and one video track, H.264 or H.265/AV1/VP9), so an unsupported set throws before any connection. It then connects, runs TLS for rtmps:// (the system's certificate validation unless RemoteCertificateValidation is set), the handshake, and the commands in ffmpeg's order: connect (with fourCcList only when a video track needs Enhanced RTMP), Set Chunk Size, releaseStream, FCPublish, createStream and publish. It returns once the server answers NetStream.Publish.Start and @setDataFrame/onMetaData is sent. WriteAsync sends each packet as an FLV tag body, preceded by its track's sequence header the first time and after UpdateTracks changes its ConfigVersion; a packet the muxer rejects throws ArgumentException and the publish carries on. CompleteAsync sends FCUnpublish and deleteStream, closes the sending side (TLS close_notify first) and waits up to CloseTimeout for the server to close, so no reset can drop the last bytes; the close_notify write counts against that timeout too, and a refusal or protocol error the server sends before closing is thrown from CompleteAsync. Packets go out as fast as the server takes them, with no real-time pacing.
Failures. Every step has a timeout. A failure closes the socket and throws RtmpClientException, and every later call throws the same exception again; there is no reconnect, so a caller (such as the coming restream hub) decides from Reason whether to try again: Rejected (an _error reply, or an onStatus at level error before or during the publish, with its StatusCode) won't succeed with the same settings, while ConnectionLost or Timeout might. The server's free-text description is not exposed, since a server may echo the key into it. Cancelling a call through its token throws OperationCanceledException; a write cancelled part-way through leaves the stream unusable, so the client is failed and closed. Pings, Set Peer Bandwidth and acknowledgement windows are answered while publishing.
// Forward an RTMP ingest (OBS publishing to this machine) to YouTube, without decoding.
server.PublishStarted += (_, stream) => _ = Task.Run(async () =>
{
await using var youtube = new RtmpPublishClient(new Uri("rtmp://a.rtmp.youtube.com/live2"), youtubeKey);
MediaTrackSet? tracks = null;
await foreach (var packet in stream.ReadPacketsAsync())
{
if (!ReferenceEquals(tracks, stream.Tracks))
{
if (tracks is null) await youtube.OpenAsync(stream.Tracks!);
else youtube.UpdateTracks(stream.Tracks!);
tracks = stream.Tracks;
}
await youtube.WriteAsync(packet);
}
if (tracks is not null) await youtube.CompleteAsync();
});