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
@@ -56,6 +56,11 @@ public class AudioInteropService : IAsyncDisposable
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.startStreamingPlayback", playerId); return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.startStreamingPlayback", playerId);
} }
public async Task<AudioOperationResult> MarkStreamCompleteAsync(string playerId)
{
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.markStreamComplete", playerId);
}
public async Task<AudioOperationResult> EnsureAudioContextReady(string playerId) public async Task<AudioOperationResult> EnsureAudioContextReady(string playerId)
{ {
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.ensureAudioContextReady", playerId); return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.ensureAudioContextReady", playerId);
@@ -27,6 +27,7 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
private bool _streamingPlaybackStarted = false; private bool _streamingPlaybackStarted = false;
private CancellationTokenSource? _streamingCancellation; private CancellationTokenSource? _streamingCancellation;
private Task? _activeStreamingTask;
private DateTime _lastNotification = DateTime.MinValue; private DateTime _lastNotification = DateTime.MinValue;
private readonly ILogger<StreamingAudioPlayerService> _logger; private readonly ILogger<StreamingAudioPlayerService> _logger;
private string? _currentTrackId; private string? _currentTrackId;
@@ -62,7 +63,11 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
private async Task LoadTrackStreaming(TrackEntity track) private async Task LoadTrackStreaming(TrackEntity track)
{ {
// Always reset to clean state before loading new track // Always reset to clean state before loading new track. ResetToIdle
// both cancels and awaits any in-flight streaming loop, so by the time
// we return from it the previous loop is guaranteed to have exited and
// there is no risk of interleaved ProcessStreamingChunk calls against
// the single-instance JS StreamDecoder.
await ResetToIdle(); await ResetToIdle();
// Save track ID for seek operations // Save track ID for seek operations
@@ -116,7 +121,8 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
return; return;
} }
await StreamAudioWithEarlyPlayback(audio, _streamingCancellation.Token); _activeStreamingTask = StreamAudioWithEarlyPlayback(audio, _streamingCancellation.Token);
await _activeStreamingTask;
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -163,8 +169,11 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
{ {
totalBytesRead += currentBytes; totalBytesRead += currentBytes;
// Use only the actual bytes read, no copying needed // Always slice to the exact number of bytes read. The pooled buffer
var actualBuffer = currentBytes == _currentBufferSize ? buffer : buffer[..currentBytes]; // is rented at MaxBufferSize and may carry stale bytes past
// currentBytes from a prior rental — handing the full array to JS
// interop would serialise that garbage into the audio stream.
var actualBuffer = buffer.AsSpan(0, currentBytes).ToArray();
// Process chunk for streaming // Process chunk for streaming
var chunkResult = await _audioInterop.ProcessStreamingChunk(PlayerId, actualBuffer); var chunkResult = await _audioInterop.ProcessStreamingChunk(PlayerId, actualBuffer);
@@ -218,6 +227,12 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
} }
} while (currentBytes > 0); } while (currentBytes > 0);
// Notify the JS decoder that the stream is finished. When the server omits
// Content-Length the StreamDecoder cannot determine completion via byte counting
// alone; this explicit signal ensures the tail-decoding path (streamComplete=true)
// fires regardless of whether Content-Length was present.
await _audioInterop.MarkStreamCompleteAsync(PlayerId);
// Mark as fully loaded // Mark as fully loaded
LoadProgress = 1.0; LoadProgress = 1.0;
await NotifyStateChanged(); await NotifyStateChanged();
@@ -314,8 +329,13 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
IsSeekingBeyondBuffer = true; IsSeekingBeyondBuffer = true;
// Cancel current streaming // Cancel the current streaming loop AND wait for it to fully exit before
// starting a new one. The previous loop's pending ReadAsync will throw
// OperationCanceledException asynchronously; if we kick off a new loop
// immediately, both can race against the single-instance JS StreamDecoder
// and corrupt decode state. Draining here is the load-bearing guarantee.
_streamingCancellation?.Cancel(); _streamingCancellation?.Cancel();
await DrainActiveStreamingTaskAsync();
_streamingCancellation?.Dispose(); _streamingCancellation?.Dispose();
_streamingCancellation = new CancellationTokenSource(); _streamingCancellation = new CancellationTokenSource();
@@ -355,7 +375,8 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
BufferedChunks = 0; BufferedChunks = 0;
// Stream audio from offset // Stream audio from offset
await StreamAudioWithEarlyPlayback(audio, _streamingCancellation.Token); _activeStreamingTask = StreamAudioWithEarlyPlayback(audio, _streamingCancellation.Token);
await _activeStreamingTask;
IsSeekingBeyondBuffer = false; IsSeekingBeyondBuffer = false;
} }
@@ -379,8 +400,11 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
/// </summary> /// </summary>
private async Task ResetToIdle() private async Task ResetToIdle()
{ {
// 1. Cancel any ongoing streaming operation // 1. Cancel any ongoing streaming operation and wait for it to exit
// before tearing down JS state. Otherwise the loop's pending
// ProcessStreamingChunk call can land after StopAsync/UnloadAsync.
_streamingCancellation?.Cancel(); _streamingCancellation?.Cancel();
await DrainActiveStreamingTaskAsync();
_streamingCancellation?.Dispose(); _streamingCancellation?.Dispose();
_streamingCancellation = null; _streamingCancellation = null;
@@ -417,6 +441,40 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
await NotifyStateChanged(); await NotifyStateChanged();
} }
/// <summary>
/// Wait for the previously-started streaming loop to fully exit. The caller
/// must have already cancelled <see cref="_streamingCancellation"/>. Swallows
/// the expected OperationCanceledException; any other exception was already
/// surfaced through the loop's own catch block, so we ignore it here too.
/// </summary>
private async Task DrainActiveStreamingTaskAsync()
{
var task = _activeStreamingTask;
if (task == null) return;
try
{
await task;
}
catch (OperationCanceledException)
{
// Expected when we cancelled the loop ourselves.
}
catch
{
// Any other failure was already logged inside the loop.
}
finally
{
// Only clear if we are still the active task — a concurrent caller
// may have started a new stream while we were draining the old one.
if (ReferenceEquals(_activeStreamingTask, task))
{
_activeStreamingTask = null;
}
}
}
private async Task ThrottledNotifyStateChanged() private async Task ThrottledNotifyStateChanged()
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
+30 -2
View File
@@ -107,12 +107,40 @@ export class AudioPlayer {
} }
} }
/**
* Signal to the decoder that the C# streaming loop has finished sending bytes.
* This sets streamComplete=true and flushes any remaining decoded tail segments.
* Must be called after the ReadAsync loop exits, regardless of whether
* Content-Length was known — without it the tail-decode path is dead when
* Content-Length is absent.
*/
async markStreamComplete(): Promise<StreamingResult> {
try {
const results = await this.streamDecoder.markStreamComplete();
if (results.length > 0) {
for (const result of results) {
this.scheduler.addBuffer(result.buffer);
}
if (this.streamingStarted && this.isPlaying) {
this.scheduler.scheduleNewBuffers();
}
}
this.streamingCompleted = true;
console.log('Stream marked complete by C# signal');
return { success: true, bufferCount: this.scheduler.getBufferCount() };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
async processStreamingChunk(chunk: Uint8Array): Promise<StreamingResult> { async processStreamingChunk(chunk: Uint8Array): Promise<StreamingResult> {
try { try {
const result = await this.streamDecoder.processChunk(chunk); const results = await this.streamDecoder.processChunk(chunk);
if (result) { if (results.length > 0) {
for (const result of results) {
this.scheduler.addBuffer(result.buffer); this.scheduler.addBuffer(result.buffer);
}
// Update duration estimate // Update duration estimate
const estimatedDuration = this.streamDecoder.getEstimatedDuration(); const estimatedDuration = this.streamDecoder.getEstimatedDuration();
+151 -22
View File
@@ -13,13 +13,26 @@ export interface DecodedChunkResult {
} }
export class StreamDecoder { export class StreamDecoder {
// Upper bound on pre-header accumulation. 256 KB is far beyond any sane WAV
// header (including extended LIST/INFO/JUNK chunks). If we have accumulated
// this many bytes without finding a valid header the stream is corrupt.
private static readonly MAX_HEADER_SEARCH_BYTES = 256 * 1024;
private contextManager: AudioContextManager; private contextManager: AudioContextManager;
private wavHeader: WavHeader | null = null; private wavHeader: WavHeader | null = null;
private rawChunks: Uint8Array[] = []; private rawChunks: Uint8Array[] = [];
private totalRawBytes: number = 0; private totalRawBytes: number = 0;
private processedBytes: number = 0; private processedBytes: number = 0;
private isFirstChunk: boolean = true;
private totalStreamLength: number = 0; private totalStreamLength: number = 0;
private streamComplete: boolean = false;
private headerError: string | null = null;
// Pre-header accumulator. WAV headers can span multiple network chunks
// (small first segment, extended LIST/INFO/JUNK chunks before 'data', etc.),
// so we buffer raw bytes here until parseHeader succeeds rather than assuming
// the whole header lives in the first chunk.
private headerBytesReceived: number = 0;
private headerSearchChunks: Uint8Array[] = [];
constructor(contextManager: AudioContextManager) { constructor(contextManager: AudioContextManager) {
this.contextManager = contextManager; this.contextManager = contextManager;
@@ -33,34 +46,78 @@ export class StreamDecoder {
this.rawChunks = []; this.rawChunks = [];
this.totalRawBytes = 0; this.totalRawBytes = 0;
this.processedBytes = 0; this.processedBytes = 0;
this.isFirstChunk = true;
this.totalStreamLength = totalStreamLength; this.totalStreamLength = totalStreamLength;
this.streamComplete = false;
this.headerBytesReceived = 0;
this.headerSearchChunks = [];
this.headerError = null;
console.log(`StreamDecoder initialized: expecting ${totalStreamLength} bytes`); console.log(`StreamDecoder initialized: expecting ${totalStreamLength} bytes`);
} }
/** /**
* Process incoming chunk and return decoded AudioBuffer if ready * Process incoming chunk and return all decoded AudioBuffers ready so far.
*
* Returns an array (possibly empty) rather than a single result because the
* final chunk may unlock the residual tail in addition to a full segment,
* and a single chunk that completes header parsing may also carry enough
* audio data to decode immediately.
*/ */
async processChunk(chunk: Uint8Array): Promise<DecodedChunkResult | null> { async processChunk(chunk: Uint8Array): Promise<DecodedChunkResult[]> {
if (this.isFirstChunk) { // If the header search already failed (corrupt/non-WAV stream), stop processing.
await this.handleFirstChunk(chunk); if (this.headerError) {
this.isFirstChunk = false; throw new Error(this.headerError);
}
if (!this.wavHeader) {
await this.tryParseHeader(chunk);
// Check again: tryParseHeader may have just set headerError.
if (this.headerError) {
throw new Error(this.headerError);
}
} else { } else {
this.addRawData(chunk); this.addRawData(chunk);
} }
return this.tryDecodeNextSegment(); this.updateStreamCompleteFlag();
const results: DecodedChunkResult[] = [];
// Drain all currently-decodable segments. Without this loop, a single
// processChunk call returns at most one segment; the trailing tail
// unlocked once streamComplete flips true would never be flushed.
while (true) {
const segment = await this.tryDecodeNextSegment();
if (!segment) break;
results.push(segment);
}
return results;
} }
/** /**
* Handle first chunk - extract WAV header and setup AudioContext * Accumulate bytes into the header-search buffer and retry parseHeader.
* Once a header is recognised, anything past headerSize becomes audio data.
*/ */
private async handleFirstChunk(chunk: Uint8Array): Promise<void> { private async tryParseHeader(chunk: Uint8Array): Promise<void> {
console.log('\n--- Processing first chunk ---'); this.headerSearchChunks.push(chunk);
this.headerBytesReceived += chunk.length;
const header = WavUtils.parseHeader([chunk], chunk.length); // Guard against unbounded accumulation from a corrupt or non-WAV stream.
if (this.headerBytesReceived > StreamDecoder.MAX_HEADER_SEARCH_BYTES) {
this.headerError = `WAV header not found after ${this.headerBytesReceived} bytes — stream may be corrupt or not a WAV file`;
console.error(this.headerError);
// Drop the search buffer so subsequent chunks are not accumulated either.
this.headerSearchChunks = [];
this.headerBytesReceived = 0;
return;
}
const header = WavUtils.parseHeader(this.headerSearchChunks, this.headerBytesReceived);
if (!header) { if (!header) {
throw new Error('Invalid WAV header in first chunk'); // Not enough bytes yet — wait for the next chunk. If the stream ends
// without ever producing a valid header, the final processChunk will
// mark streamComplete and the player will report no audio decoded;
// that is the correct failure mode, since there is no audio to play.
console.log(`Header not yet parsable: ${this.headerBytesReceived} bytes accumulated`);
return;
} }
this.wavHeader = header; this.wavHeader = header;
@@ -72,10 +129,39 @@ export class StreamDecoder {
await this.contextManager.recreateWithSampleRate(header.sampleRate); await this.contextManager.recreateWithSampleRate(header.sampleRate);
} }
// Extract audio data (skip WAV header) // Concatenate all header-search chunks and push the audio-data tail
const audioData = chunk.subarray(header.headerSize); // (everything past headerSize) into the raw audio buffer.
const concatenated = new Uint8Array(this.headerBytesReceived);
let offset = 0;
for (const c of this.headerSearchChunks) {
concatenated.set(c, offset);
offset += c.length;
}
const audioData = concatenated.subarray(header.headerSize);
if (audioData.length > 0) {
this.addRawData(audioData); this.addRawData(audioData);
console.log(`Extracted ${audioData.length} bytes of audio data`); }
console.log(`Extracted ${audioData.length} bytes of audio data from header buffer`);
// Header-search buffer no longer needed.
this.headerSearchChunks = [];
this.headerBytesReceived = 0;
}
/**
* Mark the stream complete once we've received all expected bytes. The
* computation must account for whichever stage of header parsing we're in:
* if a header has been parsed, raw audio bytes are tracked separately;
* otherwise pre-header bytes count toward the total.
*/
private updateStreamCompleteFlag(): void {
if (this.totalStreamLength <= 0) return;
const totalReceived = this.wavHeader
? this.totalRawBytes + this.wavHeader.headerSize
: this.headerBytesReceived;
if (totalReceived >= this.totalStreamLength) {
this.streamComplete = true;
}
} }
/** /**
@@ -94,10 +180,18 @@ export class StreamDecoder {
const segmentSize = 64 * 1024; // 64KB segments const segmentSize = 64 * 1024; // 64KB segments
const availableBytes = this.totalRawBytes - this.processedBytes; const availableBytes = this.totalRawBytes - this.processedBytes;
const alignedSize = WavUtils.getSampleAlignedChunkSize(this.wavHeader, segmentSize, availableBytes); // Passing streamComplete lets the aligner relax the min-frame guard
// for the final tail; otherwise residual <512-byte tails get dropped.
const alignedSize = WavUtils.getSampleAlignedChunkSize(
this.wavHeader,
segmentSize,
availableBytes,
this.streamComplete
);
if (alignedSize <= 0) return null; if (alignedSize <= 0) return null;
const segmentOffset = this.processedBytes;
console.log(`\n--- Decoding segment ---`); console.log(`\n--- Decoding segment ---`);
console.log(`Available: ${availableBytes} bytes, aligned size: ${alignedSize} bytes`); console.log(`Available: ${availableBytes} bytes, aligned size: ${alignedSize} bytes`);
@@ -106,10 +200,13 @@ export class StreamDecoder {
try { try {
const buffer = await this.decodeWithTimeout(wavFile); const buffer = await this.decodeWithTimeout(wavFile);
// Advance only after a successful decode so that a timeout or decode
// failure does not permanently skip the segment.
this.processedBytes += alignedSize;
console.log(`✓ Decoded: ${buffer.duration.toFixed(3)}s, ${buffer.numberOfChannels}ch`); console.log(`✓ Decoded: ${buffer.duration.toFixed(3)}s, ${buffer.numberOfChannels}ch`);
return { buffer, duration: buffer.duration }; return { buffer, duration: buffer.duration };
} catch (error) { } catch (error) {
console.error('Failed to decode segment:', error); console.error(`Failed to decode segment at offset ${segmentOffset}:`, error);
return null; return null;
} }
} }
@@ -145,7 +242,6 @@ export class StreamDecoder {
currentPos += chunk.length; currentPos += chunk.length;
} }
this.processedBytes += size;
return extracted; return extracted;
} }
@@ -199,7 +295,7 @@ export class StreamDecoder {
* Check if all stream data has been received * Check if all stream data has been received
*/ */
get isComplete(): boolean { get isComplete(): boolean {
return this.totalStreamLength > 0 && this.totalRawBytes >= (this.totalStreamLength - (this.wavHeader?.headerSize ?? 0)); return this.streamComplete;
} }
/** /**
@@ -221,6 +317,33 @@ export class StreamDecoder {
return Math.floor(rawOffset / this.wavHeader.blockAlign) * this.wavHeader.blockAlign; return Math.floor(rawOffset / this.wavHeader.blockAlign) * this.wavHeader.blockAlign;
} }
/**
* Explicitly mark the stream as complete.
*
* Called by the C# streaming loop after ReadAsync returns 0 (no more data).
* This ensures streamComplete is set even when the server omits Content-Length,
* which prevents updateStreamCompleteFlag from ever firing via byte counting.
* Returns all remaining decoded segments (the tail drain pass).
*
* If streamComplete was already true (set by updateStreamCompleteFlag during the
* final processChunk call), the tail was already drained inside that call's
* while(true) loop — return immediately to avoid a second drain pass that would
* set streamingCompleted = true even if the first drain had a partial failure.
*/
async markStreamComplete(): Promise<DecodedChunkResult[]> {
if (this.streamComplete) {
return [];
}
this.streamComplete = true;
const results: DecodedChunkResult[] = [];
while (true) {
const segment = await this.tryDecodeNextSegment();
if (!segment) break;
results.push(segment);
}
return results;
}
/** /**
* Reset decoder state * Reset decoder state
*/ */
@@ -229,8 +352,11 @@ export class StreamDecoder {
this.rawChunks = []; this.rawChunks = [];
this.totalRawBytes = 0; this.totalRawBytes = 0;
this.processedBytes = 0; this.processedBytes = 0;
this.isFirstChunk = true;
this.totalStreamLength = 0; this.totalStreamLength = 0;
this.streamComplete = false;
this.headerBytesReceived = 0;
this.headerSearchChunks = [];
this.headerError = null;
} }
/** /**
@@ -242,8 +368,11 @@ export class StreamDecoder {
this.rawChunks = []; this.rawChunks = [];
this.totalRawBytes = 0; this.totalRawBytes = 0;
this.processedBytes = 0; this.processedBytes = 0;
this.isFirstChunk = true;
this.totalStreamLength = totalStreamLength; this.totalStreamLength = totalStreamLength;
this.streamComplete = false;
this.headerBytesReceived = 0;
this.headerSearchChunks = [];
this.headerError = null;
// wavHeader will be reparsed from the new stream (server sends fresh header) // wavHeader will be reparsed from the new stream (server sends fresh header)
this.wavHeader = null; this.wavHeader = null;
console.log(`StreamDecoder reinitialized for offset: expecting ${totalStreamLength} bytes`); console.log(`StreamDecoder reinitialized for offset: expecting ${totalStreamLength} bytes`);
+6
View File
@@ -45,6 +45,12 @@ const DeepDrftAudio = {
return player.startStreamingPlayback(); return player.startStreamingPlayback();
}, },
markStreamComplete: async (playerId: string): Promise<StreamingResult> => {
const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' };
return player.markStreamComplete();
},
ensureAudioContextReady: async (playerId: string): Promise<AudioResult> => { ensureAudioContextReady: async (playerId: string): Promise<AudioResult> => {
const player = audioPlayers.get(playerId); const player = audioPlayers.get(playerId);
if (!player) return { success: false, error: 'Player not found' }; if (!player) return { success: false, error: 'Player not found' };
+13 -4
View File
@@ -173,14 +173,23 @@ class WavUtils {
buffer[43] = (audioDataSize >> 24) & 0xFF; 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; 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 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 // Mid-stream guard: wait for more data if below minimum.
if (availableDataSize < minAudioBytes) { if (!streamComplete && availableDataSize < minAudioBytes) {
return 0;
}
// Even when complete we still need at least one full frame to decode.
if (availableDataSize < frameSize) {
return 0; return 0;
} }