Skip to content

Commit

Permalink
feat: add audio node interface to BasePlayback (#16)
Browse files Browse the repository at this point in the history
Add comprehensive audio node connectivity support to the BasePlayback class and its mixins. This change enables direct connection management between audio nodes and parameters.

Key changes:
- Add connect/disconnect methods to BasePlayback for audio routing
- Add inputNode/outputNode abstract getters for connection points
- Implement node access in Volume, Panner, and Oscillator mixins
- Add error handling for node access after cleanup
- Add comprehensive test coverage for node connections

This improvement allows for:
- Direct connection to Web Audio nodes and parameters
- Flexible audio routing between components
- Better error handling for node lifecycle management
  • Loading branch information
ctoth authored Jan 28, 2025
1 parent 71f02b6 commit b4ee6df
Show file tree
Hide file tree
Showing 8 changed files with 251 additions and 3 deletions.
67 changes: 67 additions & 0 deletions src/basePlayback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { vi, expect, describe, it, beforeEach } from "vitest";
import { audioContextMock } from "./setupTests";
import { BasePlayback } from "./basePlayback";
import { Sound } from "./sound";

// Create a concrete implementation of BasePlayback for testing
class TestPlayback extends BasePlayback {
constructor(
public source?: AudioNode,
public gainNode?: GainNode,
public panner?: AudioNode
) {
super();
}

play(): [this] { return [this]; }
pause(): void {}
stop(): void {}
cleanup(): void {
this.source = undefined;
this.gainNode = undefined;
this.panner = undefined;
}
}

describe("BasePlayback Audio Node Interface", () => {
let playback: TestPlayback;
let destination: AudioNode;
let param: AudioParam;

beforeEach(() => {
const source = audioContextMock.createOscillator();
const gainNode = audioContextMock.createGain();
const panner = audioContextMock.createStereoPanner();
playback = new TestPlayback(source, gainNode, panner);
destination = audioContextMock.createGain();
param = destination.gain;
});

describe("Connection Methods", () => {
it("can connect to other nodes", () => {
const connectSpy = vi.spyOn(playback.outputNode, 'connect');
playback.connect(destination);
expect(connectSpy).toHaveBeenCalledWith(destination);
});

it("can connect to audio params", () => {
const connectSpy = vi.spyOn(playback.outputNode, 'connect');
playback.connect(param);
expect(connectSpy).toHaveBeenCalledWith(param);
});

it("can disconnect all outputs", () => {
const disconnectSpy = vi.spyOn(playback.outputNode, 'disconnect');
playback.disconnect();
expect(disconnectSpy).toHaveBeenCalled();
});
});

describe("Error Handling", () => {
it("throws when accessing nodes after cleanup", () => {
playback.cleanup();
expect(() => playback.connect(destination)).toThrow();
expect(() => playback.disconnect()).toThrow();
});
});
});
34 changes: 32 additions & 2 deletions src/basePlayback.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { IPlaybackContainer } from "./container";
import { AudioNode } from "./context";
import { AudioNode, AudioParam } from "./context";
import { FilterManager } from "./filters";
import { PannerMixin } from "./pannerMixin";
import { VolumeMixin } from "./volumeMixin";
Expand All @@ -14,16 +14,46 @@ export abstract class BasePlayback extends PannerMixin(
abstract play(): [this];
abstract pause(): void;
abstract stop(): void;
abstract cleanup(): void;

/**
* Checks if the audio is currently playing.
* @returns {boolean} True if the audio is playing, false otherwise.
*/

get isPlaying(): boolean {
if (!this.source) {
return false;
}
return this._playing;
}

/**
* Gets the first node in the audio chain that can receive input
*/
abstract get inputNode(): AudioNode;

/**
* Gets the final node in the audio chain
*/
abstract get outputNode(): AudioNode;

/**
* Connects the output to an audio node or param
*/
connect(destination: AudioNode | AudioParam): void {
if (!this.source) {
throw new Error('Cannot access nodes of a cleaned up sound');
}
this.outputNode.connect(destination);
}

/**
* Disconnects all outputs
*/
disconnect(): void {
if (!this.source) {
throw new Error('Cannot access nodes of a cleaned up sound');
}
this.outputNode.disconnect();
}
}
8 changes: 8 additions & 0 deletions src/filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ export type FilterCloneOverrides = {
export abstract class FilterManager {
_filters: BiquadFilterNode[] = [];

get inputNode(): AudioNode {
return this._filters[0] || this.source!;
}

get outputNode(): AudioNode {
return this._filters[this._filters.length - 1] || this.source!;
}

addFilter(filter: BiquadFilterNode) {
this._filters.push(filter);
}
Expand Down
14 changes: 14 additions & 0 deletions src/oscillatorMixin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ export function OscillatorMixin<TBase extends Constructor>(Base: TBase) {
_oscillatorOptions: Partial<OscillatorOptions> = {};
declare public source?: OscillatorNode;

get inputNode(): AudioNode {
if (!this.source) {
throw new Error('Cannot access nodes of a cleaned up sound');
}
return this.source;
}

get outputNode(): AudioNode {
if (!this.source) {
throw new Error('Cannot access nodes of a cleaned up sound');
}
return this.source;
}

get oscillatorOptions(): Partial<OscillatorOptions> {
return this._oscillatorOptions;
}
Expand Down
14 changes: 14 additions & 0 deletions src/pannerMixin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ export function PannerMixin<TBase extends Constructor>(Base: TBase) {
panner?: PannerNode | StereoPannerNode;
_panType: PanType = 'stereo';

get inputNode(): AudioNode {
if (!this.panner) {
throw new Error('Cannot access nodes of a cleaned up sound');
}
return this.panner;
}

get outputNode(): AudioNode {
if (!this.panner) {
throw new Error('Cannot access nodes of a cleaned up sound');
}
return this.panner;
}

get panType(): PanType {
return this._panType;
}
Expand Down
87 changes: 87 additions & 0 deletions src/playback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,16 @@ describe("Playback filters chain", () => {
gainNode = audioContextMock.createGain();
sound = new Sound("test-url", buffer, audioContextMock, gainNode);
playback = new Playback(sound, source, gainNode);

// Ensure filter nodes have proper connect methods
vi.spyOn(audioContextMock, 'createBiquadFilter').mockImplementation(() => ({
connect: vi.fn(),
disconnect: vi.fn(),
type: 'lowpass',
frequency: { value: 350 },
Q: { value: 1 },
gain: { value: 0 }
}));
});

it("connects multiple filters in order", () => {
Expand All @@ -280,6 +290,83 @@ describe("Playback filters chain", () => {
});
});

describe("Audio Node Chain", () => {
let playback: Playback;
let buffer: AudioBuffer;
let source: AudioBufferSourceNode;
let gainNode: GainNode;
let sound: Sound;

beforeEach(() => {
buffer = new AudioBuffer({ length: 100, sampleRate: 44100 });
source = audioContextMock.createBufferSource();
source.buffer = buffer;
gainNode = audioContextMock.createGain();
sound = new Sound("test-url", buffer, audioContextMock, gainNode);
playback = new Playback(sound, source, gainNode);
});

it("maintains correct chain order with filters", () => {
const filter1 = audioContextMock.createBiquadFilter();
const filter2 = audioContextMock.createBiquadFilter();

// Spy on the panner's connect method since that's the start of our chain
const pannerConnectSpy = vi.spyOn(playback.panner!, 'connect');

playback.addFilter(filter1 as unknown as BiquadFilterNode);
playback.addFilter(filter2 as unknown as BiquadFilterNode);

// Verify the panner was connected
expect(pannerConnectSpy).toHaveBeenCalled();
});

it("can connect to external Web Audio nodes", () => {
const externalNode = audioContextMock.createGain();
const connectSpy = vi.spyOn(playback.outputNode, 'connect');

playback.connect(externalNode);

expect(connectSpy).toHaveBeenCalledWith(externalNode);
});

it("maintains node access during play/pause/stop states", () => {
const getNodes = () => ({
input: playback.inputNode,
output: playback.outputNode
});

// Test initial state
expect(getNodes().input).toBeDefined();
expect(getNodes().output).toBeDefined();

// Test playing state
playback.play();
expect(getNodes().input).toBeDefined();
expect(getNodes().output).toBeDefined();

// Test paused state
playback.pause();
expect(getNodes().input).toBeDefined();
expect(getNodes().output).toBeDefined();

// Test stopped state
playback.stop();
expect(getNodes().input).toBeDefined();
expect(getNodes().output).toBeDefined();
});

it("properly handles connections after cleanup", () => {
const externalNode = audioContextMock.createGain();

playback.cleanup();

expect(() => playback.connect(externalNode)).toThrow('Cannot access nodes of a cleaned up sound');
expect(() => playback.disconnect()).toThrow('Cannot access nodes of a cleaned up sound');
expect(() => playback.inputNode).toThrow('Cannot access nodes of a cleaned up sound');
expect(() => playback.outputNode).toThrow('Cannot access nodes of a cleaned up sound');
});
});

describe("Playback error cases", () => {
let playback: Playback;
let buffer: AudioBuffer;
Expand Down
14 changes: 14 additions & 0 deletions src/playback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,20 @@ export class Playback extends BasePlayback implements BaseSound {
* @throws {Error} Throws an error if the sound has been cleaned up.
*/

get inputNode(): AudioNode {
if (!this.source) {
throw new Error('Cannot access nodes of a cleaned up sound');
}
return super.inputNode;
}

get outputNode(): AudioNode {
if (!this.source) {
throw new Error('Cannot access nodes of a cleaned up sound');
}
return super.outputNode;
}

private refreshFilters(): void {
if (!this.panner || !this.gainNode) {
throw new Error(
Expand Down
16 changes: 15 additions & 1 deletion src/volumeMixin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { GainNode } from "./context";
import { AudioNode, GainNode } from "./context";
import { FilterManager } from "./filters";

export type VolumeCloneOverrides = {
Expand All @@ -11,6 +11,20 @@ export function VolumeMixin<TBase extends Constructor>(Base: TBase) {
abstract class VolumeMixin extends Base {
gainNode?: GainNode;

get inputNode(): AudioNode {
if (!this.gainNode) {
throw new Error('Cannot access nodes of a cleaned up sound');
}
return this.gainNode;
}

get outputNode(): AudioNode {
if (!this.gainNode) {
throw new Error('Cannot access nodes of a cleaned up sound');
}
return this.gainNode;
}

setGainNode(gainNode: GainNode) {
this.gainNode = gainNode;
}
Expand Down

0 comments on commit b4ee6df

Please sign in to comment.