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:
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
this.scheduler.addBuffer(result.buffer);
|
for (const result of results) {
|
||||||
|
this.scheduler.addBuffer(result.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
// Update duration estimate
|
// Update duration estimate
|
||||||
const estimatedDuration = this.streamDecoder.getEstimatedDuration();
|
const estimatedDuration = this.streamDecoder.getEstimatedDuration();
|
||||||
|
|||||||
@@ -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.
|
||||||
this.addRawData(audioData);
|
const concatenated = new Uint8Array(this.headerBytesReceived);
|
||||||
console.log(`Extracted ${audioData.length} bytes of audio data`);
|
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);
|
||||||
|
}
|
||||||
|
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`);
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user