From 3217c63a2d42c73d43040ff9df61dfc0bbcda77c Mon Sep 17 00:00:00 2001 From: cloud-hai-vo-agent Date: Sat, 17 Oct 2026 18:08:19 +0700 Subject: [PATCH 1/2] feat: add FLAC streaming encoder, completing item 8 Implements the FLAC sub-item of docs/streaming-plan.md item 8 (the last of its three codec sub-items, now that the pattern has been proven three times over for AAC/Opus/MP3): FlacStreamingEncoder is the fourth IStreamingEncoder implementation, reusing FlacFrameEncoder's own encode internals unchanged. Genuinely simpler than the other three, for two real codec-level reasons rather than a shortcut: this encoder's own FIXED predictors are purely causal (never look at future samples), so there is no encoder 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 frame sizes. Complete() therefore just flushes whatever's left pending, nothing more. 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) rather than using FLAC's own available explicit escape codes. 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. Widened FlacEncoderSession.WriteStreamInfoPayload 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 that exact bit layout rather than duplicating it. Conformance verifies EXACT sample equality after a full decode, not an SNR floor -- the same bar every lossless codec in this project is already held to, unlike the lossy AAC/Opus/MP3 sub-items. This completes docs/streaming-plan.md item 8 in full. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 23 +- README.md | 14 +- docs/streaming-plan.md | 34 ++- llms-full.txt | 37 ++- llms.txt | 12 +- .../Flac/FlacStreamingEncoderTest.cs | 229 ++++++++++++++++++ src/EggEncoder/Codecs/Flac/FlacEncoder.cs | 6 +- .../Streaming/Flac/FlacStreamingEncoder.cs | 169 +++++++++++++ 8 files changed, 513 insertions(+), 11 deletions(-) create mode 100644 src/EggEncoder.UnitTests/Streaming/Flac/FlacStreamingEncoderTest.cs create mode 100644 src/EggEncoder/Streaming/Flac/FlacStreamingEncoder.cs 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..91b9ded 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 TBD) -- + `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++)); + } + } +} From 644260ee3e964e769deae250404c017fd330e3e6 Mon Sep 17 00:00:00 2001 From: cloud-hai-vo-agent Date: Sat, 17 Oct 2026 18:11:35 +0700 Subject: [PATCH 2/2] docs: link the FLAC sub-item's status note to its actual PR number Co-Authored-By: Claude Sonnet 5 --- docs/streaming-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/streaming-plan.md b/docs/streaming-plan.md index 91b9ded..9b10639 100644 --- a/docs/streaming-plan.md +++ b/docs/streaming-plan.md @@ -845,7 +845,7 @@ 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. **Done** (PR TBD) -- + 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,