From 20db222a0fc4ef2dcfbc47c875c08a8f32ebea0e Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Sun, 7 Dec 2025 04:44:54 -0500 Subject: [PATCH] Streaming Seek Support --- .../Audio/WavOffsetService.cs | 179 ++++++++++++++++++ .../Controllers/TrackController.cs | 44 ++++- DeepDrftContent/Startup.cs | 6 +- .../Clients/TrackMediaClient.cs | 13 +- .../AudioPlayerBar/AudioPlayerBar.razor | 4 +- .../AudioPlayerBar/AudioPlayerBar.razor.cs | 6 + .../Services/AudioInteropService.cs | 42 +++- .../Services/AudioPlayerService.cs | 4 +- .../Services/StreamingAudioPlayerService.cs | 124 +++++++++++- .../Interop/audio/PlaybackScheduler.ts | 45 ++++- DeepDrftWeb/Interop/audio/StreamDecoder.ts | 35 ++++ DeepDrftWeb/Interop/audio/index.ts | 17 ++ 12 files changed, 493 insertions(+), 26 deletions(-) create mode 100644 DeepDrftContent.Services/Audio/WavOffsetService.cs diff --git a/DeepDrftContent.Services/Audio/WavOffsetService.cs b/DeepDrftContent.Services/Audio/WavOffsetService.cs new file mode 100644 index 0000000..fb9a40c --- /dev/null +++ b/DeepDrftContent.Services/Audio/WavOffsetService.cs @@ -0,0 +1,179 @@ +using System.Text; + +namespace DeepDrftContent.Services.Audio; + +/// +/// Service for creating WAV audio streams starting from a byte offset. +/// Synthesizes a valid WAV header for the remaining audio data. +/// +public class WavOffsetService +{ + /// + /// Creates a stream containing a synthesized WAV header followed by audio data from the specified offset. + /// + /// 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) + { + var format = ParseWavHeader(fullAudioBuffer); + if (format == null) + return null; + + // Validate offset is within bounds and block-aligned + if (byteOffset < 0 || byteOffset >= format.DataSize) + return null; + + // Align to block boundary for clean audio + var alignedOffset = (byteOffset / format.BlockAlign) * format.BlockAlign; + + // Calculate new data size + var newDataSize = format.DataSize - (int)alignedOffset; + if (newDataSize <= 0) + return null; + + // Create new WAV header + var newHeader = CreateWavHeader(format, newDataSize); + + // Calculate source position in original buffer + var sourcePosition = format.HeaderSize + alignedOffset; + + // 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; + + return resultStream; + } + + /// + /// Parses the WAV header from a buffer to extract format information. + /// + public WavFormat? ParseWavHeader(byte[] buffer) + { + if (buffer.Length < 44) + return null; + + // Check RIFF header + var riff = Encoding.ASCII.GetString(buffer, 0, 4); + if (riff != "RIFF") + return null; + + var wave = Encoding.ASCII.GetString(buffer, 8, 4); + if (wave != "WAVE") + return null; + + // Variables to store parsed header info + int sampleRate = 0; + int channels = 0; + int bitsPerSample = 0; + int byteRate = 0; + int blockAlign = 0; + int dataSize = 0; + int headerSize = 0; + bool foundFmt = false; + bool foundData = false; + + // Find fmt and data chunks + int chunkOffset = 12; + while (chunkOffset < buffer.Length - 8) + { + var chunkId = Encoding.ASCII.GetString(buffer, chunkOffset, 4); + var chunkSize = BitConverter.ToInt32(buffer, chunkOffset + 4); + + 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) + return null; + + channels = BitConverter.ToInt16(buffer, chunkOffset + 10); + sampleRate = BitConverter.ToInt32(buffer, chunkOffset + 12); + byteRate = BitConverter.ToInt32(buffer, chunkOffset + 16); + blockAlign = BitConverter.ToInt16(buffer, chunkOffset + 20); + bitsPerSample = BitConverter.ToInt16(buffer, chunkOffset + 22); + + // Basic validation + if (channels < 1 || channels > 8) + return null; + + foundFmt = true; + } + else if (chunkId == "data") + { + dataSize = chunkSize; + headerSize = chunkOffset + 8; // Audio data starts after 'data' + size (8 bytes) + foundData = true; + } + + // Move to next chunk with proper alignment (chunks are word-aligned) + chunkOffset += 8 + ((chunkSize + 1) & ~1); + + // If we found both chunks, we're done + if (foundFmt && foundData) + break; + } + + // Must have found both fmt and data chunks + if (!foundFmt || !foundData) + return null; + + return new WavFormat( + SampleRate: sampleRate, + Channels: channels, + BitsPerSample: bitsPerSample, + ByteRate: byteRate, + BlockAlign: blockAlign, + DataSize: dataSize, + HeaderSize: headerSize + ); + } + + /// + /// Creates a standard 44-byte PCM WAV header. + /// + public byte[] CreateWavHeader(WavFormat format, int dataSize) + { + var header = new byte[44]; + var fileSize = 36 + dataSize; + + // RIFF header + header[0] = (byte)'R'; header[1] = (byte)'I'; header[2] = (byte)'F'; header[3] = (byte)'F'; + BitConverter.GetBytes(fileSize).CopyTo(header, 4); + header[8] = (byte)'W'; header[9] = (byte)'A'; header[10] = (byte)'V'; header[11] = (byte)'E'; + + // 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((short)format.Channels).CopyTo(header, 22); + BitConverter.GetBytes(format.SampleRate).CopyTo(header, 24); + BitConverter.GetBytes(format.ByteRate).CopyTo(header, 28); + BitConverter.GetBytes((short)format.BlockAlign).CopyTo(header, 32); + BitConverter.GetBytes((short)format.BitsPerSample).CopyTo(header, 34); + + // data chunk header + header[36] = (byte)'d'; header[37] = (byte)'a'; header[38] = (byte)'t'; header[39] = (byte)'a'; + BitConverter.GetBytes(dataSize).CopyTo(header, 40); + + return header; + } +} + +/// +/// WAV format information extracted from header. +/// +public record WavFormat( + int SampleRate, + int Channels, + int BitsPerSample, + int ByteRate, + int BlockAlign, + int DataSize, + int HeaderSize +); diff --git a/DeepDrftContent/Controllers/TrackController.cs b/DeepDrftContent/Controllers/TrackController.cs index 449d0cf..7a2f838 100644 --- a/DeepDrftContent/Controllers/TrackController.cs +++ b/DeepDrftContent/Controllers/TrackController.cs @@ -1,4 +1,5 @@ -using DeepDrftContent.Services.Constants; +using DeepDrftContent.Services.Audio; +using DeepDrftContent.Services.Constants; using DeepDrftContent.Services.FileDatabase.Models; using DeepDrftContent.Middleware; using Microsoft.AspNetCore.Mvc; @@ -10,30 +11,53 @@ namespace DeepDrftContent.Controllers; public class TrackController : ControllerBase { private readonly DeepDrftContent.Services.FileDatabase.Services.FileDatabase _fileDatabase; + private readonly WavOffsetService _wavOffsetService; private readonly ILogger _logger; - public TrackController(DeepDrftContent.Services.FileDatabase.Services.FileDatabase fileDatabase, ILogger logger) + public TrackController( + DeepDrftContent.Services.FileDatabase.Services.FileDatabase fileDatabase, + WavOffsetService wavOffsetService, + ILogger logger) { _fileDatabase = fileDatabase; + _wavOffsetService = wavOffsetService; _logger = logger; } [HttpGet("{trackId}")] - public async Task GetTrack(string trackId) + public async Task GetTrack(string trackId, [FromQuery] long offset = 0) { - _logger.LogInformation("GetTrack called with trackId: {TrackId}", trackId); - + _logger.LogInformation("GetTrack called with trackId: {TrackId}, offset: {Offset}", trackId, offset); + try { var file = await _fileDatabase.LoadResourceAsync(VaultConstants.Tracks, trackId); - if (file == null) - { + if (file == null) + { _logger.LogWarning("Track not found: {TrackId}", trackId); - return NotFound(); + return NotFound(); } - _logger.LogInformation("Successfully retrieved track: {TrackId}, Size: {Size} bytes", trackId, file.Buffer.Length); - return File(file.Buffer, MimeTypeExtensions.GetMimeType(file.Extension)); + 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) + { + _logger.LogWarning("Invalid offset {Offset} for track: {TrackId}", offset, trackId); + return BadRequest("Invalid offset"); + } + + _logger.LogInformation("Successfully retrieved track with offset: {TrackId}, Offset: {Offset}, StreamSize: {Size} bytes", + trackId, offset, offsetStream.Length); + return File(offsetStream, mimeType); } catch (Exception ex) { diff --git a/DeepDrftContent/Startup.cs b/DeepDrftContent/Startup.cs index 6c1be81..cb5677d 100644 --- a/DeepDrftContent/Startup.cs +++ b/DeepDrftContent/Startup.cs @@ -1,3 +1,4 @@ +using DeepDrftContent.Services.Audio; using DeepDrftContent.Services.Constants; using DeepDrftContent.Services.FileDatabase.Models; using DeepDrftContent.Services.FileDatabase.Services; @@ -9,13 +10,16 @@ namespace DeepDrftContent { public static async Task ConfigureDomainServices(WebApplicationBuilder builder) { + // Audio services + builder.Services.AddSingleton(); + // File Database builder.Configuration.AddJsonFile("environment/filedatabase.json", optional: false, reloadOnChange: true); var fileDatabaseSettings = builder.Configuration.GetSection(nameof(FileDatabaseSettings)).Get(); if (fileDatabaseSettings is null) { throw new Exception("File database settings are not configured"); } var fileDatabase = await FileDatabase.FromAsync(fileDatabaseSettings.VaultPath); - if (fileDatabase is null) { throw new Exception("Unable to initialize file database"); } + if (fileDatabase is null) { throw new Exception("Unable to initialize file database"); } builder.Services.AddSingleton(fileDatabase); await InitializeTrackVault(fileDatabase); } diff --git a/DeepDrftWeb.Client/Clients/TrackMediaClient.cs b/DeepDrftWeb.Client/Clients/TrackMediaClient.cs index ad74b9e..53f3f71 100644 --- a/DeepDrftWeb.Client/Clients/TrackMediaClient.cs +++ b/DeepDrftWeb.Client/Clients/TrackMediaClient.cs @@ -29,17 +29,22 @@ public class TrackMediaClient _http = httpClientFactory.CreateClient("DeepDrft.Content"); } - public async Task> GetTrackMedia(string trackId) + public async Task> GetTrackMedia(string trackId, long byteOffset = 0) { try { + // Build URL with optional offset parameter + var url = byteOffset > 0 + ? $"api/track/{trackId}?offset={byteOffset}" + : $"api/track/{trackId}"; + // Use HttpCompletionOption.ResponseHeadersRead to get stream immediately - var response = await _http.GetAsync($"api/track/{trackId}", HttpCompletionOption.ResponseHeadersRead); + var response = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); response.EnsureSuccessStatusCode(); - + var contentLength = response.Content.Headers.ContentLength ?? 0; var stream = await response.Content.ReadAsStreamAsync(); - + return ApiResult.CreatePassResult(new TrackMediaResponse(stream, contentLength)); } catch (Exception e) diff --git a/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor b/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor index f01c528..7f09ef6 100644 --- a/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor +++ b/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor @@ -42,7 +42,7 @@ else Step="0.1" Value="@CurrentTime" ValueChanged="@OnSeek" - Disabled="@(!IsLoaded || IsStreamingMode)"/> + Disabled="@(!CanSeek)"/>
@@ -77,7 +77,7 @@ else Step="0.1" Value="@CurrentTime" ValueChanged="@OnSeek" - Disabled="@(!IsLoaded || IsStreamingMode)"/> + Disabled="@(!CanSeek)"/>
@* Control Buttons - positioned absolutely like original *@ diff --git a/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs b/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs index 3ac2f66..9cebc41 100644 --- a/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs +++ b/DeepDrftWeb.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor.cs @@ -22,6 +22,12 @@ public partial class AudioPlayerBar : ComponentBase private double LoadProgress => PlayerService.LoadProgress; private string? ErrorMessage => PlayerService.ErrorMessage; + /// + /// Seek is enabled once track is loaded AND duration is known (from WAV header). + /// This allows seeking even during streaming, including seeking beyond buffered content. + /// + private bool CanSeek => IsLoaded && Duration.HasValue && Duration.Value > 0; + protected override async Task OnInitializedAsync() { await base.OnInitializedAsync(); diff --git a/DeepDrftWeb.Client/Services/AudioInteropService.cs b/DeepDrftWeb.Client/Services/AudioInteropService.cs index d81e7fe..23a574c 100644 --- a/DeepDrftWeb.Client/Services/AudioInteropService.cs +++ b/DeepDrftWeb.Client/Services/AudioInteropService.cs @@ -81,9 +81,39 @@ public class AudioInteropService : IAsyncDisposable return await InvokeJsAsync("DeepDrftAudio.unload", playerId); } - public async Task SeekAsync(string playerId, double position) + public async Task SeekAsync(string playerId, double position) { - return await InvokeJsAsync("DeepDrftAudio.seek", playerId, position); + return await InvokeJsAsync("DeepDrftAudio.seek", playerId, position); + } + + // New methods for seek-beyond-buffer support + public async Task GetBufferedDuration(string playerId) + { + try + { + return await _jsRuntime.InvokeAsync("DeepDrftAudio.getBufferedDuration", playerId); + } + catch + { + return 0; + } + } + + public async Task CalculateByteOffset(string playerId, double positionSeconds) + { + try + { + return (long)await _jsRuntime.InvokeAsync("DeepDrftAudio.calculateByteOffset", playerId, positionSeconds); + } + catch + { + return 0; + } + } + + public async Task ReinitializeFromOffset(string playerId, long totalStreamLength, double seekPosition) + { + return await InvokeJsAsync("DeepDrftAudio.reinitializeFromOffset", playerId, totalStreamLength, seekPosition); } public async Task SetVolumeAsync(string playerId, double volume) @@ -148,6 +178,8 @@ public class AudioInteropService : IAsyncDisposable return (T)(object)new AudioLoadResult { Success = false, Error = ex.Message }; if (typeof(T) == typeof(StreamingResult)) return (T)(object)new StreamingResult { Success = false, Error = ex.Message }; + if (typeof(T) == typeof(SeekResult)) + return (T)(object)new SeekResult { Success = false, Error = ex.Message }; throw; } } @@ -217,6 +249,12 @@ public class AudioOperationResult public string? Error { get; set; } } +public class SeekResult : AudioOperationResult +{ + public bool SeekBeyondBuffer { get; set; } + public long ByteOffset { get; set; } +} + public class AudioLoadResult : AudioOperationResult { public double Duration { get; set; } diff --git a/DeepDrftWeb.Client/Services/AudioPlayerService.cs b/DeepDrftWeb.Client/Services/AudioPlayerService.cs index 6a63d40..8915d0e 100644 --- a/DeepDrftWeb.Client/Services/AudioPlayerService.cs +++ b/DeepDrftWeb.Client/Services/AudioPlayerService.cs @@ -298,7 +298,7 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable } } - public async Task Seek(double position) + public virtual async Task Seek(double position) { if (!IsLoaded) return; @@ -314,7 +314,7 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable { ErrorMessage = $"Seek error: {result.Error}"; } - + await NotifyStateChanged(); } catch (Exception ex) diff --git a/DeepDrftWeb.Client/Services/StreamingAudioPlayerService.cs b/DeepDrftWeb.Client/Services/StreamingAudioPlayerService.cs index 88ff132..8ee643f 100644 --- a/DeepDrftWeb.Client/Services/StreamingAudioPlayerService.cs +++ b/DeepDrftWeb.Client/Services/StreamingAudioPlayerService.cs @@ -23,11 +23,13 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS public bool CanStartStreaming { get; private set; } = false; public bool HeaderParsed { get; private set; } = false; public int BufferedChunks { get; private set; } = 0; - + public bool IsSeekingBeyondBuffer { get; private set; } = false; + private bool _streamingPlaybackStarted = false; private CancellationTokenSource? _streamingCancellation; private DateTime _lastNotification = DateTime.MinValue; private readonly ILogger _logger; + private string? _currentTrackId; public StreamingAudioPlayerService( AudioInteropService audioInterop, @@ -63,6 +65,9 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS // Always reset to clean state before loading new track await ResetToIdle(); + // Save track ID for seek operations + _currentTrackId = track.EntryKey; + // Create new cancellation token for this streaming operation _streamingCancellation = new CancellationTokenSource(); @@ -254,6 +259,121 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS await ResetToIdle(); } + /// + /// Override Seek to handle seek-beyond-buffer for streaming mode. + /// + public override async Task Seek(double position) + { + if (!IsLoaded || !IsStreamingMode) return; + + try + { + var result = await _audioInterop.SeekAsync(PlayerId, position); + + if (result.Success) + { + if (result.SeekBeyondBuffer && result.ByteOffset > 0) + { + // Need to load new stream from offset + _logger.LogInformation("Seeking beyond buffer to {Position:F2}s, byte offset: {ByteOffset}", + position, result.ByteOffset); + await SeekBeyondBuffer(position, result.ByteOffset); + } + else + { + // Seek within buffer succeeded + CurrentTime = position; + ErrorMessage = null; + await NotifyStateChanged(); + } + } + else + { + ErrorMessage = $"Seek error: {result.Error}"; + await NotifyStateChanged(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Error seeking to position {Position}", position); + ErrorMessage = $"Error seeking: {ex.Message}"; + await NotifyStateChanged(); + } + } + + /// + /// Handle seeking beyond the currently buffered content by requesting a new stream from offset. + /// + private async Task SeekBeyondBuffer(double seekPosition, long byteOffset) + { + if (string.IsNullOrEmpty(_currentTrackId)) + { + ErrorMessage = "Cannot seek - no track loaded"; + return; + } + + IsSeekingBeyondBuffer = true; + + // Cancel current streaming + _streamingCancellation?.Cancel(); + _streamingCancellation?.Dispose(); + _streamingCancellation = new CancellationTokenSource(); + + try + { + // Update UI immediately + CurrentTime = seekPosition; + await NotifyStateChanged(); + + // Request new stream from offset + var mediaResult = await _trackMediaClient.GetTrackMedia(_currentTrackId, byteOffset); + if (!mediaResult.Success || mediaResult.Value == null) + { + var technicalError = mediaResult.GetMessage() ?? "Failed to load audio from position"; + _logger.LogError("Failed to get track media from offset {Offset}: {Error}", byteOffset, technicalError); + ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(technicalError); + IsSeekingBeyondBuffer = false; + return; + } + + using var audio = mediaResult.Value; + + // Reinitialize JS player for offset streaming + var reinitResult = await _audioInterop.ReinitializeFromOffset(PlayerId, audio.ContentLength, seekPosition); + if (!reinitResult.Success) + { + _logger.LogError("Failed to reinitialize for offset streaming: {Error}", reinitResult.Error); + ErrorMessage = "Failed to seek to position"; + IsSeekingBeyondBuffer = false; + return; + } + + // Reset streaming state for new stream + _streamingPlaybackStarted = false; + CanStartStreaming = false; + HeaderParsed = false; + BufferedChunks = 0; + + // Stream audio from offset + await StreamAudioWithEarlyPlayback(audio, _streamingCancellation.Token); + + IsSeekingBeyondBuffer = false; + } + catch (OperationCanceledException) + { + // Another seek or stop interrupted this one + _logger.LogDebug("Seek beyond buffer cancelled"); + IsSeekingBeyondBuffer = false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during seek beyond buffer to position {Position}", seekPosition); + ErrorMessage = StreamingErrorHandler.GetUserFriendlyMessage(ex.Message); + IsSeekingBeyondBuffer = false; + await NotifyStateChanged(); + } + } + /// /// Single method to reset all state - called by both Stop and Unload. /// @@ -291,6 +411,8 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS HeaderParsed = false; BufferedChunks = 0; _streamingPlaybackStarted = false; + IsSeekingBeyondBuffer = false; + _currentTrackId = null; await NotifyStateChanged(); } diff --git a/DeepDrftWeb/Interop/audio/PlaybackScheduler.ts b/DeepDrftWeb/Interop/audio/PlaybackScheduler.ts index 37e769f..6afebe5 100644 --- a/DeepDrftWeb/Interop/audio/PlaybackScheduler.ts +++ b/DeepDrftWeb/Interop/audio/PlaybackScheduler.ts @@ -26,6 +26,11 @@ export class PlaybackScheduler { private nextScheduleTime: number = 0; // AudioContext time for next buffer private isActive_: boolean = false; // Prevents scheduling during pause/stop + // Offset for seek-beyond-buffer scenarios + // When seeking to position T beyond buffers, we clear buffers and set playbackOffset = T + // The new stream starts at T, so buffer positions are relative to T + private playbackOffset: number = 0; + // Callbacks public onPlaybackEnded: (() => void) | null = null; @@ -56,14 +61,30 @@ export class PlaybackScheduler { } /** - * Get current playback position in seconds + * Get current playback position in seconds (includes playbackOffset for seek-beyond-buffer) */ getCurrentPosition(): number { if (this.playbackAnchorTime === 0) { - return this.playbackAnchorPosition; + return this.playbackAnchorPosition + this.playbackOffset; } const elapsed = this.contextManager.currentTime - this.playbackAnchorTime; - return Math.min(this.playbackAnchorPosition + elapsed, this.getTotalDuration()); + return Math.min(this.playbackAnchorPosition + this.playbackOffset + elapsed, this.getTotalDuration() + this.playbackOffset); + } + + /** + * Set the playback offset for seek-beyond-buffer scenarios + * This represents the absolute time position where the current buffers start + */ + setPlaybackOffset(offset: number): void { + this.playbackOffset = offset; + console.log(`📍 Playback offset set to ${offset.toFixed(3)}s`); + } + + /** + * Get the current playback offset + */ + getPlaybackOffset(): number { + return this.playbackOffset; } /** @@ -244,7 +265,7 @@ export class PlaybackScheduler { } /** - * Full reset - clears all buffers + * Full reset - clears all buffers and resets offset */ clear(): void { this.isActive_ = false; @@ -254,9 +275,25 @@ export class PlaybackScheduler { this.playbackAnchorTime = 0; this.nextBufferIndex = 0; this.nextScheduleTime = 0; + this.playbackOffset = 0; console.log('🗑️ Scheduler cleared'); } + /** + * Clear buffers but keep offset - for seek-beyond-buffer scenarios + */ + clearForSeek(): void { + this.isActive_ = false; + this.stopAllSources(); + this.buffers = []; + this.playbackAnchorPosition = 0; + this.playbackAnchorTime = 0; + this.nextBufferIndex = 0; + this.nextScheduleTime = 0; + // Note: playbackOffset is NOT reset - it will be set by the caller + console.log('🗑️ Scheduler cleared for seek (offset preserved)'); + } + /** * Check if we have buffers */ diff --git a/DeepDrftWeb/Interop/audio/StreamDecoder.ts b/DeepDrftWeb/Interop/audio/StreamDecoder.ts index 7215aa0..3f4b68a 100644 --- a/DeepDrftWeb/Interop/audio/StreamDecoder.ts +++ b/DeepDrftWeb/Interop/audio/StreamDecoder.ts @@ -202,6 +202,25 @@ export class StreamDecoder { return this.totalStreamLength > 0 && this.totalRawBytes >= (this.totalStreamLength - (this.wavHeader?.headerSize ?? 0)); } + /** + * Get the WAV header info for byte offset calculation + */ + getWavHeader(): WavHeader | null { + return this.wavHeader; + } + + /** + * Calculate byte offset from a time position (in seconds) + * Returns block-aligned byte offset for clean audio + */ + calculateByteOffset(positionSeconds: number): number { + if (!this.wavHeader || this.wavHeader.byteRate <= 0) return 0; + + const rawOffset = Math.floor(positionSeconds * this.wavHeader.byteRate); + // Align to block boundary for clean audio + return Math.floor(rawOffset / this.wavHeader.blockAlign) * this.wavHeader.blockAlign; + } + /** * Reset decoder state */ @@ -213,4 +232,20 @@ export class StreamDecoder { this.isFirstChunk = true; this.totalStreamLength = 0; } + + /** + * Reinitialize for offset streaming - preserves header format knowledge + * Called when seeking beyond buffer to prepare for new stream from server + */ + reinitializeForOffset(totalStreamLength: number): void { + // Reset data state but we'll get a fresh header from the offset stream + this.rawChunks = []; + this.totalRawBytes = 0; + this.processedBytes = 0; + this.isFirstChunk = true; + this.totalStreamLength = totalStreamLength; + // wavHeader will be reparsed from the new stream (server sends fresh header) + this.wavHeader = null; + console.log(`StreamDecoder reinitialized for offset: expecting ${totalStreamLength} bytes`); + } } diff --git a/DeepDrftWeb/Interop/audio/index.ts b/DeepDrftWeb/Interop/audio/index.ts index 548aa2d..69d41b5 100644 --- a/DeepDrftWeb/Interop/audio/index.ts +++ b/DeepDrftWeb/Interop/audio/index.ts @@ -81,6 +81,23 @@ const DeepDrftAudio = { return player.seek(position); }, + // New methods for seek-beyond-buffer support + getBufferedDuration: (playerId: string): number => { + const player = audioPlayers.get(playerId); + return player?.getBufferedDuration() ?? 0; + }, + + calculateByteOffset: (playerId: string, positionSeconds: number): number => { + const player = audioPlayers.get(playerId); + return player?.calculateByteOffset(positionSeconds) ?? 0; + }, + + reinitializeFromOffset: (playerId: string, totalStreamLength: number, seekPosition: number): AudioResult => { + const player = audioPlayers.get(playerId); + if (!player) return { success: false, error: 'Player not found' }; + return player.reinitializeFromOffset(totalStreamLength, seekPosition); + }, + setVolume: (playerId: string, volume: number): AudioResult => { const player = audioPlayers.get(playerId); if (!player) return { success: false, error: 'Player not found' };