refactor(split): rename DeepDrftWeb -> DeepDrftPublic and DeepDrftWeb.Client -> DeepDrftPublic.Client (Phase 4)
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* PlaybackScheduler - Manages AudioBuffer storage and playback scheduling.
|
||||
*
|
||||
* Single Responsibility: Store decoded buffers and schedule them for playback.
|
||||
* Supports pause/resume/seek by retaining all buffers.
|
||||
*/
|
||||
|
||||
import { AudioContextManager } from './AudioContextManager.js';
|
||||
|
||||
interface ScheduledSource {
|
||||
source: AudioBufferSourceNode;
|
||||
bufferIndex: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export class PlaybackScheduler {
|
||||
private contextManager: AudioContextManager;
|
||||
private buffers: AudioBuffer[] = [];
|
||||
private scheduledSources: ScheduledSource[] = [];
|
||||
|
||||
// Playback timing
|
||||
private playbackAnchorTime: number = 0; // AudioContext time when playback started/resumed
|
||||
private playbackAnchorPosition: number = 0; // Position in audio when playback started/resumed
|
||||
private nextBufferIndex: number = 0; // Next buffer to schedule during live streaming
|
||||
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;
|
||||
|
||||
constructor(contextManager: AudioContextManager) {
|
||||
this.contextManager = contextManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a decoded buffer to storage
|
||||
*/
|
||||
addBuffer(buffer: AudioBuffer): void {
|
||||
this.buffers.push(buffer);
|
||||
console.log(`📦 Buffer[${this.buffers.length - 1}] added: ${buffer.duration.toFixed(3)}s (total: ${this.getTotalDuration().toFixed(3)}s)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total duration of all stored buffers
|
||||
*/
|
||||
getTotalDuration(): number {
|
||||
return this.buffers.reduce((sum, b) => sum + b.duration, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of stored buffers
|
||||
*/
|
||||
getBufferCount(): number {
|
||||
return this.buffers.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current playback position in seconds (includes playbackOffset for seek-beyond-buffer)
|
||||
*/
|
||||
getCurrentPosition(): number {
|
||||
// Use isActive_ as the sentinel for "playback is running", not playbackAnchorTime == 0.
|
||||
// AudioContext.currentTime can legitimately be 0 at context creation, so comparing
|
||||
// against 0 would incorrectly treat an active stream started at t=0 as paused.
|
||||
if (!this.isActive_) {
|
||||
return this.playbackAnchorPosition + this.playbackOffset;
|
||||
}
|
||||
const elapsed = this.contextManager.currentTime - this.playbackAnchorTime;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or resume playback from a specific position
|
||||
*/
|
||||
playFromPosition(position: number): void {
|
||||
this.stopAllSources();
|
||||
|
||||
// Find which buffer contains this position
|
||||
let accumulatedTime = 0;
|
||||
let startBufferIndex = 0;
|
||||
let offsetInBuffer = 0;
|
||||
|
||||
for (let i = 0; i < this.buffers.length; i++) {
|
||||
const bufferDuration = this.buffers[i].duration;
|
||||
if (accumulatedTime + bufferDuration > position) {
|
||||
startBufferIndex = i;
|
||||
offsetInBuffer = position - accumulatedTime;
|
||||
break;
|
||||
}
|
||||
accumulatedTime += bufferDuration;
|
||||
startBufferIndex = i + 1;
|
||||
}
|
||||
|
||||
if (startBufferIndex >= this.buffers.length) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
console.log(`▶️ Playing from ${position.toFixed(3)}s: buffer[${startBufferIndex}] offset=${offsetInBuffer.toFixed(3)}s`);
|
||||
|
||||
// Set timing anchors
|
||||
this.playbackAnchorPosition = position;
|
||||
this.playbackAnchorTime = this.contextManager.currentTime;
|
||||
this.nextScheduleTime = this.contextManager.currentTime + 0.01; // Small lookahead
|
||||
this.nextBufferIndex = startBufferIndex;
|
||||
this.isActive_ = true; // Enable scheduling
|
||||
|
||||
// Schedule buffers
|
||||
this.scheduleBuffersFrom(startBufferIndex, offsetInBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule newly decoded buffers during live streaming
|
||||
*/
|
||||
scheduleNewBuffers(): void {
|
||||
if (this.nextBufferIndex >= this.buffers.length) {
|
||||
return; // No new buffers
|
||||
}
|
||||
|
||||
// Use isActive_ as the sentinel for "playback is running", not nextScheduleTime === 0.
|
||||
// AudioContext.currentTime can legitimately be 0 at context creation, which would cause
|
||||
// nextScheduleTime === 0 to incorrectly reset a value already set by playFromPosition.
|
||||
if (!this.isActive_) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleBuffersFrom(this.nextBufferIndex, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Schedule buffers starting from a specific index
|
||||
*/
|
||||
private scheduleBuffersFrom(startIndex: number, offsetInFirstBuffer: number): void {
|
||||
const lookaheadTarget = 0.5; // Schedule up to 500ms ahead
|
||||
const gainNode = this.contextManager.getGainNode();
|
||||
|
||||
for (let i = startIndex; i < this.buffers.length; i++) {
|
||||
const buffer = this.buffers[i];
|
||||
const isFirstBuffer = (i === startIndex && offsetInFirstBuffer > 0);
|
||||
const offset = isFirstBuffer ? offsetInFirstBuffer : 0;
|
||||
const duration = buffer.duration - offset;
|
||||
|
||||
// Create and configure source
|
||||
const source = this.contextManager.getContext().createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(gainNode);
|
||||
|
||||
const scheduleTime = this.nextScheduleTime;
|
||||
const endTime = scheduleTime + duration;
|
||||
|
||||
// Track scheduled source
|
||||
const scheduled: ScheduledSource = {
|
||||
source,
|
||||
bufferIndex: i,
|
||||
startTime: scheduleTime,
|
||||
endTime
|
||||
};
|
||||
this.scheduledSources.push(scheduled);
|
||||
|
||||
// Set up ended callback
|
||||
source.onended = () => this.handleSourceEnded(scheduled);
|
||||
|
||||
// Schedule the source
|
||||
source.start(scheduleTime, offset);
|
||||
|
||||
console.log(`🎵 Scheduled buffer[${i}]: ${scheduleTime.toFixed(3)}s -> ${endTime.toFixed(3)}s`);
|
||||
|
||||
// Update for next buffer
|
||||
this.nextScheduleTime = endTime;
|
||||
this.nextBufferIndex = i + 1;
|
||||
|
||||
// Check if we have enough lookahead
|
||||
const lookahead = this.nextScheduleTime - this.contextManager.currentTime;
|
||||
if (lookahead > lookaheadTarget) {
|
||||
console.log(`📋 Lookahead: ${(lookahead * 1000).toFixed(0)}ms buffered`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a source finishing playback
|
||||
*/
|
||||
private handleSourceEnded(scheduled: ScheduledSource): void {
|
||||
// Ignore if we're paused/stopped (sources fire onended when stopped)
|
||||
if (!this.isActive_) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove from scheduled list
|
||||
const index = this.scheduledSources.indexOf(scheduled);
|
||||
if (index > -1) {
|
||||
this.scheduledSources.splice(index, 1);
|
||||
}
|
||||
|
||||
// Schedule more buffers if available
|
||||
if (this.nextBufferIndex < this.buffers.length) {
|
||||
this.scheduleBuffersFrom(this.nextBufferIndex, 0);
|
||||
}
|
||||
|
||||
// Check if all playback has finished
|
||||
if (this.scheduledSources.length === 0 && this.nextBufferIndex >= this.buffers.length) {
|
||||
console.log('✓ Playback complete');
|
||||
this.isActive_ = false;
|
||||
this.playbackAnchorTime = 0;
|
||||
this.playbackAnchorPosition = 0;
|
||||
this.onPlaybackEnded?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause playback - saves position and stops sources
|
||||
*/
|
||||
pause(): number {
|
||||
const position = this.getCurrentPosition();
|
||||
this.isActive_ = false; // Prevent handleSourceEnded from scheduling more
|
||||
this.stopAllSources();
|
||||
this.playbackAnchorPosition = position;
|
||||
this.playbackAnchorTime = 0;
|
||||
this.nextScheduleTime = 0;
|
||||
console.log(`⏸️ Paused at ${position.toFixed(3)}s`);
|
||||
return position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all scheduled sources
|
||||
*/
|
||||
stopAllSources(): void {
|
||||
for (const scheduled of this.scheduledSources) {
|
||||
try {
|
||||
scheduled.source.stop();
|
||||
} catch {
|
||||
// Source may already be stopped
|
||||
}
|
||||
}
|
||||
this.scheduledSources = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to beginning (for stop)
|
||||
*/
|
||||
resetToStart(): void {
|
||||
this.isActive_ = false;
|
||||
this.stopAllSources();
|
||||
this.playbackAnchorPosition = 0;
|
||||
this.playbackAnchorTime = 0;
|
||||
this.nextBufferIndex = 0;
|
||||
this.nextScheduleTime = 0;
|
||||
console.log('⏮️ Reset to start');
|
||||
}
|
||||
|
||||
/**
|
||||
* Full reset - clears all buffers and resets offset
|
||||
*/
|
||||
clear(): void {
|
||||
this.isActive_ = false;
|
||||
this.stopAllSources();
|
||||
this.buffers = [];
|
||||
this.playbackAnchorPosition = 0;
|
||||
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
|
||||
*/
|
||||
hasBuffers(): boolean {
|
||||
return this.buffers.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we have minimum buffers for playback
|
||||
*/
|
||||
hasMinimumBuffers(minCount: number): boolean {
|
||||
return this.buffers.length >= minCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if playback is active
|
||||
*/
|
||||
isActive(): boolean {
|
||||
return this.isActive_;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user