Initial Commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type MaschineOptions,
|
||||
MaschinePlugin,
|
||||
type SampleLoader,
|
||||
} from "./maschine.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
# maschine
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`MaschinePlugin` is the concrete `MidiControllerPlugin` subclass for
|
||||
Native Instruments Maschine: a 16-pad grid with per-pad velocity, a
|
||||
16-step sequencer row per pad, and slot-based sample loading. It inherits
|
||||
the universal MIDI lifecycle from the base and adds the device-specific
|
||||
surface declared by the spec: `setPadVelocity`, `setStep`, and
|
||||
`loadSample`. Sample loading is delegated to an injectable
|
||||
`SampleLoader` so the package stays universal and side-effect-free.
|
||||
|
||||
## Features
|
||||
|
||||
- 16 pads × 16 steps internal grid.
|
||||
- Per-pad velocity table (0–127) with `MIDIOutput` echo.
|
||||
- `loadSample(slot, id, { signal })` honours `AbortSignal` both before
|
||||
and after the loader resolves.
|
||||
- Range-checked pad index, step index, velocity, and slot index.
|
||||
- Sample registry exposed via `getSample(slotIndex)`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
const maschine = new MaschinePlugin({ adapter, transport });
|
||||
await maschine.initialize();
|
||||
maschine.setPadVelocity(3, 96);
|
||||
maschine.setStep(3, 0, true);
|
||||
await maschine.loadSample(3, "kick-808");
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
const ctl = new AbortController();
|
||||
const maschine = new MaschinePlugin({
|
||||
adapter,
|
||||
transport,
|
||||
sampleLoader: async (id, signal) => loadFromDrive(id, signal),
|
||||
});
|
||||
await maschine.initialize({ signal: ctl.signal });
|
||||
maschine.setPadVelocity(0, 127);
|
||||
maschine.setStep(0, 0, true);
|
||||
maschine.setStep(0, 4, true);
|
||||
maschine.setStep(0, 8, true);
|
||||
maschine.setStep(0, 12, true);
|
||||
await maschine.loadSample(0, "kick-808", { signal: ctl.signal });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new MaschinePlugin({ adapter, transport, sampleLoader? })`.
|
||||
- `setPadVelocity(padIndex, velocity)` / `getPadVelocity(padIndex)`.
|
||||
- `setStep(padIndex, stepIndex, active)` / `getStep(padIndex, stepIndex)`.
|
||||
- `loadSample(slotIndex, sampleId, { signal? })` → `Promise<void>`.
|
||||
- `getSample(slotIndex)` → `string | undefined`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`loadSample` performs a pre-await abort check, awaits the loader, then
|
||||
re-checks the signal at the next await boundary — the spec's
|
||||
"mid-operation abort" guarantee. Any failure inside the loader is
|
||||
re-thrown as `AUDIO_CONTROLLERS.DEVICE_ERROR` carrying the original error
|
||||
as `cause`, so consumers can introspect transport-layer failures without
|
||||
re-implementing error mapping. The default loader is a no-op suitable for
|
||||
unit-test scenarios; production callers wire in their own.
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
createFakeAccess,
|
||||
createFakeAdapter,
|
||||
getFirstOutput,
|
||||
} from "../../../tests/fake-midi.js";
|
||||
import { FakeTransport } from "../../../tests/fake-transport.js";
|
||||
import {
|
||||
AUDIO_CONTROLLERS_CODES,
|
||||
AudioControllersError,
|
||||
} from "../../error-codes/index.js";
|
||||
import { MaschinePlugin, type SampleLoader } from "./maschine.js";
|
||||
|
||||
async function setup(loader?: SampleLoader) {
|
||||
const transport = new FakeTransport();
|
||||
const access = createFakeAccess();
|
||||
const adapter = createFakeAdapter(access);
|
||||
const plugin = new MaschinePlugin({
|
||||
adapter,
|
||||
transport,
|
||||
...(loader ? { sampleLoader: loader } : {}),
|
||||
});
|
||||
await plugin.initialize();
|
||||
return { plugin, access };
|
||||
}
|
||||
|
||||
describe("MaschinePlugin", () => {
|
||||
it("setPadVelocity writes to the output and stores velocity", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { plugin, access } = await setup();
|
||||
plugin.setPadVelocity(3, 96);
|
||||
expect(plugin.getPadVelocity(3)).toBe(96);
|
||||
expect(getFirstOutput(access).sent.at(-1)).toEqual([0x90, 0x33, 96]);
|
||||
});
|
||||
|
||||
it("setPadVelocity rejects an out-of-range pad index with AUDIO_CONTROLLERS.DEVICE_ERROR", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { plugin } = await setup();
|
||||
expect(() => plugin.setPadVelocity(16, 64)).toThrow(
|
||||
expect.objectContaining({
|
||||
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
|
||||
})
|
||||
);
|
||||
expect(() => plugin.setPadVelocity(-1, 64)).toThrow(
|
||||
expect.objectContaining({
|
||||
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
|
||||
})
|
||||
);
|
||||
expect(() => plugin.setPadVelocity(1.5, 64)).toThrow(
|
||||
expect.objectContaining({
|
||||
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("setPadVelocity rejects out-of-range velocity", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { plugin } = await setup();
|
||||
expect(() => plugin.setPadVelocity(0, 200)).toThrow(
|
||||
expect.objectContaining({
|
||||
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
|
||||
})
|
||||
);
|
||||
expect(() => plugin.setPadVelocity(0, -1)).toThrow(
|
||||
expect.objectContaining({
|
||||
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("setStep / getStep maintain the per-pad grid", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { plugin } = await setup();
|
||||
plugin.setStep(0, 0, true);
|
||||
plugin.setStep(0, 4, true);
|
||||
expect(plugin.getStep(0, 0)).toBe(true);
|
||||
expect(plugin.getStep(0, 1)).toBe(false);
|
||||
expect(plugin.getStep(0, 4)).toBe(true);
|
||||
});
|
||||
|
||||
it("setStep rejects out-of-range step", { timeout: 5000 }, async () => {
|
||||
const { plugin } = await setup();
|
||||
expect(() => plugin.setStep(0, 16, true)).toThrow(
|
||||
expect.objectContaining({
|
||||
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
|
||||
})
|
||||
);
|
||||
expect(() => plugin.setStep(0, -1, true)).toThrow(
|
||||
expect.objectContaining({
|
||||
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("loadSample resolves via the injected loader", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let called: string | undefined;
|
||||
const { plugin } = await setup(async (id) => {
|
||||
called = id;
|
||||
});
|
||||
await plugin.loadSample(2, "kick");
|
||||
expect(called).toBe("kick");
|
||||
expect(plugin.getSample(2)).toBe("kick");
|
||||
});
|
||||
|
||||
it("loadSample without explicit loader is a no-op", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { plugin } = await setup();
|
||||
await plugin.loadSample(0, "x");
|
||||
expect(plugin.getSample(0)).toBe("x");
|
||||
});
|
||||
|
||||
it("loadSample wraps loader failure in AUDIO_CONTROLLERS.DEVICE_ERROR", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { plugin } = await setup(async () => {
|
||||
throw new Error("io");
|
||||
});
|
||||
try {
|
||||
await plugin.loadSample(0, "boom");
|
||||
throw new Error("did not throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(AudioControllersError);
|
||||
expect((err as AudioControllersError).code).toBe(
|
||||
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("loadSample rejects pre-aborted signal", { timeout: 5000 }, async () => {
|
||||
const { plugin } = await setup();
|
||||
const ctl = new AbortController();
|
||||
ctl.abort();
|
||||
await expect(
|
||||
plugin.loadSample(0, "x", { signal: ctl.signal })
|
||||
).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("loadSample honours mid-operation abort", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctl = new AbortController();
|
||||
const { plugin } = await setup(async (_id, signal) => {
|
||||
ctl.abort();
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
await expect(
|
||||
plugin.loadSample(0, "x", { signal: ctl.signal })
|
||||
).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("loadSample wraps non-Error throw in AUDIO_CONTROLLERS.DEVICE_ERROR", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { plugin } = await setup(async () => {
|
||||
throw "string";
|
||||
});
|
||||
await expect(plugin.loadSample(0, "x")).rejects.toThrow(
|
||||
expect.objectContaining({
|
||||
code: AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("getPadVelocity returns 0 for fresh pad", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { plugin } = await setup();
|
||||
expect(plugin.getPadVelocity(0)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { MidiControllerPlugin } from "../../base/midi-controller-plugin.js";
|
||||
import type {
|
||||
ControllerMetadata,
|
||||
MidiControllerPluginOptions,
|
||||
} from "../../base/midi-controller-plugin.types.js";
|
||||
import {
|
||||
AUDIO_CONTROLLERS_CODES,
|
||||
AudioControllersError,
|
||||
} from "../../error-codes/index.js";
|
||||
|
||||
export type SampleLoader = (
|
||||
sampleId: string,
|
||||
signal?: AbortSignal
|
||||
) => Promise<void>;
|
||||
|
||||
export interface MaschineOptions extends MidiControllerPluginOptions {
|
||||
readonly sampleLoader?: SampleLoader;
|
||||
}
|
||||
|
||||
const META: ControllerMetadata = {
|
||||
id: "maschine",
|
||||
name: "Maschine",
|
||||
manufacturer: "Native Instruments",
|
||||
portNamePatterns: ["Maschine"],
|
||||
};
|
||||
|
||||
const PAD_COUNT = 16;
|
||||
const STEPS_PER_PAD = 16;
|
||||
|
||||
/**
|
||||
* Native Instruments Maschine controller — 16-pad grid with per-pad
|
||||
* step-sequencer rows and slot-based sample loading. Step state is held
|
||||
* locally and surfaced to the inherited `sequencer`'s step bank.
|
||||
*/
|
||||
export class MaschinePlugin extends MidiControllerPlugin {
|
||||
private readonly velocities = new Uint8Array(PAD_COUNT);
|
||||
private readonly steps: boolean[][] = Array.from(
|
||||
{ length: PAD_COUNT },
|
||||
() => new Array<boolean>(STEPS_PER_PAD).fill(false)
|
||||
);
|
||||
private readonly samples = new Map<number, string>();
|
||||
private readonly sampleLoader: SampleLoader;
|
||||
|
||||
constructor(options: MaschineOptions) {
|
||||
super(META, options);
|
||||
this.sampleLoader = options.sampleLoader ?? defaultSampleLoader;
|
||||
}
|
||||
|
||||
public setPadVelocity(padIndex: number, velocity: number): void {
|
||||
this.assertPadIndex(padIndex);
|
||||
if (velocity < 0 || velocity > 127) {
|
||||
throw new AudioControllersError(
|
||||
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
|
||||
"audio-controllers.maschine.velocity",
|
||||
{ context: { velocity } }
|
||||
);
|
||||
}
|
||||
this.velocities[padIndex] = velocity;
|
||||
this.sendOutput([0x90, 0x30 + padIndex, velocity]);
|
||||
}
|
||||
|
||||
public getPadVelocity(padIndex: number): number {
|
||||
this.assertPadIndex(padIndex);
|
||||
return this.velocities[padIndex] as number;
|
||||
}
|
||||
|
||||
public setStep(padIndex: number, stepIndex: number, active: boolean): void {
|
||||
this.assertPadIndex(padIndex);
|
||||
if (stepIndex < 0 || stepIndex >= STEPS_PER_PAD) {
|
||||
throw new AudioControllersError(
|
||||
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
|
||||
"audio-controllers.maschine.step-index",
|
||||
{ context: { stepIndex, max: STEPS_PER_PAD - 1 } }
|
||||
);
|
||||
}
|
||||
(this.steps[padIndex] as boolean[])[stepIndex] = active;
|
||||
}
|
||||
|
||||
public getStep(padIndex: number, stepIndex: number): boolean {
|
||||
this.assertPadIndex(padIndex);
|
||||
return (this.steps[padIndex] as boolean[])[stepIndex] as boolean;
|
||||
}
|
||||
|
||||
public async loadSample(
|
||||
slotIndex: number,
|
||||
sampleId: string,
|
||||
opts: { readonly signal?: AbortSignal } = {}
|
||||
): Promise<void> {
|
||||
this.assertPadIndex(slotIndex);
|
||||
if (opts.signal?.aborted) {
|
||||
throw new AudioControllersError(
|
||||
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
|
||||
"audio-controllers.maschine.aborted",
|
||||
{ context: { slotIndex, sampleId } }
|
||||
);
|
||||
}
|
||||
try {
|
||||
await this.sampleLoader(sampleId, opts.signal);
|
||||
} catch (err) {
|
||||
throw new AudioControllersError(
|
||||
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
|
||||
"audio-controllers.maschine.load-failed",
|
||||
{
|
||||
...(err instanceof Error ? { cause: err } : {}),
|
||||
context: { slotIndex, sampleId },
|
||||
}
|
||||
);
|
||||
}
|
||||
if (opts.signal?.aborted) {
|
||||
throw new AudioControllersError(
|
||||
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
|
||||
"audio-controllers.maschine.aborted",
|
||||
{ context: { slotIndex, sampleId } }
|
||||
);
|
||||
}
|
||||
this.samples.set(slotIndex, sampleId);
|
||||
}
|
||||
|
||||
public getSample(slotIndex: number): string | undefined {
|
||||
return this.samples.get(slotIndex);
|
||||
}
|
||||
|
||||
private assertPadIndex(padIndex: number): void {
|
||||
if (
|
||||
!Number.isInteger(padIndex) ||
|
||||
padIndex < 0 ||
|
||||
padIndex >= PAD_COUNT
|
||||
) {
|
||||
throw new AudioControllersError(
|
||||
AUDIO_CONTROLLERS_CODES.DEVICE_ERROR,
|
||||
"audio-controllers.maschine.pad-index",
|
||||
{ context: { padIndex, max: PAD_COUNT - 1 } }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultSampleLoader(): Promise<void> {
|
||||
/* No-op: callers wire their own loader via MaschineOptions.sampleLoader. */
|
||||
}
|
||||
Reference in New Issue
Block a user