Streaming Seek Support
This commit is contained in:
@@ -0,0 +1,179 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DeepDrftContent.Services.Audio;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for creating WAV audio streams starting from a byte offset.
|
||||||
|
/// Synthesizes a valid WAV header for the remaining audio data.
|
||||||
|
/// </summary>
|
||||||
|
public class WavOffsetService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a stream containing a synthesized WAV header followed by audio data from the specified offset.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="fullAudioBuffer">The complete WAV file buffer</param>
|
||||||
|
/// <param name="byteOffset">Byte offset into the raw audio data (not including original header)</param>
|
||||||
|
/// <returns>MemoryStream with new WAV header + audio data from offset, or null if invalid</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the WAV header from a buffer to extract format information.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a standard 44-byte PCM WAV header.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WAV format information extracted from header.
|
||||||
|
/// </summary>
|
||||||
|
public record WavFormat(
|
||||||
|
int SampleRate,
|
||||||
|
int Channels,
|
||||||
|
int BitsPerSample,
|
||||||
|
int ByteRate,
|
||||||
|
int BlockAlign,
|
||||||
|
int DataSize,
|
||||||
|
int HeaderSize
|
||||||
|
);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using DeepDrftContent.Services.Constants;
|
using DeepDrftContent.Services.Audio;
|
||||||
|
using DeepDrftContent.Services.Constants;
|
||||||
using DeepDrftContent.Services.FileDatabase.Models;
|
using DeepDrftContent.Services.FileDatabase.Models;
|
||||||
using DeepDrftContent.Middleware;
|
using DeepDrftContent.Middleware;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -10,30 +11,53 @@ namespace DeepDrftContent.Controllers;
|
|||||||
public class TrackController : ControllerBase
|
public class TrackController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly DeepDrftContent.Services.FileDatabase.Services.FileDatabase _fileDatabase;
|
private readonly DeepDrftContent.Services.FileDatabase.Services.FileDatabase _fileDatabase;
|
||||||
|
private readonly WavOffsetService _wavOffsetService;
|
||||||
private readonly ILogger<TrackController> _logger;
|
private readonly ILogger<TrackController> _logger;
|
||||||
|
|
||||||
public TrackController(DeepDrftContent.Services.FileDatabase.Services.FileDatabase fileDatabase, ILogger<TrackController> logger)
|
public TrackController(
|
||||||
|
DeepDrftContent.Services.FileDatabase.Services.FileDatabase fileDatabase,
|
||||||
|
WavOffsetService wavOffsetService,
|
||||||
|
ILogger<TrackController> logger)
|
||||||
{
|
{
|
||||||
_fileDatabase = fileDatabase;
|
_fileDatabase = fileDatabase;
|
||||||
|
_wavOffsetService = wavOffsetService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{trackId}")]
|
[HttpGet("{trackId}")]
|
||||||
public async Task<ActionResult> GetTrack(string trackId)
|
public async Task<ActionResult> 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
|
try
|
||||||
{
|
{
|
||||||
var file = await _fileDatabase.LoadResourceAsync<AudioBinary>(VaultConstants.Tracks, trackId);
|
var file = await _fileDatabase.LoadResourceAsync<AudioBinary>(VaultConstants.Tracks, trackId);
|
||||||
if (file == null)
|
if (file == null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Track not found: {TrackId}", trackId);
|
_logger.LogWarning("Track not found: {TrackId}", trackId);
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Successfully retrieved track: {TrackId}, Size: {Size} bytes", trackId, file.Buffer.Length);
|
var mimeType = MimeTypeExtensions.GetMimeType(file.Extension);
|
||||||
return File(file.Buffer, 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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using DeepDrftContent.Services.Audio;
|
||||||
using DeepDrftContent.Services.Constants;
|
using DeepDrftContent.Services.Constants;
|
||||||
using DeepDrftContent.Services.FileDatabase.Models;
|
using DeepDrftContent.Services.FileDatabase.Models;
|
||||||
using DeepDrftContent.Services.FileDatabase.Services;
|
using DeepDrftContent.Services.FileDatabase.Services;
|
||||||
@@ -9,13 +10,16 @@ namespace DeepDrftContent
|
|||||||
{
|
{
|
||||||
public static async Task ConfigureDomainServices(WebApplicationBuilder builder)
|
public static async Task ConfigureDomainServices(WebApplicationBuilder builder)
|
||||||
{
|
{
|
||||||
|
// Audio services
|
||||||
|
builder.Services.AddSingleton<WavOffsetService>();
|
||||||
|
|
||||||
// File Database
|
// File Database
|
||||||
builder.Configuration.AddJsonFile("environment/filedatabase.json", optional: false, reloadOnChange: true);
|
builder.Configuration.AddJsonFile("environment/filedatabase.json", optional: false, reloadOnChange: true);
|
||||||
var fileDatabaseSettings = builder.Configuration.GetSection(nameof(FileDatabaseSettings)).Get<FileDatabaseSettings>();
|
var fileDatabaseSettings = builder.Configuration.GetSection(nameof(FileDatabaseSettings)).Get<FileDatabaseSettings>();
|
||||||
if (fileDatabaseSettings is null) { throw new Exception("File database settings are not configured"); }
|
if (fileDatabaseSettings is null) { throw new Exception("File database settings are not configured"); }
|
||||||
|
|
||||||
var fileDatabase = await FileDatabase.FromAsync(fileDatabaseSettings.VaultPath);
|
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);
|
builder.Services.AddSingleton(fileDatabase);
|
||||||
await InitializeTrackVault(fileDatabase);
|
await InitializeTrackVault(fileDatabase);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,17 +29,22 @@ public class TrackMediaClient
|
|||||||
_http = httpClientFactory.CreateClient("DeepDrft.Content");
|
_http = httpClientFactory.CreateClient("DeepDrft.Content");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(string trackId)
|
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(string trackId, long byteOffset = 0)
|
||||||
{
|
{
|
||||||
try
|
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
|
// 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();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
var contentLength = response.Content.Headers.ContentLength ?? 0;
|
var contentLength = response.Content.Headers.ContentLength ?? 0;
|
||||||
var stream = await response.Content.ReadAsStreamAsync();
|
var stream = await response.Content.ReadAsStreamAsync();
|
||||||
|
|
||||||
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength));
|
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength));
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ else
|
|||||||
Step="0.1"
|
Step="0.1"
|
||||||
Value="@CurrentTime"
|
Value="@CurrentTime"
|
||||||
ValueChanged="@OnSeek"
|
ValueChanged="@OnSeek"
|
||||||
Disabled="@(!IsLoaded || IsStreamingMode)"/>
|
Disabled="@(!CanSeek)"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="volume-right">
|
<div class="volume-right">
|
||||||
@@ -77,7 +77,7 @@ else
|
|||||||
Step="0.1"
|
Step="0.1"
|
||||||
Value="@CurrentTime"
|
Value="@CurrentTime"
|
||||||
ValueChanged="@OnSeek"
|
ValueChanged="@OnSeek"
|
||||||
Disabled="@(!IsLoaded || IsStreamingMode)"/>
|
Disabled="@(!CanSeek)"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* Control Buttons - positioned absolutely like original *@
|
@* Control Buttons - positioned absolutely like original *@
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ public partial class AudioPlayerBar : ComponentBase
|
|||||||
private double LoadProgress => PlayerService.LoadProgress;
|
private double LoadProgress => PlayerService.LoadProgress;
|
||||||
private string? ErrorMessage => PlayerService.ErrorMessage;
|
private string? ErrorMessage => PlayerService.ErrorMessage;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private bool CanSeek => IsLoaded && Duration.HasValue && Duration.Value > 0;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
await base.OnInitializedAsync();
|
await base.OnInitializedAsync();
|
||||||
|
|||||||
@@ -81,9 +81,39 @@ public class AudioInteropService : IAsyncDisposable
|
|||||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.unload", playerId);
|
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.unload", playerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AudioOperationResult> SeekAsync(string playerId, double position)
|
public async Task<SeekResult> SeekAsync(string playerId, double position)
|
||||||
{
|
{
|
||||||
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.seek", playerId, position);
|
return await InvokeJsAsync<SeekResult>("DeepDrftAudio.seek", playerId, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
// New methods for seek-beyond-buffer support
|
||||||
|
public async Task<double> GetBufferedDuration(string playerId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _jsRuntime.InvokeAsync<double>("DeepDrftAudio.getBufferedDuration", playerId);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<long> CalculateByteOffset(string playerId, double positionSeconds)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return (long)await _jsRuntime.InvokeAsync<double>("DeepDrftAudio.calculateByteOffset", playerId, positionSeconds);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AudioOperationResult> ReinitializeFromOffset(string playerId, long totalStreamLength, double seekPosition)
|
||||||
|
{
|
||||||
|
return await InvokeJsAsync<AudioOperationResult>("DeepDrftAudio.reinitializeFromOffset", playerId, totalStreamLength, seekPosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<AudioOperationResult> SetVolumeAsync(string playerId, double volume)
|
public async Task<AudioOperationResult> SetVolumeAsync(string playerId, double volume)
|
||||||
@@ -148,6 +178,8 @@ public class AudioInteropService : IAsyncDisposable
|
|||||||
return (T)(object)new AudioLoadResult { Success = false, Error = ex.Message };
|
return (T)(object)new AudioLoadResult { Success = false, Error = ex.Message };
|
||||||
if (typeof(T) == typeof(StreamingResult))
|
if (typeof(T) == typeof(StreamingResult))
|
||||||
return (T)(object)new StreamingResult { Success = false, Error = ex.Message };
|
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;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,6 +249,12 @@ public class AudioOperationResult
|
|||||||
public string? Error { get; set; }
|
public string? Error { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class SeekResult : AudioOperationResult
|
||||||
|
{
|
||||||
|
public bool SeekBeyondBuffer { get; set; }
|
||||||
|
public long ByteOffset { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class AudioLoadResult : AudioOperationResult
|
public class AudioLoadResult : AudioOperationResult
|
||||||
{
|
{
|
||||||
public double Duration { get; set; }
|
public double Duration { get; set; }
|
||||||
|
|||||||
@@ -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;
|
if (!IsLoaded) return;
|
||||||
|
|
||||||
@@ -314,7 +314,7 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
ErrorMessage = $"Seek error: {result.Error}";
|
ErrorMessage = $"Seek error: {result.Error}";
|
||||||
}
|
}
|
||||||
|
|
||||||
await NotifyStateChanged();
|
await NotifyStateChanged();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -23,11 +23,13 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
|||||||
public bool CanStartStreaming { get; private set; } = false;
|
public bool CanStartStreaming { get; private set; } = false;
|
||||||
public bool HeaderParsed { get; private set; } = false;
|
public bool HeaderParsed { get; private set; } = false;
|
||||||
public int BufferedChunks { get; private set; } = 0;
|
public int BufferedChunks { get; private set; } = 0;
|
||||||
|
public bool IsSeekingBeyondBuffer { get; private set; } = false;
|
||||||
|
|
||||||
private bool _streamingPlaybackStarted = false;
|
private bool _streamingPlaybackStarted = false;
|
||||||
private CancellationTokenSource? _streamingCancellation;
|
private CancellationTokenSource? _streamingCancellation;
|
||||||
private DateTime _lastNotification = DateTime.MinValue;
|
private DateTime _lastNotification = DateTime.MinValue;
|
||||||
private readonly ILogger<StreamingAudioPlayerService> _logger;
|
private readonly ILogger<StreamingAudioPlayerService> _logger;
|
||||||
|
private string? _currentTrackId;
|
||||||
|
|
||||||
public StreamingAudioPlayerService(
|
public StreamingAudioPlayerService(
|
||||||
AudioInteropService audioInterop,
|
AudioInteropService audioInterop,
|
||||||
@@ -63,6 +65,9 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
|||||||
// Always reset to clean state before loading new track
|
// Always reset to clean state before loading new track
|
||||||
await ResetToIdle();
|
await ResetToIdle();
|
||||||
|
|
||||||
|
// Save track ID for seek operations
|
||||||
|
_currentTrackId = track.EntryKey;
|
||||||
|
|
||||||
// Create new cancellation token for this streaming operation
|
// Create new cancellation token for this streaming operation
|
||||||
_streamingCancellation = new CancellationTokenSource();
|
_streamingCancellation = new CancellationTokenSource();
|
||||||
|
|
||||||
@@ -254,6 +259,121 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
|||||||
await ResetToIdle();
|
await ResetToIdle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Override Seek to handle seek-beyond-buffer for streaming mode.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handle seeking beyond the currently buffered content by requesting a new stream from offset.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Single method to reset all state - called by both Stop and Unload.
|
/// Single method to reset all state - called by both Stop and Unload.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -291,6 +411,8 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
|||||||
HeaderParsed = false;
|
HeaderParsed = false;
|
||||||
BufferedChunks = 0;
|
BufferedChunks = 0;
|
||||||
_streamingPlaybackStarted = false;
|
_streamingPlaybackStarted = false;
|
||||||
|
IsSeekingBeyondBuffer = false;
|
||||||
|
_currentTrackId = null;
|
||||||
|
|
||||||
await NotifyStateChanged();
|
await NotifyStateChanged();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export class PlaybackScheduler {
|
|||||||
private nextScheduleTime: number = 0; // AudioContext time for next buffer
|
private nextScheduleTime: number = 0; // AudioContext time for next buffer
|
||||||
private isActive_: boolean = false; // Prevents scheduling during pause/stop
|
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
|
// Callbacks
|
||||||
public onPlaybackEnded: (() => void) | null = null;
|
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 {
|
getCurrentPosition(): number {
|
||||||
if (this.playbackAnchorTime === 0) {
|
if (this.playbackAnchorTime === 0) {
|
||||||
return this.playbackAnchorPosition;
|
return this.playbackAnchorPosition + this.playbackOffset;
|
||||||
}
|
}
|
||||||
const elapsed = this.contextManager.currentTime - this.playbackAnchorTime;
|
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 {
|
clear(): void {
|
||||||
this.isActive_ = false;
|
this.isActive_ = false;
|
||||||
@@ -254,9 +275,25 @@ export class PlaybackScheduler {
|
|||||||
this.playbackAnchorTime = 0;
|
this.playbackAnchorTime = 0;
|
||||||
this.nextBufferIndex = 0;
|
this.nextBufferIndex = 0;
|
||||||
this.nextScheduleTime = 0;
|
this.nextScheduleTime = 0;
|
||||||
|
this.playbackOffset = 0;
|
||||||
console.log('🗑️ Scheduler cleared');
|
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
|
* Check if we have buffers
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -202,6 +202,25 @@ export class StreamDecoder {
|
|||||||
return this.totalStreamLength > 0 && this.totalRawBytes >= (this.totalStreamLength - (this.wavHeader?.headerSize ?? 0));
|
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
|
* Reset decoder state
|
||||||
*/
|
*/
|
||||||
@@ -213,4 +232,20 @@ export class StreamDecoder {
|
|||||||
this.isFirstChunk = true;
|
this.isFirstChunk = true;
|
||||||
this.totalStreamLength = 0;
|
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`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,23 @@ const DeepDrftAudio = {
|
|||||||
return player.seek(position);
|
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 => {
|
setVolume: (playerId: string, volume: number): 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' };
|
||||||
|
|||||||
Reference in New Issue
Block a user