File size: 5,333 Bytes
f56a29b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 | /**
* Audio Player - Audio player interface
*
* Handles audio playback, pause, stop, and other operations
* Loads pre-generated TTS audio files from IndexedDB
*
*/
import { db } from '@/lib/utils/database';
import { createLogger } from '@/lib/logger';
const log = createLogger('AudioPlayer');
/**
* Audio player implementation
*/
export class AudioPlayer {
private audio: HTMLAudioElement | null = null;
private onEndedCallback: (() => void) | null = null;
private muted: boolean = false;
private volume: number = 1;
private playbackRate: number = 1;
/**
* Play audio (from URL or IndexedDB pre-generated cache)
* @param audioId Audio ID
* @param audioUrl Optional server-generated audio URL (takes priority over IndexedDB)
* @returns true if audio started playing, false if no audio (TTS disabled or not generated)
*/
public async play(audioId: string, audioUrl?: string): Promise<boolean> {
try {
// 1. Try audioUrl first (server-generated TTS)
if (audioUrl) {
this.stop();
this.audio = new Audio();
this.audio.src = audioUrl;
if (this.muted) this.audio.volume = 0;
else this.audio.volume = this.volume;
this.audio.defaultPlaybackRate = this.playbackRate;
this.audio.playbackRate = this.playbackRate;
this.audio.addEventListener('ended', () => {
this.onEndedCallback?.();
});
await this.audio.play();
this.audio.playbackRate = this.playbackRate;
return true;
}
// 2. Fall back to IndexedDB (client-generated TTS)
const audioRecord = await db.audioFiles.get(audioId);
if (!audioRecord) {
// Pre-generated audio does not exist (generation failed), skip silently
return false;
}
// Stop current playback
this.stop();
// Create audio element
this.audio = new Audio();
// Set audio source
const blobUrl = URL.createObjectURL(audioRecord.blob);
this.audio.src = blobUrl;
if (this.muted) this.audio.volume = 0;
else this.audio.volume = this.volume;
// Apply playback rate
this.audio.defaultPlaybackRate = this.playbackRate;
this.audio.playbackRate = this.playbackRate;
// Set ended callback
this.audio.addEventListener('ended', () => {
URL.revokeObjectURL(blobUrl);
this.onEndedCallback?.();
});
// Play
await this.audio.play();
// Re-apply after play() — some browsers reset during load
this.audio.playbackRate = this.playbackRate;
return true;
} catch (error) {
log.error('Failed to play audio:', error);
throw error;
}
}
/**
* Pause playback
*/
public pause(): void {
if (this.audio && !this.audio.paused) {
this.audio.pause();
}
}
/**
* Stop playback
*/
public stop(): void {
if (this.audio) {
this.audio.pause();
this.audio.currentTime = 0;
this.audio = null;
}
// Note: onEndedCallback intentionally NOT cleared here because play()
// calls stop() internally — clearing would break the callback chain.
// Stale callbacks are harmless: engine mode check prevents processNext().
}
/**
* Resume playback
*/
public resume(): void {
if (this.audio?.paused) {
this.audio.playbackRate = this.playbackRate;
this.audio.play().catch((error) => {
log.error('Failed to resume audio:', error);
});
}
}
/**
* Get current playback status (actively playing, not paused)
*/
public isPlaying(): boolean {
return this.audio !== null && !this.audio.paused;
}
/**
* Whether there is active audio (playing or paused, but not ended)
* Used to decide whether to resume playback or skip to the next line
*/
public hasActiveAudio(): boolean {
return this.audio !== null;
}
/**
* Get current playback time (milliseconds)
*/
public getCurrentTime(): number {
return this.audio ? this.audio.currentTime * 1000 : 0;
}
/**
* Get audio duration (milliseconds)
*/
public getDuration(): number {
return this.audio && !isNaN(this.audio.duration) ? this.audio.duration * 1000 : 0;
}
/**
* Set playback ended callback
*/
public onEnded(callback: () => void): void {
this.onEndedCallback = callback;
}
/**
* Set mute state (takes effect immediately on currently playing audio)
*/
public setMuted(muted: boolean): void {
this.muted = muted;
if (this.audio) {
this.audio.volume = muted ? 0 : this.volume;
}
}
/**
* Set volume (0-1)
*/
public setVolume(volume: number): void {
this.volume = Math.max(0, Math.min(1, volume));
if (this.audio && !this.muted) {
this.audio.volume = this.volume;
}
}
/**
* Set playback speed (takes effect immediately on currently playing audio)
*/
public setPlaybackRate(rate: number): void {
this.playbackRate = Math.max(0.5, Math.min(2, rate));
if (this.audio) {
this.audio.playbackRate = this.playbackRate;
}
}
/**
* Destroy the player
*/
public destroy(): void {
this.stop();
this.onEndedCallback = null;
}
}
/**
* Create an audio player instance
*/
export function createAudioPlayer(): AudioPlayer {
return new AudioPlayer();
}
|