Skip to content

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