/ proof-of-concept / cymbalsim
Sign in
proof-of-concept / cymbalsim
cymbalsim / src / audio / player.ts
84 lines · 2.7 KBCodeBlameHistory
5778036cymbalsim: scripted plate and cymbal designer with a modal linear model and a von Karman GPU modelJeremy Magland 1// Web Audio playback of a mono Float32Array signal with position reporting.
2// The AudioContext is created on the first play, at the signal's sample rate
3// so that no resampling happens in the browser; if a later signal has a
4// different rate the context is replaced.
6export class Player {
7 private ctx: AudioContext | null = null;
8 private source: AudioBufferSourceNode | null = null;
9 private startedAt = 0; // context time at which the source was started
10 private offset = 0; // signal time [s] at which playback started
11 private length = 0; // signal duration [s]
12 private lastPosition = 0;
13 private isPlaying = false;
15 get playing(): boolean {
16 return this.isPlaying;
17 }
19 /** Current position in seconds while playing (from the AudioContext clock), else the last position. */
20 position(): number {
21 if (this.isPlaying && this.ctx) {
22 const t = this.offset + (this.ctx.currentTime - this.startedAt);
23 return Math.max(0, Math.min(this.length, t));
24 }
25 return this.lastPosition;
26 }
28 /** Starts playback of the mono signal at `fs` from `startSeconds`; `onEnded` fires when it finishes on its own (not on stop). */
29 play(samples: Float32Array, fs: number, startSeconds: number, onEnded?: () => void): void {
30 this.stop();
31 if (samples.length === 0 || !(fs > 0)) return;
32 const ctx = this.context(fs);
33 const buffer = ctx.createBuffer(1, samples.length, fs);
34 buffer.getChannelData(0).set(samples);
35 const src = ctx.createBufferSource();
36 src.buffer = buffer;
37 src.connect(ctx.destination);
38 this.length = samples.length / fs;
39 this.offset = Math.max(0, Math.min(this.length, startSeconds || 0));
40 src.onended = () => {
41 if (this.source !== src) return;
42 this.source = null;
43 this.isPlaying = false;
44 this.lastPosition = this.length;
45 onEnded?.();
46 };
47 this.source = src;
48 this.isPlaying = true;
49 if (ctx.state === "suspended") void ctx.resume();
50 this.startedAt = ctx.currentTime;
51 src.start(0, this.offset);
52 }
54 stop(): void {
55 const src = this.source;
56 if (!src) return;
57 this.lastPosition = this.position();
58 this.source = null;
59 this.isPlaying = false;
60 src.onended = null;
61 try {
62 src.stop();
63 } catch {
64 // Already stopped.
65 }
66 src.disconnect();
67 }
69 private context(fs: number): AudioContext {
70 if (this.ctx && this.ctx.sampleRate !== fs) {
71 void this.ctx.close();
72 this.ctx = null;
73 }
74 if (!this.ctx) {
75 try {
76 this.ctx = new AudioContext({ sampleRate: fs });
77 } catch {
78 // The browser refused the rate; fall back to its default and let Web Audio resample.
79 this.ctx = new AudioContext();
80 }
81 }
82 return this.ctx;
83 }
↑↓ move↵openescclose