feat: replace ?offset= seek with HTTP Range streaming across API, proxy, and client
- API: enableRangeProcessing true on no-offset FileStream path - Proxy: transparent Range relay, forwards 206/416/Content-Range verbatim - TrackMediaClient: Range: bytes=X- replaces ?offset=X; response disposed via TrackMediaResponse - StreamDecoder: reinitializeForRangeContinuation retains wavHeader, counts raw PCM against 206 Content-Length - AudioPlayer: seekBeyondBuffer adds headerSize for file-absolute offset; duration guard prevents continuation overwriting full-track duration - StreamingAudioPlayerService: seek guard corrected to >= 0 (file-absolute offset contract)
This commit is contained in:
@@ -321,18 +321,28 @@ export class AudioPlayer {
|
||||
*/
|
||||
private seekBeyondBuffer(position: number): AudioResult {
|
||||
try {
|
||||
const byteOffset = this.streamDecoder.calculateByteOffset(position);
|
||||
const audioOffset = this.streamDecoder.calculateByteOffset(position);
|
||||
// 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) {
|
||||
if (audioOffset < 0) {
|
||||
return { success: false, error: 'Cannot calculate byte offset' };
|
||||
}
|
||||
|
||||
// Signal that C# needs to request new stream from offset
|
||||
// The Range request is file-absolute: byte position from the start of the
|
||||
// file on disk, header included. calculateByteOffset returns an audio-data-
|
||||
// relative offset, so add headerSize to land on the right byte. (The old
|
||||
// ?offset= contract was audio-relative; the server added the header itself.)
|
||||
const header = this.streamDecoder.getWavHeader();
|
||||
if (!header) {
|
||||
return { success: false, error: 'Cannot calculate byte offset' };
|
||||
}
|
||||
const fileOffset = header.headerSize + audioOffset;
|
||||
|
||||
// Signal that C# needs to request a new stream from this file-absolute offset
|
||||
return {
|
||||
success: true,
|
||||
seekBeyondBuffer: true,
|
||||
byteOffset: byteOffset
|
||||
byteOffset: fileOffset
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
@@ -368,8 +378,10 @@ export class AudioPlayer {
|
||||
this.scheduler.clearForSeek();
|
||||
this.scheduler.setPlaybackOffset(seekPosition);
|
||||
|
||||
// Reinitialize decoder for new stream
|
||||
this.streamDecoder.reinitializeForOffset(totalStreamLength);
|
||||
// Reinitialize decoder for the Range-continuation stream. totalStreamLength
|
||||
// here is the 206 Content-Length (range start → EOF), not the full file size —
|
||||
// the decoder uses it to detect stream-complete against raw audio bytes.
|
||||
this.streamDecoder.reinitializeForRangeContinuation(totalStreamLength);
|
||||
|
||||
// Update state
|
||||
this.pausePosition = seekPosition;
|
||||
|
||||
@@ -60,6 +60,14 @@ export class StreamDecoder {
|
||||
private streamComplete: boolean = false;
|
||||
private headerError: string | null = null;
|
||||
|
||||
// Range-continuation state. After a seek-beyond-buffer the server responds 206
|
||||
// with raw PCM from a file-absolute offset (no WAV header). We retain the header
|
||||
// parsed from the initial stream and treat the whole body as audio data. The
|
||||
// stream-complete check then counts raw bytes against the 206 Content-Length
|
||||
// (remainingByteLength) rather than the full-file totalStreamLength + headerSize.
|
||||
private isContinuation: boolean = false;
|
||||
private remainingByteLength: number = 0;
|
||||
|
||||
// Pre-header accumulator. WAV headers can span multiple network chunks
|
||||
// (small first segment, extended LIST/INFO/JUNK chunks before 'data', etc.),
|
||||
// so we buffer raw bytes here until parseHeader succeeds rather than assuming
|
||||
@@ -84,6 +92,8 @@ export class StreamDecoder {
|
||||
this.headerBytesReceived = 0;
|
||||
this.headerSearchChunks = [];
|
||||
this.headerError = null;
|
||||
this.isContinuation = false;
|
||||
this.remainingByteLength = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,6 +193,16 @@ export class StreamDecoder {
|
||||
* otherwise pre-header bytes count toward the total.
|
||||
*/
|
||||
private updateStreamCompleteFlag(): void {
|
||||
// Range-continuation: the 206 body is pure audio (no header), so compare raw
|
||||
// audio bytes directly against the 206 Content-Length. Do NOT add headerSize —
|
||||
// there is no header in this response.
|
||||
if (this.isContinuation) {
|
||||
if (this.remainingByteLength > 0 && this.totalRawBytes >= this.remainingByteLength) {
|
||||
this.streamComplete = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.totalStreamLength <= 0) return;
|
||||
const totalReceived = this.wavHeader
|
||||
? this.totalRawBytes + this.wavHeader.headerSize
|
||||
@@ -445,23 +465,34 @@ export class StreamDecoder {
|
||||
this.headerBytesReceived = 0;
|
||||
this.headerSearchChunks = [];
|
||||
this.headerError = null;
|
||||
this.isContinuation = false;
|
||||
this.remainingByteLength = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinitialize for offset streaming - preserves header format knowledge
|
||||
* Called when seeking beyond buffer to prepare for new stream from server
|
||||
* Reinitialize for a Range-continuation stream after seek-beyond-buffer.
|
||||
*
|
||||
* The server responds to a Range request with 206 Partial Content carrying raw
|
||||
* PCM from a file-absolute offset — there is NO WAV header in this body. We retain
|
||||
* the header parsed from the initial stream (its format describes every segment we
|
||||
* synthesise via createWavFile) and feed the entire 206 body straight into the
|
||||
* decode pipeline. The `if (!this.wavHeader)` branch in processChunk therefore goes
|
||||
* directly to addRawData and tryParseHeader is never re-entered.
|
||||
*
|
||||
* @param remainingByteLength the Content-Length of the 206 response — the number of
|
||||
* bytes from the range start to EOF, NOT the full file size. Stream-complete is
|
||||
* reached when totalRawBytes >= this value.
|
||||
*/
|
||||
reinitializeForOffset(totalStreamLength: number): void {
|
||||
// Reset data state but we'll get a fresh header from the offset stream
|
||||
reinitializeForRangeContinuation(remainingByteLength: number): void {
|
||||
// Retain this.wavHeader — the 206 body carries no header to reparse.
|
||||
this.rawChunks = [];
|
||||
this.totalRawBytes = 0;
|
||||
this.processedBytes = 0;
|
||||
this.totalStreamLength = totalStreamLength;
|
||||
this.streamComplete = false;
|
||||
this.headerBytesReceived = 0;
|
||||
this.headerSearchChunks = [];
|
||||
this.headerError = null;
|
||||
// wavHeader will be reparsed from the new stream (server sends fresh header)
|
||||
this.wavHeader = null;
|
||||
this.isContinuation = true;
|
||||
this.remainingByteLength = remainingByteLength;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user