Fix Critical: streaming race, dirty buffer, dropped tail, fragmented header

- SeekBeyondBuffer and LoadTrackStreaming assign _activeStreamingTask before
  awaiting; DrainActiveStreamingTaskAsync awaits previous task before new
  stream starts, closing the concurrent-seek race on the JS StreamDecoder
- Always slice ArrayPool buffer to currentBytes before sending to JS interop;
  eliminates stale bytes from prior rentals reaching the audio decoder
- getSampleAlignedChunkSize accepts streamComplete flag; bypasses minimum
  chunk guard on final tail so trailing bytes are decoded, not dropped
- StreamDecoder accumulates headerSearchChunks until parseHeader succeeds,
  with 256 KB MAX_HEADER_SEARCH_BYTES bound; handles fragmented first chunks
  and extended WAV headers with LIST/INFO/JUNK chunks
- markStreamComplete early-returns when streamComplete already set to prevent
  double-drain and incorrect streamingCompleted flag after partial failure
- processedBytes advances only after successful decode; failed segments leave
  cursor in place rather than permanently skipping audio
- AudioInteropService.MarkStreamCompleteAsync wires C# loop exit to JS decoder
  ensuring tail drain fires even when Content-Length header is absent
This commit is contained in:
Daniel Harvey
2026-05-17 11:28:53 -04:00
parent 56d15027e4
commit dd96caa709
6 changed files with 277 additions and 42 deletions
+16 -7
View File
@@ -173,17 +173,26 @@ class WavUtils {
buffer[43] = (audioDataSize >> 24) & 0xFF;
}
static getSampleAlignedChunkSize(header: WavHeader, maxChunkSize: number, availableDataSize: number): number {
static getSampleAlignedChunkSize(header: WavHeader, maxChunkSize: number, availableDataSize: number, streamComplete: boolean = false): number {
const frameSize = header.blockAlign;
// Much smaller minimum for streaming - just enough for Web Audio API
// Much smaller minimum for streaming - just enough for Web Audio API.
// The minimum exists to avoid decoding partial-frame artifacts on
// mid-stream chunks while the rest is still in flight. Once the stream
// is fully received, we must drain whatever remains regardless of size,
// otherwise the trailing tail (often <512 bytes) is silently lost.
const minAudioBytes = Math.max(512, frameSize * 10); // At least 512 bytes or 10 frames
// If we don't have enough data, return 0 to wait for more
if (availableDataSize < minAudioBytes) {
// Mid-stream guard: wait for more data if below minimum.
if (!streamComplete && availableDataSize < minAudioBytes) {
return 0;
}
// Even when complete we still need at least one full frame to decode.
if (availableDataSize < frameSize) {
return 0;
}
// Calculate frames for the available data
const requestedSize = Math.min(maxChunkSize, availableDataSize);
const frames = Math.floor(requestedSize / frameSize);