audio.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. import * as youtube from './youtube';
  2. export class AudioManager {
  3. context: AudioContext;
  4. volume: GainNode;
  5. output: AudioNode;
  6. constructor() {
  7. this.context = new AudioContext();
  8. this.volume = this.context.createGain();
  9. this.volume.connect(this.context.destination);
  10. this.output = this.volume;
  11. }
  12. getTime(): number {
  13. return this.context.currentTime;
  14. }
  15. async loadTrack(url: string): Promise<FileTrack> {
  16. const response = await window.fetch(url);
  17. const buffer = await response.arrayBuffer();
  18. const audioBuffer = await this.context.decodeAudioData(buffer);
  19. return new FileTrack(this, audioBuffer);
  20. }
  21. async loadTrackFromFile(file: File): Promise<FileTrack> {
  22. const promise = new Promise<ArrayBuffer>((resolve, _) => {
  23. const reader = new FileReader();
  24. reader.onload = () => resolve(reader.result as ArrayBuffer);
  25. reader.readAsArrayBuffer(file);
  26. });
  27. const buffer = await promise;
  28. const audioBuffer = await this.context.decodeAudioData(buffer);
  29. return new FileTrack(this, audioBuffer);
  30. }
  31. async loadTrackWithProgress(
  32. url: string,
  33. listener: (percentage: number) => void
  34. ): Promise<FileTrack> {
  35. const promise = new Promise<ArrayBuffer>((resolve, reject) => {
  36. let xhr = new XMLHttpRequest();
  37. xhr.open('GET', url);
  38. xhr.responseType = 'arraybuffer';
  39. xhr.onprogress = (event) => {
  40. if (event.lengthComputable) {
  41. // only up to 80 to factor in decoding time
  42. let percentage = (event.loaded / event.total) * 80;
  43. listener(percentage);
  44. }
  45. };
  46. xhr.onload = () => resolve(xhr.response);
  47. xhr.onerror = () => reject();
  48. xhr.send();
  49. });
  50. const buffer = await promise;
  51. const audioBuffer = await this.context.decodeAudioData(buffer);
  52. return new FileTrack(this, audioBuffer);
  53. }
  54. async loadTrackFromYoutube(
  55. id: string,
  56. element: HTMLElement,
  57. listener: (percentage: number) => void
  58. ): Promise<YoutubeTrack> {
  59. await youtube.loadYoutubeApi();
  60. listener(30);
  61. const player = await youtube.createPlayer(element);
  62. listener(60);
  63. const track = new YoutubeTrack(player, id);
  64. await track.preload();
  65. listener(90);
  66. return track;
  67. }
  68. }
  69. export enum PlayState {
  70. UNSTARTED = 'unstarted',
  71. PLAYING = 'playing',
  72. PAUSED = 'paused',
  73. STOPPED = 'stopped',
  74. }
  75. export type TrackListener = (track: Track, state: PlayState) => void;
  76. export abstract class Track {
  77. private listeners: TrackListener[] = [];
  78. addListener(listener: TrackListener) {
  79. this.listeners.push(listener);
  80. }
  81. clearListeners() {
  82. this.listeners = [];
  83. }
  84. emit(state: PlayState) {
  85. this.listeners.forEach((l) => l(this, state));
  86. }
  87. abstract start(fromTime?: number, duration?: number): void;
  88. abstract pause(): void;
  89. abstract stop(): void;
  90. abstract exit(): void;
  91. abstract getState(): PlayState;
  92. abstract getTime(): number;
  93. abstract getDuration(): number;
  94. }
  95. export class FileTrack extends Track {
  96. manager: AudioManager;
  97. buffer: AudioBuffer;
  98. source: AudioBufferSourceNode | null;
  99. playStartTime: number;
  100. resumeTime: number;
  101. state: PlayState;
  102. constructor(manager: AudioManager, buffer: AudioBuffer) {
  103. super();
  104. this.manager = manager;
  105. this.buffer = buffer;
  106. this.source = null;
  107. this.playStartTime = 0;
  108. this.resumeTime = 0;
  109. this.state = PlayState.UNSTARTED;
  110. }
  111. /**
  112. * Play and forget useful for SFX and the like
  113. */
  114. play(): void {
  115. const source = this.manager.context.createBufferSource();
  116. source.buffer = this.buffer;
  117. source.connect(this.manager.output);
  118. source.start();
  119. }
  120. start(fromTime?: number, duration?: number): void {
  121. if (this.state === PlayState.PLAYING) {
  122. this.stop();
  123. }
  124. if (fromTime !== undefined) {
  125. this.resumeTime = fromTime;
  126. }
  127. this.source = this.manager.context.createBufferSource();
  128. this.source.buffer = this.buffer;
  129. this.source.connect(this.manager.output);
  130. this.source.onended = (event) => {
  131. if (this.source == event.target) {
  132. this.resumeTime = this.manager.getTime() - this.playStartTime;
  133. if (this.resumeTime > this.getDuration()) {
  134. this.resumeTime = 0;
  135. this.setState(PlayState.STOPPED);
  136. } else {
  137. this.setState(PlayState.PAUSED);
  138. }
  139. }
  140. };
  141. this.playStartTime = this.manager.getTime() - this.resumeTime;
  142. this.setState(PlayState.PLAYING);
  143. this.source.start(0, this.resumeTime, duration);
  144. }
  145. pause(): void {
  146. if (
  147. this.state === PlayState.UNSTARTED ||
  148. this.state === PlayState.PAUSED ||
  149. this.state === PlayState.STOPPED
  150. )
  151. return;
  152. this.resumeTime = this.manager.getTime() - this.playStartTime;
  153. if (this.source) {
  154. this.source.stop();
  155. }
  156. this.setState(PlayState.PAUSED);
  157. }
  158. stop(): void {
  159. this.resumeTime = 0;
  160. if (this.source) {
  161. this.source.stop();
  162. }
  163. this.setState(PlayState.STOPPED);
  164. }
  165. exit(): void {
  166. this.stop();
  167. }
  168. getState(): PlayState {
  169. return this.state;
  170. }
  171. getTime(): number {
  172. if (this.state === PlayState.UNSTARTED) {
  173. return 0;
  174. } else if (
  175. this.state === PlayState.PAUSED ||
  176. this.state === PlayState.STOPPED
  177. ) {
  178. if (this.resumeTime > 0) {
  179. return this.resumeTime;
  180. } else {
  181. return this.getDuration();
  182. }
  183. } else {
  184. return this.manager.getTime() - this.playStartTime;
  185. }
  186. }
  187. getDuration(): number {
  188. return this.buffer.duration;
  189. }
  190. private setState(state: PlayState): void {
  191. this.state = state;
  192. this.emit(state);
  193. }
  194. }
  195. export class YoutubeTrack extends Track {
  196. private timeoutHandle?: number;
  197. constructor(readonly player: YT.Player, readonly id: string) {
  198. super();
  199. }
  200. preload(): Promise<void> {
  201. return new Promise((resolve) => {
  202. let loaded = false;
  203. const onStateChange: YT.PlayerStateChangeListener = ({ data }) => {
  204. if (data === YT.PlayerState.PLAYING) {
  205. if (!loaded) {
  206. loaded = true;
  207. this.player.pauseVideo();
  208. this.player.seekTo(0);
  209. this.player.unMute();
  210. resolve();
  211. }
  212. }
  213. this.emit(this.mapState(data));
  214. };
  215. this.player.addEventListener('onStateChange', onStateChange);
  216. this.player.mute();
  217. this.player.loadVideoById(this.id);
  218. });
  219. }
  220. start(fromTime?: number, duration?: number): void {
  221. this.clearTimeout();
  222. if (duration) {
  223. this.timeoutHandle = setTimeout(() => {
  224. this.player.pauseVideo();
  225. }, duration * 1000);
  226. }
  227. if (fromTime !== undefined) {
  228. this.player.seekTo(fromTime, true);
  229. }
  230. this.player.playVideo();
  231. }
  232. pause(): void {
  233. this.clearTimeout();
  234. this.player.pauseVideo();
  235. }
  236. stop(): void {
  237. this.clearTimeout();
  238. this.player.stopVideo();
  239. }
  240. exit(): void {
  241. // the element gets removed by the background manager and stops that way
  242. }
  243. getState(): PlayState {
  244. return this.mapState(this.player.getPlayerState());
  245. }
  246. getTime(): number {
  247. return this.player.getCurrentTime();
  248. }
  249. getDuration(): number {
  250. return this.player.getDuration();
  251. }
  252. private clearTimeout(): void {
  253. if (this.timeoutHandle) {
  254. clearTimeout(this.timeoutHandle);
  255. }
  256. }
  257. private mapState(ytState: YT.PlayerState): PlayState {
  258. switch (ytState) {
  259. case YT.PlayerState.PLAYING:
  260. return PlayState.PLAYING;
  261. case YT.PlayerState.ENDED:
  262. return PlayState.STOPPED;
  263. case YT.PlayerState.UNSTARTED:
  264. case YT.PlayerState.CUED:
  265. return PlayState.UNSTARTED;
  266. case YT.PlayerState.BUFFERING:
  267. case YT.PlayerState.PAUSED:
  268. return PlayState.PAUSED;
  269. }
  270. }
  271. }