RolandJansen/intermix.js

View on GitHub
src/plugins/Sampler.ts

Summary

Maintainability
A
35 mins
Test Coverage
A
90%
import { AbstractPlugin } from "../registry/AbstractPlugin";
import { Tuple, IntermixNote } from "../interfaces/interfaces";
import { IOscActionDef } from "../interfaces/IActions";
import { IPlugin, IPluginConstructor } from "../interfaces/IRegistryItems";

const Plugin: IPluginConstructor = class Sampler extends AbstractPlugin implements IPlugin {
    public static readonly METADATA = {
        type: "instrument",
        name: "Intermix Sampler",
        version: "1.0.0",
        authors: "R. Jansen",
        desc: "A sample player",
    };
    private static readonly PREFIX = "/intermix/plugin/<UID>/";

    public readonly actionDefs: IOscActionDef[] = [
        {
            address: Sampler.PREFIX + "audioData",
            typeTag: ",b",
        },
    ];

    // private myState: IState = {};
    private gainNode: GainNode;
    private audioData: AudioBuffer;
    private queue: AudioBufferSourceNode[] = []; // list of polyphonic voices

    constructor(public readonly uid: string, private ac: AudioContext) {
        super();

        // create gain node
        this.gainNode = this.ac.createGain();
        this.gainNode.gain.value = 1;

        // create an empty audio buffer to prevent potential errors
        this.audioData = ac.createBuffer(1, 1, ac.sampleRate);
    }

    // list of all audio output nodes
    public get outputs(): AudioNode[] {
        return [this.gainNode];
    }

    // list of all input nodes, if no inputs, return an empty list
    public get inputs(): AudioNode[] {
        return [];
    }

    // onChange gets called
    // on every state change
    public onChange(changed: Tuple): boolean {
        switch (changed[0]) {
            case "note":
                const note: IntermixNote = changed[1];
                this.handleNote(note);
                return true;
            case "volume":
                this.handleVolume(changed[1]);
                return true;
            case "stop":
                this.stop();
                return true;
            case "audioData":
                const buffer: AudioBuffer = changed[1];
                this.handleAudioData(buffer);
                return true;
            default:
                return false;
        }
    }

    private handleNote(note: IntermixNote): void {
        if (note[1] >= 0 && note[1] <= 127) {
            this.start(note);
        }
    }

    private handleVolume(volume: number): void {
        if (volume >= 0 && volume <= 127) {
            this.gainNode.gain.value = volume / 128;
        }
    }

    private handleAudioData(buffer: AudioBuffer): void {
        this.audioData = buffer;
    }

    /**
     * Starts a sound (AudioBufferSourceNode) and stores a references
     * in a queue. This enables you to play multiple sounds at once
     * and even stop them all at a given time.
     */
    private start(note: IntermixNote): void {
        // const frequency = this.frequencyLookup[note.value];
        const playbackRate = this.getPlaybackRate(note[1]);

        const bufferSrcNode = this.createBufferSrcNode();

        bufferSrcNode.playbackRate.value = playbackRate;
        bufferSrcNode.connect(this.gainNode);

        this.queue.push(bufferSrcNode);
        bufferSrcNode.start(note[4]);
    }

    /**
     * Stops all audio stream, even the ones that are just scheduled.
     * It also cleans the queue so that the sound object is ready for another round.
     */
    private stop(): void {
        this.queue.forEach((node) => {
            node.stop();
            node.disconnect();
        });
        this.queue = []; // release all references
    }

    /**
     * Creates and configures a BufferSourceNode
     * that can be played once and then destroys itself.
     */
    private createBufferSrcNode(): AudioBufferSourceNode {
        const bufferSrc: AudioBufferSourceNode = this.ac.createBufferSource();
        bufferSrc.buffer = this.audioData;
        bufferSrc.onended = (): void => {
            this.destroyBufferSrcNode(bufferSrc);
        };

        return bufferSrc;
    }

    /**
     * This is not really note-acurate at the moment,
     * it just computes a value between 0-2 (1=default playback rate)
     * See: AudioBufferSourceNode.playbackRate at MDN
     * @param noteValue midi note number (0-127)
     */
    private getPlaybackRate(noteValue: number): number {
        const normalized = (noteValue - 64) / 64;
        const normalizedShifted = normalized + 1;
        return normalizedShifted;
    }

    /**
     * Destroyes a given AudioBufferSourceNode and deletes it
     * from the sourceNode queue. This is used in the onended
     * callback of all BufferSourceNodes to avoid dead references.
     */
    private destroyBufferSrcNode(bufferSrc: AudioBufferSourceNode): void {
        bufferSrc.disconnect();

        this.queue.forEach((node, index) => {
            if (bufferSrc === node) {
                this.queue.splice(index, 1);
            }
        });
    }
};
export default Plugin;