From 02d146ce02ef2fa6b29b189d2dafdeebbd2d7325 Mon Sep 17 00:00:00 2001 From: Daniel Harvey Date: Sun, 17 May 2026 16:57:20 -0400 Subject: [PATCH] Fix streaming majors: PCM-only validation, stream-from-disk, ConcatStream offset, AsyncDisposable, HTTP cancellation, await ensureReady, seekBeyondBuffer offset-0 guard, negative WAV chunk guard --- .../Audio/WavOffsetService.cs | 180 +++++++++++++++--- .../FileDatabase/Services/MediaVault.cs | 68 ++++++- .../Controllers/TrackController.cs | 64 ++++++- .../Clients/TrackMediaClient.cs | 21 +- .../Controls/AudioPlayerProvider.razor.cs | 28 ++- .../Services/AudioPlayerService.cs | 2 +- .../Services/StreamingAudioPlayerService.cs | 31 ++- DeepDrftWeb/Interop/audio/AudioPlayer.ts | 22 ++- .../Interop/audio/PlaybackScheduler.ts | 10 +- DeepDrftWeb/Interop/audio/StreamDecoder.ts | 111 ++++++++++- DeepDrftWeb/Interop/audio/index.ts | 4 +- DeepDrftWeb/Interop/wavutils.ts | 8 +- 12 files changed, 481 insertions(+), 68 deletions(-) diff --git a/DeepDrftContent.Services/Audio/WavOffsetService.cs b/DeepDrftContent.Services/Audio/WavOffsetService.cs index fb9a40c..c5467c3 100644 --- a/DeepDrftContent.Services/Audio/WavOffsetService.cs +++ b/DeepDrftContent.Services/Audio/WavOffsetService.cs @@ -8,13 +8,23 @@ namespace DeepDrftContent.Services.Audio; /// public class WavOffsetService { + /// + /// WAV audio format code for linear PCM. The pipeline (AudioProcessor, + /// WavOffsetService, and wavutils.ts) is PCM-only by design — IEEE Float + /// (format 3) and other formats are rejected at parse time so the + /// synthesized header here can safely assume PCM. + /// + public const short PcmFormat = 1; + /// /// Creates a stream containing a synthesized WAV header followed by audio data from the specified offset. + /// The returned stream is composed of a small header buffer and a non-owning slice over the input + /// buffer — no copy of the audio payload is made. /// /// The complete WAV file buffer /// Byte offset into the raw audio data (not including original header) - /// MemoryStream with new WAV header + audio data from offset, or null if invalid - public MemoryStream? CreateOffsetStream(byte[] fullAudioBuffer, long byteOffset) + /// Stream with new WAV header + audio data from offset, or null if invalid + public Stream? CreateOffsetStream(byte[] fullAudioBuffer, long byteOffset) { var format = ParseWavHeader(fullAudioBuffer); if (format == null) @@ -27,28 +37,44 @@ public class WavOffsetService // Align to block boundary for clean audio var alignedOffset = (byteOffset / format.BlockAlign) * format.BlockAlign; - // Calculate new data size - var newDataSize = format.DataSize - (int)alignedOffset; + // Calculate new data size (long arithmetic — DataSize may be up to ~4 GB) + var newDataSize = format.DataSize - alignedOffset; if (newDataSize <= 0) return null; - // Create new WAV header - var newHeader = CreateWavHeader(format, newDataSize); - - // Calculate source position in original buffer + // MemoryStream does not support offsets or lengths beyond int.MaxValue. + // RF64 (>2 GB audio segments) is not supported; reject before truncating. var sourcePosition = format.HeaderSize + alignedOffset; + if (sourcePosition > int.MaxValue || newDataSize > int.MaxValue) + throw new NotSupportedException("Audio file segment exceeds 2 GB; RF64 not supported"); - // Create result stream: new header + audio data from offset - var resultStream = new MemoryStream(44 + newDataSize); - resultStream.Write(newHeader, 0, 44); - resultStream.Write(fullAudioBuffer, (int)sourcePosition, newDataSize); - resultStream.Position = 0; + var newDataSizeInt = (int)newDataSize; + var sourcePositionInt = (int)sourcePosition; - return resultStream; + // Create new WAV header using the format reported by the parsed header. + // PCM is the only format we accept (see PcmFormat / ParseWavHeader), but + // threading format.AudioFormat through keeps the header self-consistent + // and prevents drift if the validation contract is ever relaxed. + var newHeader = CreateWavHeader(format, newDataSizeInt); + + // Compose: 44-byte header followed by a non-copying slice of the audio payload. + // Wrapping the original buffer in a MemoryStream window avoids a 100MB+ copy + // that the previous MemoryStream(capacity).Write(...) implementation forced. + var headerStream = new MemoryStream(newHeader, writable: false); + var dataStream = new MemoryStream( + fullAudioBuffer, + sourcePositionInt, + newDataSizeInt, + writable: false, + publiclyVisible: false); + + return new ConcatStream(headerStream, dataStream); } /// /// Parses the WAV header from a buffer to extract format information. + /// PCM-only — IEEE Float (format 3) and other non-PCM formats are rejected + /// so downstream synthesis can safely assume PCM sample encoding. /// public WavFormat? ParseWavHeader(byte[] buffer) { @@ -70,8 +96,9 @@ public class WavOffsetService int bitsPerSample = 0; int byteRate = 0; int blockAlign = 0; - int dataSize = 0; + long dataSize = 0; int headerSize = 0; + short audioFormat = 0; bool foundFmt = false; bool foundData = false; @@ -82,14 +109,20 @@ public class WavOffsetService var chunkId = Encoding.ASCII.GetString(buffer, chunkOffset, 4); var chunkSize = BitConverter.ToInt32(buffer, chunkOffset + 4); + if (chunkSize < 0) + return null; + if (chunkId == "fmt ") { if (chunkSize < 16) return null; - var audioFormat = BitConverter.ToInt16(buffer, chunkOffset + 8); - // Support PCM (1) and IEEE Float (3) formats - if (audioFormat != 1 && audioFormat != 3) + audioFormat = BitConverter.ToInt16(buffer, chunkOffset + 8); + // PCM only. Float32 WAVs were previously accepted here but the synthesized + // header below is PCM-shaped — accepting Float would produce a corrupt file + // claiming PCM with Float-encoded samples. AudioProcessor also rejects + // non-PCM at upload time so this branch is defense in depth. + if (audioFormat != PcmFormat) return null; channels = BitConverter.ToInt16(buffer, chunkOffset + 10); @@ -106,7 +139,9 @@ public class WavOffsetService } else if (chunkId == "data") { - dataSize = chunkSize; + // WAV stores DataSize as a 32-bit unsigned int. Read as uint to preserve + // values above int.MaxValue (files between 2–4 GB), then widen to long. + dataSize = (long)BitConverter.ToUInt32(buffer, chunkOffset + 4); headerSize = chunkOffset + 8; // Audio data starts after 'data' + size (8 bytes) foundData = true; } @@ -124,6 +159,7 @@ public class WavOffsetService return null; return new WavFormat( + AudioFormat: audioFormat, SampleRate: sampleRate, Channels: channels, BitsPerSample: bitsPerSample, @@ -135,7 +171,9 @@ public class WavOffsetService } /// - /// Creates a standard 44-byte PCM WAV header. + /// Creates a standard 44-byte WAV header. The audio format code is taken from + /// rather than hardcoded so the synthesized header matches + /// what was parsed (today always ; see ParseWavHeader). /// public byte[] CreateWavHeader(WavFormat format, int dataSize) { @@ -150,7 +188,7 @@ public class WavOffsetService // fmt chunk header[12] = (byte)'f'; header[13] = (byte)'m'; header[14] = (byte)'t'; header[15] = (byte)' '; BitConverter.GetBytes(16).CopyTo(header, 16); // fmt chunk size - BitConverter.GetBytes((short)1).CopyTo(header, 20); // Audio format (PCM) + BitConverter.GetBytes(format.AudioFormat).CopyTo(header, 20); // Audio format (from parsed header) BitConverter.GetBytes((short)format.Channels).CopyTo(header, 22); BitConverter.GetBytes(format.SampleRate).CopyTo(header, 24); BitConverter.GetBytes(format.ByteRate).CopyTo(header, 28); @@ -168,12 +206,110 @@ public class WavOffsetService /// /// WAV format information extracted from header. /// +/// WAV fmt-chunk audio format code (1 = PCM; the only value accepted today). public record WavFormat( + short AudioFormat, int SampleRate, int Channels, int BitsPerSample, int ByteRate, int BlockAlign, - int DataSize, + long DataSize, int HeaderSize ); + +/// +/// Forward-only read stream over two underlying streams concatenated end-to-end. +/// Lets us serve "[synthesized header][slice of original buffer]" without +/// allocating a single contiguous buffer for the combined payload. +/// +internal sealed class ConcatStream : Stream +{ + private readonly Stream _first; + private readonly Stream _second; + private readonly long _length; + private long _position; + + public ConcatStream(Stream first, Stream second) + { + _first = first; + _second = second; + _length = first.Length + second.Length; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => _length; + + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + var total = 0; + + // Loop over _first until it returns 0 (exhausted) or the caller's buffer + // is full. Stream.Read is not required to fill the buffer in one call even + // when data is available (e.g. a future non-MemoryStream _first), so we must + // keep pulling until we get 0 before advancing to _second. + while (count > 0 && _position < _first.Length) + { + var read = _first.Read(buffer, offset, count); + if (read == 0) break; + total += read; + _position += read; + offset += read; + count -= read; + } + + if (count > 0) + { + var read = _second.Read(buffer, offset, count); + total += read; + _position += read; + } + return total; + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var total = 0; + + // Same loop contract as Read() — exhaust _first before reading _second. + while (!buffer.IsEmpty && _position < _first.Length) + { + var read = await _first.ReadAsync(buffer, cancellationToken); + if (read == 0) break; + total += read; + _position += read; + buffer = buffer[read..]; + } + + if (!buffer.IsEmpty) + { + var read = await _second.ReadAsync(buffer, cancellationToken); + total += read; + _position += read; + } + return total; + } + + public override void Flush() { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _first.Dispose(); + _second.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/DeepDrftContent.Services/FileDatabase/Services/MediaVault.cs b/DeepDrftContent.Services/FileDatabase/Services/MediaVault.cs index 525d5a5..3a4d019 100644 --- a/DeepDrftContent.Services/FileDatabase/Services/MediaVault.cs +++ b/DeepDrftContent.Services/FileDatabase/Services/MediaVault.cs @@ -84,6 +84,53 @@ public abstract class MediaVault : VaultIndexDirectory return (T)result; } + /// + /// Opens a read-only stream over an entry's backing file plus its metadata + /// (extension/MIME), without buffering the file into memory. + /// Returns null if the entry is unknown or the backing file is missing. + /// + /// Use this when the caller will forward bytes to a network response — the + /// existing allocates a full byte[] + /// and pushes large WAVs onto the LOH for every request. + /// + /// The caller owns the returned stream and must dispose it. Error-handling + /// follows the same swallow-and-return-null contract as the rest of the + /// FileDatabase API; the caller checks for null. + /// + public async Task GetEntryStreamAsync(string entryId) + { + try + { + if (!await HasIndexEntry(entryId)) + return null; + + var metaData = await GetEntryMetadata(entryId); + if (metaData == null) + return null; + + var mediaPath = GetMediaPathFromEntryKey(metaData.MediaKey, metaData.Extension); + if (!FileUtils.FileExists(mediaPath)) + return null; + + // Async-capable, sequential-scan FileStream — the response writer will pull + // bytes in order. bufferSize matches FileUtils.FetchFileAsync (64 KB). + var stream = new FileStream( + mediaPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 64 * 1024, + useAsync: true); + + return new MediaStream(stream, metaData.Extension); + } + catch + { + // Match FileDatabase error-swallow contract. + return null; + } + } + /// /// Extracts buffer and extension from a media binary /// @@ -127,7 +174,7 @@ public class ImageVault : MediaVault public class AudioVault : MediaVault { private AudioVault(string rootPath, VaultIndex index) : base(rootPath, index) { } - + public static async Task FromAsync(string rootPath) { var factoryService = new IndexFactoryService(); @@ -141,3 +188,22 @@ public class AudioVault : MediaVault return null; } } + +/// +/// An open read-only stream over a vault entry plus the extension needed to +/// resolve its MIME type. Caller owns the stream and must dispose it. +/// +public sealed class MediaStream : IDisposable, IAsyncDisposable +{ + public Stream Stream { get; } + public string Extension { get; } + + public MediaStream(Stream stream, string extension) + { + Stream = stream; + Extension = extension; + } + + public void Dispose() => Stream.Dispose(); + public ValueTask DisposeAsync() => Stream.DisposeAsync(); +} diff --git a/DeepDrftContent/Controllers/TrackController.cs b/DeepDrftContent/Controllers/TrackController.cs index 7a2f838..0bac1b2 100644 --- a/DeepDrftContent/Controllers/TrackController.cs +++ b/DeepDrftContent/Controllers/TrackController.cs @@ -1,6 +1,7 @@ -using DeepDrftContent.Services.Audio; +using DeepDrftContent.Services.Audio; using DeepDrftContent.Services.Constants; using DeepDrftContent.Services.FileDatabase.Models; +using DeepDrftContent.Services.FileDatabase.Services; using DeepDrftContent.Middleware; using Microsoft.AspNetCore.Mvc; @@ -31,6 +32,57 @@ public class TrackController : ControllerBase try { + // No-offset path: stream the file straight from disk so a 100 MB WAV does not + // force a 100 MB LOH allocation per request. The offset path still loads + // the full buffer because WavOffsetService block-aligns and reslices into + // a composite stream over the in-memory buffer. + if (offset == 0) + { + var vault = _fileDatabase.GetVault(VaultConstants.Tracks); + if (vault == null) + { + _logger.LogWarning("Tracks vault not found"); + return NotFound(); + } + + var mediaStream = await vault.GetEntryStreamAsync(trackId); + if (mediaStream == null) + { + _logger.LogWarning("Track not found: {TrackId}", trackId); + return NotFound(); + } + + // Resolve MIME and log before handing the stream to File(). + // If anything here throws, the finally block disposes the wrapper + // (and its inner FileStream) so neither leaks. On the success path + // File() takes ownership of the inner stream; ASP.NET Core disposes + // it after the response body is sent. The wrapper is a thin struct + // with no extra resources, so disposing it after extracting the + // inner stream is a no-op — we only call Dispose() in the catch path. + string streamMimeType; + long streamLength; + Stream innerStream; + try + { + streamMimeType = MimeTypeExtensions.GetMimeType(mediaStream.Extension); + streamLength = mediaStream.Stream.Length; + innerStream = mediaStream.Stream; + } + catch + { + await mediaStream.DisposeAsync(); + throw; + } + + _logger.LogInformation( + "Streaming track from disk: {TrackId}, Size: {Size} bytes", + trackId, streamLength); + // enableRangeProcessing: false — seek is served by WavOffsetService, not Range. + return File(innerStream, streamMimeType, enableRangeProcessing: false); + } + + // Offset path: buffer the file (current behaviour) and synthesise a + // header-prefixed slice via WavOffsetService. var file = await _fileDatabase.LoadResourceAsync(VaultConstants.Tracks, trackId); if (file == null) { @@ -40,14 +92,6 @@ public class TrackController : ControllerBase var mimeType = MimeTypeExtensions.GetMimeType(file.Extension); - // If no offset, return the full file - if (offset == 0) - { - _logger.LogInformation("Successfully retrieved track: {TrackId}, Size: {Size} bytes", trackId, file.Buffer.Length); - return File(file.Buffer, mimeType); - } - - // Create offset stream with synthesized header var offsetStream = _wavOffsetService.CreateOffsetStream(file.Buffer, offset); if (offsetStream == null) { @@ -75,4 +119,4 @@ public class TrackController : ControllerBase var success = await _fileDatabase.RegisterResourceAsync(VaultConstants.Tracks, trackId, audioBinary); return success ? Ok() : BadRequest("Failed to store audio track"); } -} \ No newline at end of file +} diff --git a/DeepDrftWeb.Client/Clients/TrackMediaClient.cs b/DeepDrftWeb.Client/Clients/TrackMediaClient.cs index 53f3f71..b0555f4 100644 --- a/DeepDrftWeb.Client/Clients/TrackMediaClient.cs +++ b/DeepDrftWeb.Client/Clients/TrackMediaClient.cs @@ -7,7 +7,7 @@ public class TrackMediaResponse : IDisposable { public Stream Stream { get; } public long ContentLength { get; } - + public TrackMediaResponse(Stream stream, long contentLength) { Stream = stream; @@ -23,13 +23,22 @@ public class TrackMediaResponse : IDisposable public class TrackMediaClient { private readonly HttpClient _http; - + public TrackMediaClient(IHttpClientFactory httpClientFactory) { _http = httpClientFactory.CreateClient("DeepDrft.Content"); } - public async Task> GetTrackMedia(string trackId, long byteOffset = 0) + /// + /// Fetches the WAV stream for a track, optionally starting from a byte offset. + /// The cancellation token is forwarded to so a + /// navigation or seek-replacement aborts the in-flight server connection rather + /// than leaving the server draining bytes into a dead socket. + /// + public async Task> GetTrackMedia( + string trackId, + long byteOffset = 0, + CancellationToken cancellationToken = default) { try { @@ -39,11 +48,11 @@ public class TrackMediaClient : $"api/track/{trackId}"; // Use HttpCompletionOption.ResponseHeadersRead to get stream immediately - var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); + var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken); response.EnsureSuccessStatusCode(); var contentLength = response.Content.Headers.ContentLength ?? 0; - var stream = await response.Content.ReadAsStreamAsync(); + var stream = await response.Content.ReadAsStreamAsync(cancellationToken); return ApiResult.CreatePassResult(new TrackMediaResponse(stream, contentLength)); } @@ -52,4 +61,4 @@ public class TrackMediaClient return ApiResult.CreateFailResult(e.Message); } } -} \ No newline at end of file +} diff --git a/DeepDrftWeb.Client/Controls/AudioPlayerProvider.razor.cs b/DeepDrftWeb.Client/Controls/AudioPlayerProvider.razor.cs index 3735268..bf03df5 100644 --- a/DeepDrftWeb.Client/Controls/AudioPlayerProvider.razor.cs +++ b/DeepDrftWeb.Client/Controls/AudioPlayerProvider.razor.cs @@ -1,20 +1,20 @@ -using DeepDrftWeb.Client.Services; +using DeepDrftWeb.Client.Services; using DeepDrftWeb.Client.Clients; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Logging; namespace DeepDrftWeb.Client.Controls; -public partial class AudioPlayerProvider : ComponentBase +public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable { [Inject] public required AudioInteropService AudioInterop { get; set; } [Inject] public required TrackMediaClient TrackMediaClient { get; set; } [Inject] public required ILogger Logger { get; set; } - + private StreamingAudioPlayerService? _audioPlayerService; - + [Parameter] public RenderFragment? ChildContent { get; set; } - + protected override void OnInitialized() { // Create the service immediately (but don't initialize yet) @@ -25,7 +25,7 @@ public partial class AudioPlayerProvider : ComponentBase _audioPlayerService.OnStateChanged = new EventCallback(this, () => InvokeAsync(StateHasChanged)); // OnTrackSelected will be set by individual child components that need it } - + protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender && _audioPlayerService != null) @@ -35,4 +35,18 @@ public partial class AudioPlayerProvider : ComponentBase StateHasChanged(); } } -} \ No newline at end of file + + /// + /// Dispose the player on unmount so the JS setInterval driving progress + /// callbacks no longer holds a DotNetObjectReference into a destroyed + /// component (otherwise it throws every 100ms after navigation away). + /// + public async ValueTask DisposeAsync() + { + if (_audioPlayerService != null) + { + await _audioPlayerService.DisposeAsync(); + _audioPlayerService = null; + } + } +} diff --git a/DeepDrftWeb.Client/Services/AudioPlayerService.cs b/DeepDrftWeb.Client/Services/AudioPlayerService.cs index 8915d0e..e63f6a1 100644 --- a/DeepDrftWeb.Client/Services/AudioPlayerService.cs +++ b/DeepDrftWeb.Client/Services/AudioPlayerService.cs @@ -392,7 +392,7 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable await OnTrackSelected.Value.InvokeAsync(); } - public async ValueTask DisposeAsync() + public virtual async ValueTask DisposeAsync() { if (IsInitialized) { diff --git a/DeepDrftWeb.Client/Services/StreamingAudioPlayerService.cs b/DeepDrftWeb.Client/Services/StreamingAudioPlayerService.cs index 707854e..e3c6492 100644 --- a/DeepDrftWeb.Client/Services/StreamingAudioPlayerService.cs +++ b/DeepDrftWeb.Client/Services/StreamingAudioPlayerService.cs @@ -90,7 +90,12 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS await NotifyStateChanged(); - var mediaResult = await _trackMediaClient.GetTrackMedia(track.EntryKey); + // Pass the streaming token to the HTTP layer so a navigation/track switch + // aborts the server connection instead of leaving it draining bytes. + var mediaResult = await _trackMediaClient.GetTrackMedia( + track.EntryKey, + byteOffset: 0, + cancellationToken: _streamingCancellation.Token); if (!mediaResult.Success) { var technicalError = mediaResult.GetMessage(); @@ -346,7 +351,10 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS await NotifyStateChanged(); // Request new stream from offset - var mediaResult = await _trackMediaClient.GetTrackMedia(_currentTrackId, byteOffset); + var mediaResult = await _trackMediaClient.GetTrackMedia( + _currentTrackId, + byteOffset, + cancellationToken: _streamingCancellation.Token); if (!mediaResult.Success || mediaResult.Value == null) { var technicalError = mediaResult.GetMessage() ?? "Failed to load audio from position"; @@ -485,6 +493,25 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS } } + /// + /// On component unmount we must cancel the in-flight streaming loop and tear + /// down JS callbacks before the JS side's setInterval fires again with a + /// stale DotNetObjectReference. ResetToIdle covers cancellation + JS stop + /// + state reset; the base then disposes the JS player and its callbacks. + /// + public override async ValueTask DisposeAsync() + { + try + { + await ResetToIdle(); + } + catch + { + // Disposal must not throw; any failure here is best-effort cleanup. + } + await base.DisposeAsync(); + } + private void AdaptBufferSize(int bytesRead, long readTimeMs) { // Adaptive buffer sizing based on network performance diff --git a/DeepDrftWeb/Interop/audio/AudioPlayer.ts b/DeepDrftWeb/Interop/audio/AudioPlayer.ts index 21eae34..e1b00c7 100644 --- a/DeepDrftWeb/Interop/audio/AudioPlayer.ts +++ b/DeepDrftWeb/Interop/audio/AudioPlayer.ts @@ -175,13 +175,21 @@ export class AudioPlayer { } } - startStreamingPlayback(): AudioResult { + async startStreamingPlayback(): Promise { if (!this.scheduler.hasBuffers()) { return { success: false, error: 'No buffers available' }; } try { console.log('\n=== Starting streaming playback ==='); + + // A backgrounded tab leaves AudioContext suspended. createBufferSource/start + // against a suspended context produces no audio without throwing — the same + // failure mode that was fixed for play() (resume path). Awaiting ensureReady() + // here guarantees the context is running before playFromPosition schedules + // any AudioBufferSourceNodes. + await this.contextManager.ensureReady(); + this.streamingStarted = true; this.isPlaying = true; this.isPaused = false; @@ -199,7 +207,7 @@ export class AudioPlayer { // ==================== Playback Control ==================== - play(): AudioResult { + async play(): Promise { if (!this.isStreamingMode) { return { success: false, error: 'Not in streaming mode' }; } @@ -215,7 +223,11 @@ export class AudioPlayer { } try { - this.contextManager.ensureReady(); + // Must await: a backgrounded tab leaves AudioContext suspended, and + // createBufferSource/source.start against a suspended context produces + // no audio without throwing. Firing ensureReady() without await meant + // play() returned success but the user heard nothing. + await this.contextManager.ensureReady(); this.isPlaying = true; this.isPaused = false; @@ -313,7 +325,9 @@ export class AudioPlayer { private seekBeyondBuffer(position: number): AudioResult { try { const byteOffset = this.streamDecoder.calculateByteOffset(position); - if (byteOffset <= 0) { + // 0 is a valid offset (seek to start of audio data). Only a negative result + // indicates calculation failure — typically a missing/unparsed WAV header. + if (byteOffset < 0) { return { success: false, error: 'Cannot calculate byte offset' }; } diff --git a/DeepDrftWeb/Interop/audio/PlaybackScheduler.ts b/DeepDrftWeb/Interop/audio/PlaybackScheduler.ts index 6afebe5..c4ff178 100644 --- a/DeepDrftWeb/Interop/audio/PlaybackScheduler.ts +++ b/DeepDrftWeb/Interop/audio/PlaybackScheduler.ts @@ -110,7 +110,15 @@ export class PlaybackScheduler { } if (startBufferIndex >= this.buffers.length) { - console.log('Position beyond available buffers'); + // Position landed at or past the end of all buffers. Previously this + // returned silently, leaving the player stuck "playing" with no source + // scheduled — a pause near the end followed by play never recovered. + // Treat this as end-of-track so listeners (UI / end callback) fire. + console.log('Position at/beyond available buffers — ending playback'); + this.isActive_ = false; + this.playbackAnchorTime = 0; + this.playbackAnchorPosition = 0; + this.onPlaybackEnded?.(); return; } diff --git a/DeepDrftWeb/Interop/audio/StreamDecoder.ts b/DeepDrftWeb/Interop/audio/StreamDecoder.ts index 6a1a3a4..3162ba6 100644 --- a/DeepDrftWeb/Interop/audio/StreamDecoder.ts +++ b/DeepDrftWeb/Interop/audio/StreamDecoder.ts @@ -12,6 +12,36 @@ export interface DecodedChunkResult { duration: number; } +/** + * Thrown when decodeAudioData exceeds the per-segment deadline. Distinct from + * DecodeError so callers (and operators reading logs) can tell a slow/throttled + * decoder from corrupt audio data — the previous "Decode timeout" string error + * was indistinguishable from any other Error and was silently swallowed. + */ +export class DecodeTimeoutError extends Error { + constructor(public readonly segmentOffset: number, public readonly byteCount: number) { + super(`Decode timeout at offset ${segmentOffset} (${byteCount} bytes)`); + this.name = 'DecodeTimeoutError'; + } +} + +/** + * Thrown when decodeAudioData rejects for non-timeout reasons (corrupt header, + * unsupported format, etc.). Carries the segment offset so callers can log + * which part of the stream failed. + */ +export class DecodeError extends Error { + constructor( + message: string, + public readonly segmentOffset: number, + public readonly byteCount: number, + public readonly cause?: Error + ) { + super(message); + this.name = 'DecodeError'; + } +} + 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 @@ -173,7 +203,15 @@ export class StreamDecoder { } /** - * Try to decode the next segment of audio + * Try to decode the next segment of audio. + * + * Failure modes: + * - Decode timeout: retry once, then surface as DecodeTimeoutError (typed). + * - Other decode error (corrupt data, format mismatch): surface as DecodeError. + * Both are thrown rather than silently swallowed — callers (processChunk / + * markStreamComplete) decide whether to abort the stream or skip the segment. + * processedBytes is only advanced on success so a thrown failure does not + * silently consume the failed segment. */ private async tryDecodeNextSegment(): Promise { if (!this.wavHeader) return null; @@ -199,15 +237,63 @@ export class StreamDecoder { const wavFile = this.createWavFile(rawSegment); try { - 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. + const buffer = await this.decodeWithRetry(wavFile, segmentOffset, alignedSize); + // Advance only after a successful decode so a thrown timeout/decode + // failure does not silently drop the segment. this.processedBytes += alignedSize; console.log(`✓ Decoded: ${buffer.duration.toFixed(3)}s, ${buffer.numberOfChannels}ch`); return { buffer, duration: buffer.duration }; } catch (error) { - console.error(`Failed to decode segment at offset ${segmentOffset}:`, error); - return null; + // Re-throw typed errors so the outer drain loop in processChunk / + // markStreamComplete sees the real failure instead of an empty array. + // The previous silent return hid timeouts entirely. + if (error instanceof DecodeTimeoutError || error instanceof DecodeError) { + throw error; + } + // Unknown synchronous failure during decode — wrap and surface. + throw new DecodeError( + `Decode failed at offset ${segmentOffset} (${alignedSize} bytes): ${(error as Error).message}`, + segmentOffset, + alignedSize, + error as Error); + } + } + + /** + * Decode with a single retry on timeout. Web Audio's decodeAudioData is + * occasionally flaky under tab throttling; a retry costs little and recovers + * the common transient case without dropping the segment. + */ + private async decodeWithRetry( + wavData: Uint8Array, + segmentOffset: number, + alignedSize: number): Promise { + try { + return await this.decodeWithTimeout(wavData); + } catch (error) { + if (!(error instanceof DecodeTimeoutError)) { + throw new DecodeError( + `Decode failed at offset ${segmentOffset} (${alignedSize} bytes): ${(error as Error).message}`, + segmentOffset, + alignedSize, + error as Error); + } + console.warn( + `Decode timeout at offset ${segmentOffset} (${alignedSize} bytes) — retrying once`); + try { + return await this.decodeWithTimeout(wavData); + } catch (retryError) { + if (retryError instanceof DecodeTimeoutError) { + console.error( + `Decode timeout after retry at offset ${segmentOffset} (${alignedSize} bytes)`); + throw new DecodeTimeoutError(segmentOffset, alignedSize); + } + throw new DecodeError( + `Decode failed on retry at offset ${segmentOffset} (${alignedSize} bytes): ${(retryError as Error).message}`, + segmentOffset, + alignedSize, + retryError as Error); + } } } @@ -257,18 +343,25 @@ export class StreamDecoder { } /** - * Decode with timeout to prevent hanging + * Decode with timeout to prevent hanging. Throws DecodeTimeoutError if the + * deadline expires so callers can distinguish timeout from corrupt-data + * failures (decodeAudioData throws DOMException for the latter). */ private async decodeWithTimeout(wavData: Uint8Array, timeoutMs: number = 5000): Promise { const buffer = new ArrayBuffer(wavData.length); new Uint8Array(buffer).set(wavData); const decodePromise = this.contextManager.decodeAudioData(buffer); + let timer: ReturnType | null = null; const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Decode timeout')), timeoutMs); + timer = setTimeout(() => reject(new DecodeTimeoutError(-1, wavData.length)), timeoutMs); }); - return Promise.race([decodePromise, timeoutPromise]); + try { + return await Promise.race([decodePromise, timeoutPromise]); + } finally { + if (timer !== null) clearTimeout(timer); + } } /** diff --git a/DeepDrftWeb/Interop/audio/index.ts b/DeepDrftWeb/Interop/audio/index.ts index 48b9839..a037326 100644 --- a/DeepDrftWeb/Interop/audio/index.ts +++ b/DeepDrftWeb/Interop/audio/index.ts @@ -39,7 +39,7 @@ const DeepDrftAudio = { return player.processStreamingChunk(chunk); }, - startStreamingPlayback: (playerId: string): AudioResult => { + startStreamingPlayback: async (playerId: string): Promise => { const player = audioPlayers.get(playerId); if (!player) return { success: false, error: 'Player not found' }; return player.startStreamingPlayback(); @@ -57,7 +57,7 @@ const DeepDrftAudio = { return player.ensureAudioContextReady(); }, - play: (playerId: string): AudioResult => { + play: async (playerId: string): Promise => { const player = audioPlayers.get(playerId); if (!player) return { success: false, error: 'Player not found' }; return player.play(); diff --git a/DeepDrftWeb/Interop/wavutils.ts b/DeepDrftWeb/Interop/wavutils.ts index d0c6ac4..0ef0390 100644 --- a/DeepDrftWeb/Interop/wavutils.ts +++ b/DeepDrftWeb/Interop/wavutils.ts @@ -51,9 +51,11 @@ class WavUtils { if (chunkSize < 16) return null; const audioFormat = view.getUint16(chunkOffset + 8, true); - // Support PCM (1) and IEEE Float (3) formats - if (audioFormat !== 1 && audioFormat !== 3) { - console.warn(`Unsupported audio format: ${audioFormat} (only PCM=1 and IEEE Float=3 supported)`); + // PCM only. The server's WavOffsetService synthesises PCM-shaped headers, + // and AudioProcessor rejects non-PCM at upload — accepting Float here would + // hand the decoder a header/payload mismatch that surfaces as garbled audio. + if (audioFormat !== 1) { + console.warn(`Unsupported audio format: ${audioFormat} (only PCM=1 supported)`); return null; }