audio-player.ts 5.2 KB

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