Files
audio-ui/lib/audio/player.ts
Sebastian Krüger f414573655 fix: seeking backwards no longer jumps to frame 0
Fixed critical bug where seeking backwards during playback would
jump to position 0 instead of the desired position.

Root cause:
- When stop() was called on a playing source node, it triggered
  the onended event callback
- The onended callback would set pauseTime = 0 (thinking playback
  naturally ended)
- This interfered with the seek operation which was trying to set
  a new position

Solution:
- Clear sourceNode.onended callback BEFORE calling stop()
- This prevents the ended event from firing when we manually stop
- Seeking now works correctly in all directions

The fix ensures clean state transitions during seeking operations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 17:32:50 +01:00

195 lines
4.4 KiB
TypeScript

/**
* Audio playback controller
*/
import { getAudioContext, resumeAudioContext } from './context';
export class AudioPlayer {
private audioContext: AudioContext;
private audioBuffer: AudioBuffer | null = null;
private sourceNode: AudioBufferSourceNode | null = null;
private gainNode: GainNode;
private startTime: number = 0;
private pauseTime: number = 0;
private isPlaying: boolean = false;
private isPaused: boolean = false;
constructor() {
this.audioContext = getAudioContext();
this.gainNode = this.audioContext.createGain();
this.gainNode.connect(this.audioContext.destination);
}
/**
* Load an audio buffer for playback
*/
loadBuffer(buffer: AudioBuffer): void {
this.stop();
this.audioBuffer = buffer;
this.pauseTime = 0;
}
/**
* Start playback from current position
*/
async play(startOffset: number = 0): Promise<void> {
if (!this.audioBuffer) {
throw new Error('No audio buffer loaded');
}
// Resume audio context if needed
await resumeAudioContext();
// Calculate start offset BEFORE stopping (since stop() resets pauseTime)
const offset = this.isPaused ? this.pauseTime : startOffset;
// Stop any existing playback
this.stop();
// Create new source node
this.sourceNode = this.audioContext.createBufferSource();
this.sourceNode.buffer = this.audioBuffer;
this.sourceNode.connect(this.gainNode);
// Set start time
this.startTime = this.audioContext.currentTime - offset;
// Start playback
this.sourceNode.start(0, offset);
this.isPlaying = true;
this.isPaused = false;
// Handle playback end
this.sourceNode.onended = () => {
if (this.isPlaying) {
this.isPlaying = false;
this.isPaused = false;
this.pauseTime = 0;
}
};
}
/**
* Pause playback
*/
pause(): void {
if (!this.isPlaying) return;
this.pauseTime = this.getCurrentTime();
this.stop();
this.isPaused = true;
}
/**
* Stop playback
*/
stop(): void {
if (this.sourceNode) {
try {
// Clear onended callback first to prevent interference
this.sourceNode.onended = null;
this.sourceNode.stop();
} catch (error) {
// Ignore errors if already stopped
}
this.sourceNode.disconnect();
this.sourceNode = null;
}
this.isPlaying = false;
this.isPaused = false;
this.pauseTime = 0;
this.startTime = 0;
}
/**
* Get current playback time in seconds
*/
getCurrentTime(): number {
if (!this.audioBuffer) return 0;
if (this.isPlaying) {
const currentTime = this.audioContext.currentTime - this.startTime;
return Math.min(currentTime, this.audioBuffer.duration);
}
return this.pauseTime;
}
/**
* Seek to a specific time
* @param time - Time in seconds to seek to
* @param autoPlay - Whether to automatically start playback after seeking (default: false)
*/
async seek(time: number, autoPlay: boolean = false): Promise<void> {
if (!this.audioBuffer) return;
const wasPlaying = this.isPlaying;
const clampedTime = Math.max(0, Math.min(time, this.audioBuffer.duration));
this.stop();
this.pauseTime = clampedTime;
// Auto-play if requested, or continue playing if was already playing
if (autoPlay || wasPlaying) {
await this.play(clampedTime);
} else {
this.isPaused = true;
}
}
/**
* Set playback volume (0 to 1)
*/
setVolume(volume: number): void {
const clampedVolume = Math.max(0, Math.min(1, volume));
this.gainNode.gain.setValueAtTime(clampedVolume, this.audioContext.currentTime);
}
/**
* Get current volume
*/
getVolume(): number {
return this.gainNode.gain.value;
}
/**
* Get playback state
*/
getState(): {
isPlaying: boolean;
isPaused: boolean;
currentTime: number;
duration: number;
} {
return {
isPlaying: this.isPlaying,
isPaused: this.isPaused,
currentTime: this.getCurrentTime(),
duration: this.audioBuffer?.duration ?? 0,
};
}
/**
* Get audio buffer
*/
getBuffer(): AudioBuffer | null {
return this.audioBuffer;
}
/**
* Check if audio is loaded
*/
hasBuffer(): boolean {
return this.audioBuffer !== null;
}
/**
* Cleanup resources
*/
dispose(): void {
this.stop();
this.gainNode.disconnect();
this.audioBuffer = null;
}
}