Skip to content

Commit f44e6a5

Browse files
author
NellowTCS
committed
Audio
1 parent ebd20d7 commit f44e6a5

6 files changed

Lines changed: 750 additions & 0 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import type { IAudioBackend } from "../index";
2+
3+
export class HTMLAudioBackend implements IAudioBackend {
4+
private audio: HTMLAudioElement;
5+
private timeUpdateCallback: ((time: number) => void) | null = null;
6+
private endedCallback: (() => void) | null = null;
7+
private errorCallback: ((error: Error) => void) | null = null;
8+
private boundOnTimeUpdate: () => void;
9+
private boundOnEnded: () => void;
10+
private boundOnError: () => void;
11+
private boundOnLoadedMetadata: () => void;
12+
private duration = 0;
13+
14+
constructor() {
15+
this.audio = new Audio();
16+
this.audio.preload = "auto";
17+
18+
this.boundOnTimeUpdate = this.handleTimeUpdate.bind(this);
19+
this.boundOnEnded = this.handleEnded.bind(this);
20+
this.boundOnError = this.handleError.bind(this);
21+
this.boundOnLoadedMetadata = this.handleLoadedMetadata.bind(this);
22+
23+
this.audio.addEventListener("timeupdate", this.boundOnTimeUpdate);
24+
this.audio.addEventListener("ended", this.boundOnEnded);
25+
this.audio.addEventListener("error", this.boundOnError);
26+
this.audio.addEventListener("loadedmetadata", this.boundOnLoadedMetadata);
27+
}
28+
29+
async load(url: string): Promise<void> {
30+
return new Promise((resolve, reject) => {
31+
const onCanPlayThrough = () => {
32+
this.audio.removeEventListener("canplaythrough", onCanPlayThrough);
33+
this.audio.removeEventListener("error", onError);
34+
resolve();
35+
};
36+
37+
const onError = () => {
38+
this.audio.removeEventListener("canplaythrough", onCanPlayThrough);
39+
this.audio.removeEventListener("error", onError);
40+
reject(new Error(`Failed to load audio: ${url}`));
41+
};
42+
43+
this.audio.addEventListener("canplaythrough", onCanPlayThrough);
44+
this.audio.addEventListener("error", onError);
45+
46+
this.audio.src = url;
47+
this.audio.load();
48+
});
49+
}
50+
51+
async play(): Promise<void> {
52+
if (this.audio.paused) {
53+
try {
54+
await this.audio.play();
55+
} catch (error) {
56+
throw new Error(`Play failed: ${(error as Error).message}`);
57+
}
58+
}
59+
}
60+
61+
pause(): void {
62+
if (!this.audio.paused) {
63+
this.audio.pause();
64+
}
65+
}
66+
67+
stop(): void {
68+
this.audio.pause();
69+
this.audio.currentTime = 0;
70+
}
71+
72+
seek(time: number): void {
73+
const wasPlaying = !this.audio.paused;
74+
this.audio.currentTime = time;
75+
if (wasPlaying) {
76+
this.audio.play().catch(() => {});
77+
}
78+
}
79+
80+
setVolume(volume: number): void {
81+
this.audio.volume = Math.max(0, Math.min(1, volume));
82+
}
83+
84+
setPlaybackRate(rate: number): void {
85+
this.audio.playbackRate = Math.max(0.25, Math.min(4, rate));
86+
}
87+
88+
getCurrentTime(): number {
89+
return this.audio.currentTime;
90+
}
91+
92+
getDuration(): number {
93+
return this.duration || this.audio.duration || 0;
94+
}
95+
96+
onTimeUpdate(callback: (time: number) => void): void {
97+
this.timeUpdateCallback = callback;
98+
}
99+
100+
onEnded(callback: () => void): void {
101+
this.endedCallback = callback;
102+
}
103+
104+
onError(callback: (error: Error) => void): void {
105+
this.errorCallback = callback;
106+
}
107+
108+
dispose(): void {
109+
this.audio.removeEventListener("timeupdate", this.boundOnTimeUpdate);
110+
this.audio.removeEventListener("ended", this.boundOnEnded);
111+
this.audio.removeEventListener("error", this.boundOnError);
112+
this.audio.removeEventListener("loadedmetadata", this.boundOnLoadedMetadata);
113+
114+
this.audio.pause();
115+
this.audio.src = "";
116+
this.audio.load();
117+
118+
this.timeUpdateCallback = null;
119+
this.endedCallback = null;
120+
this.errorCallback = null;
121+
}
122+
123+
private handleTimeUpdate(): void {
124+
if (this.timeUpdateCallback) {
125+
this.timeUpdateCallback(this.audio.currentTime);
126+
}
127+
}
128+
129+
private handleEnded(): void {
130+
if (this.endedCallback) {
131+
this.endedCallback();
132+
}
133+
}
134+
135+
private handleError(): void {
136+
const error = this.audio.error;
137+
if (this.errorCallback) {
138+
this.errorCallback(
139+
new Error(error?.message ?? "Unknown audio error")
140+
);
141+
}
142+
}
143+
144+
private handleLoadedMetadata(): void {
145+
this.duration = this.audio.duration;
146+
}
147+
}
148+
149+
export function createHTMLBackend(): IAudioBackend {
150+
return new HTMLAudioBackend();
151+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import type { IAudioBackend } from "../index";
2+
3+
interface TonePlayerInstance {
4+
pitch: number;
5+
playbackRate: number;
6+
dispose: () => void;
7+
}
8+
9+
let Tone: typeof import("tone") | null = null;
10+
let playerInstance: TonePlayerInstance | null = null;
11+
12+
async function loadTone(): Promise<typeof import("tone")> {
13+
if (!Tone) {
14+
Tone = await import("tone");
15+
await Tone.start();
16+
}
17+
return Tone;
18+
}
19+
20+
export class PitchBackend implements IAudioBackend {
21+
private inner: IAudioBackend | null = null;
22+
private pitchShift: import("tone").PitchShift | null = null;
23+
private isToneLoaded = false;
24+
private pendingLoad: (() => Promise<void>) | null = null;
25+
26+
setInnerBackend(backend: IAudioBackend): void {
27+
this.inner = backend;
28+
}
29+
30+
async load(url: string): Promise<void> {
31+
if (!this.inner) {
32+
throw new Error("No inner backend configured");
33+
}
34+
35+
if (this.pendingLoad) {
36+
this.pendingLoad = null;
37+
}
38+
39+
this.pendingLoad = async () => {
40+
await this.inner!.load(url);
41+
};
42+
43+
await this.pendingLoad();
44+
}
45+
46+
async play(): Promise<void> {
47+
if (!this.inner) return;
48+
49+
if (!this.isToneLoaded) {
50+
await this.initializeTone();
51+
}
52+
53+
await this.inner.play();
54+
}
55+
56+
pause(): void {
57+
this.inner?.pause();
58+
}
59+
60+
stop(): void {
61+
this.inner?.stop();
62+
}
63+
64+
seek(time: number): void {
65+
this.inner?.seek(time);
66+
}
67+
68+
setVolume(volume: number): void {
69+
this.inner?.setVolume(volume);
70+
}
71+
72+
setPlaybackRate(rate: number): void {
73+
if (playerInstance) {
74+
playerInstance.playbackRate = rate;
75+
}
76+
this.inner?.setPlaybackRate(rate);
77+
}
78+
79+
async setPitch(semitones: number): Promise<void> {
80+
if (!this.isToneLoaded) {
81+
await this.initializeTone();
82+
}
83+
84+
if (playerInstance) {
85+
playerInstance.pitch = semitones;
86+
}
87+
}
88+
89+
getCurrentTime(): number {
90+
return this.inner?.getCurrentTime() ?? 0;
91+
}
92+
93+
getDuration(): number {
94+
return this.inner?.getDuration() ?? 0;
95+
}
96+
97+
onTimeUpdate(callback: (time: number) => void): void {
98+
this.inner?.onTimeUpdate(callback);
99+
}
100+
101+
onEnded(callback: () => void): void {
102+
this.inner?.onEnded(callback);
103+
}
104+
105+
onError(callback: (error: Error) => void): void {
106+
this.inner?.onError(callback);
107+
}
108+
109+
dispose(): void {
110+
if (playerInstance) {
111+
playerInstance.dispose();
112+
playerInstance = null;
113+
}
114+
115+
if (this.pitchShift) {
116+
this.pitchShift.dispose();
117+
this.pitchShift = null;
118+
}
119+
120+
this.inner?.dispose();
121+
this.inner = null;
122+
}
123+
124+
private async initializeTone(): Promise<void> {
125+
const tone = await loadTone();
126+
this.isToneLoaded = true;
127+
128+
const player = new tone.Player().toDestination();
129+
playerInstance = player;
130+
131+
this.pitchShift = new tone.PitchShift({
132+
pitch: 0,
133+
windowSize: 0.1,
134+
});
135+
}
136+
137+
getTonePlayer(): TonePlayerInstance | null {
138+
return playerInstance;
139+
}
140+
}
141+
142+
export function createPitchBackend(inner?: IAudioBackend): PitchBackend {
143+
const backend = new PitchBackend();
144+
if (inner) {
145+
backend.setInnerBackend(inner);
146+
}
147+
return backend;
148+
}

0 commit comments

Comments
 (0)