/ proof-of-concept / cymroom
Sign in
proof-of-concept / cymroom
cymroom / src / audio / player.ts
72 lines · 3.3 KBBlameHistoryRaw
1// Playback of the part of the signal that exists so far, through Web Audio, and WAV export.
3export class Player {
4 private ctx: AudioContext | null = null;
5 private gain: GainNode | null = null;
6 private source: AudioBufferSourceNode | null = null;
7 private startedAt = 0; private offset = 0; private length = 0;
8 private fs = 44100;
9 playing = false;
10 onEnded: (() => void) | null = null;
12 private ensure() {
13 if (!this.ctx) { this.ctx = new AudioContext(); this.gain = this.ctx.createGain(); this.gain.connect(this.ctx.destination); }
14 return this.ctx;
15 }
17 setGainDb(db: number) { if (this.gain) this.gain.gain.value = Math.pow(10, db / 20); this.gainDb = db; }
18 private gainDb = -12;
20 /** Play ``samples`` (already scaled to [-1, 1]) from ``fromSec``; the sound stops where the samples end. */
21 play(samples: Float32Array, fs: number, fromSec: number) {
22 this.stop();
23 const ctx = this.ensure();
24 this.setGainDb(this.gainDb);
25 if (ctx.state === "suspended") void ctx.resume();
26 const from = Math.min(Math.max(0, Math.floor(fromSec * fs)), samples.length);
27 const part = samples.subarray(from);
28 if (part.length === 0) return;
29 const buf = ctx.createBuffer(1, part.length, fs);
30 buf.copyToChannel(Float32Array.from(part), 0);
31 const src = ctx.createBufferSource();
32 src.buffer = buf; src.connect(this.gain!);
33 src.onended = () => { if (this.source === src) { this.playing = false; this.source = null; this.onEnded?.(); } };
34 src.start();
35 this.source = src; this.startedAt = ctx.currentTime; this.offset = from / fs; this.length = samples.length / fs; this.fs = fs;
36 this.playing = true;
37 }
39 stop() {
40 if (this.source) { const s = this.source; this.source = null; try { s.stop(); } catch { /* already stopped */ } }
41 this.playing = false;
42 }
44 /** Current playback position in seconds (or the last position when stopped). */
45 position(): number {
46 if (!this.playing || !this.ctx) return this.offset;
47 return Math.min(this.offset + this.ctx.currentTime - this.startedAt, this.length);
48 }
50 get sampleRate() { return this.fs; }
53/** Scale a pressure signal for playback: peak at 0.9 full scale (normalize) or 1 Pa = 0.05 FS. */
54export function scaleForPlayback(p: Float32Array, normalize: boolean): Float32Array {
55 let peak = 0;
56 for (const x of p) peak = Math.max(peak, Math.abs(x));
57 const s = normalize ? (peak > 0 ? 0.9 / peak : 0) : 0.05;
58 const out = new Float32Array(p.length);
59 for (let i = 0; i < p.length; i++) out[i] = Math.max(-1, Math.min(1, p[i] * s));
60 return out;
63/** 16-bit PCM mono WAV. */
64export function encodeWav(samples: Float32Array, fs: number): Blob {
65 const n = samples.length, buf = new ArrayBuffer(44 + 2 * n), v = new DataView(buf);
66 const str = (o: number, s: string) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); };
67 str(0, "RIFF"); v.setUint32(4, 36 + 2 * n, true); str(8, "WAVE"); str(12, "fmt "); v.setUint32(16, 16, true);
68 v.setUint16(20, 1, true); v.setUint16(22, 1, true); v.setUint32(24, fs, true); v.setUint32(28, fs * 2, true);
69 v.setUint16(32, 2, true); v.setUint16(34, 16, true); str(36, "data"); v.setUint32(40, 2 * n, true);
70 for (let i = 0; i < n; i++) v.setInt16(44 + 2 * i, Math.round(Math.max(-1, Math.min(1, samples[i])) * 32767), true);
71 return new Blob([buf], { type: "audio/wav" });
↑↓ move↵openescclose