Writing an Audio Backend
Implement IAudioBackend to replace the built-in AudioElementBackend or WebAudioBackend with a custom audio pipeline, for example a Capacitor native audio bridge, a Cordova plugin wrapper, or a WebCodecs-based decoder.
For the general IAudioBackend interface reference, see IAudioBackend.
Minimal implementation
TypeScript
import type {
IAudioBackend,
BackendEvent,
BackendState,
BackendLoaderState,
} from '@nomercy-entertainment/nomercy-music-player/adapters/audio-backend';
type Listener = (data?: any) => void;
export class NativeAudioBackend implements IAudioBackend {
readonly kind = 'audio-element' as const; // or 'webaudio'
private listeners = new Map<string, Set<Listener>>();
private _state: BackendState = 'idle';
// ── Lifecycle ──────────────────────────────────────────────────────────────
async load(url: string): Promise<void> {
this._state = 'loading';
// Send load command to native layer
await NativeAudio.load(url);
this._state = 'ready';
this.fire('loadedmetadata');
this.fire('canplay');
}
unload(): void {
NativeAudio.unload();
this._state = 'idle';
}
dispose(): void {
NativeAudio.dispose();
this.listeners.clear();
}
// ── Transport ──────────────────────────────────────────────────────────────
async play(): Promise<void> {
await NativeAudio.play();
this._state = 'playing';
this.fire('play');
this.fire('playing');
}
pause(): void {
NativeAudio.pause();
this._state = 'paused';
this.fire('pause');
}
stop(): void {
NativeAudio.stop();
this._state = 'idle';
}
// ── Time ──────────────────────────────────────────────────────────────────
currentTime(): number;
currentTime(seconds: number): void;
currentTime(seconds?: number): number | void {
if (seconds === undefined) return NativeAudio.currentTime();
NativeAudio.seek(seconds);
}
duration(): number {
return NativeAudio.duration();
}
buffered(): number {
return NativeAudio.buffered();
}
bufferedRanges(): TimeRanges {
return NativeAudio.bufferedRanges();
}
seekable(): TimeRanges {
return NativeAudio.seekable();
}
playbackRate(): number;
playbackRate(rate: number): void;
playbackRate(rate?: number): number | void {
if (rate === undefined) return NativeAudio.playbackRate();
NativeAudio.setPlaybackRate(rate);
}
// ── Volume ─────────────────────────────────────────────────────────────────
volume(): number;
volume(level: number): void;
volume(level?: number): number | void {
if (level === undefined) return NativeAudio.volume();
NativeAudio.setVolume(level);
}
mute(): void {
NativeAudio.mute();
}
unmute(): void {
NativeAudio.unmute();
}
// ── State ──────────────────────────────────────────────────────────────────
state(): BackendState {
return this._state;
}
// ── Graph (stubs, native bridge doesn't expose Web Audio) ────────────────
outputNode(ctx: AudioContext): AudioNode {
return ctx.destination;
}
analyserSource(ctx: AudioContext): AudioNode {
return ctx.destination;
}
// ── Raw / Capture ──────────────────────────────────────────────────────────
mediaElement(): HTMLMediaElement {
throw new Error('No media element in native backend');
}
captureStream(): MediaStream {
throw new Error('Not supported');
}
// ── Routing ───────────────────────────────────────────────────────────────
async setSinkId(_deviceId: string): Promise<void> {
/* no-op */
}
getSinkId(): string {
return 'default';
}
// ── DRM ───────────────────────────────────────────────────────────────────
mediaKeys(): MediaKeys | undefined {
return undefined;
}
async setMediaKeys(_keys: MediaKeys): Promise<void> {
/* no-op */
}
outputProtectionState(): 'unrestricted' {
return 'unrestricted';
}
// ── Backpressure ──────────────────────────────────────────────────────────
pauseLoader(): void {
/* no-op */
}
resumeLoader(): void {
/* no-op */
}
loaderState(): BackendLoaderState {
return 'running';
}
// ── Crossfade ─────────────────────────────────────────────────────────────
// Native backends that cannot dual-buffer should return false:
supportsCrossfade(): boolean {
return false;
}
async loadSecondary(_url: string): Promise<void> {
throw new Error('Crossfade not supported by this backend');
}
disposeSecondary(): void {
/* no-op */
}
async primeSecondary(_seekMs?: number): Promise<void> {
throw new Error('Crossfade not supported by this backend');
}
async crossfade(_durationMs: number): Promise<void> {
throw new Error('Crossfade not supported by this backend');
}
secondaryGain(): number;
secondaryGain(_value: number): void;
secondaryGain(_value?: number): number | void {
return 0;
}
// ── Events ────────────────────────────────────────────────────────────────
on(event: BackendEvent, fn: Listener): void {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event)!.add(fn);
}
off(event: BackendEvent, fn: Listener): void {
this.listeners.get(event)?.delete(fn);
}
private fire(event: BackendEvent, data?: unknown): void {
this.listeners.get(event)?.forEach((fn) => fn(data));
}
}
Wiring with backendFactory
TypeScript
player.setup({
backendFactory: (_kind, _config) => new NativeAudioBackend(),
});
timeupdate requirement
The player drives its time event from the backend’s timeupdate event.
Fire it at regular intervals during playback:
TypeScript
async play(): Promise<void> {
await NativeAudio.play();
this._state = 'playing';
this.fire('play');
this.fire('playing');
// Drive timeupdate:
this._rafId = requestAnimationFrame(() => this.tickTime());
}
private _rafId = 0;
private tickTime(): void {
if (this._state !== 'playing') return;
this.fire('timeupdate');
this._rafId = requestAnimationFrame(() => this.tickTime());
}
See also
- IAudioBackend, full interface reference
- Backends overview, built-in backend capabilities
- Configuration,
backendFactoryoption