Fix streaming majors: PCM-only validation, stream-from-disk, ConcatStream offset, AsyncDisposable, HTTP cancellation, await ensureReady, seekBeyondBuffer offset-0 guard, negative WAV chunk guard
This commit is contained in:
@@ -8,13 +8,23 @@ namespace DeepDrftContent.Services.Audio;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class WavOffsetService
|
public class WavOffsetService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public const short PcmFormat = 1;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a stream containing a synthesized WAV header followed by audio data from the specified offset.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="fullAudioBuffer">The complete WAV file buffer</param>
|
/// <param name="fullAudioBuffer">The complete WAV file buffer</param>
|
||||||
/// <param name="byteOffset">Byte offset into the raw audio data (not including original header)</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>
|
/// <returns>Stream with new WAV header + audio data from offset, or null if invalid</returns>
|
||||||
public MemoryStream? CreateOffsetStream(byte[] fullAudioBuffer, long byteOffset)
|
public Stream? CreateOffsetStream(byte[] fullAudioBuffer, long byteOffset)
|
||||||
{
|
{
|
||||||
var format = ParseWavHeader(fullAudioBuffer);
|
var format = ParseWavHeader(fullAudioBuffer);
|
||||||
if (format == null)
|
if (format == null)
|
||||||
@@ -27,28 +37,44 @@ public class WavOffsetService
|
|||||||
// Align to block boundary for clean audio
|
// Align to block boundary for clean audio
|
||||||
var alignedOffset = (byteOffset / format.BlockAlign) * format.BlockAlign;
|
var alignedOffset = (byteOffset / format.BlockAlign) * format.BlockAlign;
|
||||||
|
|
||||||
// Calculate new data size
|
// Calculate new data size (long arithmetic — DataSize may be up to ~4 GB)
|
||||||
var newDataSize = format.DataSize - (int)alignedOffset;
|
var newDataSize = format.DataSize - alignedOffset;
|
||||||
if (newDataSize <= 0)
|
if (newDataSize <= 0)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
// Create new WAV header
|
// MemoryStream does not support offsets or lengths beyond int.MaxValue.
|
||||||
var newHeader = CreateWavHeader(format, newDataSize);
|
// RF64 (>2 GB audio segments) is not supported; reject before truncating.
|
||||||
|
|
||||||
// Calculate source position in original buffer
|
|
||||||
var sourcePosition = format.HeaderSize + alignedOffset;
|
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 newDataSizeInt = (int)newDataSize;
|
||||||
var resultStream = new MemoryStream(44 + newDataSize);
|
var sourcePositionInt = (int)sourcePosition;
|
||||||
resultStream.Write(newHeader, 0, 44);
|
|
||||||
resultStream.Write(fullAudioBuffer, (int)sourcePosition, newDataSize);
|
|
||||||
resultStream.Position = 0;
|
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses the WAV header from a buffer to extract format information.
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public WavFormat? ParseWavHeader(byte[] buffer)
|
public WavFormat? ParseWavHeader(byte[] buffer)
|
||||||
{
|
{
|
||||||
@@ -70,8 +96,9 @@ public class WavOffsetService
|
|||||||
int bitsPerSample = 0;
|
int bitsPerSample = 0;
|
||||||
int byteRate = 0;
|
int byteRate = 0;
|
||||||
int blockAlign = 0;
|
int blockAlign = 0;
|
||||||
int dataSize = 0;
|
long dataSize = 0;
|
||||||
int headerSize = 0;
|
int headerSize = 0;
|
||||||
|
short audioFormat = 0;
|
||||||
bool foundFmt = false;
|
bool foundFmt = false;
|
||||||
bool foundData = false;
|
bool foundData = false;
|
||||||
|
|
||||||
@@ -82,14 +109,20 @@ public class WavOffsetService
|
|||||||
var chunkId = Encoding.ASCII.GetString(buffer, chunkOffset, 4);
|
var chunkId = Encoding.ASCII.GetString(buffer, chunkOffset, 4);
|
||||||
var chunkSize = BitConverter.ToInt32(buffer, chunkOffset + 4);
|
var chunkSize = BitConverter.ToInt32(buffer, chunkOffset + 4);
|
||||||
|
|
||||||
|
if (chunkSize < 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
if (chunkId == "fmt ")
|
if (chunkId == "fmt ")
|
||||||
{
|
{
|
||||||
if (chunkSize < 16)
|
if (chunkSize < 16)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var audioFormat = BitConverter.ToInt16(buffer, chunkOffset + 8);
|
audioFormat = BitConverter.ToInt16(buffer, chunkOffset + 8);
|
||||||
// Support PCM (1) and IEEE Float (3) formats
|
// PCM only. Float32 WAVs were previously accepted here but the synthesized
|
||||||
if (audioFormat != 1 && audioFormat != 3)
|
// 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;
|
return null;
|
||||||
|
|
||||||
channels = BitConverter.ToInt16(buffer, chunkOffset + 10);
|
channels = BitConverter.ToInt16(buffer, chunkOffset + 10);
|
||||||
@@ -106,7 +139,9 @@ public class WavOffsetService
|
|||||||
}
|
}
|
||||||
else if (chunkId == "data")
|
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)
|
headerSize = chunkOffset + 8; // Audio data starts after 'data' + size (8 bytes)
|
||||||
foundData = true;
|
foundData = true;
|
||||||
}
|
}
|
||||||
@@ -124,6 +159,7 @@ public class WavOffsetService
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
return new WavFormat(
|
return new WavFormat(
|
||||||
|
AudioFormat: audioFormat,
|
||||||
SampleRate: sampleRate,
|
SampleRate: sampleRate,
|
||||||
Channels: channels,
|
Channels: channels,
|
||||||
BitsPerSample: bitsPerSample,
|
BitsPerSample: bitsPerSample,
|
||||||
@@ -135,7 +171,9 @@ public class WavOffsetService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a standard 44-byte PCM WAV header.
|
/// Creates a standard 44-byte WAV header. The audio format code is taken from
|
||||||
|
/// <paramref name="format"/> rather than hardcoded so the synthesized header matches
|
||||||
|
/// what was parsed (today always <see cref="PcmFormat"/>; see ParseWavHeader).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public byte[] CreateWavHeader(WavFormat format, int dataSize)
|
public byte[] CreateWavHeader(WavFormat format, int dataSize)
|
||||||
{
|
{
|
||||||
@@ -150,7 +188,7 @@ public class WavOffsetService
|
|||||||
// fmt chunk
|
// fmt chunk
|
||||||
header[12] = (byte)'f'; header[13] = (byte)'m'; header[14] = (byte)'t'; header[15] = (byte)' ';
|
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(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((short)format.Channels).CopyTo(header, 22);
|
||||||
BitConverter.GetBytes(format.SampleRate).CopyTo(header, 24);
|
BitConverter.GetBytes(format.SampleRate).CopyTo(header, 24);
|
||||||
BitConverter.GetBytes(format.ByteRate).CopyTo(header, 28);
|
BitConverter.GetBytes(format.ByteRate).CopyTo(header, 28);
|
||||||
@@ -168,12 +206,110 @@ public class WavOffsetService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// WAV format information extracted from header.
|
/// WAV format information extracted from header.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="AudioFormat">WAV fmt-chunk audio format code (1 = PCM; the only value accepted today).</param>
|
||||||
public record WavFormat(
|
public record WavFormat(
|
||||||
|
short AudioFormat,
|
||||||
int SampleRate,
|
int SampleRate,
|
||||||
int Channels,
|
int Channels,
|
||||||
int BitsPerSample,
|
int BitsPerSample,
|
||||||
int ByteRate,
|
int ByteRate,
|
||||||
int BlockAlign,
|
int BlockAlign,
|
||||||
int DataSize,
|
long DataSize,
|
||||||
int HeaderSize
|
int HeaderSize
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<int> ReadAsync(Memory<byte> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,6 +84,53 @@ public abstract class MediaVault : VaultIndexDirectory
|
|||||||
return (T)result;
|
return (T)result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <see cref="GetEntryAsync{T}"/> allocates a full <c>byte[]</c>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<MediaStream?> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extracts buffer and extension from a media binary
|
/// Extracts buffer and extension from a media binary
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -141,3 +188,22 @@ public class AudioVault : MediaVault
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using DeepDrftContent.Services.Audio;
|
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.Middleware;
|
using DeepDrftContent.Middleware;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@@ -31,6 +32,57 @@ public class TrackController : ControllerBase
|
|||||||
|
|
||||||
try
|
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<AudioBinary>(VaultConstants.Tracks, trackId);
|
var file = await _fileDatabase.LoadResourceAsync<AudioBinary>(VaultConstants.Tracks, trackId);
|
||||||
if (file == null)
|
if (file == null)
|
||||||
{
|
{
|
||||||
@@ -40,14 +92,6 @@ public class TrackController : ControllerBase
|
|||||||
|
|
||||||
var mimeType = 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);
|
var offsetStream = _wavOffsetService.CreateOffsetStream(file.Buffer, offset);
|
||||||
if (offsetStream == null)
|
if (offsetStream == null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,7 +29,16 @@ public class TrackMediaClient
|
|||||||
_http = httpClientFactory.CreateClient("DeepDrft.Content");
|
_http = httpClientFactory.CreateClient("DeepDrft.Content");
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(string trackId, long byteOffset = 0)
|
/// <summary>
|
||||||
|
/// Fetches the WAV stream for a track, optionally starting from a byte offset.
|
||||||
|
/// The cancellation token is forwarded to <see cref="HttpClient.GetAsync"/> so a
|
||||||
|
/// navigation or seek-replacement aborts the in-flight server connection rather
|
||||||
|
/// than leaving the server draining bytes into a dead socket.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ApiResult<TrackMediaResponse>> GetTrackMedia(
|
||||||
|
string trackId,
|
||||||
|
long byteOffset = 0,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -39,11 +48,11 @@ public class TrackMediaClient
|
|||||||
: $"api/track/{trackId}";
|
: $"api/track/{trackId}";
|
||||||
|
|
||||||
// Use HttpCompletionOption.ResponseHeadersRead to get stream immediately
|
// 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();
|
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(cancellationToken);
|
||||||
|
|
||||||
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength));
|
return ApiResult<TrackMediaResponse>.CreatePassResult(new TrackMediaResponse(stream, contentLength));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
using DeepDrftWeb.Client.Services;
|
using DeepDrftWeb.Client.Services;
|
||||||
using DeepDrftWeb.Client.Clients;
|
using DeepDrftWeb.Client.Clients;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace DeepDrftWeb.Client.Controls;
|
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 AudioInteropService AudioInterop { get; set; }
|
||||||
[Inject] public required TrackMediaClient TrackMediaClient { get; set; }
|
[Inject] public required TrackMediaClient TrackMediaClient { get; set; }
|
||||||
@@ -35,4 +35,18 @@ public partial class AudioPlayerProvider : ComponentBase
|
|||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_audioPlayerService != null)
|
||||||
|
{
|
||||||
|
await _audioPlayerService.DisposeAsync();
|
||||||
|
_audioPlayerService = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -392,7 +392,7 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
|||||||
await OnTrackSelected.Value.InvokeAsync();
|
await OnTrackSelected.Value.InvokeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public virtual async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (IsInitialized)
|
if (IsInitialized)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -90,7 +90,12 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
|||||||
|
|
||||||
await NotifyStateChanged();
|
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)
|
if (!mediaResult.Success)
|
||||||
{
|
{
|
||||||
var technicalError = mediaResult.GetMessage();
|
var technicalError = mediaResult.GetMessage();
|
||||||
@@ -346,7 +351,10 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
|||||||
await NotifyStateChanged();
|
await NotifyStateChanged();
|
||||||
|
|
||||||
// Request new stream from offset
|
// 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)
|
if (!mediaResult.Success || mediaResult.Value == null)
|
||||||
{
|
{
|
||||||
var technicalError = mediaResult.GetMessage() ?? "Failed to load audio from position";
|
var technicalError = mediaResult.GetMessage() ?? "Failed to load audio from position";
|
||||||
@@ -485,6 +493,25 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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)
|
private void AdaptBufferSize(int bytesRead, long readTimeMs)
|
||||||
{
|
{
|
||||||
// Adaptive buffer sizing based on network performance
|
// Adaptive buffer sizing based on network performance
|
||||||
|
|||||||
@@ -175,13 +175,21 @@ export class AudioPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
startStreamingPlayback(): AudioResult {
|
async startStreamingPlayback(): Promise<AudioResult> {
|
||||||
if (!this.scheduler.hasBuffers()) {
|
if (!this.scheduler.hasBuffers()) {
|
||||||
return { success: false, error: 'No buffers available' };
|
return { success: false, error: 'No buffers available' };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('\n=== Starting streaming playback ===');
|
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.streamingStarted = true;
|
||||||
this.isPlaying = true;
|
this.isPlaying = true;
|
||||||
this.isPaused = false;
|
this.isPaused = false;
|
||||||
@@ -199,7 +207,7 @@ export class AudioPlayer {
|
|||||||
|
|
||||||
// ==================== Playback Control ====================
|
// ==================== Playback Control ====================
|
||||||
|
|
||||||
play(): AudioResult {
|
async play(): Promise<AudioResult> {
|
||||||
if (!this.isStreamingMode) {
|
if (!this.isStreamingMode) {
|
||||||
return { success: false, error: 'Not in streaming mode' };
|
return { success: false, error: 'Not in streaming mode' };
|
||||||
}
|
}
|
||||||
@@ -215,7 +223,11 @@ export class AudioPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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.isPlaying = true;
|
||||||
this.isPaused = false;
|
this.isPaused = false;
|
||||||
@@ -313,7 +325,9 @@ export class AudioPlayer {
|
|||||||
private seekBeyondBuffer(position: number): AudioResult {
|
private seekBeyondBuffer(position: number): AudioResult {
|
||||||
try {
|
try {
|
||||||
const byteOffset = this.streamDecoder.calculateByteOffset(position);
|
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' };
|
return { success: false, error: 'Cannot calculate byte offset' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,15 @@ export class PlaybackScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (startBufferIndex >= this.buffers.length) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,36 @@ export interface DecodedChunkResult {
|
|||||||
duration: number;
|
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 {
|
export class StreamDecoder {
|
||||||
// Upper bound on pre-header accumulation. 256 KB is far beyond any sane WAV
|
// 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
|
// 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<DecodedChunkResult | null> {
|
private async tryDecodeNextSegment(): Promise<DecodedChunkResult | null> {
|
||||||
if (!this.wavHeader) return null;
|
if (!this.wavHeader) return null;
|
||||||
@@ -199,15 +237,63 @@ export class StreamDecoder {
|
|||||||
const wavFile = this.createWavFile(rawSegment);
|
const wavFile = this.createWavFile(rawSegment);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = await this.decodeWithTimeout(wavFile);
|
const buffer = await this.decodeWithRetry(wavFile, segmentOffset, alignedSize);
|
||||||
// Advance only after a successful decode so that a timeout or decode
|
// Advance only after a successful decode so a thrown timeout/decode
|
||||||
// failure does not permanently skip the segment.
|
// failure does not silently drop the segment.
|
||||||
this.processedBytes += alignedSize;
|
this.processedBytes += alignedSize;
|
||||||
console.log(`✓ Decoded: ${buffer.duration.toFixed(3)}s, ${buffer.numberOfChannels}ch`);
|
console.log(`✓ Decoded: ${buffer.duration.toFixed(3)}s, ${buffer.numberOfChannels}ch`);
|
||||||
return { buffer, duration: buffer.duration };
|
return { buffer, duration: buffer.duration };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to decode segment at offset ${segmentOffset}:`, error);
|
// Re-throw typed errors so the outer drain loop in processChunk /
|
||||||
return null;
|
// 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<AudioBuffer> {
|
||||||
|
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<AudioBuffer> {
|
private async decodeWithTimeout(wavData: Uint8Array, timeoutMs: number = 5000): Promise<AudioBuffer> {
|
||||||
const buffer = new ArrayBuffer(wavData.length);
|
const buffer = new ArrayBuffer(wavData.length);
|
||||||
new Uint8Array(buffer).set(wavData);
|
new Uint8Array(buffer).set(wavData);
|
||||||
|
|
||||||
const decodePromise = this.contextManager.decodeAudioData(buffer);
|
const decodePromise = this.contextManager.decodeAudioData(buffer);
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
const timeoutPromise = new Promise<never>((_, 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const DeepDrftAudio = {
|
|||||||
return player.processStreamingChunk(chunk);
|
return player.processStreamingChunk(chunk);
|
||||||
},
|
},
|
||||||
|
|
||||||
startStreamingPlayback: (playerId: string): AudioResult => {
|
startStreamingPlayback: async (playerId: string): Promise<AudioResult> => {
|
||||||
const player = audioPlayers.get(playerId);
|
const player = audioPlayers.get(playerId);
|
||||||
if (!player) return { success: false, error: 'Player not found' };
|
if (!player) return { success: false, error: 'Player not found' };
|
||||||
return player.startStreamingPlayback();
|
return player.startStreamingPlayback();
|
||||||
@@ -57,7 +57,7 @@ const DeepDrftAudio = {
|
|||||||
return player.ensureAudioContextReady();
|
return player.ensureAudioContextReady();
|
||||||
},
|
},
|
||||||
|
|
||||||
play: (playerId: string): AudioResult => {
|
play: async (playerId: string): Promise<AudioResult> => {
|
||||||
const player = audioPlayers.get(playerId);
|
const player = audioPlayers.get(playerId);
|
||||||
if (!player) return { success: false, error: 'Player not found' };
|
if (!player) return { success: false, error: 'Player not found' };
|
||||||
return player.play();
|
return player.play();
|
||||||
|
|||||||
@@ -51,9 +51,11 @@ class WavUtils {
|
|||||||
if (chunkSize < 16) return null;
|
if (chunkSize < 16) return null;
|
||||||
|
|
||||||
const audioFormat = view.getUint16(chunkOffset + 8, true);
|
const audioFormat = view.getUint16(chunkOffset + 8, true);
|
||||||
// Support PCM (1) and IEEE Float (3) formats
|
// PCM only. The server's WavOffsetService synthesises PCM-shaped headers,
|
||||||
if (audioFormat !== 1 && audioFormat !== 3) {
|
// and AudioProcessor rejects non-PCM at upload — accepting Float here would
|
||||||
console.warn(`Unsupported audio format: ${audioFormat} (only PCM=1 and IEEE Float=3 supported)`);
|
// 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user