| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205 |
- /**
- * 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();
- }
|