diff --git a/CLAUDE.md b/CLAUDE.md index e8d77ea..4ba6fdf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,7 +193,7 @@ ordered plan, acceptance criteria per item, and what's merged versus still open. detecting drift at all. Reads `Channels`/`SampleRate` directly from the wrapped encoder, so the pump can never disagree with it about either value. - `Streaming.Opus.OpusStreamingEncoder` (`Streaming/Opus/`) -- the second `IStreamingEncoder` - implementation (item 8's own Opus sub-item; FLAC still open), same scope as + implementation (item 8's own Opus sub-item), same scope as `Codecs.Opus.OpusEncoderSession` (fixed 48kHz, mono/stereo, 16-bit PCM), reusing that session's own Concentus-backed encode internals unchanged. Unlike `AacStreamingEncoder`, `EncoderDelaySamples` is a per-instance property reading the underlying encoder's own @@ -207,7 +207,7 @@ ordered plan, acceptance criteria per item, and what's merged versus still open. nothing remains pending afterward -- that exists in the file-based session purely to flag an Ogg page's own end-of-stream bit, a container-level concept this raw-packet encoder has none of. - `Streaming.Mp3.Mp3StreamingEncoder` (`Streaming/Mp3/`) -- the third `IStreamingEncoder` - implementation (item 8's own MP3 sub-item; FLAC still open), same scope as + implementation (item 8's own MP3 sub-item), same scope as `Codecs.Mp3.Mp3EncoderSession` (mono/stereo, 32000/44100/48000Hz, CBR only), reusing `Mp3FrameEncoder`'s own encode internals unchanged. Writes no dedicated "Info tag" (LAME-tag) header frame and no ID3v2 tag -- both are container/file-level conventions with no equivalent @@ -222,6 +222,25 @@ ordered plan, acceptance criteria per item, and what's merged versus still open. of `0`. Since MP3 frames are self-delimiting with no container needed at all, the streaming encoder's own raw frames are simply concatenated directly into a file for conformance testing, decoded through the project's own public `Mp3Decoder` (NLayer). +- `Streaming.Flac.FlacStreamingEncoder` (`Streaming/Flac/`) -- the fourth and last + `IStreamingEncoder` implementation (item 8's own FLAC sub-item, completing it), same scope as + `Codecs.Flac.FlacEncoderSession` (any channel count, 1-32 bits per sample), reusing + `FlacFrameEncoder`'s own encode internals unchanged. Genuinely simpler than the other three, for + two real codec-level reasons: this encoder's own FIXED predictors are purely causal (never look + at "future" samples), so there is no lookahead to drain at all (`EncoderDelaySamples` is always + `0`); and FLAC's own frame format natively allows a final block shorter than the nominal size + with no padding, unlike AAC-LC/Opus/MP3's own fixed-or-padded sizes -- `Complete()` just flushes + whatever's pending, no mandatory extra frame. **Genuinely not independently decodable**, unlike + the other three: `FlacFrameEncoder`'s own existing frame header always defers sample-rate and + bits-per-sample to STREAMINFO (a pre-existing characteristic of this project's frame encoder, not + introduced here) -- building a STREAMINFO (a whole-stream, container-level concept) is out of + scope for this raw packet-level encoder, the same boundary every other `IStreamingEncoder` already + draws. `FlacEncoderSession.WriteStreamInfoPayload` was widened from `private` to `internal` (zero + behavior change) so this item's own conformance tests can build a real STREAMINFO out-of-band and + reuse that exact bit layout rather than duplicating it. Conformance: unlike the lossy AAC/Opus/MP3 + sub-items' own SNR-floor checks, this verifies EXACT sample equality after a full decode (via the + project's own `FlacDecoder`) -- the same bar `docs/managed-codec-rewrite-plan.md` already holds + every lossless codec in this project to. ### Native binary packaging diff --git a/README.md b/README.md index 76792c7..97ec010 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ Covers **resampling** (`ResamplingTransform`), **gain / peak normalization** (`V ## Live Streaming (early) -A push-style, incremental HLS packaging pipeline, genuinely independent of the `IMediaEncoder` file/`Stream` surface above. `IStreamingEncoder` encodes PCM into frames via a callback; a fragmented MP4 (fMP4/CMAF) muxer or an MPEG-TS muxer packages frames into segments; `HlsSegmenter` decides segment boundaries and writes segments + an `.m3u8` playlist through an `IStreamingSink`, in either VOD mode (every segment stays in the playlist) or live mode (a bounded sliding window of the most recent segments, old ones reclaimed via the sink). A top-level master playlist (`M3u8MasterPlaylistWriter`) lists multiple audio-bitrate renditions for a player to choose from. `LiveAudioPump` feeds PCM from a genuinely live source (not neat, exactly-on-time blocks) into an encoder, with an optional silence-fill policy for real gaps in arrival. AAC-LC (`AacStreamingEncoder`), Opus (`OpusStreamingEncoder`), and MP3 (`Mp3StreamingEncoder`) all implement `IStreamingEncoder`; the muxer/segmenter/playlist layers above are AAC-LC-only so far — see [docs/streaming-plan.md](docs/streaming-plan.md) for the full ordered plan and what's merged versus still open. +A push-style, incremental HLS packaging pipeline, genuinely independent of the `IMediaEncoder` file/`Stream` surface above. `IStreamingEncoder` encodes PCM into frames via a callback; a fragmented MP4 (fMP4/CMAF) muxer or an MPEG-TS muxer packages frames into segments; `HlsSegmenter` decides segment boundaries and writes segments + an `.m3u8` playlist through an `IStreamingSink`, in either VOD mode (every segment stays in the playlist) or live mode (a bounded sliding window of the most recent segments, old ones reclaimed via the sink). A top-level master playlist (`M3u8MasterPlaylistWriter`) lists multiple audio-bitrate renditions for a player to choose from. `LiveAudioPump` feeds PCM from a genuinely live source (not neat, exactly-on-time blocks) into an encoder, with an optional silence-fill policy for real gaps in arrival. AAC-LC (`AacStreamingEncoder`), Opus (`OpusStreamingEncoder`), MP3 (`Mp3StreamingEncoder`), and FLAC (`FlacStreamingEncoder`) all implement `IStreamingEncoder`; the muxer/segmenter/playlist layers above are AAC-LC-only so far — see [docs/streaming-plan.md](docs/streaming-plan.md) for the full ordered plan and what's merged versus still open. ```csharp using EggEncoder.Streaming; @@ -173,6 +173,18 @@ using var mp3Encoder = Mp3StreamingEncoder.Create(channels: 1, sampleRate: 44100 }, bitRateKbps: 192); ``` +`FlacStreamingEncoder` is the fourth and last, lossless, with no encoder lookahead at all -- but its raw frames, unlike the other three, are not independently decodable without a real STREAMINFO built out-of-band (a container-level concept this raw encoder deliberately leaves out of scope): + +```csharp +using EggEncoder.Streaming.Flac; + +using var flacEncoder = FlacStreamingEncoder.Create(channels: 1, sampleRate: 44100, bitsPerSample: 16, frame => +{ + // frame.SampleCount reports the real sample count for every frame, including a genuinely + // short final one -- FLAC needs no padding at all, unlike AAC-LC/Opus/MP3 +}); +``` + ## Supported Formats | Format | Probe | Decode | Encode | diff --git a/docs/streaming-plan.md b/docs/streaming-plan.md index 1cc5cf2..9b10639 100644 --- a/docs/streaming-plan.md +++ b/docs/streaming-plan.md @@ -845,10 +845,36 @@ lands: real audio, not just an approximately-right duration. - **FLAC**: lossless streaming is a real but lower-demand case for a *live-streaming* plan specifically (FLAC-over-HLS is uncommon in practice vs. AAC/Opus/MP3); worth doing once the - pattern is proven three times over, not before. Not started. - -**Status**: partially done -- Opus and MP3 sub-items complete (see above), FLAC sub-item not -started. + pattern is proven three times over, not before. **Done** ([PR #121](https://github.com/eggspot/EggEncoder/pull/121)) -- + `Streaming.Flac.FlacStreamingEncoder` (`src/EggEncoder/Streaming/Flac/`), same scope as + `FlacEncoderSession` (any channel count, 1-32 bits per sample), reusing `FlacFrameEncoder`'s own + encode internals unchanged. Genuinely simpler than the other three sub-items, for two real, + codec-level reasons rather than a shortcut: this encoder's own FIXED predictors are purely + causal (only ever look at already-encoded samples within the same block, never "future" ones), + so there is no encoder lookahead to drain at all (`EncoderDelaySamples` is `0`, not merely + omitted); and FLAC's own frame format natively allows a final block shorter than the nominal + size with no padding, unlike AAC-LC/Opus/MP3's own fixed-or-padded frame sizes -- `Complete()` + therefore just flushes whatever's left pending, nothing more, no mandatory extra frame. + **Genuinely not independently decodable, unlike the other three sub-items**: `FlacFrameEncoder`'s + own existing frame header (unchanged here) always defers sample-rate and bits-per-sample to + STREAMINFO (writes code `0` for both, rather than using FLAC's own available explicit escape + codes) -- a real, pre-existing characteristic of this project's frame encoder, not introduced by + this item. Building a STREAMINFO (a whole-stream, container-level concept -- total duration, a + whole-stream MD5, min/max block size) is out of scope here, the same "container concerns belong + to a downstream muxer" boundary every other `IStreamingEncoder` already draws; a caller needing a + real, playable FLAC stream must construct one itself from `Channels`/`SampleRate`/the + `bitsPerSample` it already passed to `Create`. `FlacEncoderSession.WriteStreamInfoPayload` was + widened from `private` to `internal` (zero behavior change, confirmed by the full existing + `FlacEncoderTest` suite still passing unchanged) so this item's own conformance tests can build a + real STREAMINFO out-of-band and reuse the exact same bit layout rather than duplicating it. + Conformance: unlike the lossy AAC/Opus/MP3 sub-items' own SNR-floor checks, this verifies EXACT + sample equality after a full decode (via the project's own `FlacDecoder`) of a real file built + from the streaming encoder's own raw frames plus that out-of-band STREAMINFO -- the same "exact + original samples reproduced" bar `docs/managed-codec-rewrite-plan.md` already holds every + lossless codec in this project to. + +**Status**: done -- Opus, MP3, and FLAC sub-items all complete (see above). Item 8 is now fully +done. ## Top-level test strategy (applies across every item above) diff --git a/llms-full.txt b/llms-full.txt index e96d313..5164308 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -999,8 +999,8 @@ public sealed class Mp3StreamingEncoder : IStreamingEncoder } ``` -The third `IStreamingEncoder` implementation (item 8's own MP3 sub-item; FLAC sub-item still -open), reusing `Mp3FrameEncoder`'s own encode internals unchanged -- only how each encoded frame +The third `IStreamingEncoder` implementation (item 8's own MP3 sub-item), reusing +`Mp3FrameEncoder`'s own encode internals unchanged -- only how each encoded frame is delivered differs. Writes no dedicated "Info tag" (LAME-tag) header frame and no ID3v2 tag at all -- both are container/file-level conventions (gapless-playback metadata meant to be read back from a complete, seekable file) with no equivalent for a raw, open-ended frame stream. @@ -1020,6 +1020,39 @@ technique `Mp3EncoderSnrTest`'s own regression test for this exact fix already e this restructured, callback-based version of the flush-frame fix still preserves the true tail of real audio. +```csharp +public sealed class FlacStreamingEncoder : IStreamingEncoder +{ + public const int EncoderDelaySamples = 0; // FIXED predictors are purely causal -- no lookahead to drain, unlike AAC-LC/Opus/MP3 + + public static IStreamingEncoder Create(int channels, int sampleRate, int bitsPerSample, EncodedAudioFrameCallback onFrameEncoded); + // throws ArgumentNullException for a null callback, NotSupportedException for channels < 1 + // or bitsPerSample outside 1-32 -- the same scope as Codecs.Flac.FlacEncoderSession +} +``` + +The fourth and last `IStreamingEncoder` implementation (item 8's own FLAC sub-item, completing +it), reusing `FlacFrameEncoder`'s own encode internals unchanged. Genuinely simpler than the other +three, for two real codec-level reasons: FIXED predictors are purely causal (never look at +"future" samples), so there is no lookahead to drain; and FLAC's own frame format natively allows +a final block shorter than the nominal size with no padding at all, unlike AAC-LC/Opus/MP3's own +fixed-or-padded sizes -- `Complete()` therefore just flushes whatever's left pending, no mandatory +extra frame. **Genuinely not independently decodable**, unlike the other three: +`FlacFrameEncoder`'s own existing frame header always defers sample-rate and bits-per-sample to +STREAMINFO (writes code `0` for both -- a real, pre-existing characteristic of this project's +frame encoder, not introduced here) rather than using FLAC's own available explicit escape codes. +Building a STREAMINFO (a whole-stream, container-level concept: total duration, a whole-stream +MD5, min/max block size) is out of scope for this raw packet-level encoder, the same "container +concerns belong to a downstream muxer" boundary every other `IStreamingEncoder` already draws -- a +caller needing a real, playable FLAC stream must construct one itself from `Channels`/ +`SampleRate`/the `bitsPerSample` it already passed to `Create`. +`Codecs.Flac.FlacEncoderSession.WriteStreamInfoPayload` was widened from `private` to `internal` +(zero behavior change) so this item's own conformance tests can build a real STREAMINFO +out-of-band and reuse that exact bit layout rather than duplicating it. Conformance: unlike the +lossy AAC/Opus/MP3 sub-items' own SNR-floor checks, this verifies EXACT sample equality after a +full decode (via the project's own `FlacDecoder`) -- the same bar every lossless codec in this +project is already held to. + ## AudioCutter — `EggEncoder.Codecs` Format-dispatching convert/cut/mix/concatenate used internally by `NativeEncoder`; also usable directly: diff --git a/llms.txt b/llms.txt index 2d701b9..cde3821 100644 --- a/llms.txt +++ b/llms.txt @@ -235,7 +235,7 @@ lookahead isn't fixed at one project-wide value the way AAC-LC's is), and every `SampleCount` is always `960` -- Opus has no bitstream-level short-final-frame concept. `EggEncoder.Streaming.Mp3.Mp3StreamingEncoder` is the third `IStreamingEncoder` implementation -(item 8's MP3 sub-item; FLAC still open), same scope as `Codecs.Mp3.Mp3EncoderSession` +(item 8's MP3 sub-item), same scope as `Codecs.Mp3.Mp3EncoderSession` (mono/stereo, 32000/44100/48000Hz, CBR only), reusing `Mp3FrameEncoder`'s own encode internals. Writes no LAME-tag header frame or ID3v2 tag (both are container/file-level conventions with no equivalent for a raw frame stream); `Mp3Encoder.EncoderDelaySamples` (`1680`) remains the right @@ -244,6 +244,16 @@ count (like `AacStreamingEncoder`) rather than always the nominal `1152` -- exce own mandatory flush frame (draining the one-granule MDCT history), which is purely synthetic and reports `0`. +`EggEncoder.Streaming.Flac.FlacStreamingEncoder` is the fourth and last `IStreamingEncoder` +implementation (item 8's FLAC sub-item, completing it), same scope as +`Codecs.Flac.FlacEncoderSession` (any channel count, 1-32 bits per sample), reusing +`FlacFrameEncoder`'s own encode internals. Simpler than the other three: FIXED predictors are +purely causal (no lookahead to drain -- `EncoderDelaySamples` is always `0`), and FLAC's own frame +format allows a short final block with no padding at all, so `Complete()` just flushes whatever's +pending. Genuinely **not** independently decodable (unlike the other three): its frame header +defers sample-rate/bits-per-sample to STREAMINFO, a whole-stream container concept this raw +encoder leaves out of scope -- a caller needing a real FLAC stream must build one itself. + ## MP3 ID3v2 Tags `Probe`-ing an MP3 file with an ID3v2.3/2.4 tag populates the same `ProbeResult.Tags` WAV's own diff --git a/src/EggEncoder.UnitTests/Streaming/Flac/FlacStreamingEncoderTest.cs b/src/EggEncoder.UnitTests/Streaming/Flac/FlacStreamingEncoderTest.cs new file mode 100644 index 0000000..1ed1ccf --- /dev/null +++ b/src/EggEncoder.UnitTests/Streaming/Flac/FlacStreamingEncoderTest.cs @@ -0,0 +1,229 @@ +using EggEncoder.Codecs.Flac; +using EggEncoder.Streaming; +using EggEncoder.Streaming.Flac; +using EggEncoder.Transform; +using EggEncoder.UnitTests.TestUtilities; +using FluentAssertions; + +namespace EggEncoder.UnitTests.Streaming.Flac +{ + public class FlacStreamingEncoderTest + { + private const int SampleRate = 44100; + private const int BitsPerSample = 16; + + [Fact] + public void Create_WithNullCallback_Should_Throw() + { + var act = () => FlacStreamingEncoder.Create(1, SampleRate, BitsPerSample, null!); + + act.Should().ThrowExactly(); + } + + [Fact] + public void Create_WithZeroChannels_Should_Throw() + { + var act = () => FlacStreamingEncoder.Create(0, SampleRate, BitsPerSample, _ => { }); + + act.Should().ThrowExactly(); + } + + [Theory] + [InlineData(0)] + [InlineData(33)] + public void Create_WithOutOfRangeBitsPerSample_Should_Throw(int bitsPerSample) + { + var act = () => FlacStreamingEncoder.Create(1, SampleRate, bitsPerSample, _ => { }); + + act.Should().ThrowExactly(); + } + + [Fact] + public void Create_WithValidParameters_Should_ExposeChannelsSampleRateAndFrameSize() + { + using var encoder = FlacStreamingEncoder.Create(2, SampleRate, BitsPerSample, _ => { }); + + encoder.Channels.Should().Be(2); + encoder.SampleRate.Should().Be(SampleRate); + encoder.SamplesPerFrame.Should().Be(4096); + } + + [Fact] + public void Complete_WithNoSamplesWritten_Should_EmitNoFrames() + { + var emitted = false; + using var encoder = FlacStreamingEncoder.Create(1, SampleRate, BitsPerSample, _ => emitted = true); + + encoder.Complete(); + + emitted.Should().BeFalse(); + } + + [Fact] + public void Complete_CalledTwice_Should_BeIdempotent() + { + var emitCount = 0; + using var encoder = FlacStreamingEncoder.Create(1, SampleRate, BitsPerSample, _ => emitCount++); + encoder.Write(GenerateToneFrames(frameCount: 100, channels: 1)); + + encoder.Complete(); + var countAfterFirstComplete = emitCount; + encoder.Complete(); + + emitCount.Should().Be(countAfterFirstComplete); + } + + [Fact] + public void Write_AfterComplete_Should_Throw() + { + using var encoder = FlacStreamingEncoder.Create(1, SampleRate, BitsPerSample, _ => { }); + encoder.Complete(); + + var act = () => encoder.Write(new[] { 1, 2, 3 }); + + act.Should().ThrowExactly(); + } + + [Fact] + public void Dispose_CalledTwice_Should_BeSafe() + { + var encoder = FlacStreamingEncoder.Create(1, SampleRate, BitsPerSample, _ => { }); + + encoder.Dispose(); + var act = encoder.Dispose; + + act.Should().NotThrow(); + } + + [Fact] + public void Dispose_WithoutCallingComplete_Should_BeSafe() + { + var encoder = FlacStreamingEncoder.Create(1, SampleRate, BitsPerSample, _ => { }); + encoder.Write(new[] { 1, 2, 3 }); + + var act = encoder.Dispose; + + act.Should().NotThrow(); + } + + [Fact] + public void FrameIndex_Should_IncrementMonotonically() + { + var frameIndices = new List(); + using var encoder = FlacStreamingEncoder.Create(1, SampleRate, BitsPerSample, frame => frameIndices.Add(frame.FrameIndex)); + + encoder.Write(GenerateToneFrames(frameCount: 4096 * 3 + 100, channels: 1)); + encoder.Complete(); + + frameIndices.Should().Equal(Enumerable.Range(0, frameIndices.Count).Select(i => (long)i)); + } + + [Fact] + public void Complete_WithPartialBlock_Should_ReportTrueShortSampleCount_WithNoPadding() + { + var sampleCounts = new List(); + using var encoder = FlacStreamingEncoder.Create(1, SampleRate, BitsPerSample, frame => sampleCounts.Add(frame.SampleCount)); + + encoder.Write(GenerateToneFrames(frameCount: 300, channels: 1)); // less than one 4096-sample block + sampleCounts.Should().BeEmpty(); + + encoder.Complete(); + + sampleCounts.Should().Equal(new[] { 300 }, "FLAC's own frame format allows a genuinely short final block natively -- no padding and no separate flush frame, unlike AAC-LC/Opus/MP3"); + } + + [Fact] + public void Complete_WithExactlyOneFullBlock_Should_EmitExactlyOneFrame_AndNothingElse() + { + var sampleCounts = new List(); + using var encoder = FlacStreamingEncoder.Create(1, SampleRate, BitsPerSample, frame => sampleCounts.Add(frame.SampleCount)); + + encoder.Write(GenerateToneFrames(frameCount: 4096, channels: 1)); + sampleCounts.Should().Equal(4096); + + encoder.Complete(); + + sampleCounts.Should().Equal(new[] { 4096 }, "FLAC has no lookahead to drain (unlike Opus/MP3), so nothing pending means Complete() emits nothing further"); + } + + [Fact] + public void Mono_Should_RoundTrip_Exactly() + { + AssertExactRoundTrip(GenerateToneFrames(frameCount: 10000, channels: 1), channels: 1); + } + + [Fact] + public void Stereo_Should_RoundTrip_Exactly() + { + AssertExactRoundTrip(GenerateToneFrames(frameCount: 10000, channels: 2), channels: 2); + } + + private static void AssertExactRoundTrip(int[] originalInterleaved, int channels) + { + var frameBytes = new List(); + using (var encoder = FlacStreamingEncoder.Create(channels, SampleRate, BitsPerSample, frame => frameBytes.Add(frame.Payload.ToArray()))) + { + encoder.Write(originalInterleaved); + encoder.Complete(); + } + + var filePath = Path.Combine(Path.GetTempPath(), $"flac_streaming_{Guid.NewGuid():N}.flac"); + try + { + File.WriteAllBytes(filePath, BuildFlacFile(frameBytes, channels, BitsPerSample, SampleRate)); + + var (streamInfo, decodedSamples) = FlacTestDecoder.DecodeAll(filePath); + + streamInfo.Channels.Should().Be(channels); + streamInfo.SampleRate.Should().Be(SampleRate); + decodedSamples.Should().Equal(originalInterleaved, "FLAC is lossless -- the decoded samples must match exactly, not just approximately"); + } + finally + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + } + + // Builds a complete, real, decodable FLAC file from this encoder's own raw frames: a real + // STREAMINFO block out-of-band (reusing FlacEncoderSession's own exact bit layout via its + // now-internal WriteStreamInfoPayload, rather than duplicating it) followed by the frames + // themselves -- the construction this type's own doc comment says a caller must do, since + // (unlike AacStreamingEncoder/OpusStreamingEncoder/Mp3StreamingEncoder) these raw frames are + // not independently decodable on their own. totalSamples=0/an all-zero MD5 are both safe + // placeholders here: confirmed directly from FlacDecoder's own decode loop, which reads + // frames until end-of-stream regardless of the declared total (only using it for a + // post-hoc consistency check skipped entirely when it's 0), and never reads MD5 at all. + private static byte[] BuildFlacFile(IReadOnlyList frames, int channels, int bitsPerSample, int sampleRate) + { + var writer = new BitWriter(); + writer.WriteBits('f', 8); + writer.WriteBits('L', 8); + writer.WriteBits('a', 8); + writer.WriteBits('C', 8); + writer.WriteBits(1, 1); // isLast -- no other metadata block follows + writer.WriteBits(0, 7); // block type 0: STREAMINFO + writer.WriteBits(34, 24); + FlacEncoderSession.WriteStreamInfoPayload(writer, channels, bitsPerSample, sampleRate, minBlockSize: 4096, maxBlockSize: 4096, totalSamples: 0, md5: new byte[16]); + + return writer.ToArray().Concat(frames.SelectMany(f => f)).ToArray(); + } + + private static int[] GenerateToneFrames(int frameCount, int channels) + { + var samples = new int[frameCount * channels]; + for (var frame = 0; frame < frameCount; frame++) + { + var value = (int)(8000 * Math.Sin(2 * Math.PI * 440 * frame / SampleRate)); + for (var channel = 0; channel < channels; channel++) + { + samples[(frame * channels) + channel] = value; + } + } + + return samples; + } + } +} diff --git a/src/EggEncoder/Codecs/Flac/FlacEncoder.cs b/src/EggEncoder/Codecs/Flac/FlacEncoder.cs index c86eb69..d06998d 100644 --- a/src/EggEncoder/Codecs/Flac/FlacEncoder.cs +++ b/src/EggEncoder/Codecs/Flac/FlacEncoder.cs @@ -243,7 +243,11 @@ private void FlushPendingBlock() _pendingFrameCount = 0; } - private static void WriteStreamInfoPayload(BitWriter writer, int channels, int bitsPerSample, int sampleRate, int minBlockSize, int maxBlockSize, long totalSamples, byte[] md5) + // internal rather than private so test code (and Streaming.Flac.FlacStreamingEncoder's own + // conformance tests specifically, which must build a real STREAMINFO out-of-band to make + // its own raw frames decodable at all -- see that type's own doc comment) can reuse this + // exact bit layout rather than duplicating it. + internal static void WriteStreamInfoPayload(BitWriter writer, int channels, int bitsPerSample, int sampleRate, int minBlockSize, int maxBlockSize, long totalSamples, byte[] md5) { writer.WriteBits((uint)minBlockSize, 16); writer.WriteBits((uint)maxBlockSize, 16); diff --git a/src/EggEncoder/Streaming/Flac/FlacStreamingEncoder.cs b/src/EggEncoder/Streaming/Flac/FlacStreamingEncoder.cs new file mode 100644 index 0000000..a1a7d8d --- /dev/null +++ b/src/EggEncoder/Streaming/Flac/FlacStreamingEncoder.cs @@ -0,0 +1,169 @@ +using EggEncoder.Codecs.Flac; + +namespace EggEncoder.Streaming.Flac +{ + /// + /// Push-style incremental FLAC encoder -- the counterpart to + /// , emitting each encoded frame via a callback instead of + /// writing bytes to one destination file. Same codec scope as : + /// any channel count, 1-32 bits per sample. See docs/streaming-plan.md item 8. + /// + /// + /// + /// Unlike // + /// , this encoder's raw frames are genuinely not + /// independently decodable: 's own frame header (unchanged here) + /// always writes sample-rate-code 0 and bits-per-sample-code 0 ("get both from STREAMINFO") + /// rather than FLAC's own available explicit escape codes, so a decoder needs a real STREAMINFO + /// block to know either value at all -- a genuine, pre-existing characteristic of this + /// project's own frame encoder, not something introduced here. Building that STREAMINFO (a + /// whole-stream, container-level concept: total duration, a whole-stream MD5, min/max block + /// size) is deliberately out of scope for this raw packet-level encoder, the same "container + /// concerns belong to a downstream muxer" boundary every other + /// already draws -- a caller that needs a real, playable FLAC stream must construct one itself + /// using //this type's own bitsPerSample + /// (not itself exposed by , so a caller must already know it, + /// the same value it passed to ). + /// + /// + /// Also unlike the other three: FLAC's FIXED predictors (this project's own encoder uses no + /// other kind -- see 's own doc comment) are purely causal, + /// looking only at already-encoded samples within the same block, never "future" samples -- + /// so there is no encoder lookahead to drain at all ( is + /// always 0), and FLAC's own frame format natively allows a final block shorter than + /// the nominal size with no padding, unlike AAC-LC/Opus/MP3's own fixed-or-padded frame sizes. + /// therefore just flushes whatever's left pending, nothing more. + /// + /// + public sealed class FlacStreamingEncoder : IStreamingEncoder + { + // Matches FlacEncoderSession's own BlockSize exactly -- the same "4096 frames per block" + // convention this codebase's own various encoder sessions already share. + private const int BlockSize = 4096; + + /// Always 0 -- FIXED predictors are purely causal, so there is no lookahead to drain (see this type's own doc comment). + public const int EncoderDelaySamples = 0; + + private readonly int _bitsPerSample; + private readonly EncodedAudioFrameCallback _onFrameEncoded; + private readonly int[] _pendingInterleaved; + + private int _pendingFrameCount; + private long _frameIndex; + private bool _completed; + + private FlacStreamingEncoder(int channels, int sampleRate, int bitsPerSample, EncodedAudioFrameCallback onFrameEncoded) + { + Channels = channels; + SampleRate = sampleRate; + _bitsPerSample = bitsPerSample; + _onFrameEncoded = onFrameEncoded; + _pendingInterleaved = new int[BlockSize * channels]; + } + + /// is null. + /// is less than 1, or is not 1-32. + public static IStreamingEncoder Create(int channels, int sampleRate, int bitsPerSample, EncodedAudioFrameCallback onFrameEncoded) + { + ArgumentNullException.ThrowIfNull(onFrameEncoded); + + if (channels < 1) + { + throw new NotSupportedException($"{channels} channels requested; FLAC requires at least 1."); + } + + // Matches FlacEncoder.OpenSession's own check exactly: STREAMINFO's bits-per-sample + // field is 5 bits wide, storing (bitsPerSample - 1) -- an out-of-range value here would + // silently wrap into a different, wrong-but-valid-looking bit depth in any STREAMINFO a + // caller later builds from this same value, rather than failing. + if (bitsPerSample is < 1 or > 32) + { + throw new NotSupportedException($"{bitsPerSample} bits per sample requested; FLAC's STREAMINFO can only represent 1-32."); + } + + return new FlacStreamingEncoder(channels, sampleRate, bitsPerSample, onFrameEncoded); + } + + public int Channels { get; } + + public int SampleRate { get; } + + public int SamplesPerFrame => BlockSize; + + /// has already been called. + public void Write(ReadOnlySpan interleavedSamples) + { + if (_completed) + { + throw new InvalidOperationException("Cannot write more samples after Complete() has been called."); + } + + var frameCount = interleavedSamples.Length / Channels; + var sourceOffset = 0; + while (sourceOffset < frameCount) + { + var take = Math.Min(frameCount - sourceOffset, BlockSize - _pendingFrameCount); + interleavedSamples.Slice(sourceOffset * Channels, take * Channels) + .CopyTo(_pendingInterleaved.AsSpan(_pendingFrameCount * Channels, take * Channels)); + + _pendingFrameCount += take; + sourceOffset += take; + + if (_pendingFrameCount == BlockSize) + { + FlushPendingBlock(); + } + } + } + + /// + /// Encodes whatever partial block remains buffered as one final, possibly-short frame -- + /// FLAC's own frame format allows this natively, with no padding needed at all (unlike + /// AAC-LC/Opus/MP3). Safe to call with nothing ever written (emits no frame in that case). + /// Safe to call more than once -- every call after the first is a no-op. + /// + public void Complete() + { + if (_completed) + { + return; + } + + _completed = true; + + if (_pendingFrameCount > 0) + { + FlushPendingBlock(); + } + } + + public void Dispose() + { + // Holds no unmanaged/disposable resource of its own. + } + + private void FlushPendingBlock() + { + var channelSamples = new int[Channels][]; + for (var c = 0; c < Channels; c++) + { + channelSamples[c] = new int[_pendingFrameCount]; + } + + for (var i = 0; i < _pendingFrameCount; i++) + { + var baseIndex = i * Channels; + for (var c = 0; c < Channels; c++) + { + channelSamples[c][i] = _pendingInterleaved[baseIndex + c]; + } + } + + var frameBytes = FlacFrameEncoder.EncodeFrame(channelSamples, _pendingFrameCount, _bitsPerSample, _frameIndex); + var sampleCount = _pendingFrameCount; + _pendingFrameCount = 0; + + _onFrameEncoded(new EncodedAudioFrame(frameBytes, sampleCount, _frameIndex++)); + } + } +}