Initial Commit
This commit is contained in:
90
packages/media/audio/midi/README.md
Normal file
90
packages/media/audio/midi/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# @suaveplan/midi - Universal MIDI primitives: 18-variant MidiMessage discriminated union, encode/decode with running-status, NRPN/RPN/CC14 reducers, SysEx framing with manufacturer registry, MidiPortLike contract, MIDI Clock helpers, General MIDI tables (Level 1, Level 2, GS, XG), and MIDI 2.0 UMP framing with lossless MIDI1<->UMP bridge. Zero browser/Node coupling.
|
||||
|
||||
Universal MIDI primitives for the Suaveplan platform. Owns the canonical types, byte parsers, message constructors, control-surface dispatchers (NRPN, RPN, 14-bit CC), SysEx framing with a manufacturer-id registry, MIDI Clock helpers, the General MIDI Level 1 / Level 2 / GS / XG patch tables, MIDI 2.0 UMP framing with a lossless MIDI1 ↔ UMP bridge, and an OpenTelemetry-shaped (structural) instrumentation surface. Zero browser/Node coupling — Web MIDI access lives in `@suaveplan/react-midi`, node-midi access in a future `@suaveplan/midi-node`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [clock](./src/clock/clock.md) — Helpers for converting between MIDI Clock ticks, beats, and tempo. MIDI 1.0 defines 24 pulses per quarter note (`PPQN =…
|
||||
- [clock](./src/clock/clock.md) — Helpers for converting between MIDI Clock ticks, beats, and tempo. MIDI 1.0 defines 24 pulses per quarter note (`PPQN =…
|
||||
- [cc14](./src/dispatch/cc14.md) — `CC14Reducer` accumulates paired MIDI 1.0 control-change messages into 14-bit values. The wire convention is: CCs 0..31…
|
||||
- [nrpn](./src/dispatch/nrpn.md) — `NRPNReducer` accumulates the four-CC "dance" that MIDI 1.0 uses to express Non-Registered Parameter Number changes int…
|
||||
- [rpn](./src/dispatch/rpn.md) — `RPNReducer` is the sister of `NRPNReducer` for Registered Parameter Numbers — the parameter MSB and LSB CC numbers mov…
|
||||
- [running-status](./src/dispatch/running-status.md) — `RunningStatusDecoder` is the streaming MIDI 1.0 decoder that honours running status — the wire convention where succes…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the `MidiError` class and the `MIDI_CODES` namespaced registry used by every `@suaveplan/midi` module that rais…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the `MidiError` class and the `MIDI_CODES` namespaced registry used by every `@suaveplan/midi` module that rais…
|
||||
- [gs](./src/gm/gs.md) — Roland's GS dialect predates GM2 and uses Bank-Select LSB (CC 32) to select instrument variations on top of the GM1 bas…
|
||||
- [level1](./src/gm/level1.md) — The General MIDI Level 1 patch and percussion tables — the canonical instrument-name lookups every GM-compliant playbac…
|
||||
- [level2](./src/gm/level2.md) — General MIDI Level 2 added a bank-select-driven variation layer on top of GM1. Each GM1 program (0..127) gains optional…
|
||||
- [xg](./src/gm/xg.md) — Yamaha's XG dialect uses Bank-Select MSB (CC 0) to select instrument variations on top of the GM1 base programs — the i…
|
||||
- [decode](./src/message/decode.md) — `decodeMessage` parses one MIDI 1.0 message starting at a given offset in a `Uint8Array` and returns both the typed mes…
|
||||
- [encode](./src/message/encode.md) — `encodeMessage` converts a strongly-typed `MidiMessage` value into the byte sequence a MIDI 1.0 wire format expects. It…
|
||||
- [sysex](./src/sysex/sysex.md) — `parseSysEx` and `buildSysEx` are the canonical helpers for working with MIDI System Exclusive payloads — the manufactu…
|
||||
- [manufacturers](./src/sysex/manufacturers.md) — The manufacturer-id registry maps the bytes that follow `F0` in a SysEx frame to a human-readable name. The MIDI Manufa…
|
||||
- [sysex](./src/sysex/sysex.md) — `parseSysEx` and `buildSysEx` are the canonical helpers for working with MIDI System Exclusive payloads — the manufactu…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — OpenTelemetry-shaped instrumentation for `@suaveplan/midi` without taking a runtime dependency on `@opentelemetry/api`.…
|
||||
- [telemetry](./src/telemetry/telemetry.md) — OpenTelemetry-shaped instrumentation for `@suaveplan/midi` without taking a runtime dependency on `@opentelemetry/api`.…
|
||||
- [ump](./src/ump/ump.md) — MIDI 2.0 introduces the Universal MIDI Packet (UMP) format — fixed-width 32, 64, 96, or 128 bit packets that carry MIDI…
|
||||
- [ump](./src/ump/ump.md) — MIDI 2.0 introduces the Universal MIDI Packet (UMP) format — fixed-width 32, 64, 96, or 128 bit packets that carry MIDI…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/midi
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import {
|
||||
decodeMessage,
|
||||
encodeMessage,
|
||||
RunningStatusDecoder,
|
||||
} from "@suaveplan/midi";
|
||||
|
||||
// Encode a NoteOn.
|
||||
const bytes = encodeMessage({
|
||||
type: "noteOn",
|
||||
channel: 0,
|
||||
note: 60,
|
||||
velocity: 100,
|
||||
});
|
||||
|
||||
// Decode a buffer with running-status enabled.
|
||||
const decoder = new RunningStatusDecoder();
|
||||
decoder.push(bytes);
|
||||
const messages = decoder.drain();
|
||||
// messages = [{ type: "noteOn", channel: 0, note: 60, velocity: 100 }]
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- [error-codes](./src/error-codes.md) — `MidiError` and stable `MIDI.*` code registry.
|
||||
- [message/encode](./src/message/encode.md) — `encodeMessage`, `STATUS` byte constants.
|
||||
- [message/decode](./src/message/decode.md) — `decodeMessage` with optional running-status.
|
||||
- [dispatch/running-status](./src/dispatch/running-status.md) — streaming decoder honouring MIDI 1.0 running status + real-time interleaving.
|
||||
- [dispatch/nrpn](./src/dispatch/nrpn.md) — Non-Registered Parameter Number reducer.
|
||||
- [dispatch/rpn](./src/dispatch/rpn.md) — Registered Parameter Number reducer.
|
||||
- [dispatch/cc14](./src/dispatch/cc14.md) — paired-CC 14-bit reducer.
|
||||
- [sysex/sysex](./src/sysex/sysex.md) — `parseSysEx`, `buildSysEx`, `SysExFrame`.
|
||||
- [sysex/manufacturers](./src/sysex/manufacturers.md) — manufacturer-id registry + `lookupManufacturer`.
|
||||
- [clock/clock](./src/clock/clock.md) — `bpmToClock`, `clockToBpm`, `PPQN`.
|
||||
- [gm/level1](./src/gm/level1.md) — General MIDI Level 1 patch + percussion tables.
|
||||
- [gm/level2](./src/gm/level2.md) — GM2 variation bank lookup.
|
||||
- [gm/gs](./src/gm/gs.md) — Roland GS variation lookup.
|
||||
- [gm/xg](./src/gm/xg.md) — Yamaha XG variation lookup.
|
||||
- [ump/ump](./src/ump/ump.md) — MIDI 2.0 UMP framing + MIDI1 ↔ UMP bridge.
|
||||
- [telemetry/telemetry](./src/telemetry/telemetry.md) — structural OTel-compatible counter + histogram.
|
||||
|
||||
## Tier and dependencies
|
||||
|
||||
- Tier: **universal** — no `window`, no `navigator`, no `node:*`.
|
||||
- Runtime deps: `@suaveplan/error`, `@suaveplan/types`, `tslib`, `zod`.
|
||||
- Test deps: `@suaveplan/testing`, `fast-check`.
|
||||
- No `@opentelemetry/api` runtime dep — OTel is consumed structurally per `MidiTelemetry`.
|
||||
|
||||
## Coverage gate
|
||||
|
||||
`bunx turbo test:coverage --filter=@suaveplan/midi` enforces 100/100/100/100 (statements / branches / functions / lines).
|
||||
|
||||
154
packages/media/audio/midi/package.json
Normal file
154
packages/media/audio/midi/package.json
Normal file
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"name": "@suaveplan/midi",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal MIDI primitives: 18-variant MidiMessage discriminated union, encode/decode with running-status, NRPN/RPN/CC14 reducers, SysEx framing with manufacturer registry, MidiPortLike contract, MIDI Clock helpers, General MIDI tables (Level 1, Level 2, GS, XG), and MIDI 2.0 UMP framing with lossless MIDI1<->UMP bridge. Zero browser/Node coupling.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./message/encode": {
|
||||
"import": {
|
||||
"types": "./dist/message/encode.d.ts",
|
||||
"default": "./dist/message/encode.js"
|
||||
}
|
||||
},
|
||||
"./message/decode": {
|
||||
"import": {
|
||||
"types": "./dist/message/decode.d.ts",
|
||||
"default": "./dist/message/decode.js"
|
||||
}
|
||||
},
|
||||
"./dispatch/running-status": {
|
||||
"import": {
|
||||
"types": "./dist/dispatch/running-status.d.ts",
|
||||
"default": "./dist/dispatch/running-status.js"
|
||||
}
|
||||
},
|
||||
"./dispatch/nrpn": {
|
||||
"import": {
|
||||
"types": "./dist/dispatch/nrpn.d.ts",
|
||||
"default": "./dist/dispatch/nrpn.js"
|
||||
}
|
||||
},
|
||||
"./dispatch/rpn": {
|
||||
"import": {
|
||||
"types": "./dist/dispatch/rpn.d.ts",
|
||||
"default": "./dist/dispatch/rpn.js"
|
||||
}
|
||||
},
|
||||
"./dispatch/cc14": {
|
||||
"import": {
|
||||
"types": "./dist/dispatch/cc14.d.ts",
|
||||
"default": "./dist/dispatch/cc14.js"
|
||||
}
|
||||
},
|
||||
"./sysex/sysex": {
|
||||
"import": {
|
||||
"types": "./dist/sysex/sysex.d.ts",
|
||||
"default": "./dist/sysex/sysex.js"
|
||||
}
|
||||
},
|
||||
"./sysex/manufacturers": {
|
||||
"import": {
|
||||
"types": "./dist/sysex/manufacturers.d.ts",
|
||||
"default": "./dist/sysex/manufacturers.js"
|
||||
}
|
||||
},
|
||||
"./clock/clock": {
|
||||
"import": {
|
||||
"types": "./dist/clock/clock.d.ts",
|
||||
"default": "./dist/clock/clock.js"
|
||||
}
|
||||
},
|
||||
"./gm/level1": {
|
||||
"import": {
|
||||
"types": "./dist/gm/level1.d.ts",
|
||||
"default": "./dist/gm/level1.js"
|
||||
}
|
||||
},
|
||||
"./gm/level2": {
|
||||
"import": {
|
||||
"types": "./dist/gm/level2.d.ts",
|
||||
"default": "./dist/gm/level2.js"
|
||||
}
|
||||
},
|
||||
"./gm/gs": {
|
||||
"import": {
|
||||
"types": "./dist/gm/gs.d.ts",
|
||||
"default": "./dist/gm/gs.js"
|
||||
}
|
||||
},
|
||||
"./gm/xg": {
|
||||
"import": {
|
||||
"types": "./dist/gm/xg.d.ts",
|
||||
"default": "./dist/gm/xg.js"
|
||||
}
|
||||
},
|
||||
"./ump/ump": {
|
||||
"import": {
|
||||
"types": "./dist/ump/ump.d.ts",
|
||||
"default": "./dist/ump/ump.js"
|
||||
}
|
||||
},
|
||||
"./telemetry/telemetry": {
|
||||
"import": {
|
||||
"types": "./dist/telemetry/telemetry.d.ts",
|
||||
"default": "./dist/telemetry/telemetry.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
67
packages/media/audio/midi/src/clock/clock.md
Normal file
67
packages/media/audio/midi/src/clock/clock.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# clock/clock
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Helpers for converting between MIDI Clock ticks, beats, and tempo. MIDI 1.0 defines 24 pulses per quarter note (`PPQN = 24`); every other unit derives from that constant. This module is the canonical place those derivations live so downstream sequencers and beat-aligned UI code do not re-implement them (and get the rounding subtly wrong).
|
||||
|
||||
`bpmToClock` returns the per-tick interval in milliseconds for a given tempo. `clockToBpm` is the inverse: given a window of observed inter-tick intervals (from a hardware Clock source), it returns the estimated BPM. The two compose into a stable tempo-follower when the input window is large enough to average out jitter.
|
||||
|
||||
## Features
|
||||
|
||||
- `PPQN = 24` and `TICKS_PER_BEAT` alias for clarity at call sites.
|
||||
- `bpmToClock`, `clockToBpm` — bidirectional conversion.
|
||||
- `beatsToTicks`, `ticksToBeats` — beat-grid conversions for sequencer code.
|
||||
- Aggressive input validation — empty arrays, non-positive intervals, NaN, infinity all throw `MidiError`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { bpmToClock, clockToBpm } from "@suaveplan/midi";
|
||||
|
||||
const tickMs = bpmToClock(120); // ~20.833 ms
|
||||
const bpm = clockToBpm([tickMs, tickMs, tickMs]); // ≈ 120
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { clockToBpm, PPQN } from "@suaveplan/midi";
|
||||
|
||||
// Build a rolling-window tempo follower from incoming Clock messages.
|
||||
class TempoFollower {
|
||||
private readonly windowSize = PPQN * 4; // average over a full bar
|
||||
private intervals: number[] = [];
|
||||
private lastTickMs: number | undefined;
|
||||
|
||||
onClock(nowMs: number) {
|
||||
if (this.lastTickMs !== undefined) {
|
||||
this.intervals.push(nowMs - this.lastTickMs);
|
||||
if (this.intervals.length > this.windowSize) this.intervals.shift();
|
||||
}
|
||||
this.lastTickMs = nowMs;
|
||||
}
|
||||
|
||||
bpm(): number | undefined {
|
||||
return this.intervals.length >= this.windowSize / 2
|
||||
? clockToBpm(this.intervals)
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PPQN: 24` — MIDI Clock pulses per quarter note.
|
||||
- `TICKS_PER_BEAT: 24` — alias of PPQN for readability.
|
||||
- `bpmToClock(bpm: number): number` — milliseconds per tick. Throws on non-positive input.
|
||||
- `clockToBpm(intervalsMs: readonly number[]): number` — averages a window of intervals to a BPM. Throws on empty / invalid input.
|
||||
- `beatsToTicks(beats: number): number` — round-to-integer tick count. Throws on negative input.
|
||||
- `ticksToBeats(ticks: number): number` — rational beat count (no rounding).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`clockToBpm` uses the arithmetic mean rather than the median because the input window is expected to be small (~24..96 entries) and the median's k-th-order-statistic cost dwarfs the precision benefit. Validation runs before the sum to fail fast: a single bad interval rejects the whole batch rather than silently skewing the result. `beatsToTicks` uses `Math.round` so half-beat inputs land on the nearest tick — `Math.floor` would systematically slip ahead of the beat, `Math.ceil` behind it.
|
||||
65
packages/media/audio/midi/src/clock/clock.test.ts
Normal file
65
packages/media/audio/midi/src/clock/clock.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MidiError } from "../error-codes/error-codes.js";
|
||||
import {
|
||||
beatsToTicks,
|
||||
bpmToClock,
|
||||
clockToBpm,
|
||||
PPQN,
|
||||
TICKS_PER_BEAT,
|
||||
ticksToBeats,
|
||||
} from "./clock.js";
|
||||
|
||||
describe("clock", () => {
|
||||
it("PPQN is the MIDI 1.0 standard 24 ticks per beat", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(PPQN).toBe(24);
|
||||
expect(TICKS_PER_BEAT).toBe(24);
|
||||
});
|
||||
|
||||
it("bpmToClock(120) returns ~20.833 ms per tick", { timeout: 5000 }, () => {
|
||||
expect(bpmToClock(120)).toBeCloseTo(60_000 / 120 / 24, 6);
|
||||
});
|
||||
|
||||
it("clockToBpm reverses bpmToClock for a steady stream", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const intervalMs = bpmToClock(140);
|
||||
const intervals = new Array(24).fill(intervalMs);
|
||||
expect(clockToBpm(intervals)).toBeCloseTo(140, 6);
|
||||
});
|
||||
|
||||
it("clockToBpm averages jittery intervals", { timeout: 5000 }, () => {
|
||||
const target = bpmToClock(100);
|
||||
const intervals = [target * 1.05, target * 0.95, target, target];
|
||||
expect(clockToBpm(intervals)).toBeCloseTo(100, 1);
|
||||
});
|
||||
|
||||
it("ticksToBeats and beatsToTicks roundtrip", { timeout: 5000 }, () => {
|
||||
expect(ticksToBeats(48)).toBe(2);
|
||||
expect(beatsToTicks(2)).toBe(48);
|
||||
expect(beatsToTicks(0.5)).toBe(12);
|
||||
});
|
||||
|
||||
it("rejects empty interval array", { timeout: 5000 }, () => {
|
||||
expect(() => clockToBpm([])).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("rejects non-positive intervals", { timeout: 5000 }, () => {
|
||||
expect(() => clockToBpm([10, -1, 10])).toThrow(MidiError);
|
||||
expect(() => clockToBpm([10, 0, 10])).toThrow(MidiError);
|
||||
expect(() => clockToBpm([Number.POSITIVE_INFINITY])).toThrow(MidiError);
|
||||
expect(() => clockToBpm([Number.NaN])).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("rejects non-positive bpm", { timeout: 5000 }, () => {
|
||||
expect(() => bpmToClock(0)).toThrow(MidiError);
|
||||
expect(() => bpmToClock(-50)).toThrow(MidiError);
|
||||
expect(() => bpmToClock(Number.POSITIVE_INFINITY)).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("rejects negative beats", { timeout: 5000 }, () => {
|
||||
expect(() => beatsToTicks(-1)).toThrow(MidiError);
|
||||
expect(() => beatsToTicks(Number.NaN)).toThrow(MidiError);
|
||||
});
|
||||
});
|
||||
60
packages/media/audio/midi/src/clock/clock.ts
Normal file
60
packages/media/audio/midi/src/clock/clock.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { MidiError } from "../error-codes/error-codes.js";
|
||||
|
||||
/** MIDI 1.0 specifies 24 clock pulses per quarter note. */
|
||||
export const PPQN = 24;
|
||||
|
||||
/**
|
||||
* Convert a series of MIDI-Clock inter-tick intervals (milliseconds) into
|
||||
* a beats-per-minute estimate. The mean interval is used; outliers from
|
||||
* jitter average out over a long-enough window.
|
||||
*
|
||||
* Throws `MidiError("MIDI.INVALID_MESSAGE")` if the input is empty or any
|
||||
* interval is non-positive.
|
||||
*/
|
||||
export function clockToBpm(intervalsMs: readonly number[]): number {
|
||||
if (intervalsMs.length === 0) {
|
||||
throw new MidiError(
|
||||
"MIDI.INVALID_MESSAGE",
|
||||
"midi.clock.empty_intervals"
|
||||
);
|
||||
}
|
||||
let sum = 0;
|
||||
for (const interval of intervalsMs) {
|
||||
if (!Number.isFinite(interval) || interval <= 0) {
|
||||
throw new MidiError(
|
||||
"MIDI.INVALID_MESSAGE",
|
||||
"midi.clock.invalid_interval"
|
||||
);
|
||||
}
|
||||
sum += interval;
|
||||
}
|
||||
const mean = sum / intervalsMs.length;
|
||||
return 60_000 / (mean * PPQN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a tempo (beats per minute) into the per-tick interval in
|
||||
* milliseconds. At 120 BPM the result is `60_000 / 120 / 24` ≈ 20.833 ms.
|
||||
*/
|
||||
export function bpmToClock(bpm: number): number {
|
||||
if (!Number.isFinite(bpm) || bpm <= 0) {
|
||||
throw new MidiError("MIDI.INVALID_MESSAGE", "midi.clock.invalid_bpm");
|
||||
}
|
||||
return 60_000 / bpm / PPQN;
|
||||
}
|
||||
|
||||
/** Number of MIDI Clock ticks per beat (alias for PPQN, kept for clarity). */
|
||||
export const TICKS_PER_BEAT = PPQN;
|
||||
|
||||
/** Convert a tick count to a beat count (rational). */
|
||||
export function ticksToBeats(ticks: number): number {
|
||||
return ticks / PPQN;
|
||||
}
|
||||
|
||||
/** Convert a beat count to a tick count, rounded to the nearest integer. */
|
||||
export function beatsToTicks(beats: number): number {
|
||||
if (!Number.isFinite(beats) || beats < 0) {
|
||||
throw new MidiError("MIDI.INVALID_MESSAGE", "midi.clock.invalid_beats");
|
||||
}
|
||||
return Math.round(beats * PPQN);
|
||||
}
|
||||
8
packages/media/audio/midi/src/clock/index.ts
Normal file
8
packages/media/audio/midi/src/clock/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export {
|
||||
beatsToTicks,
|
||||
bpmToClock,
|
||||
clockToBpm,
|
||||
PPQN,
|
||||
TICKS_PER_BEAT,
|
||||
ticksToBeats,
|
||||
} from "./clock.js";
|
||||
53
packages/media/audio/midi/src/dispatch/cc14.md
Normal file
53
packages/media/audio/midi/src/dispatch/cc14.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# dispatch/cc14
|
||||
|
||||
## Purpose
|
||||
|
||||
`CC14Reducer` accumulates paired MIDI 1.0 control-change messages into 14-bit values. The wire convention is: CCs 0..31 carry the most-significant byte of a controller, CCs 32..63 carry the least-significant byte of the *same* logical controller (`lsbController = msbController + 32`). Sending MSB then LSB lets the receiver follow value changes smoothly across a 14-bit range — far more useful than the 7-bit single-CC form for parameters like volume, expression, and pan.
|
||||
|
||||
The reducer maintains per-channel, per-controller state and emits one `CC14ChangeEvent` on every LSB arrival, regardless of whether the MSB has been seen yet (controllers that send LSB-only updates simply use the last seen MSB, or zero on the first message).
|
||||
|
||||
## Features
|
||||
|
||||
- Per-channel, per-MSB-controller partial state.
|
||||
- Emits a consolidated `CC14ChangeEvent` on every CC 32..63 arrival.
|
||||
- Ignores controllers outside 0..63 — they belong to NRPN/RPN dispatch or are 7-bit-only.
|
||||
- `reset()` to clear state on port reset.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { CC14Reducer } from "@suaveplan/midi";
|
||||
|
||||
const r = new CC14Reducer();
|
||||
r.consume({ type: "controlChange", channel: 0, controller: 7, value: 0x40 }); // MSB
|
||||
const evt = r.consume({ type: "controlChange", channel: 0, controller: 39, value: 0x05 }); // LSB
|
||||
// evt = { type: "cc14Change", channel: 0, controller: 7, value: (0x40 << 7) | 0x05 }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { CC14Reducer } from "@suaveplan/midi";
|
||||
|
||||
const reducer = new CC14Reducer();
|
||||
|
||||
// Combine with the running-status decoder to extract smooth volume curves.
|
||||
decoder.onMessage((m) => {
|
||||
if (m.type !== "controlChange") return;
|
||||
const evt = reducer.consume(m);
|
||||
if (evt && evt.controller === 7) {
|
||||
mixer.setVolume(evt.channel, evt.value / 16383);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class CC14Reducer`
|
||||
- `consume(msg: ControlChangeMessage): CC14ChangeEvent | undefined`
|
||||
- `reset(): void`
|
||||
- `CC14ChangeEvent { type: "cc14Change"; channel; controller; value }` — `controller` is the MSB CC number (0..31).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
State is keyed by a composite `${channel}:${msbController}` string so a single `Map` covers all 16 channels × 32 controllers without nested maps. The reducer treats an LSB without a preceding MSB as `msb = 0`, matching the behaviour of every consumer-grade synth tested against the spec. Out-of-range controllers (≥ 64) are silently ignored rather than throwing — they have legitimate uses (sustain pedal, sostenuto, NRPN parameter MSB) that this reducer should not interfere with.
|
||||
60
packages/media/audio/midi/src/dispatch/cc14.test.ts
Normal file
60
packages/media/audio/midi/src/dispatch/cc14.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { ControlChangeMessage } from "../message/message.types.js";
|
||||
import { CC14Reducer } from "./cc14.js";
|
||||
|
||||
function cc(
|
||||
channel: number,
|
||||
controller: number,
|
||||
value: number
|
||||
): ControlChangeMessage {
|
||||
return { type: "controlChange", channel, controller, value };
|
||||
}
|
||||
|
||||
describe("CC14Reducer", () => {
|
||||
it("emits a 14-bit value on LSB after MSB", { timeout: 5000 }, () => {
|
||||
const r = new CC14Reducer();
|
||||
expect(r.consume(cc(0, 7, 0x40))).toBeUndefined();
|
||||
expect(r.consume(cc(0, 39, 0x05))).toEqual({
|
||||
type: "cc14Change",
|
||||
channel: 0,
|
||||
controller: 7,
|
||||
value: (0x40 << 7) | 0x05,
|
||||
});
|
||||
});
|
||||
|
||||
it("emits even if MSB was never seen (assumes MSB=0)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new CC14Reducer();
|
||||
expect(r.consume(cc(1, 32, 0x42))).toEqual({
|
||||
type: "cc14Change",
|
||||
channel: 1,
|
||||
controller: 0,
|
||||
value: 0x42,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores controllers outside 0..63", { timeout: 5000 }, () => {
|
||||
const r = new CC14Reducer();
|
||||
expect(r.consume(cc(0, 64, 0))).toBeUndefined();
|
||||
expect(r.consume(cc(0, 127, 0))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("isolates state per channel and MSB-controller pair", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new CC14Reducer();
|
||||
r.consume(cc(0, 7, 0x10));
|
||||
r.consume(cc(1, 7, 0x20));
|
||||
expect(r.consume(cc(0, 39, 0x01))?.value).toBe((0x10 << 7) | 0x01);
|
||||
expect(r.consume(cc(1, 39, 0x02))?.value).toBe((0x20 << 7) | 0x02);
|
||||
});
|
||||
|
||||
it("reset clears state", { timeout: 5000 }, () => {
|
||||
const r = new CC14Reducer();
|
||||
r.consume(cc(0, 7, 0x10));
|
||||
r.reset();
|
||||
// After reset, the next LSB should treat MSB as 0
|
||||
expect(r.consume(cc(0, 39, 0x05))?.value).toBe(0x05);
|
||||
});
|
||||
});
|
||||
74
packages/media/audio/midi/src/dispatch/cc14.ts
Normal file
74
packages/media/audio/midi/src/dispatch/cc14.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { ControlChangeMessage } from "../message/message.types.js";
|
||||
|
||||
/**
|
||||
* Event emitted when a paired CC 0..31 (MSB) + CC 32..63 (LSB) combine into
|
||||
* a 14-bit controller value. The MSB+LSB CCs share the convention that
|
||||
* `lsbController = msbController + 32`.
|
||||
*/
|
||||
export interface CC14ChangeEvent {
|
||||
readonly type: "cc14Change";
|
||||
readonly channel: number;
|
||||
readonly controller: number;
|
||||
readonly value: number;
|
||||
}
|
||||
|
||||
interface PerSlot {
|
||||
msb?: number;
|
||||
lsb?: number;
|
||||
}
|
||||
|
||||
const MSB_RANGE_MAX = 31;
|
||||
const LSB_OFFSET = 32;
|
||||
|
||||
/**
|
||||
* Reducer that accumulates paired 14-bit CCs into single
|
||||
* {@link CC14ChangeEvent}s. Most controllers emit the MSB first, then the LSB
|
||||
* — this reducer emits the combined event on each LSB arrival, falling back
|
||||
* to the last seen MSB (or 0) when the MSB has not been seen yet.
|
||||
*
|
||||
* Controllers outside the 0..31 / 32..63 range are ignored (they belong to
|
||||
* NRPN/RPN dispatch or are 7-bit-only).
|
||||
*/
|
||||
export class CC14Reducer {
|
||||
private state = new Map<string, PerSlot>();
|
||||
|
||||
private key(channel: number, msbController: number): string {
|
||||
return `${channel}:${msbController}`;
|
||||
}
|
||||
|
||||
private slot(channel: number, msbController: number): PerSlot {
|
||||
const k = this.key(channel, msbController);
|
||||
let s = this.state.get(k);
|
||||
if (s === undefined) {
|
||||
s = {};
|
||||
this.state.set(k, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
public consume(msg: ControlChangeMessage): CC14ChangeEvent | undefined {
|
||||
const c = msg.controller;
|
||||
if (c <= MSB_RANGE_MAX) {
|
||||
const s = this.slot(msg.channel, c);
|
||||
s.msb = msg.value;
|
||||
return undefined;
|
||||
}
|
||||
if (c >= LSB_OFFSET && c <= LSB_OFFSET + MSB_RANGE_MAX) {
|
||||
const msbController = c - LSB_OFFSET;
|
||||
const s = this.slot(msg.channel, msbController);
|
||||
s.lsb = msg.value;
|
||||
const msb = s.msb ?? 0;
|
||||
return {
|
||||
type: "cc14Change",
|
||||
channel: msg.channel,
|
||||
controller: msbController,
|
||||
value: (msb << 7) | msg.value,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.state.clear();
|
||||
}
|
||||
}
|
||||
4
packages/media/audio/midi/src/dispatch/index.ts
Normal file
4
packages/media/audio/midi/src/dispatch/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { type CC14ChangeEvent, CC14Reducer } from "./cc14.js";
|
||||
export { type NRPNChangeEvent, NRPNReducer } from "./nrpn.js";
|
||||
export { type RPNChangeEvent, RPNReducer } from "./rpn.js";
|
||||
export { decodeStream, RunningStatusDecoder } from "./running-status.js";
|
||||
59
packages/media/audio/midi/src/dispatch/nrpn.md
Normal file
59
packages/media/audio/midi/src/dispatch/nrpn.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# dispatch/nrpn
|
||||
|
||||
## Purpose
|
||||
|
||||
`NRPNReducer` accumulates the four-CC "dance" that MIDI 1.0 uses to express Non-Registered Parameter Number changes into a single, consolidated `NRPNChangeEvent`. The dance is: send CC 99 (parameter MSB), CC 98 (parameter LSB), CC 6 (data MSB), then CC 38 (data LSB). The reducer absorbs the first three and emits exactly one event on the fourth — much easier for downstream synth-parameter routers than reacting to each CC individually.
|
||||
|
||||
It also handles the data-increment (CC 96) and data-decrement (CC 97) shortcuts, which apply deltas to the currently addressed parameter without re-sending the parameter MSB/LSB. The reducer maintains per-channel state so 16 simultaneous parameter conversations interleave correctly on a single port.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-channel partial state — 16 conversations multiplex without interference.
|
||||
- Emits exactly one `NRPNChangeEvent` per complete CC 6 + CC 38 pair.
|
||||
- Honours CC 96 (increment) / CC 97 (decrement) shortcuts with 14-bit value clamping.
|
||||
- Throws `MidiError("MIDI.NRPN_INCOMPLETE")` when data arrives before a parameter pair.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { NRPNReducer } from "@suaveplan/midi";
|
||||
|
||||
const r = new NRPNReducer();
|
||||
r.consume({ type: "controlChange", channel: 0, controller: 99, value: 0x20 });
|
||||
r.consume({ type: "controlChange", channel: 0, controller: 98, value: 0x10 });
|
||||
r.consume({ type: "controlChange", channel: 0, controller: 6, value: 0x40 });
|
||||
const evt = r.consume({ type: "controlChange", channel: 0, controller: 38, value: 0x00 });
|
||||
// evt = { type: "nrpnChange", channel: 0, paramHigh: 0x20, paramLow: 0x10, value: 0x2000 }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { NRPNReducer, type NRPNChangeEvent } from "@suaveplan/midi";
|
||||
|
||||
const r = new NRPNReducer();
|
||||
|
||||
decoder.onMessage((m) => {
|
||||
if (m.type !== "controlChange") return;
|
||||
let evt: NRPNChangeEvent | undefined;
|
||||
try {
|
||||
evt = r.consume(m);
|
||||
} catch (err) {
|
||||
logger.warn("nrpn dance broken", { cause: err });
|
||||
r.reset();
|
||||
return;
|
||||
}
|
||||
if (evt) parameterStore.set(`${evt.paramHigh}/${evt.paramLow}`, evt.value);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class NRPNReducer`
|
||||
- `consume(msg: ControlChangeMessage): NRPNChangeEvent | undefined` — feed a CC; returns the consolidated event when the dance completes (CC 38, CC 96, CC 97), `undefined` otherwise.
|
||||
- `reset(): void` — clear all per-channel state.
|
||||
- `NRPNChangeEvent { type: "nrpnChange"; channel; paramHigh; paramLow; value }` — the emitted event.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Parameter state is keyed by channel number in a `Map`, lazily populated by `slot(channel)`. Increment/decrement use saturation arithmetic against the 14-bit range `[0, 16383]` — overflow past 16383 stays at 16383, underflow past 0 stays at 0. Data-MSB without an LSB writes `value = msb << 7`; this matches the convention that most synths sample value on every CC 38 LSB but commit the MSB independently for chunky sweeps.
|
||||
139
packages/media/audio/midi/src/dispatch/nrpn.test.ts
Normal file
139
packages/media/audio/midi/src/dispatch/nrpn.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MidiError } from "../error-codes/error-codes.js";
|
||||
import type { ControlChangeMessage } from "../message/message.types.js";
|
||||
import { NRPNReducer } from "./nrpn.js";
|
||||
|
||||
function cc(
|
||||
channel: number,
|
||||
controller: number,
|
||||
value: number
|
||||
): ControlChangeMessage {
|
||||
return { type: "controlChange", channel, controller, value };
|
||||
}
|
||||
|
||||
describe("NRPNReducer", () => {
|
||||
it("emits one event after the four-CC dance completes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new NRPNReducer();
|
||||
expect(r.consume(cc(0, 99, 0x20))).toBeUndefined();
|
||||
expect(r.consume(cc(0, 98, 0x10))).toBeUndefined();
|
||||
expect(r.consume(cc(0, 6, 0x40))).toBeUndefined();
|
||||
const evt = r.consume(cc(0, 38, 0x00));
|
||||
expect(evt).toEqual({
|
||||
type: "nrpnChange",
|
||||
channel: 0,
|
||||
paramHigh: 0x20,
|
||||
paramLow: 0x10,
|
||||
value: 0x2000,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats missing data MSB as zero on LSB", { timeout: 5000 }, () => {
|
||||
const r = new NRPNReducer();
|
||||
r.consume(cc(2, 99, 0x01));
|
||||
r.consume(cc(2, 98, 0x02));
|
||||
const evt = r.consume(cc(2, 38, 0x55));
|
||||
expect(evt).toEqual({
|
||||
type: "nrpnChange",
|
||||
channel: 2,
|
||||
paramHigh: 0x01,
|
||||
paramLow: 0x02,
|
||||
value: 0x55,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws NRPN_INCOMPLETE when data arrives before param", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new NRPNReducer();
|
||||
r.consume(cc(0, 99, 0x20));
|
||||
expect(() => r.consume(cc(0, 6, 0x40))).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("throws on data LSB before parameter pair", { timeout: 5000 }, () => {
|
||||
const r = new NRPNReducer();
|
||||
expect(() => r.consume(cc(0, 38, 0x40))).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("handles data increment / decrement CCs after a baseline value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new NRPNReducer();
|
||||
r.consume(cc(0, 99, 0x01));
|
||||
r.consume(cc(0, 98, 0x02));
|
||||
r.consume(cc(0, 6, 0x10));
|
||||
r.consume(cc(0, 38, 0x00));
|
||||
// After dance: value = (0x10 << 7) | 0 = 0x800 = 2048.
|
||||
const inc = r.consume(cc(0, 96, 1));
|
||||
expect(inc?.value).toBe(0x800 + 1);
|
||||
const dec = r.consume(cc(0, 97, 2));
|
||||
expect(dec?.value).toBe(0x800 + 1 - 2);
|
||||
});
|
||||
|
||||
it("treats missing baseline as zero on dec immediately after param pair", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new NRPNReducer();
|
||||
r.consume(cc(0, 99, 0x01));
|
||||
r.consume(cc(0, 98, 0x02));
|
||||
const dec = r.consume(cc(0, 97, 5));
|
||||
expect(dec?.value).toBe(0);
|
||||
});
|
||||
|
||||
it("treats missing baseline as zero on inc immediately after param pair", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new NRPNReducer();
|
||||
r.consume(cc(0, 99, 0x01));
|
||||
r.consume(cc(0, 98, 0x02));
|
||||
const inc = r.consume(cc(0, 96, 5));
|
||||
expect(inc?.value).toBe(5);
|
||||
});
|
||||
|
||||
it("clamps increment / decrement at the 14-bit boundaries", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new NRPNReducer();
|
||||
r.consume(cc(0, 99, 0x00));
|
||||
r.consume(cc(0, 98, 0x00));
|
||||
const inc = r.consume(cc(0, 96, 20000));
|
||||
expect(inc?.value).toBe(16383);
|
||||
const dec = r.consume(cc(0, 97, 20000));
|
||||
expect(dec?.value).toBe(0);
|
||||
});
|
||||
|
||||
it("throws on inc/dec before parameter pair", { timeout: 5000 }, () => {
|
||||
const r = new NRPNReducer();
|
||||
expect(() => r.consume(cc(0, 96, 1))).toThrow(MidiError);
|
||||
expect(() => r.consume(cc(0, 97, 1))).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("ignores unrelated CCs", { timeout: 5000 }, () => {
|
||||
const r = new NRPNReducer();
|
||||
expect(r.consume(cc(0, 7, 100))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("isolates state per channel", { timeout: 5000 }, () => {
|
||||
const r = new NRPNReducer();
|
||||
r.consume(cc(0, 99, 0x01));
|
||||
r.consume(cc(0, 98, 0x02));
|
||||
expect(() => r.consume(cc(1, 6, 0x10))).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("reset clears accumulated state", { timeout: 5000 }, () => {
|
||||
const r = new NRPNReducer();
|
||||
r.consume(cc(0, 99, 0x01));
|
||||
r.consume(cc(0, 98, 0x02));
|
||||
r.reset();
|
||||
expect(() => r.consume(cc(0, 6, 0x10))).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("rejects data MSB before parameter pair", { timeout: 5000 }, () => {
|
||||
const r = new NRPNReducer();
|
||||
expect(() => r.consume(cc(3, 6, 0x10))).toThrow(MidiError);
|
||||
// half-set parameter still incomplete
|
||||
r.consume(cc(3, 99, 0x01));
|
||||
expect(() => r.consume(cc(3, 6, 0x10))).toThrow(MidiError);
|
||||
});
|
||||
});
|
||||
128
packages/media/audio/midi/src/dispatch/nrpn.ts
Normal file
128
packages/media/audio/midi/src/dispatch/nrpn.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { MidiError } from "../error-codes/error-codes.js";
|
||||
import type { ControlChangeMessage } from "../message/message.types.js";
|
||||
|
||||
/** Discrete event emitted by the NRPN reducer when a four-CC dance completes. */
|
||||
export interface NRPNChangeEvent {
|
||||
readonly type: "nrpnChange";
|
||||
readonly channel: number;
|
||||
readonly paramHigh: number;
|
||||
readonly paramLow: number;
|
||||
readonly value: number;
|
||||
}
|
||||
|
||||
const CC_NRPN_MSB = 99;
|
||||
const CC_NRPN_LSB = 98;
|
||||
const CC_DATA_MSB = 6;
|
||||
const CC_DATA_LSB = 38;
|
||||
const CC_DATA_INC = 96;
|
||||
const CC_DATA_DEC = 97;
|
||||
|
||||
interface PerChannel {
|
||||
paramHigh?: number;
|
||||
paramLow?: number;
|
||||
dataMsb?: number;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reducer that accumulates the canonical NRPN four-CC dance into single
|
||||
* {@link NRPNChangeEvent}s. Inputs are individual `ControlChangeMessage`
|
||||
* frames; the reducer keeps per-channel partial state until all four bytes
|
||||
* have arrived.
|
||||
*
|
||||
* Throws `MidiError("MIDI.NRPN_INCOMPLETE")` when a data CC arrives without
|
||||
* the matching parameter MSB/LSB pair already in place.
|
||||
*/
|
||||
export class NRPNReducer {
|
||||
private state = new Map<number, PerChannel>();
|
||||
|
||||
private slot(channel: number): PerChannel {
|
||||
let s = this.state.get(channel);
|
||||
if (s === undefined) {
|
||||
s = {};
|
||||
this.state.set(channel, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
public consume(msg: ControlChangeMessage): NRPNChangeEvent | undefined {
|
||||
const s = this.slot(msg.channel);
|
||||
switch (msg.controller) {
|
||||
case CC_NRPN_MSB:
|
||||
s.paramHigh = msg.value;
|
||||
return undefined;
|
||||
case CC_NRPN_LSB:
|
||||
s.paramLow = msg.value;
|
||||
return undefined;
|
||||
case CC_DATA_MSB:
|
||||
if (s.paramHigh === undefined || s.paramLow === undefined) {
|
||||
throw new MidiError(
|
||||
"MIDI.NRPN_INCOMPLETE",
|
||||
"midi.nrpn.data_before_param"
|
||||
);
|
||||
}
|
||||
s.dataMsb = msg.value;
|
||||
return undefined;
|
||||
case CC_DATA_LSB: {
|
||||
if (s.paramHigh === undefined || s.paramLow === undefined) {
|
||||
throw new MidiError(
|
||||
"MIDI.NRPN_INCOMPLETE",
|
||||
"midi.nrpn.data_before_param"
|
||||
);
|
||||
}
|
||||
const msb = s.dataMsb ?? 0;
|
||||
const value = (msb << 7) | msg.value;
|
||||
s.value = value;
|
||||
return {
|
||||
type: "nrpnChange",
|
||||
channel: msg.channel,
|
||||
paramHigh: s.paramHigh,
|
||||
paramLow: s.paramLow,
|
||||
value,
|
||||
};
|
||||
}
|
||||
case CC_DATA_INC: {
|
||||
if (s.paramHigh === undefined || s.paramLow === undefined) {
|
||||
throw new MidiError(
|
||||
"MIDI.NRPN_INCOMPLETE",
|
||||
"midi.nrpn.inc_before_param"
|
||||
);
|
||||
}
|
||||
const current = s.value ?? 0;
|
||||
const next = Math.min(16383, current + msg.value);
|
||||
s.value = next;
|
||||
return {
|
||||
type: "nrpnChange",
|
||||
channel: msg.channel,
|
||||
paramHigh: s.paramHigh,
|
||||
paramLow: s.paramLow,
|
||||
value: next,
|
||||
};
|
||||
}
|
||||
case CC_DATA_DEC: {
|
||||
if (s.paramHigh === undefined || s.paramLow === undefined) {
|
||||
throw new MidiError(
|
||||
"MIDI.NRPN_INCOMPLETE",
|
||||
"midi.nrpn.dec_before_param"
|
||||
);
|
||||
}
|
||||
const current = s.value ?? 0;
|
||||
const next = Math.max(0, current - msg.value);
|
||||
s.value = next;
|
||||
return {
|
||||
type: "nrpnChange",
|
||||
channel: msg.channel,
|
||||
paramHigh: s.paramHigh,
|
||||
paramLow: s.paramLow,
|
||||
value: next,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.state.clear();
|
||||
}
|
||||
}
|
||||
58
packages/media/audio/midi/src/dispatch/rpn.md
Normal file
58
packages/media/audio/midi/src/dispatch/rpn.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# dispatch/rpn
|
||||
|
||||
## Purpose
|
||||
|
||||
`RPNReducer` is the sister of `NRPNReducer` for Registered Parameter Numbers — the parameter MSB and LSB CC numbers move from 99/98 to 101/100, but the rest of the four-CC dance is identical. RPN is the MIDI Manufacturers Association's way of standardising a handful of common parameters (pitch-bend range, fine/coarse tuning, modulation-depth range, MPE configuration) that every spec-compliant synth interprets identically.
|
||||
|
||||
Separating RPN from NRPN avoids cross-contamination: an `RPNReducer` will never accidentally interpret CC 99 as a parameter MSB, and vice versa. Consumers typically run both reducers in parallel on the same control-change stream.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-channel partial state, exactly mirroring `NRPNReducer`.
|
||||
- Emits one `RPNChangeEvent` on CC 38 (data LSB) once the parameter pair is set.
|
||||
- Throws `MidiError("MIDI.NRPN_INCOMPLETE")` when data arrives before the parameter MSB/LSB.
|
||||
- Ignores unrelated CCs cleanly — composes with other CC-aware reducers without interference.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { RPNReducer } from "@suaveplan/midi";
|
||||
|
||||
const r = new RPNReducer();
|
||||
r.consume({ type: "controlChange", channel: 0, controller: 101, value: 0 });
|
||||
r.consume({ type: "controlChange", channel: 0, controller: 100, value: 0 });
|
||||
r.consume({ type: "controlChange", channel: 0, controller: 6, value: 2 });
|
||||
const evt = r.consume({ type: "controlChange", channel: 0, controller: 38, value: 0 });
|
||||
// evt.value = 0x100 (pitch-bend range = 2 semitones, coarse)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { NRPNReducer, RPNReducer } from "@suaveplan/midi";
|
||||
|
||||
const nrpn = new NRPNReducer();
|
||||
const rpn = new RPNReducer();
|
||||
|
||||
decoder.onMessage((m) => {
|
||||
if (m.type !== "controlChange") return;
|
||||
const rpnEvt = rpn.consume(m);
|
||||
if (rpnEvt) {
|
||||
handleRpn(rpnEvt);
|
||||
return; // RPN takes priority
|
||||
}
|
||||
const nrpnEvt = nrpn.consume(m);
|
||||
if (nrpnEvt) handleNrpn(nrpnEvt);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class RPNReducer`
|
||||
- `consume(msg: ControlChangeMessage): RPNChangeEvent | undefined`
|
||||
- `reset(): void`
|
||||
- `RPNChangeEvent { type: "rpnChange"; channel; paramHigh; paramLow; value }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Because RPN does not specify increment/decrement shortcuts in the same canonical form as NRPN (the MMA's recommendation is that vendors generally avoid them for RPN), this reducer omits CC 96/97 handling — adding them would invite confusion when both reducers run on the same stream. Vendors that do support RPN inc/dec can layer their own behaviour on top by inspecting CC 96/97 outside this class.
|
||||
79
packages/media/audio/midi/src/dispatch/rpn.test.ts
Normal file
79
packages/media/audio/midi/src/dispatch/rpn.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MidiError } from "../error-codes/error-codes.js";
|
||||
import type { ControlChangeMessage } from "../message/message.types.js";
|
||||
import { RPNReducer } from "./rpn.js";
|
||||
|
||||
function cc(
|
||||
channel: number,
|
||||
controller: number,
|
||||
value: number
|
||||
): ControlChangeMessage {
|
||||
return { type: "controlChange", channel, controller, value };
|
||||
}
|
||||
|
||||
describe("RPNReducer", () => {
|
||||
it("emits one event after the four-CC dance completes (CC 101 + 100 + 6 + 38)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new RPNReducer();
|
||||
expect(r.consume(cc(0, 101, 0))).toBeUndefined();
|
||||
expect(r.consume(cc(0, 100, 0))).toBeUndefined();
|
||||
expect(r.consume(cc(0, 6, 2))).toBeUndefined();
|
||||
expect(r.consume(cc(0, 38, 0))).toEqual({
|
||||
type: "rpnChange",
|
||||
channel: 0,
|
||||
paramHigh: 0,
|
||||
paramLow: 0,
|
||||
value: 256,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats missing data MSB as zero on LSB", { timeout: 5000 }, () => {
|
||||
const r = new RPNReducer();
|
||||
r.consume(cc(1, 101, 0));
|
||||
r.consume(cc(1, 100, 5));
|
||||
expect(r.consume(cc(1, 38, 0x33))).toEqual({
|
||||
type: "rpnChange",
|
||||
channel: 1,
|
||||
paramHigh: 0,
|
||||
paramLow: 5,
|
||||
value: 0x33,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when data MSB arrives before parameter pair", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new RPNReducer();
|
||||
expect(() => r.consume(cc(0, 6, 1))).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("throws when data LSB arrives before parameter pair", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new RPNReducer();
|
||||
expect(() => r.consume(cc(0, 38, 1))).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("ignores unrelated CCs", { timeout: 5000 }, () => {
|
||||
const r = new RPNReducer();
|
||||
expect(r.consume(cc(0, 7, 100))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reset clears state", { timeout: 5000 }, () => {
|
||||
const r = new RPNReducer();
|
||||
r.consume(cc(0, 101, 0));
|
||||
r.consume(cc(0, 100, 0));
|
||||
r.reset();
|
||||
expect(() => r.consume(cc(0, 6, 1))).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("partial-state data after only paramHigh still throws", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const r = new RPNReducer();
|
||||
r.consume(cc(2, 101, 1));
|
||||
expect(() => r.consume(cc(2, 6, 1))).toThrow(MidiError);
|
||||
expect(() => r.consume(cc(2, 38, 1))).toThrow(MidiError);
|
||||
});
|
||||
});
|
||||
83
packages/media/audio/midi/src/dispatch/rpn.ts
Normal file
83
packages/media/audio/midi/src/dispatch/rpn.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { MidiError } from "../error-codes/error-codes.js";
|
||||
import type { ControlChangeMessage } from "../message/message.types.js";
|
||||
|
||||
/** Discrete event emitted by the RPN reducer when a four-CC dance completes. */
|
||||
export interface RPNChangeEvent {
|
||||
readonly type: "rpnChange";
|
||||
readonly channel: number;
|
||||
readonly paramHigh: number;
|
||||
readonly paramLow: number;
|
||||
readonly value: number;
|
||||
}
|
||||
|
||||
const CC_RPN_MSB = 101;
|
||||
const CC_RPN_LSB = 100;
|
||||
const CC_DATA_MSB = 6;
|
||||
const CC_DATA_LSB = 38;
|
||||
|
||||
interface PerChannel {
|
||||
paramHigh?: number;
|
||||
paramLow?: number;
|
||||
dataMsb?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reducer for Registered Parameter Numbers (RPN) — same accumulation pattern
|
||||
* as {@link NRPNReducer}, but the parameter MSB/LSB live on CC 101/100 and
|
||||
* map to standardised parameters (pitch-bend range, fine tuning, etc.).
|
||||
*/
|
||||
export class RPNReducer {
|
||||
private state = new Map<number, PerChannel>();
|
||||
|
||||
private slot(channel: number): PerChannel {
|
||||
let s = this.state.get(channel);
|
||||
if (s === undefined) {
|
||||
s = {};
|
||||
this.state.set(channel, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
public consume(msg: ControlChangeMessage): RPNChangeEvent | undefined {
|
||||
const s = this.slot(msg.channel);
|
||||
switch (msg.controller) {
|
||||
case CC_RPN_MSB:
|
||||
s.paramHigh = msg.value;
|
||||
return undefined;
|
||||
case CC_RPN_LSB:
|
||||
s.paramLow = msg.value;
|
||||
return undefined;
|
||||
case CC_DATA_MSB:
|
||||
if (s.paramHigh === undefined || s.paramLow === undefined) {
|
||||
throw new MidiError(
|
||||
"MIDI.NRPN_INCOMPLETE",
|
||||
"midi.rpn.data_before_param"
|
||||
);
|
||||
}
|
||||
s.dataMsb = msg.value;
|
||||
return undefined;
|
||||
case CC_DATA_LSB: {
|
||||
if (s.paramHigh === undefined || s.paramLow === undefined) {
|
||||
throw new MidiError(
|
||||
"MIDI.NRPN_INCOMPLETE",
|
||||
"midi.rpn.data_before_param"
|
||||
);
|
||||
}
|
||||
const msb = s.dataMsb ?? 0;
|
||||
return {
|
||||
type: "rpnChange",
|
||||
channel: msg.channel,
|
||||
paramHigh: s.paramHigh,
|
||||
paramLow: s.paramLow,
|
||||
value: (msb << 7) | msg.value,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.state.clear();
|
||||
}
|
||||
}
|
||||
53
packages/media/audio/midi/src/dispatch/running-status.md
Normal file
53
packages/media/audio/midi/src/dispatch/running-status.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# dispatch/running-status
|
||||
|
||||
## Purpose
|
||||
|
||||
`RunningStatusDecoder` is the streaming MIDI 1.0 decoder that honours running status — the wire convention where successive same-status messages omit redundant status bytes. The MIDI 1.0 spec mandates that consumers retain the last channel-voice status byte and apply it implicitly to following data-only frames; serial-MIDI hardware almost universally exploits this to save bandwidth.
|
||||
|
||||
This module also handles the orthogonal real-time interleaving rule: any byte in the range `0xF8..0xFF` is a one-byte real-time message that can appear at any moment, even mid-frame, without disturbing the running status state. Implementing both rules correctly is the difference between a stable port adapter and one that drops one note in twenty.
|
||||
|
||||
## Features
|
||||
|
||||
- Stateful streaming decoder — feed bytes incrementally via `push`, drain complete messages via `drain`.
|
||||
- Honours running status across multiple `push` calls; partial data accumulates until a complete frame arrives.
|
||||
- Splits real-time messages out without affecting the running status pointer.
|
||||
- Clears running status after every system-common message, matching the MIDI 1.0 spec.
|
||||
- `decodeStream` convenience wrapper for one-shot decoding of a finished buffer.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { decodeStream } from "@suaveplan/midi";
|
||||
|
||||
const messages = decodeStream([0x91, 0x40, 0x64, 0x42, 0x55]);
|
||||
// two noteOn events on channel 1: notes 64+100 and 66+85
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { RunningStatusDecoder } from "@suaveplan/midi";
|
||||
|
||||
const decoder = new RunningStatusDecoder();
|
||||
inputPort.onRawBytes((chunk) => {
|
||||
decoder.push(chunk);
|
||||
for (const msg of decoder.drain()) {
|
||||
handleMessage(msg);
|
||||
}
|
||||
});
|
||||
|
||||
// When the port disconnects and reconnects, reset the implicit state.
|
||||
inputPort.onDisconnect(() => decoder.reset());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class RunningStatusDecoder`
|
||||
- `push(bytes: Uint8Array | readonly number[]): void` — append bytes to the internal queue.
|
||||
- `drain(): MidiMessage[]` — return every complete message available; partial frames remain queued for the next call.
|
||||
- `reset(): void` — clear the buffer and running-status pointer (use on port reconnect).
|
||||
- `decodeStream(bytes: Uint8Array | readonly number[]): MidiMessage[]` — convenience for one-shot decoding when the entire stream is known up-front.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The decoder uses a `number[]` buffer rather than a `Uint8Array` because it needs `shift` / `splice` semantics that `Uint8Array` does not provide; the alternative is a ring buffer with explicit head/tail pointers, but for typical port chunk sizes (≤ 64 bytes) the array form is faster and far less code. SysEx accumulates until the `F7` terminator is found; if the terminator never arrives the messages stay queued and `drain` returns the messages decoded so far, with the partial SysEx still pending — callers can `reset` to drop it on a port reset.
|
||||
134
packages/media/audio/midi/src/dispatch/running-status.test.ts
Normal file
134
packages/media/audio/midi/src/dispatch/running-status.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MidiError } from "../error-codes/error-codes.js";
|
||||
import { decodeStream, RunningStatusDecoder } from "./running-status.js";
|
||||
|
||||
describe("running-status decoder", () => {
|
||||
it("emits two NoteOn events when the status byte is omitted", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const msgs = decodeStream([0x91, 0x40, 0x64, 0x42, 0x55]);
|
||||
expect(msgs).toEqual([
|
||||
{ type: "noteOn", channel: 1, note: 0x40, velocity: 0x64 },
|
||||
{ type: "noteOn", channel: 1, note: 0x42, velocity: 0x55 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts a Uint8Array directly", { timeout: 5000 }, () => {
|
||||
const msgs = decodeStream(Uint8Array.of(0x90, 60, 64));
|
||||
expect(msgs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("interleaves real-time messages without disturbing running status", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const msgs = decodeStream([0x91, 0x40, 0x64, 0xf8, 0x42, 0x55]);
|
||||
expect(msgs.map((m) => m.type)).toEqual(["noteOn", "clock", "noteOn"]);
|
||||
});
|
||||
|
||||
it("waits for more bytes when the buffer is short", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const decoder = new RunningStatusDecoder();
|
||||
decoder.push(Uint8Array.of(0x91, 0x40));
|
||||
expect(decoder.drain()).toEqual([]);
|
||||
decoder.push(Uint8Array.of(0x64));
|
||||
expect(decoder.drain()).toEqual([
|
||||
{ type: "noteOn", channel: 1, note: 0x40, velocity: 0x64 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles single-byte data status (program change) under running status", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const msgs = decodeStream([0xc1, 0x10, 0x20]);
|
||||
expect(msgs).toEqual([
|
||||
{ type: "programChange", channel: 1, program: 0x10 },
|
||||
{ type: "programChange", channel: 1, program: 0x20 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears running status after a system common message", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// SongSelect (0xf3) consumes one data byte, then data without status must throw.
|
||||
const decoder = new RunningStatusDecoder();
|
||||
decoder.push(Uint8Array.of(0x91, 0x40, 0x64, 0xf3, 0x05, 0x42, 0x55));
|
||||
expect(() => decoder.drain()).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("accumulates SysEx across multiple push calls", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const decoder = new RunningStatusDecoder();
|
||||
decoder.push(Uint8Array.of(0xf0, 0x7e, 0x00));
|
||||
expect(decoder.drain()).toEqual([]);
|
||||
decoder.push(Uint8Array.of(0x06, 0x01, 0xf7));
|
||||
const msgs = decoder.drain();
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0]?.type).toBe("systemExclusive");
|
||||
});
|
||||
|
||||
it("rejects data without ever seeing a status byte", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const decoder = new RunningStatusDecoder();
|
||||
decoder.push(Uint8Array.of(0x40, 0x64));
|
||||
expect(() => decoder.drain()).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("rejects entirely unknown status bytes", { timeout: 5000 }, () => {
|
||||
const decoder = new RunningStatusDecoder();
|
||||
decoder.push(Uint8Array.of(0xf5, 0x00));
|
||||
expect(() => decoder.drain()).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("reset clears buffer and last status", { timeout: 5000 }, () => {
|
||||
const decoder = new RunningStatusDecoder();
|
||||
decoder.push(Uint8Array.of(0x91, 0x40));
|
||||
decoder.reset();
|
||||
decoder.push(Uint8Array.of(0x40, 0x64));
|
||||
expect(() => decoder.drain()).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("decodes a TimeCode quarter-frame (0xf1) via running-status decoder", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const msgs = decodeStream([0xf1, 0x05]);
|
||||
expect(msgs).toEqual([{ type: "timeCode", channel: null, value: 5 }]);
|
||||
});
|
||||
|
||||
it("decodes a TuneRequest (0xf6) as a zero-data system common message", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const msgs = decodeStream([0xf6]);
|
||||
expect(msgs).toEqual([{ type: "tuneRequest", channel: null }]);
|
||||
});
|
||||
|
||||
it("emits real-time messages even when at the start of buffer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const msgs = decodeStream([0xf8, 0xfa]);
|
||||
expect(msgs.map((m) => m.type)).toEqual(["clock", "start"]);
|
||||
});
|
||||
|
||||
it("handles song position (two data bytes) under system status", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const msgs = decodeStream([0xf2, 0x10, 0x20]);
|
||||
expect(msgs).toEqual([
|
||||
{
|
||||
type: "songPosition",
|
||||
channel: null,
|
||||
position: (0x20 << 7) | 0x10,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns early when the system common message is still short", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const decoder = new RunningStatusDecoder();
|
||||
decoder.push(Uint8Array.of(0xf2, 0x10));
|
||||
expect(decoder.drain()).toEqual([]);
|
||||
});
|
||||
});
|
||||
132
packages/media/audio/midi/src/dispatch/running-status.ts
Normal file
132
packages/media/audio/midi/src/dispatch/running-status.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { MidiError } from "../error-codes/error-codes.js";
|
||||
import { decodeMessage } from "../message/decode.js";
|
||||
import { STATUS } from "../message/encode.js";
|
||||
import type { MidiMessage } from "../message/message.types.js";
|
||||
|
||||
/**
|
||||
* Streaming MIDI 1.0 decoder honouring running status. Feed bytes via
|
||||
* {@link push}; call {@link drain} to emit every complete message accumulated
|
||||
* so far. Real-time messages (0xF8..0xFF) do NOT update the running status,
|
||||
* matching MIDI 1.0 behaviour.
|
||||
*/
|
||||
export class RunningStatusDecoder {
|
||||
private buffer: number[] = [];
|
||||
private lastStatus: number | undefined;
|
||||
|
||||
/** Append bytes to the internal queue. */
|
||||
public push(bytes: Uint8Array | readonly number[]): void {
|
||||
for (const b of bytes) {
|
||||
this.buffer.push(b);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drain every complete message, returning them in arrival order. */
|
||||
public drain(): MidiMessage[] {
|
||||
const out: MidiMessage[] = [];
|
||||
while (this.buffer.length > 0) {
|
||||
const first = this.buffer[0] as number;
|
||||
// Real-time interleaving — single status byte, no running-status update.
|
||||
if (first >= 0xf8) {
|
||||
const slice = Uint8Array.of(first);
|
||||
const { msg } = decodeMessage(slice, 0);
|
||||
out.push(msg);
|
||||
this.buffer.shift();
|
||||
continue;
|
||||
}
|
||||
const isStatus = first >= 0x80;
|
||||
const status = isStatus ? first : this.lastStatus;
|
||||
if (status === undefined) {
|
||||
throw new MidiError(
|
||||
"MIDI.INVALID_MESSAGE",
|
||||
"midi.running.no_status_seen"
|
||||
);
|
||||
}
|
||||
const expectedDataBytes = dataByteCount(status);
|
||||
if (expectedDataBytes === null) {
|
||||
// SysEx is only reachable when the F0 is the actual leading byte
|
||||
// (running status never applies to system common). Search for the
|
||||
// F7 terminator past the F0 itself.
|
||||
const terminator = this.buffer.indexOf(STATUS.SYSEX_END, 1);
|
||||
if (terminator < 0) {
|
||||
return out; // wait for more bytes
|
||||
}
|
||||
const slice = Uint8Array.from(
|
||||
this.buffer.slice(0, terminator + 1)
|
||||
);
|
||||
const { msg, next } = decodeMessage(slice, 0);
|
||||
out.push(msg);
|
||||
this.buffer.splice(0, next);
|
||||
this.lastStatus = undefined;
|
||||
continue;
|
||||
}
|
||||
const required = (isStatus ? 1 : 0) + expectedDataBytes;
|
||||
if (this.buffer.length < required) {
|
||||
return out;
|
||||
}
|
||||
const slice = Uint8Array.from(this.buffer.slice(0, required));
|
||||
const { msg } = decodeMessage(
|
||||
slice,
|
||||
0,
|
||||
isStatus ? undefined : status
|
||||
);
|
||||
out.push(msg);
|
||||
this.buffer.splice(0, required);
|
||||
if (status < 0xf0) {
|
||||
this.lastStatus = status;
|
||||
} else {
|
||||
this.lastStatus = undefined; // system common clears running status
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Reset internal state — used when port is closed/reopened. */
|
||||
public reset(): void {
|
||||
this.buffer = [];
|
||||
this.lastStatus = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode a full buffer in one shot using running status. */
|
||||
export function decodeStream(
|
||||
bytes: Uint8Array | readonly number[]
|
||||
): MidiMessage[] {
|
||||
const decoder = new RunningStatusDecoder();
|
||||
decoder.push(bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes));
|
||||
return decoder.drain();
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of data bytes a status nibble expects, or `null` for variable-length
|
||||
* SysEx. Used by the streaming decoder to know when a message is complete.
|
||||
*/
|
||||
function dataByteCount(status: number): number | null {
|
||||
const high = status & 0xf0;
|
||||
if (status < 0xf0) {
|
||||
switch (high) {
|
||||
case STATUS.PROGRAM_CHANGE:
|
||||
case STATUS.CHANNEL_PRESSURE:
|
||||
return 1;
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
// Real-time bytes (0xf8..0xff) are routed by the caller's fast-path; only
|
||||
// system common bytes (0xf0..0xf7) reach here.
|
||||
switch (status) {
|
||||
case STATUS.SYSEX_START:
|
||||
return null;
|
||||
case STATUS.TIME_CODE:
|
||||
case STATUS.SONG_SELECT:
|
||||
return 1;
|
||||
case STATUS.SONG_POSITION:
|
||||
return 2;
|
||||
case STATUS.TUNE_REQUEST:
|
||||
return 0;
|
||||
default:
|
||||
throw new MidiError(
|
||||
"MIDI.INVALID_MESSAGE",
|
||||
"midi.running.unknown_status"
|
||||
);
|
||||
}
|
||||
}
|
||||
57
packages/media/audio/midi/src/error-codes/error-codes.md
Normal file
57
packages/media/audio/midi/src/error-codes/error-codes.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the `MidiError` class and the `MIDI_CODES` namespaced registry used by every `@suaveplan/midi` module that raises faults. The registry exists so consumers can pattern-match on stable strings rather than `instanceof` checks against transient class identities, and so dashboards can attribute incidents to the exact failure mode without scraping freeform messages.
|
||||
|
||||
The codes themselves are produced by `defineCodes` from `@suaveplan/error`, which guarantees the resulting object is `Object.freeze`-d and that every value is a fully-qualified `MIDI.<REASON>` string. `MidiError` extends `SuaveplanError` so cause chains, OTel span events, and i18n message resolution work uniformly with the rest of the platform.
|
||||
|
||||
## Features
|
||||
|
||||
- Five stable codes: `INVALID_MESSAGE`, `INVALID_SYSEX`, `BUFFER_UNDERRUN`, `UNSUPPORTED_UMP_VARIANT`, `NRPN_INCOMPLETE`.
|
||||
- Frozen `MIDI_ERROR_METADATA` registry exposing a description and severity tag for every code.
|
||||
- `MidiError` subclass with `category: "midi"` tag and runtime-checkable `name: "MidiError"`.
|
||||
- Cause-chain support via the optional `options.cause` constructor argument.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { MidiError, MIDI_CODES } from "@suaveplan/midi";
|
||||
|
||||
if (something.invalid) {
|
||||
throw new MidiError(MIDI_CODES.INVALID_MESSAGE);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { MidiError, MIDI_CODES, MIDI_ERROR_METADATA } from "@suaveplan/midi";
|
||||
|
||||
try {
|
||||
// ...decode logic
|
||||
} catch (cause) {
|
||||
throw new MidiError(MIDI_CODES.BUFFER_UNDERRUN, "midi.decode.short_input", {
|
||||
cause,
|
||||
context: { offset: 12 },
|
||||
});
|
||||
}
|
||||
|
||||
// Build a dashboard label.
|
||||
const label = MIDI_ERROR_METADATA[MIDI_CODES.BUFFER_UNDERRUN].description;
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `MIDI_CODES: Readonly<{ INVALID_MESSAGE: "MIDI.INVALID_MESSAGE", ... }>` — frozen namespaced lookup table.
|
||||
- `MidiErrorCode` — string-literal union of every value in `MIDI_CODES`.
|
||||
- `MIDI_ERROR_METADATA: Readonly<Record<MidiErrorCode, { description: string; severity: "warning" | "error" }>>` — per-code metadata for UI surfaces.
|
||||
- `class MidiError extends SuaveplanError<MidiErrorCode>` — constructor `(code, messageKey?, options?)`; carries `category: "midi"` and `name: "MidiError"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`MidiError` defaults `messageKey` to the code itself when none is supplied; this means an unhandled raise still produces a useful i18n key without forcing every call site to invent one. The base class clock is the platform `defaultClock`, so timestamps line up across packages without per-package configuration. The metadata object is frozen at module load to prevent ad-hoc mutation; downstream packages compose dialect-specific metadata by spreading rather than mutating in place.
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
MIDI_CODES,
|
||||
MIDI_ERROR_METADATA,
|
||||
MidiError,
|
||||
type MidiErrorCode,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("error-codes", () => {
|
||||
it("exposes namespaced codes via defineCodes", { timeout: 5000 }, () => {
|
||||
expect(MIDI_CODES.INVALID_MESSAGE).toBe("MIDI.INVALID_MESSAGE");
|
||||
expect(MIDI_CODES.INVALID_SYSEX).toBe("MIDI.INVALID_SYSEX");
|
||||
expect(MIDI_CODES.BUFFER_UNDERRUN).toBe("MIDI.BUFFER_UNDERRUN");
|
||||
expect(MIDI_CODES.UNSUPPORTED_UMP_VARIANT).toBe(
|
||||
"MIDI.UNSUPPORTED_UMP_VARIANT"
|
||||
);
|
||||
expect(MIDI_CODES.NRPN_INCOMPLETE).toBe("MIDI.NRPN_INCOMPLETE");
|
||||
});
|
||||
|
||||
it("freezes the metadata registry", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(MIDI_ERROR_METADATA)).toBe(true);
|
||||
for (const code of Object.values(MIDI_CODES) as MidiErrorCode[]) {
|
||||
const meta = MIDI_ERROR_METADATA[code];
|
||||
expect(meta.description.length).toBeGreaterThan(20);
|
||||
expect(["warning", "error"]).toContain(meta.severity);
|
||||
}
|
||||
});
|
||||
|
||||
it("constructs MidiError with code, category, and name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new MidiError("MIDI.INVALID_MESSAGE");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.code).toBe("MIDI.INVALID_MESSAGE");
|
||||
expect(err.name).toBe("MidiError");
|
||||
expect(err.category).toBe("midi");
|
||||
});
|
||||
|
||||
it("honours custom messageKey and options.cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("boom");
|
||||
const err = new MidiError("MIDI.INVALID_SYSEX", "midi.test.key", {
|
||||
cause,
|
||||
});
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
});
|
||||
71
packages/media/audio/midi/src/error-codes/error-codes.ts
Normal file
71
packages/media/audio/midi/src/error-codes/error-codes.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
defineCodes,
|
||||
SuaveplanError,
|
||||
type SuaveplanErrorOptions,
|
||||
} from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/midi`. The string values are the
|
||||
* namespaced `MIDI.<REASON>` form produced by `defineCodes`.
|
||||
*/
|
||||
export const MIDI_CODES = defineCodes("MIDI", [
|
||||
"INVALID_MESSAGE",
|
||||
"INVALID_SYSEX",
|
||||
"BUFFER_UNDERRUN",
|
||||
"UNSUPPORTED_UMP_VARIANT",
|
||||
"NRPN_INCOMPLETE",
|
||||
] as const);
|
||||
|
||||
export type MidiErrorCode = (typeof MIDI_CODES)[keyof typeof MIDI_CODES];
|
||||
|
||||
export interface MidiErrorMetadata {
|
||||
readonly description: string;
|
||||
readonly severity: "warning" | "error";
|
||||
}
|
||||
|
||||
export const MIDI_ERROR_METADATA: Readonly<
|
||||
Record<MidiErrorCode, MidiErrorMetadata>
|
||||
> = Object.freeze({
|
||||
[MIDI_CODES.INVALID_MESSAGE]: {
|
||||
description:
|
||||
"Byte stream does not encode a recognised MIDI 1.0 status nibble, or a channel-voice message advertises a data byte with bit 7 set.",
|
||||
severity: "error",
|
||||
},
|
||||
[MIDI_CODES.INVALID_SYSEX]: {
|
||||
description:
|
||||
"A System Exclusive frame was started with 0xF0 but never terminated with 0xF7, or the manufacturer-id prefix is malformed.",
|
||||
severity: "error",
|
||||
},
|
||||
[MIDI_CODES.BUFFER_UNDERRUN]: {
|
||||
description:
|
||||
"The decoder ran past the end of the supplied buffer before the message was complete — caller fed a short slice or the stream truncated.",
|
||||
severity: "error",
|
||||
},
|
||||
[MIDI_CODES.UNSUPPORTED_UMP_VARIANT]: {
|
||||
description:
|
||||
"A UMP packet was requested in a form the bridge cannot losslessly produce — e.g. converting a MIDI 2.0 per-note controller to MIDI 1.0.",
|
||||
severity: "error",
|
||||
},
|
||||
[MIDI_CODES.NRPN_INCOMPLETE]: {
|
||||
description:
|
||||
"An NRPN or RPN reducer received a data-MSB/LSB without seeing the matching parameter-MSB/LSB pair first, leaving the four-CC dance partial.",
|
||||
severity: "error",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Concrete `SuaveplanError` subclass for every fault raised by
|
||||
* `@suaveplan/midi`.
|
||||
*/
|
||||
export class MidiError extends SuaveplanError<MidiErrorCode> {
|
||||
public readonly category = "midi" as const;
|
||||
|
||||
public constructor(
|
||||
code: MidiErrorCode,
|
||||
messageKey: string = code,
|
||||
options: SuaveplanErrorOptions = {}
|
||||
) {
|
||||
super(code, messageKey, options);
|
||||
this.name = "MidiError";
|
||||
}
|
||||
}
|
||||
9
packages/media/audio/midi/src/error-codes/index.ts
Normal file
9
packages/media/audio/midi/src/error-codes/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
MidiErrorCode,
|
||||
MidiErrorMetadata,
|
||||
} from "./error-codes.js";
|
||||
export {
|
||||
MIDI_CODES,
|
||||
MIDI_ERROR_METADATA,
|
||||
MidiError,
|
||||
} from "./error-codes.js";
|
||||
49
packages/media/audio/midi/src/gm/gs.md
Normal file
49
packages/media/audio/midi/src/gm/gs.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# gm/gs
|
||||
|
||||
## Purpose
|
||||
|
||||
Roland's GS dialect predates GM2 and uses Bank-Select LSB (CC 32) to select instrument variations on top of the GM1 base programs. The variation values differ from GM2 (Roland's "Capital Tones" sit at LSB 8, 16, 24, ...; GM2 uses 1, 2, 3, ...), so a separate dialect-specific table is necessary — sharing a table would mis-name patches on the wrong dialect.
|
||||
|
||||
This module exposes `GS_VARIATIONS` covering the most commonly supported Capital Tones plus a handful of frequently-quoted variations from the GS bank-select map. Engines aiming at full GS compatibility layer additional entries via the frozen-object idiom.
|
||||
|
||||
## Features
|
||||
|
||||
- `GS_VARIATIONS` frozen map keyed by `program * 256 + bankLsb`.
|
||||
- Covers Roland's Capital Tones and the most commonly cited GS variation banks.
|
||||
- Designed to compose with the `gm2Key` helper from `./level2.ts`.
|
||||
- Strict `Object.freeze` for safe extension via spread.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { GS_VARIATIONS, gm2Key } from "@suaveplan/midi";
|
||||
|
||||
console.log(GS_VARIATIONS[gm2Key(0, 8)]); // "Piano 1w"
|
||||
console.log(GS_VARIATIONS[gm2Key(40, 0)]); // undefined → fall back to GM1
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
GM_LEVEL1_PATCHES,
|
||||
GS_VARIATIONS,
|
||||
gm2Key,
|
||||
} from "@suaveplan/midi";
|
||||
|
||||
function gsPatchName(program: number, bankLsb: number): string {
|
||||
return (
|
||||
GS_VARIATIONS[gm2Key(program, bankLsb)] ??
|
||||
GM_LEVEL1_PATCHES[program] ??
|
||||
`Program ${program}`
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GS_VARIATIONS: Readonly<Record<number, string>>` — frozen map; same key encoding as `GM_LEVEL2_VARIATIONS`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
GS uses Bank LSB (CC 32) for variation selection — the opposite of XG which uses Bank MSB. Mixing the two on the same instance leads to silent mis-naming; consumers picking a dialect should choose at engine-construction time, not per-message. The table is intentionally a curated subset; full GS coverage (~480 entries) lives in downstream SoundFont rendering packages that map their own GS-format banks to display names.
|
||||
45
packages/media/audio/midi/src/gm/gs.ts
Normal file
45
packages/media/audio/midi/src/gm/gs.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Roland GS dialect — bank-LSB-addressed instrument variations layered on
|
||||
* top of the GM Level 1 programme map. The key encoding matches
|
||||
* {@link gm2Key} from `./level2.ts` (`program * 256 + bankLsb`).
|
||||
*
|
||||
* The set published here covers Roland's mandatory "Capital Tones" and the
|
||||
* commonly-supported variations from the GS bank-select map. Engines
|
||||
* implementing full GS may layer additional entries on top — `Object.freeze`
|
||||
* guarantees this table is immutable so derived sets compose via
|
||||
* `Object.assign({}, GS_VARIATIONS, …)`.
|
||||
*/
|
||||
export const GS_VARIATIONS: Readonly<Record<number, string>> = Object.freeze({
|
||||
[0 * 256 + 8]: "Piano 1w",
|
||||
[0 * 256 + 16]: "European Pf",
|
||||
[1 * 256 + 8]: "Piano 2w",
|
||||
[2 * 256 + 8]: "Piano 3w",
|
||||
[4 * 256 + 8]: "Detuned EP 1",
|
||||
[4 * 256 + 16]: "60s E.Piano",
|
||||
[5 * 256 + 8]: "Detuned EP 2",
|
||||
[6 * 256 + 8]: "Coupled Harpsichord",
|
||||
[16 * 256 + 8]: "Detuned Organ 1",
|
||||
[16 * 256 + 32]: "60s Organ 1",
|
||||
[17 * 256 + 8]: "Detuned Organ 2",
|
||||
[18 * 256 + 8]: "Rotary Organ",
|
||||
[19 * 256 + 8]: "Church Organ 2",
|
||||
[19 * 256 + 16]: "Church Organ 3",
|
||||
[24 * 256 + 8]: "Ukulele",
|
||||
[25 * 256 + 8]: "12-string Guitar",
|
||||
[25 * 256 + 16]: "Mandolin",
|
||||
[27 * 256 + 8]: "Chorus Guitar",
|
||||
[28 * 256 + 8]: "Funk Guitar",
|
||||
[29 * 256 + 8]: "Feedback Guitar",
|
||||
[30 * 256 + 8]: "Feedback Guitar 2",
|
||||
[33 * 256 + 8]: "Finger Slap Bass",
|
||||
[38 * 256 + 8]: "Synth Bass 101",
|
||||
[39 * 256 + 8]: "Synth Bass 4",
|
||||
[48 * 256 + 8]: "Orchestra",
|
||||
[56 * 256 + 8]: "Dark Trumpet",
|
||||
[57 * 256 + 8]: "Trombone 2",
|
||||
[60 * 256 + 8]: "French Horn 2",
|
||||
[61 * 256 + 8]: "Brass 2",
|
||||
[62 * 256 + 8]: "Synth Brass 3",
|
||||
[80 * 256 + 8]: "Square",
|
||||
[81 * 256 + 8]: "Sine Wave",
|
||||
});
|
||||
8
packages/media/audio/midi/src/gm/index.ts
Normal file
8
packages/media/audio/midi/src/gm/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { GS_VARIATIONS } from "./gs.js";
|
||||
export {
|
||||
GM_LEVEL1_FAMILIES,
|
||||
GM_LEVEL1_PATCHES,
|
||||
GM_LEVEL1_PERCUSSION,
|
||||
} from "./level1.js";
|
||||
export { GM_LEVEL2_VARIATIONS, gm2Key } from "./level2.js";
|
||||
export { XG_VARIATIONS } from "./xg.js";
|
||||
51
packages/media/audio/midi/src/gm/level1.md
Normal file
51
packages/media/audio/midi/src/gm/level1.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# gm/level1
|
||||
|
||||
## Purpose
|
||||
|
||||
The General MIDI Level 1 patch and percussion tables — the canonical instrument-name lookups every GM-compliant playback engine, score editor, and patch-browser needs. Without a shared source of truth, every consumer ends up hand-typing the same 128-entry table with the same handful of typos ("Honky-Tonk Piano" vs "Honky-tonk Piano" being a perennial favourite).
|
||||
|
||||
This module exposes three immutable, exhaustive tables: the 128-entry patch map (`GM_LEVEL1_PATCHES`, program 0..127), the 47-entry percussion map (`GM_LEVEL1_PERCUSSION`, notes 35..81), and the 16-entry family list (`GM_LEVEL1_FAMILIES`, grouped in eight-program blocks). Spelling and capitalisation follow the official MMA specification.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `GM_LEVEL1_PATCHES` covering every program 0..127 with the spec-canonical name.
|
||||
- Frozen `GM_LEVEL1_PERCUSSION` covering channel-10 drum notes 35..81.
|
||||
- Frozen `GM_LEVEL1_FAMILIES` for UI grouping (program ÷ 8).
|
||||
- All tables use the MMA canonical capitalisation — diff-compatible with other specs.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { GM_LEVEL1_PATCHES, GM_LEVEL1_PERCUSSION } from "@suaveplan/midi";
|
||||
|
||||
console.log(GM_LEVEL1_PATCHES[0]); // "Acoustic Grand Piano"
|
||||
console.log(GM_LEVEL1_PERCUSSION[36]); // "Bass Drum 1"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { GM_LEVEL1_FAMILIES, GM_LEVEL1_PATCHES } from "@suaveplan/midi";
|
||||
|
||||
// Build a hierarchical patch browser for a UI tree component.
|
||||
function buildPatchTree() {
|
||||
const tree = GM_LEVEL1_FAMILIES.map((family, i) => ({
|
||||
name: family,
|
||||
patches: Array.from({ length: 8 }, (_, j) => {
|
||||
const program = i * 8 + j;
|
||||
return { program, name: GM_LEVEL1_PATCHES[program]! };
|
||||
}),
|
||||
}));
|
||||
return tree;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GM_LEVEL1_PATCHES: Readonly<Record<number, string>>` — frozen map, 128 entries (programs 0..127).
|
||||
- `GM_LEVEL1_PERCUSSION: Readonly<Record<number, string>>` — frozen map, 47 entries (notes 35..81 on channel 10).
|
||||
- `GM_LEVEL1_FAMILIES: readonly string[]` — 16 instrument-family names, indexed by `Math.floor(program / 8)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Both maps are declared as `Object.freeze({ ... })` rather than via numeric arrays because programs and percussion notes are sparse from the consumer's perspective — looking up `PERCUSSION[36]` directly is clearer than `PERCUSSION[36 - 35]`. The freeze guarantees downstream compose patterns (`{ ...GM_LEVEL1_PATCHES, 0: 'Custom Piano' }`) produce a new object rather than mutating the canonical table.
|
||||
214
packages/media/audio/midi/src/gm/level1.ts
Normal file
214
packages/media/audio/midi/src/gm/level1.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* General MIDI Level 1 patch + percussion tables.
|
||||
*
|
||||
* `GM_LEVEL1_PATCHES` maps MIDI program number (0..127) to the canonical
|
||||
* instrument name from the GM specification, organised in 8-patch families
|
||||
* (Piano, Chromatic Percussion, Organ, Guitar, Bass, Strings, Ensemble,
|
||||
* Brass, Reed, Pipe, Synth Lead, Synth Pad, Synth Effects, Ethnic,
|
||||
* Percussive, Sound Effects).
|
||||
*
|
||||
* `GM_LEVEL1_PERCUSSION` maps drum note number (35..81) — channel 10 — to
|
||||
* its canonical drum name.
|
||||
*/
|
||||
export const GM_LEVEL1_PATCHES: Readonly<Record<number, string>> =
|
||||
Object.freeze({
|
||||
0: "Acoustic Grand Piano",
|
||||
1: "Bright Acoustic Piano",
|
||||
2: "Electric Grand Piano",
|
||||
3: "Honky-tonk Piano",
|
||||
4: "Electric Piano 1",
|
||||
5: "Electric Piano 2",
|
||||
6: "Harpsichord",
|
||||
7: "Clavinet",
|
||||
8: "Celesta",
|
||||
9: "Glockenspiel",
|
||||
10: "Music Box",
|
||||
11: "Vibraphone",
|
||||
12: "Marimba",
|
||||
13: "Xylophone",
|
||||
14: "Tubular Bells",
|
||||
15: "Dulcimer",
|
||||
16: "Drawbar Organ",
|
||||
17: "Percussive Organ",
|
||||
18: "Rock Organ",
|
||||
19: "Church Organ",
|
||||
20: "Reed Organ",
|
||||
21: "Accordion",
|
||||
22: "Harmonica",
|
||||
23: "Tango Accordion",
|
||||
24: "Acoustic Guitar (nylon)",
|
||||
25: "Acoustic Guitar (steel)",
|
||||
26: "Electric Guitar (jazz)",
|
||||
27: "Electric Guitar (clean)",
|
||||
28: "Electric Guitar (muted)",
|
||||
29: "Overdriven Guitar",
|
||||
30: "Distortion Guitar",
|
||||
31: "Guitar harmonics",
|
||||
32: "Acoustic Bass",
|
||||
33: "Electric Bass (finger)",
|
||||
34: "Electric Bass (pick)",
|
||||
35: "Fretless Bass",
|
||||
36: "Slap Bass 1",
|
||||
37: "Slap Bass 2",
|
||||
38: "Synth Bass 1",
|
||||
39: "Synth Bass 2",
|
||||
40: "Violin",
|
||||
41: "Viola",
|
||||
42: "Cello",
|
||||
43: "Contrabass",
|
||||
44: "Tremolo Strings",
|
||||
45: "Pizzicato Strings",
|
||||
46: "Orchestral Harp",
|
||||
47: "Timpani",
|
||||
48: "String Ensemble 1",
|
||||
49: "String Ensemble 2",
|
||||
50: "Synth Strings 1",
|
||||
51: "Synth Strings 2",
|
||||
52: "Choir Aahs",
|
||||
53: "Voice Oohs",
|
||||
54: "Synth Voice",
|
||||
55: "Orchestra Hit",
|
||||
56: "Trumpet",
|
||||
57: "Trombone",
|
||||
58: "Tuba",
|
||||
59: "Muted Trumpet",
|
||||
60: "French Horn",
|
||||
61: "Brass Section",
|
||||
62: "Synth Brass 1",
|
||||
63: "Synth Brass 2",
|
||||
64: "Soprano Sax",
|
||||
65: "Alto Sax",
|
||||
66: "Tenor Sax",
|
||||
67: "Baritone Sax",
|
||||
68: "Oboe",
|
||||
69: "English Horn",
|
||||
70: "Bassoon",
|
||||
71: "Clarinet",
|
||||
72: "Piccolo",
|
||||
73: "Flute",
|
||||
74: "Recorder",
|
||||
75: "Pan Flute",
|
||||
76: "Blown Bottle",
|
||||
77: "Shakuhachi",
|
||||
78: "Whistle",
|
||||
79: "Ocarina",
|
||||
80: "Lead 1 (square)",
|
||||
81: "Lead 2 (sawtooth)",
|
||||
82: "Lead 3 (calliope)",
|
||||
83: "Lead 4 (chiff)",
|
||||
84: "Lead 5 (charang)",
|
||||
85: "Lead 6 (voice)",
|
||||
86: "Lead 7 (fifths)",
|
||||
87: "Lead 8 (bass + lead)",
|
||||
88: "Pad 1 (new age)",
|
||||
89: "Pad 2 (warm)",
|
||||
90: "Pad 3 (polysynth)",
|
||||
91: "Pad 4 (choir)",
|
||||
92: "Pad 5 (bowed)",
|
||||
93: "Pad 6 (metallic)",
|
||||
94: "Pad 7 (halo)",
|
||||
95: "Pad 8 (sweep)",
|
||||
96: "FX 1 (rain)",
|
||||
97: "FX 2 (soundtrack)",
|
||||
98: "FX 3 (crystal)",
|
||||
99: "FX 4 (atmosphere)",
|
||||
100: "FX 5 (brightness)",
|
||||
101: "FX 6 (goblins)",
|
||||
102: "FX 7 (echoes)",
|
||||
103: "FX 8 (sci-fi)",
|
||||
104: "Sitar",
|
||||
105: "Banjo",
|
||||
106: "Shamisen",
|
||||
107: "Koto",
|
||||
108: "Kalimba",
|
||||
109: "Bag pipe",
|
||||
110: "Fiddle",
|
||||
111: "Shanai",
|
||||
112: "Tinkle Bell",
|
||||
113: "Agogo",
|
||||
114: "Steel Drums",
|
||||
115: "Woodblock",
|
||||
116: "Taiko Drum",
|
||||
117: "Melodic Tom",
|
||||
118: "Synth Drum",
|
||||
119: "Reverse Cymbal",
|
||||
120: "Guitar Fret Noise",
|
||||
121: "Breath Noise",
|
||||
122: "Seashore",
|
||||
123: "Bird Tweet",
|
||||
124: "Telephone Ring",
|
||||
125: "Helicopter",
|
||||
126: "Applause",
|
||||
127: "Gunshot",
|
||||
});
|
||||
|
||||
export const GM_LEVEL1_PERCUSSION: Readonly<Record<number, string>> =
|
||||
Object.freeze({
|
||||
35: "Acoustic Bass Drum",
|
||||
36: "Bass Drum 1",
|
||||
37: "Side Stick",
|
||||
38: "Acoustic Snare",
|
||||
39: "Hand Clap",
|
||||
40: "Electric Snare",
|
||||
41: "Low Floor Tom",
|
||||
42: "Closed Hi Hat",
|
||||
43: "High Floor Tom",
|
||||
44: "Pedal Hi-Hat",
|
||||
45: "Low Tom",
|
||||
46: "Open Hi-Hat",
|
||||
47: "Low-Mid Tom",
|
||||
48: "Hi-Mid Tom",
|
||||
49: "Crash Cymbal 1",
|
||||
50: "High Tom",
|
||||
51: "Ride Cymbal 1",
|
||||
52: "Chinese Cymbal",
|
||||
53: "Ride Bell",
|
||||
54: "Tambourine",
|
||||
55: "Splash Cymbal",
|
||||
56: "Cowbell",
|
||||
57: "Crash Cymbal 2",
|
||||
58: "Vibraslap",
|
||||
59: "Ride Cymbal 2",
|
||||
60: "Hi Bongo",
|
||||
61: "Low Bongo",
|
||||
62: "Mute Hi Conga",
|
||||
63: "Open Hi Conga",
|
||||
64: "Low Conga",
|
||||
65: "High Timbale",
|
||||
66: "Low Timbale",
|
||||
67: "High Agogo",
|
||||
68: "Low Agogo",
|
||||
69: "Cabasa",
|
||||
70: "Maracas",
|
||||
71: "Short Whistle",
|
||||
72: "Long Whistle",
|
||||
73: "Short Guiro",
|
||||
74: "Long Guiro",
|
||||
75: "Claves",
|
||||
76: "Hi Wood Block",
|
||||
77: "Low Wood Block",
|
||||
78: "Mute Cuica",
|
||||
79: "Open Cuica",
|
||||
80: "Mute Triangle",
|
||||
81: "Open Triangle",
|
||||
});
|
||||
|
||||
/** Family names indexed by program / 8 — used for UI grouping. */
|
||||
export const GM_LEVEL1_FAMILIES: readonly string[] = [
|
||||
"Piano",
|
||||
"Chromatic Percussion",
|
||||
"Organ",
|
||||
"Guitar",
|
||||
"Bass",
|
||||
"Strings",
|
||||
"Ensemble",
|
||||
"Brass",
|
||||
"Reed",
|
||||
"Pipe",
|
||||
"Synth Lead",
|
||||
"Synth Pad",
|
||||
"Synth Effects",
|
||||
"Ethnic",
|
||||
"Percussive",
|
||||
"Sound Effects",
|
||||
];
|
||||
51
packages/media/audio/midi/src/gm/level2.md
Normal file
51
packages/media/audio/midi/src/gm/level2.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# gm/level2
|
||||
|
||||
## Purpose
|
||||
|
||||
General MIDI Level 2 added a bank-select-driven variation layer on top of GM1. Each GM1 program (0..127) gains optional variation banks selected by Bank-Select LSB (CC 32). Bank 0 is always the GM1 default; banks 1..127 are reserved for GM2 variations. This module exposes the variation-name lookup so engines can render `(program, bankLsb)` pairs into a user-visible patch name.
|
||||
|
||||
The table here mirrors the GM2 v1.2 *mandatory* variation set. Full-coverage engines (hundreds of additional variations) layer their own tables on top via `Object.assign({}, GM_LEVEL2_VARIATIONS, …)` — keeping the base set small means importing this module costs negligible bundle size for consumers that only need the GM1 fallback path.
|
||||
|
||||
## Features
|
||||
|
||||
- `GM_LEVEL2_VARIATIONS` frozen map keyed by `program * 256 + bankLsb`.
|
||||
- `gm2Key(program, bankLsb)` helper for composing the lookup key.
|
||||
- Bank 0 entries are deliberately omitted; consumers fall back to `GM_LEVEL1_PATCHES`.
|
||||
- Composable with downstream extension tables via the frozen-object idiom.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { GM_LEVEL2_VARIATIONS, gm2Key } from "@suaveplan/midi";
|
||||
|
||||
const name = GM_LEVEL2_VARIATIONS[gm2Key(4, 1)];
|
||||
// "Detuned Electric Piano 1"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
GM_LEVEL1_PATCHES,
|
||||
GM_LEVEL2_VARIATIONS,
|
||||
gm2Key,
|
||||
} from "@suaveplan/midi";
|
||||
|
||||
function resolvePatchName(program: number, bankLsb: number): string {
|
||||
if (bankLsb === 0) return GM_LEVEL1_PATCHES[program] ?? `Program ${program}`;
|
||||
return (
|
||||
GM_LEVEL2_VARIATIONS[gm2Key(program, bankLsb)] ??
|
||||
GM_LEVEL1_PATCHES[program] ??
|
||||
`Program ${program}`
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GM_LEVEL2_VARIATIONS: Readonly<Record<number, string>>` — frozen map keyed by `program * 256 + bankLsb`.
|
||||
- `gm2Key(program: number, bankLsb: number): number` — pure function returning the composite lookup key.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The composite key encoding (`program * 256 + bankLsb`) is preferred over a nested `Record<number, Record<number, string>>` because a flat key has constant-time lookup with no intermediate object allocation, and because both forms have the same `Object.freeze` cost. Variations are added selectively — full GM2 / GS / XG coverage is huge and varies by vendor, so consumers can choose which dialect tables to spread on top.
|
||||
43
packages/media/audio/midi/src/gm/level2.ts
Normal file
43
packages/media/audio/midi/src/gm/level2.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* General MIDI Level 2 patch-bank extensions.
|
||||
*
|
||||
* GM2 keeps GM1's program-number layout for `bank LSB = 0` and adds curated
|
||||
* variation banks for each program. The table here maps a `(program, bank)`
|
||||
* key — encoded as `program * 256 + bankLsb` — to the variation name. Bank
|
||||
* 0 (the GM1 default) is omitted; consumers fall back to
|
||||
* `GM_LEVEL1_PATCHES[program]` when no GM2 variation exists.
|
||||
*
|
||||
* The selection below is the GM2 v1.2 mandatory variation set published by
|
||||
* the MMA — a representative ten programs each with their canonical bank-1
|
||||
* variation. Full coverage of every GM2 variation (~256 entries) lives in
|
||||
* downstream rendering packages (e.g. SoundFont engines) which mirror this
|
||||
* table at runtime.
|
||||
*/
|
||||
export const GM_LEVEL2_VARIATIONS: Readonly<Record<number, string>> =
|
||||
Object.freeze({
|
||||
[0 * 256 + 1]: "Wide Acoustic Grand Piano",
|
||||
[1 * 256 + 1]: "Wide Bright Acoustic Piano",
|
||||
[4 * 256 + 1]: "Detuned Electric Piano 1",
|
||||
[5 * 256 + 1]: "Detuned Electric Piano 2",
|
||||
[6 * 256 + 1]: "Coupled Harpsichord",
|
||||
[16 * 256 + 1]: "Detuned Organ 1",
|
||||
[17 * 256 + 1]: "Detuned Organ 2",
|
||||
[19 * 256 + 1]: "Church Organ 2",
|
||||
[24 * 256 + 1]: "Ukulele",
|
||||
[25 * 256 + 1]: "12-String Guitar",
|
||||
[26 * 256 + 1]: "Hawaiian Guitar",
|
||||
[27 * 256 + 1]: "Chorus Guitar",
|
||||
[40 * 256 + 1]: "Slow Violin",
|
||||
[48 * 256 + 1]: "Orchestral Pad",
|
||||
[56 * 256 + 1]: "Dark Trumpet",
|
||||
[61 * 256 + 1]: "Brass 2",
|
||||
[80 * 256 + 1]: "Square Wave Lead",
|
||||
[81 * 256 + 1]: "Sine Wave Lead",
|
||||
[88 * 256 + 1]: "Pad 1 (new age) v2",
|
||||
[120 * 256 + 1]: "Fret Slide Down",
|
||||
});
|
||||
|
||||
/** Encode `(program, bankLsb)` to the lookup key used in {@link GM_LEVEL2_VARIATIONS}. */
|
||||
export function gm2Key(program: number, bankLsb: number): number {
|
||||
return program * 256 + bankLsb;
|
||||
}
|
||||
48
packages/media/audio/midi/src/gm/xg.md
Normal file
48
packages/media/audio/midi/src/gm/xg.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# gm/xg
|
||||
|
||||
## Purpose
|
||||
|
||||
Yamaha's XG dialect uses Bank-Select MSB (CC 0) to select instrument variations on top of the GM1 base programs — the inverse of GS, which uses Bank LSB. The XG specification covers hundreds of variations across the full program range; this module exposes a curated set of the canonical XG patches every XG-aware engine should at least be able to label.
|
||||
|
||||
The table key encoding intentionally matches `gm2Key(program, bankMsb)` so consumers can write a single resolver function that handles all three dialects by branching on which CC carried the bank select.
|
||||
|
||||
## Features
|
||||
|
||||
- `XG_VARIATIONS` frozen map keyed by `program * 256 + bankMsb`.
|
||||
- Mandatory XG variation set; vendor-specific extensions layer on top.
|
||||
- Same key encoding as `GS_VARIATIONS` and `GM_LEVEL2_VARIATIONS` — uniform composition.
|
||||
- Frozen object to safeguard against accidental mutation.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { XG_VARIATIONS, gm2Key } from "@suaveplan/midi";
|
||||
|
||||
console.log(XG_VARIATIONS[gm2Key(56, 16)]); // "Trumpet 2"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
GM_LEVEL1_PATCHES,
|
||||
XG_VARIATIONS,
|
||||
gm2Key,
|
||||
} from "@suaveplan/midi";
|
||||
|
||||
function xgPatchName(program: number, bankMsb: number): string {
|
||||
return (
|
||||
XG_VARIATIONS[gm2Key(program, bankMsb)] ??
|
||||
GM_LEVEL1_PATCHES[program] ??
|
||||
`Program ${program}`
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `XG_VARIATIONS: Readonly<Record<number, string>>` — frozen map; key encoding is `program * 256 + bankMsb`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Because XG selects on Bank MSB rather than LSB, an engine running multiple dialects in parallel must track both CCs separately and dispatch on the dialect chosen for the channel. The frozen-object idiom keeps the runtime contract immutable: composing `{ ...XG_VARIATIONS, [gm2Key(80, 64)]: 'Custom Lead' }` produces a fresh object without touching the canonical export.
|
||||
34
packages/media/audio/midi/src/gm/xg.ts
Normal file
34
packages/media/audio/midi/src/gm/xg.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Yamaha XG dialect — bank-MSB-addressed instrument variations layered on
|
||||
* top of the GM Level 1 programme map. Key encoding here is
|
||||
* `program * 256 + bankMsb` because XG selects variations via MSB rather
|
||||
* than LSB (the inverse of GS / GM2).
|
||||
*
|
||||
* The table covers Yamaha's mandatory XG variation set; engines aiming at
|
||||
* full XG compatibility layer ~480 additional bank-MSB variations on top of
|
||||
* this base via `Object.assign({}, XG_VARIATIONS, …)`.
|
||||
*/
|
||||
export const XG_VARIATIONS: Readonly<Record<number, string>> = Object.freeze({
|
||||
[0 * 256 + 1]: "GrandPno KSP",
|
||||
[0 * 256 + 18]: "MelloPno",
|
||||
[0 * 256 + 40]: "PianoStr",
|
||||
[4 * 256 + 1]: "El.Piano 1 KSP",
|
||||
[4 * 256 + 18]: "60sEP",
|
||||
[4 * 256 + 40]: "EP Phase",
|
||||
[16 * 256 + 32]: "Organ Bass",
|
||||
[16 * 256 + 35]: "70s DrawOrg",
|
||||
[24 * 256 + 16]: "NylonGtr 2",
|
||||
[24 * 256 + 25]: "ClassGtr 2",
|
||||
[25 * 256 + 16]: "12-string Guitar",
|
||||
[27 * 256 + 1]: "ChorusGtr KSP",
|
||||
[40 * 256 + 8]: "Slow Violin",
|
||||
[48 * 256 + 35]: "70s Strings",
|
||||
[56 * 256 + 16]: "Trumpet 2",
|
||||
[56 * 256 + 17]: "Bright Trumpet",
|
||||
[80 * 256 + 6]: "Square 2",
|
||||
[80 * 256 + 8]: "LMSquare",
|
||||
[81 * 256 + 6]: "Sawtooth 2",
|
||||
[81 * 256 + 8]: "ThickSaw",
|
||||
[88 * 256 + 64]: "New Age Pad 2",
|
||||
[120 * 256 + 1]: "Gtr.Cut Noise 2",
|
||||
});
|
||||
102
packages/media/audio/midi/src/index.ts
Normal file
102
packages/media/audio/midi/src/index.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
export {
|
||||
beatsToTicks,
|
||||
bpmToClock,
|
||||
clockToBpm,
|
||||
PPQN,
|
||||
TICKS_PER_BEAT,
|
||||
ticksToBeats,
|
||||
} from "./clock/index.js";
|
||||
export {
|
||||
type CC14ChangeEvent,
|
||||
CC14Reducer,
|
||||
decodeStream,
|
||||
type NRPNChangeEvent,
|
||||
NRPNReducer,
|
||||
type RPNChangeEvent,
|
||||
RPNReducer,
|
||||
RunningStatusDecoder,
|
||||
} from "./dispatch/index.js";
|
||||
export {
|
||||
MIDI_CODES,
|
||||
MIDI_ERROR_METADATA,
|
||||
MidiError,
|
||||
type MidiErrorCode,
|
||||
type MidiErrorMetadata,
|
||||
} from "./error-codes/index.js";
|
||||
export {
|
||||
GM_LEVEL1_FAMILIES,
|
||||
GM_LEVEL1_PATCHES,
|
||||
GM_LEVEL1_PERCUSSION,
|
||||
GM_LEVEL2_VARIATIONS,
|
||||
GS_VARIATIONS,
|
||||
gm2Key,
|
||||
XG_VARIATIONS,
|
||||
} from "./gm/index.js";
|
||||
export {
|
||||
type ActiveSensingMessage,
|
||||
type ChannelPressureMessage,
|
||||
type ClockMessage,
|
||||
type ContinueMessage,
|
||||
type ControlChangeMessage,
|
||||
type DecodeResult,
|
||||
decodeMessage,
|
||||
encodeMessage,
|
||||
MIDI_MESSAGE_TYPES,
|
||||
type Midi7,
|
||||
type Midi14,
|
||||
type MidiChannel,
|
||||
type MidiChannelNumber,
|
||||
type MidiMessage,
|
||||
type MidiMessageType,
|
||||
type NoteOffMessage,
|
||||
type NoteOnMessage,
|
||||
type PitchBendMessage,
|
||||
type PolyPressureMessage,
|
||||
type ProgramChangeMessage,
|
||||
type ResetMessage,
|
||||
type SongPositionMessage,
|
||||
type SongSelectMessage,
|
||||
STATUS,
|
||||
type StartMessage,
|
||||
type StopMessage,
|
||||
type SystemExclusiveMessage,
|
||||
type TimeCodeMessage,
|
||||
type TuneRequestMessage,
|
||||
} from "./message/index.js";
|
||||
export type {
|
||||
MidiPortLike,
|
||||
MidiPortState,
|
||||
Unsubscribe,
|
||||
} from "./port/index.js";
|
||||
export {
|
||||
buildSysEx,
|
||||
lookupManufacturer,
|
||||
MANUFACTURERS,
|
||||
type ManufacturerEntry,
|
||||
type ManufacturerId,
|
||||
parseSysEx,
|
||||
SYSEX_UNIVERSAL_NON_REALTIME,
|
||||
SYSEX_UNIVERSAL_REALTIME,
|
||||
type SysExFrame,
|
||||
} from "./sysex/index.js";
|
||||
export {
|
||||
type CounterLike,
|
||||
createInMemoryTelemetry,
|
||||
type HistogramLike,
|
||||
type InMemoryTelemetry,
|
||||
MIDI_TELEMETRY_NAMES,
|
||||
type MidiTelemetry,
|
||||
type MidiTelemetrySnapshot,
|
||||
nowNanos,
|
||||
recordDecode,
|
||||
} from "./telemetry/index.js";
|
||||
export {
|
||||
midi1ToUmp,
|
||||
UMP_MESSAGE_TYPE,
|
||||
type UMP32,
|
||||
type UMP64,
|
||||
type UMP96,
|
||||
type UMP128,
|
||||
type UmpPacket,
|
||||
umpToMidi1,
|
||||
} from "./ump/index.js";
|
||||
57
packages/media/audio/midi/src/message/decode.md
Normal file
57
packages/media/audio/midi/src/message/decode.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# message/decode
|
||||
|
||||
## Purpose
|
||||
|
||||
`decodeMessage` parses one MIDI 1.0 message starting at a given offset in a `Uint8Array` and returns both the typed message and the next-offset to feed back into the decoder. It is the byte-level inverse of `encodeMessage`, designed to compose with the streaming `RunningStatusDecoder` for serial-MIDI inputs where status bytes are sometimes omitted.
|
||||
|
||||
The decoder rejects malformed input loudly: a data byte arriving without a prior status byte (and no `runningStatus` argument) throws `MidiError("MIDI.INVALID_MESSAGE")`; a SysEx frame missing its `F7` terminator throws `MidiError("MIDI.INVALID_SYSEX")`; a buffer that ends mid-message throws `MidiError("MIDI.BUFFER_UNDERRUN")`. This is deliberate — silent skipping is how stuck notes appear in production.
|
||||
|
||||
## Features
|
||||
|
||||
- Parses every one of the 18 MIDI 1.0 message variants into the discriminated `MidiMessage` union.
|
||||
- Optional `runningStatus` parameter for parsers that already know the status byte was omitted.
|
||||
- Strict data-byte high-bit checks — surfacing transport-level corruption rather than silently producing wrong notes.
|
||||
- Returns the `next` offset so consumers can drive a single buffer through repeated calls.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { decodeMessage } from "@suaveplan/midi";
|
||||
|
||||
const { msg, next } = decodeMessage(new Uint8Array([0x91, 64, 100]), 0);
|
||||
// msg = { type: "noteOn", channel: 1, note: 64, velocity: 100 }, next = 3
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { decodeMessage } from "@suaveplan/midi";
|
||||
|
||||
function decodeAll(bytes: Uint8Array) {
|
||||
const messages = [];
|
||||
let offset = 0;
|
||||
let runningStatus: number | undefined;
|
||||
while (offset < bytes.length) {
|
||||
const first = bytes[offset]!;
|
||||
const { msg, next } = decodeMessage(bytes, offset, runningStatus);
|
||||
messages.push(msg);
|
||||
if (first >= 0x80 && first < 0xf0) runningStatus = first;
|
||||
if (first >= 0xf0 && first < 0xf8) runningStatus = undefined;
|
||||
offset = next;
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `decodeMessage(bytes: Uint8Array, offset: number, runningStatus?: number): DecodeResult`
|
||||
- `bytes` — buffer containing the serial MIDI stream.
|
||||
- `offset` — index of the first byte to consume.
|
||||
- `runningStatus` — optional last-seen status byte, used when `bytes[offset] < 0x80`.
|
||||
- Returns `{ msg: MidiMessage; next: number }` — `next` is the index immediately past the consumed bytes (for SysEx, this is the position after `F7`).
|
||||
- `DecodeResult` — the return type, exported for callers building their own dispatchers.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The decoder splits into three internal helpers: `decodeChannelVoice` handles statuses `0x8X..0xEX`, `decodeSystem` handles `0xF0..0xFF`, and `decodeSysEx` walks the variable-length F0..F7 payload. `read7` enforces the high-bit-clear invariant on every data byte read so we surface `INVALID_MESSAGE` immediately rather than producing a corrupted value. SysEx returns a slice (not a view) of the payload to insulate callers from later buffer reuse.
|
||||
183
packages/media/audio/midi/src/message/decode.test.ts
Normal file
183
packages/media/audio/midi/src/message/decode.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MidiError } from "../error-codes/error-codes.js";
|
||||
import { decodeMessage } from "./decode.js";
|
||||
|
||||
describe("decodeMessage", () => {
|
||||
it("decodes NoteOn into the canonical shape", { timeout: 5000 }, () => {
|
||||
const { msg, next } = decodeMessage(Uint8Array.of(0x91, 64, 100), 0);
|
||||
expect(msg).toEqual({
|
||||
type: "noteOn",
|
||||
channel: 1,
|
||||
note: 64,
|
||||
velocity: 100,
|
||||
});
|
||||
expect(next).toBe(3);
|
||||
});
|
||||
|
||||
it("decodes NoteOff, PolyPressure, ControlChange, ProgramChange, ChannelPressure", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(decodeMessage(Uint8Array.of(0x81, 60, 0), 0).msg).toEqual({
|
||||
type: "noteOff",
|
||||
channel: 1,
|
||||
note: 60,
|
||||
velocity: 0,
|
||||
});
|
||||
expect(decodeMessage(Uint8Array.of(0xa2, 50, 90), 0).msg).toEqual({
|
||||
type: "polyPressure",
|
||||
channel: 2,
|
||||
note: 50,
|
||||
pressure: 90,
|
||||
});
|
||||
expect(decodeMessage(Uint8Array.of(0xb3, 7, 127), 0).msg).toEqual({
|
||||
type: "controlChange",
|
||||
channel: 3,
|
||||
controller: 7,
|
||||
value: 127,
|
||||
});
|
||||
expect(decodeMessage(Uint8Array.of(0xc4, 10), 0).msg).toEqual({
|
||||
type: "programChange",
|
||||
channel: 4,
|
||||
program: 10,
|
||||
});
|
||||
expect(decodeMessage(Uint8Array.of(0xd5, 80), 0).msg).toEqual({
|
||||
type: "channelPressure",
|
||||
channel: 5,
|
||||
pressure: 80,
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes PitchBend into the combined 14-bit value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { msg } = decodeMessage(Uint8Array.of(0xe0, 0, 64), 0);
|
||||
expect(msg).toEqual({ type: "pitchBend", channel: 0, value: 8192 });
|
||||
});
|
||||
|
||||
it("decodes SysEx body between F0 and F7", { timeout: 5000 }, () => {
|
||||
const { msg, next } = decodeMessage(
|
||||
Uint8Array.of(0xf0, 0x43, 0x10, 0x4c, 0xf7),
|
||||
0
|
||||
);
|
||||
if (msg.type !== "systemExclusive") throw new Error("type mismatch");
|
||||
expect(Array.from(msg.payload)).toEqual([0x43, 0x10, 0x4c]);
|
||||
expect(next).toBe(5);
|
||||
});
|
||||
|
||||
it("decodes system common and realtime messages", { timeout: 5000 }, () => {
|
||||
expect(decodeMessage(Uint8Array.of(0xf1, 5), 0).msg).toEqual({
|
||||
type: "timeCode",
|
||||
channel: null,
|
||||
value: 5,
|
||||
});
|
||||
expect(
|
||||
decodeMessage(Uint8Array.of(0xf2, 200 & 0x7f, (200 >> 7) & 0x7f), 0)
|
||||
.msg
|
||||
).toEqual({ type: "songPosition", channel: null, position: 200 });
|
||||
expect(decodeMessage(Uint8Array.of(0xf3, 3), 0).msg).toEqual({
|
||||
type: "songSelect",
|
||||
channel: null,
|
||||
song: 3,
|
||||
});
|
||||
expect(decodeMessage(Uint8Array.of(0xf6), 0).msg).toEqual({
|
||||
type: "tuneRequest",
|
||||
channel: null,
|
||||
});
|
||||
expect(decodeMessage(Uint8Array.of(0xf8), 0).msg).toEqual({
|
||||
type: "clock",
|
||||
channel: null,
|
||||
});
|
||||
expect(decodeMessage(Uint8Array.of(0xfa), 0).msg).toEqual({
|
||||
type: "start",
|
||||
channel: null,
|
||||
});
|
||||
expect(decodeMessage(Uint8Array.of(0xfb), 0).msg).toEqual({
|
||||
type: "continue",
|
||||
channel: null,
|
||||
});
|
||||
expect(decodeMessage(Uint8Array.of(0xfc), 0).msg).toEqual({
|
||||
type: "stop",
|
||||
channel: null,
|
||||
});
|
||||
expect(decodeMessage(Uint8Array.of(0xfe), 0).msg).toEqual({
|
||||
type: "activeSensing",
|
||||
channel: null,
|
||||
});
|
||||
expect(decodeMessage(Uint8Array.of(0xff), 0).msg).toEqual({
|
||||
type: "reset",
|
||||
channel: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects empty buffer with BUFFER_UNDERRUN", { timeout: 5000 }, () => {
|
||||
expect(() => decodeMessage(new Uint8Array(), 0)).toThrow(MidiError);
|
||||
expect(() => decodeMessage(Uint8Array.of(0x91), -1)).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("rejects data byte without running status", { timeout: 5000 }, () => {
|
||||
expect(() => decodeMessage(Uint8Array.of(0x40, 0x64), 0)).toThrow(
|
||||
MidiError
|
||||
);
|
||||
});
|
||||
|
||||
it("honours runningStatus argument when first byte is data", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { msg, next } = decodeMessage(Uint8Array.of(64, 100), 0, 0x91);
|
||||
expect(msg).toEqual({
|
||||
type: "noteOn",
|
||||
channel: 1,
|
||||
note: 64,
|
||||
velocity: 100,
|
||||
});
|
||||
expect(next).toBe(2);
|
||||
});
|
||||
|
||||
it("rejects data byte with the high bit set", { timeout: 5000 }, () => {
|
||||
expect(() => decodeMessage(Uint8Array.of(0x91, 0x80, 100), 0)).toThrow(
|
||||
MidiError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects buffer underrun mid-message", { timeout: 5000 }, () => {
|
||||
expect(() => decodeMessage(Uint8Array.of(0x91, 64), 0)).toThrow(
|
||||
MidiError
|
||||
);
|
||||
expect(() => decodeMessage(Uint8Array.of(0x91), 0)).toThrow(MidiError);
|
||||
expect(() => decodeMessage(Uint8Array.of(0xc4), 0)).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("rejects unterminated SysEx with INVALID_SYSEX", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => decodeMessage(Uint8Array.of(0xf0, 0x7e, 0x00), 0)).toThrow(
|
||||
MidiError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects status byte interruption inside SysEx", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => decodeMessage(Uint8Array.of(0xf0, 0x90, 0xf7), 0)).toThrow(
|
||||
MidiError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown system status bytes", { timeout: 5000 }, () => {
|
||||
expect(() => decodeMessage(Uint8Array.of(0xf4, 0x00), 0)).toThrow(
|
||||
MidiError
|
||||
);
|
||||
expect(() => decodeMessage(Uint8Array.of(0xf5, 0x00), 0)).toThrow(
|
||||
MidiError
|
||||
);
|
||||
expect(() => decodeMessage(Uint8Array.of(0xfd), 0)).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("rejects running status that itself is not a status byte", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => decodeMessage(Uint8Array.of(0x40, 0x60), 0, 0x40)).toThrow(
|
||||
MidiError
|
||||
);
|
||||
});
|
||||
});
|
||||
240
packages/media/audio/midi/src/message/decode.ts
Normal file
240
packages/media/audio/midi/src/message/decode.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { MidiError } from "../error-codes/error-codes.js";
|
||||
import { STATUS } from "./encode.js";
|
||||
import type { MidiMessage } from "./message.types.js";
|
||||
|
||||
/**
|
||||
* Result of a single `decodeMessage` call. `msg` is the parsed message and
|
||||
* `next` is the buffer offset immediately past the consumed status + data
|
||||
* bytes (a SysEx frame consumes from F0 through F7 inclusive).
|
||||
*/
|
||||
export interface DecodeResult {
|
||||
readonly msg: MidiMessage;
|
||||
readonly next: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the MIDI 1.0 message starting at `offset` in `bytes`.
|
||||
*
|
||||
* If `runningStatus` is provided AND the byte at `offset` does not have bit 7
|
||||
* set (i.e. it is a data byte rather than a status byte), the decoder treats
|
||||
* the byte at `offset` as the first data byte of a re-use of the running
|
||||
* status. This mirrors how serial MIDI streams omit redundant status bytes.
|
||||
*
|
||||
* Throws {@link MidiError} with code `MIDI.INVALID_MESSAGE`,
|
||||
* `MIDI.INVALID_SYSEX`, or `MIDI.BUFFER_UNDERRUN` for malformed input.
|
||||
*/
|
||||
export function decodeMessage(
|
||||
bytes: Uint8Array,
|
||||
offset: number,
|
||||
runningStatus?: number
|
||||
): DecodeResult {
|
||||
if (offset < 0 || offset >= bytes.length) {
|
||||
throw new MidiError("MIDI.BUFFER_UNDERRUN", "midi.decode.empty_buffer");
|
||||
}
|
||||
const first = bytes[offset] as number;
|
||||
let status: number;
|
||||
let dataOffset: number;
|
||||
if (first < 0x80) {
|
||||
if (runningStatus === undefined || runningStatus < 0x80) {
|
||||
throw new MidiError(
|
||||
"MIDI.INVALID_MESSAGE",
|
||||
"midi.decode.no_running_status"
|
||||
);
|
||||
}
|
||||
status = runningStatus;
|
||||
dataOffset = offset;
|
||||
} else {
|
||||
status = first;
|
||||
dataOffset = offset + 1;
|
||||
}
|
||||
return decodeFromStatus(bytes, status, dataOffset);
|
||||
}
|
||||
|
||||
function read7(bytes: Uint8Array, at: number, label: string): number {
|
||||
if (at >= bytes.length) {
|
||||
throw new MidiError(
|
||||
"MIDI.BUFFER_UNDERRUN",
|
||||
`midi.decode.short_${label}`
|
||||
);
|
||||
}
|
||||
const v = bytes[at] as number;
|
||||
if ((v & 0x80) !== 0) {
|
||||
throw new MidiError(
|
||||
"MIDI.INVALID_MESSAGE",
|
||||
`midi.decode.data_byte_high_bit_${label}`
|
||||
);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function decodeChannelVoice(
|
||||
bytes: Uint8Array,
|
||||
status: number,
|
||||
dataOffset: number
|
||||
): DecodeResult {
|
||||
const high = status & 0xf0;
|
||||
const channel = status & 0x0f;
|
||||
switch (high) {
|
||||
case STATUS.NOTE_OFF: {
|
||||
const note = read7(bytes, dataOffset, "note");
|
||||
const velocity = read7(bytes, dataOffset + 1, "velocity");
|
||||
return {
|
||||
msg: { type: "noteOff", channel, note, velocity },
|
||||
next: dataOffset + 2,
|
||||
};
|
||||
}
|
||||
case STATUS.NOTE_ON: {
|
||||
const note = read7(bytes, dataOffset, "note");
|
||||
const velocity = read7(bytes, dataOffset + 1, "velocity");
|
||||
return {
|
||||
msg: { type: "noteOn", channel, note, velocity },
|
||||
next: dataOffset + 2,
|
||||
};
|
||||
}
|
||||
case STATUS.POLY_PRESSURE: {
|
||||
const note = read7(bytes, dataOffset, "note");
|
||||
const pressure = read7(bytes, dataOffset + 1, "pressure");
|
||||
return {
|
||||
msg: { type: "polyPressure", channel, note, pressure },
|
||||
next: dataOffset + 2,
|
||||
};
|
||||
}
|
||||
case STATUS.CONTROL_CHANGE: {
|
||||
const controller = read7(bytes, dataOffset, "controller");
|
||||
const value = read7(bytes, dataOffset + 1, "value");
|
||||
return {
|
||||
msg: { type: "controlChange", channel, controller, value },
|
||||
next: dataOffset + 2,
|
||||
};
|
||||
}
|
||||
case STATUS.PROGRAM_CHANGE: {
|
||||
const program = read7(bytes, dataOffset, "program");
|
||||
return {
|
||||
msg: { type: "programChange", channel, program },
|
||||
next: dataOffset + 1,
|
||||
};
|
||||
}
|
||||
case STATUS.CHANNEL_PRESSURE: {
|
||||
const pressure = read7(bytes, dataOffset, "pressure");
|
||||
return {
|
||||
msg: { type: "channelPressure", channel, pressure },
|
||||
next: dataOffset + 1,
|
||||
};
|
||||
}
|
||||
case STATUS.PITCH_BEND: {
|
||||
const lsb = read7(bytes, dataOffset, "pitchbend_lsb");
|
||||
const msb = read7(bytes, dataOffset + 1, "pitchbend_msb");
|
||||
return {
|
||||
msg: { type: "pitchBend", channel, value: (msb << 7) | lsb },
|
||||
next: dataOffset + 2,
|
||||
};
|
||||
}
|
||||
/* istanbul ignore next -- @preserve: `decodeFromStatus` routes only 0x80..0xE0 here; high nibbles outside that set go through `decodeSystem`. */
|
||||
default:
|
||||
throw new MidiError(
|
||||
"MIDI.INVALID_MESSAGE",
|
||||
"midi.decode.unknown_channel_status"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeSysEx(bytes: Uint8Array, dataOffset: number): DecodeResult {
|
||||
let end = dataOffset;
|
||||
while (end < bytes.length && bytes[end] !== STATUS.SYSEX_END) {
|
||||
const b = bytes[end] as number;
|
||||
if ((b & 0x80) !== 0) {
|
||||
throw new MidiError(
|
||||
"MIDI.INVALID_SYSEX",
|
||||
"midi.decode.sysex_status_in_payload"
|
||||
);
|
||||
}
|
||||
end += 1;
|
||||
}
|
||||
if (end >= bytes.length) {
|
||||
throw new MidiError(
|
||||
"MIDI.INVALID_SYSEX",
|
||||
"midi.decode.sysex_unterminated"
|
||||
);
|
||||
}
|
||||
const payload = bytes.slice(dataOffset, end);
|
||||
return {
|
||||
msg: { type: "systemExclusive", channel: null, payload },
|
||||
next: end + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeSystem(
|
||||
bytes: Uint8Array,
|
||||
status: number,
|
||||
dataOffset: number
|
||||
): DecodeResult {
|
||||
switch (status) {
|
||||
case STATUS.SYSEX_START:
|
||||
return decodeSysEx(bytes, dataOffset);
|
||||
case STATUS.TIME_CODE: {
|
||||
const value = read7(bytes, dataOffset, "timecode");
|
||||
return {
|
||||
msg: { type: "timeCode", channel: null, value },
|
||||
next: dataOffset + 1,
|
||||
};
|
||||
}
|
||||
case STATUS.SONG_POSITION: {
|
||||
const lsb = read7(bytes, dataOffset, "songpos_lsb");
|
||||
const msb = read7(bytes, dataOffset + 1, "songpos_msb");
|
||||
return {
|
||||
msg: {
|
||||
type: "songPosition",
|
||||
channel: null,
|
||||
position: (msb << 7) | lsb,
|
||||
},
|
||||
next: dataOffset + 2,
|
||||
};
|
||||
}
|
||||
case STATUS.SONG_SELECT: {
|
||||
const song = read7(bytes, dataOffset, "song");
|
||||
return {
|
||||
msg: { type: "songSelect", channel: null, song },
|
||||
next: dataOffset + 1,
|
||||
};
|
||||
}
|
||||
case STATUS.TUNE_REQUEST:
|
||||
return {
|
||||
msg: { type: "tuneRequest", channel: null },
|
||||
next: dataOffset,
|
||||
};
|
||||
case STATUS.CLOCK:
|
||||
return { msg: { type: "clock", channel: null }, next: dataOffset };
|
||||
case STATUS.START:
|
||||
return { msg: { type: "start", channel: null }, next: dataOffset };
|
||||
case STATUS.CONTINUE:
|
||||
return {
|
||||
msg: { type: "continue", channel: null },
|
||||
next: dataOffset,
|
||||
};
|
||||
case STATUS.STOP:
|
||||
return { msg: { type: "stop", channel: null }, next: dataOffset };
|
||||
case STATUS.ACTIVE_SENSING:
|
||||
return {
|
||||
msg: { type: "activeSensing", channel: null },
|
||||
next: dataOffset,
|
||||
};
|
||||
case STATUS.RESET:
|
||||
return { msg: { type: "reset", channel: null }, next: dataOffset };
|
||||
default:
|
||||
throw new MidiError(
|
||||
"MIDI.INVALID_MESSAGE",
|
||||
"midi.decode.unknown_system_status"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeFromStatus(
|
||||
bytes: Uint8Array,
|
||||
status: number,
|
||||
dataOffset: number
|
||||
): DecodeResult {
|
||||
if (status < 0xf0) {
|
||||
return decodeChannelVoice(bytes, status, dataOffset);
|
||||
}
|
||||
return decodeSystem(bytes, status, dataOffset);
|
||||
}
|
||||
61
packages/media/audio/midi/src/message/encode.md
Normal file
61
packages/media/audio/midi/src/message/encode.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# message/encode
|
||||
|
||||
## Purpose
|
||||
|
||||
`encodeMessage` converts a strongly-typed `MidiMessage` value into the byte sequence a MIDI 1.0 wire format expects. It is the canonical serialisation function for the package and is shared by the streaming dispatcher, the UMP bridge (for status-byte derivation), and any consumer writing to a `MidiPortLike`.
|
||||
|
||||
The encoder validates every payload field at the type-boundary: channels must be 0..15, 7-bit data must be 0..127, 14-bit values (pitch bend, song position) must be 0..16383. Out-of-range values raise `MidiError("MIDI.INVALID_MESSAGE")` rather than silently masking bits, because silent masking is a leading cause of "the synth jumped an octave when I sent the wrong velocity" bugs.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure function returning a freshly allocated `Uint8Array`; no shared buffers, safe to call concurrently.
|
||||
- Validates channel, 7-bit, and 14-bit ranges; rejects non-integer values.
|
||||
- Exposes the `STATUS` byte-constant table for the decoder and UMP bridge.
|
||||
- SysEx framing automatically inserts the `F0` prefix and `F7` terminator.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { encodeMessage } from "@suaveplan/midi";
|
||||
|
||||
const bytes = encodeMessage({
|
||||
type: "noteOn",
|
||||
channel: 0,
|
||||
note: 60,
|
||||
velocity: 100,
|
||||
});
|
||||
// Uint8Array(3) [0x90, 60, 100]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { encodeMessage, STATUS } from "@suaveplan/midi";
|
||||
|
||||
// Compose a series of messages into a single buffer for batched send.
|
||||
const messages = [
|
||||
{ type: "controlChange" as const, channel: 0, controller: 7, value: 100 },
|
||||
{ type: "pitchBend" as const, channel: 0, value: 8192 },
|
||||
{ type: "noteOn" as const, channel: 0, note: 64, velocity: 127 },
|
||||
];
|
||||
const chunks = messages.map(encodeMessage);
|
||||
const total = chunks.reduce((n, c) => n + c.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const c of chunks) {
|
||||
out.set(c, offset);
|
||||
offset += c.length;
|
||||
}
|
||||
|
||||
port.send(out);
|
||||
expect(STATUS.NOTE_ON).toBe(0x90);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `encodeMessage(msg: MidiMessage): Uint8Array` — returns the bytes for any of the 18 message variants. Throws `MidiError("MIDI.INVALID_MESSAGE")` on out-of-range fields.
|
||||
- `STATUS` — frozen object exposing every status-byte constant the encoder writes (`NOTE_OFF`, `NOTE_ON`, `POLY_PRESSURE`, ..., `SYSEX_START`, `SYSEX_END`, `CLOCK`, etc.).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The encoder uses `Uint8Array.of(...)` rather than `new Uint8Array(n)` + index assignments because `of` accepts the elements directly and lets V8/JSCore use the inline cache for small arrays. Field assertions are inlined helpers (`assertChannel`, `assert7`, `assert14`) to keep the branch density low — every variant pays at most three boundary checks plus the `Uint8Array.of` call. SysEx allocates `payload.length + 2` to leave room for the F0/F7 endpoints.
|
||||
193
packages/media/audio/midi/src/message/encode.test.ts
Normal file
193
packages/media/audio/midi/src/message/encode.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MidiError } from "../error-codes/error-codes.js";
|
||||
import { encodeMessage, STATUS } from "./encode.js";
|
||||
|
||||
describe("encodeMessage", () => {
|
||||
it("encodes NoteOn with status 0x9c + note + velocity", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bytes = encodeMessage({
|
||||
type: "noteOn",
|
||||
channel: 12,
|
||||
note: 64,
|
||||
velocity: 100,
|
||||
});
|
||||
expect(Array.from(bytes)).toEqual([0x9c, 64, 100]);
|
||||
});
|
||||
|
||||
it("encodes NoteOff, PolyPressure, ControlChange, ProgramChange, ChannelPressure", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
Array.from(
|
||||
encodeMessage({
|
||||
type: "noteOff",
|
||||
channel: 1,
|
||||
note: 60,
|
||||
velocity: 0,
|
||||
})
|
||||
)
|
||||
).toEqual([0x81, 60, 0]);
|
||||
expect(
|
||||
Array.from(
|
||||
encodeMessage({
|
||||
type: "polyPressure",
|
||||
channel: 2,
|
||||
note: 50,
|
||||
pressure: 90,
|
||||
})
|
||||
)
|
||||
).toEqual([0xa2, 50, 90]);
|
||||
expect(
|
||||
Array.from(
|
||||
encodeMessage({
|
||||
type: "controlChange",
|
||||
channel: 3,
|
||||
controller: 7,
|
||||
value: 127,
|
||||
})
|
||||
)
|
||||
).toEqual([0xb3, 7, 127]);
|
||||
expect(
|
||||
Array.from(
|
||||
encodeMessage({
|
||||
type: "programChange",
|
||||
channel: 4,
|
||||
program: 10,
|
||||
})
|
||||
)
|
||||
).toEqual([0xc4, 10]);
|
||||
expect(
|
||||
Array.from(
|
||||
encodeMessage({
|
||||
type: "channelPressure",
|
||||
channel: 5,
|
||||
pressure: 80,
|
||||
})
|
||||
)
|
||||
).toEqual([0xd5, 80]);
|
||||
});
|
||||
|
||||
it("encodes PitchBend with split LSB/MSB", { timeout: 5000 }, () => {
|
||||
const bytes = encodeMessage({
|
||||
type: "pitchBend",
|
||||
channel: 0,
|
||||
value: 8192,
|
||||
});
|
||||
expect(Array.from(bytes)).toEqual([STATUS.PITCH_BEND, 0, 64]);
|
||||
});
|
||||
|
||||
it("encodes SysEx with F0 prefix and F7 terminator", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const payload = Uint8Array.of(0x43, 0x10, 0x4c);
|
||||
const bytes = encodeMessage({
|
||||
type: "systemExclusive",
|
||||
channel: null,
|
||||
payload,
|
||||
});
|
||||
expect(bytes[0]).toBe(STATUS.SYSEX_START);
|
||||
expect(bytes[bytes.length - 1]).toBe(STATUS.SYSEX_END);
|
||||
expect(Array.from(bytes.slice(1, -1))).toEqual([0x43, 0x10, 0x4c]);
|
||||
});
|
||||
|
||||
it("encodes system common messages", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
Array.from(
|
||||
encodeMessage({ type: "timeCode", channel: null, value: 5 })
|
||||
)
|
||||
).toEqual([STATUS.TIME_CODE, 5]);
|
||||
expect(
|
||||
Array.from(
|
||||
encodeMessage({
|
||||
type: "songPosition",
|
||||
channel: null,
|
||||
position: 200,
|
||||
})
|
||||
)
|
||||
).toEqual([STATUS.SONG_POSITION, 200 & 0x7f, (200 >> 7) & 0x7f]);
|
||||
expect(
|
||||
Array.from(
|
||||
encodeMessage({ type: "songSelect", channel: null, song: 3 })
|
||||
)
|
||||
).toEqual([STATUS.SONG_SELECT, 3]);
|
||||
expect(
|
||||
Array.from(encodeMessage({ type: "tuneRequest", channel: null }))
|
||||
).toEqual([STATUS.TUNE_REQUEST]);
|
||||
});
|
||||
|
||||
it("encodes system realtime messages", { timeout: 5000 }, () => {
|
||||
expect(encodeMessage({ type: "clock", channel: null })[0]).toBe(
|
||||
STATUS.CLOCK
|
||||
);
|
||||
expect(encodeMessage({ type: "start", channel: null })[0]).toBe(
|
||||
STATUS.START
|
||||
);
|
||||
expect(encodeMessage({ type: "continue", channel: null })[0]).toBe(
|
||||
STATUS.CONTINUE
|
||||
);
|
||||
expect(encodeMessage({ type: "stop", channel: null })[0]).toBe(
|
||||
STATUS.STOP
|
||||
);
|
||||
expect(encodeMessage({ type: "activeSensing", channel: null })[0]).toBe(
|
||||
STATUS.ACTIVE_SENSING
|
||||
);
|
||||
expect(encodeMessage({ type: "reset", channel: null })[0]).toBe(
|
||||
STATUS.RESET
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects channel out of range", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
encodeMessage({ type: "noteOn", channel: 16, note: 0, velocity: 0 })
|
||||
).toThrow(MidiError);
|
||||
expect(() =>
|
||||
encodeMessage({ type: "noteOn", channel: -1, note: 0, velocity: 0 })
|
||||
).toThrow(MidiError);
|
||||
expect(() =>
|
||||
encodeMessage({
|
||||
type: "noteOn",
|
||||
channel: 0.5,
|
||||
note: 0,
|
||||
velocity: 0,
|
||||
})
|
||||
).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("rejects 7-bit values out of range", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
encodeMessage({
|
||||
type: "noteOn",
|
||||
channel: 0,
|
||||
note: 128,
|
||||
velocity: 0,
|
||||
})
|
||||
).toThrow(MidiError);
|
||||
expect(() =>
|
||||
encodeMessage({ type: "noteOn", channel: 0, note: 0, velocity: -1 })
|
||||
).toThrow(MidiError);
|
||||
expect(() =>
|
||||
encodeMessage({
|
||||
type: "noteOn",
|
||||
channel: 0,
|
||||
note: 1.5,
|
||||
velocity: 0,
|
||||
})
|
||||
).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("rejects 14-bit values out of range", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
encodeMessage({ type: "pitchBend", channel: 0, value: 16384 })
|
||||
).toThrow(MidiError);
|
||||
expect(() =>
|
||||
encodeMessage({ type: "pitchBend", channel: 0, value: -1 })
|
||||
).toThrow(MidiError);
|
||||
expect(() =>
|
||||
encodeMessage({ type: "pitchBend", channel: 0, value: 0.5 })
|
||||
).toThrow(MidiError);
|
||||
expect(() =>
|
||||
encodeMessage({ type: "songPosition", channel: null, position: -5 })
|
||||
).toThrow(MidiError);
|
||||
});
|
||||
});
|
||||
180
packages/media/audio/midi/src/message/encode.ts
Normal file
180
packages/media/audio/midi/src/message/encode.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { MidiError } from "../error-codes/error-codes.js";
|
||||
import type { MidiMessage } from "./message.types.js";
|
||||
|
||||
/**
|
||||
* MIDI 1.0 status nibbles for channel-voice messages.
|
||||
* The high nibble identifies the message type; the low nibble carries the
|
||||
* channel (0..15).
|
||||
*/
|
||||
const STATUS_NOTE_OFF = 0x80;
|
||||
const STATUS_NOTE_ON = 0x90;
|
||||
const STATUS_POLY_PRESSURE = 0xa0;
|
||||
const STATUS_CONTROL_CHANGE = 0xb0;
|
||||
const STATUS_PROGRAM_CHANGE = 0xc0;
|
||||
const STATUS_CHANNEL_PRESSURE = 0xd0;
|
||||
const STATUS_PITCH_BEND = 0xe0;
|
||||
|
||||
/** System common / real-time fixed status bytes. */
|
||||
const STATUS_SYSEX_START = 0xf0;
|
||||
const STATUS_TIME_CODE = 0xf1;
|
||||
const STATUS_SONG_POSITION = 0xf2;
|
||||
const STATUS_SONG_SELECT = 0xf3;
|
||||
const STATUS_TUNE_REQUEST = 0xf6;
|
||||
const STATUS_SYSEX_END = 0xf7;
|
||||
const STATUS_CLOCK = 0xf8;
|
||||
const STATUS_START = 0xfa;
|
||||
const STATUS_CONTINUE = 0xfb;
|
||||
const STATUS_STOP = 0xfc;
|
||||
const STATUS_ACTIVE_SENSING = 0xfe;
|
||||
const STATUS_RESET = 0xff;
|
||||
|
||||
function assertChannel(channel: number): number {
|
||||
if (!Number.isInteger(channel) || channel < 0 || channel > 15) {
|
||||
throw new MidiError(
|
||||
"MIDI.INVALID_MESSAGE",
|
||||
"midi.encode.invalid_channel"
|
||||
);
|
||||
}
|
||||
return channel & 0x0f;
|
||||
}
|
||||
|
||||
function assert7(value: number, field: string): number {
|
||||
if (!Number.isInteger(value) || value < 0 || value > 127) {
|
||||
throw new MidiError(
|
||||
"MIDI.INVALID_MESSAGE",
|
||||
`midi.encode.invalid_${field}`
|
||||
);
|
||||
}
|
||||
return value & 0x7f;
|
||||
}
|
||||
|
||||
function assert14(value: number, field: string): number {
|
||||
if (!Number.isInteger(value) || value < 0 || value > 16383) {
|
||||
throw new MidiError(
|
||||
"MIDI.INVALID_MESSAGE",
|
||||
`midi.encode.invalid_${field}`
|
||||
);
|
||||
}
|
||||
return value & 0x3fff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a {@link MidiMessage} to its wire byte sequence. The result is a
|
||||
* freshly allocated {@link Uint8Array}; callers concatenate frames themselves.
|
||||
*/
|
||||
export function encodeMessage(msg: MidiMessage): Uint8Array {
|
||||
switch (msg.type) {
|
||||
case "noteOff": {
|
||||
const ch = assertChannel(msg.channel);
|
||||
return Uint8Array.of(
|
||||
STATUS_NOTE_OFF | ch,
|
||||
assert7(msg.note, "note"),
|
||||
assert7(msg.velocity, "velocity")
|
||||
);
|
||||
}
|
||||
case "noteOn": {
|
||||
const ch = assertChannel(msg.channel);
|
||||
return Uint8Array.of(
|
||||
STATUS_NOTE_ON | ch,
|
||||
assert7(msg.note, "note"),
|
||||
assert7(msg.velocity, "velocity")
|
||||
);
|
||||
}
|
||||
case "polyPressure": {
|
||||
const ch = assertChannel(msg.channel);
|
||||
return Uint8Array.of(
|
||||
STATUS_POLY_PRESSURE | ch,
|
||||
assert7(msg.note, "note"),
|
||||
assert7(msg.pressure, "pressure")
|
||||
);
|
||||
}
|
||||
case "controlChange": {
|
||||
const ch = assertChannel(msg.channel);
|
||||
return Uint8Array.of(
|
||||
STATUS_CONTROL_CHANGE | ch,
|
||||
assert7(msg.controller, "controller"),
|
||||
assert7(msg.value, "value")
|
||||
);
|
||||
}
|
||||
case "programChange": {
|
||||
const ch = assertChannel(msg.channel);
|
||||
return Uint8Array.of(
|
||||
STATUS_PROGRAM_CHANGE | ch,
|
||||
assert7(msg.program, "program")
|
||||
);
|
||||
}
|
||||
case "channelPressure": {
|
||||
const ch = assertChannel(msg.channel);
|
||||
return Uint8Array.of(
|
||||
STATUS_CHANNEL_PRESSURE | ch,
|
||||
assert7(msg.pressure, "pressure")
|
||||
);
|
||||
}
|
||||
case "pitchBend": {
|
||||
const ch = assertChannel(msg.channel);
|
||||
const v = assert14(msg.value, "value");
|
||||
return Uint8Array.of(
|
||||
STATUS_PITCH_BEND | ch,
|
||||
v & 0x7f,
|
||||
(v >> 7) & 0x7f
|
||||
);
|
||||
}
|
||||
case "systemExclusive": {
|
||||
const payload = msg.payload;
|
||||
const out = new Uint8Array(payload.length + 2);
|
||||
out[0] = STATUS_SYSEX_START;
|
||||
out.set(payload, 1);
|
||||
out[payload.length + 1] = STATUS_SYSEX_END;
|
||||
return out;
|
||||
}
|
||||
case "timeCode":
|
||||
return Uint8Array.of(STATUS_TIME_CODE, assert7(msg.value, "value"));
|
||||
case "songPosition": {
|
||||
const v = assert14(msg.position, "position");
|
||||
return Uint8Array.of(
|
||||
STATUS_SONG_POSITION,
|
||||
v & 0x7f,
|
||||
(v >> 7) & 0x7f
|
||||
);
|
||||
}
|
||||
case "songSelect":
|
||||
return Uint8Array.of(STATUS_SONG_SELECT, assert7(msg.song, "song"));
|
||||
case "tuneRequest":
|
||||
return Uint8Array.of(STATUS_TUNE_REQUEST);
|
||||
case "clock":
|
||||
return Uint8Array.of(STATUS_CLOCK);
|
||||
case "start":
|
||||
return Uint8Array.of(STATUS_START);
|
||||
case "continue":
|
||||
return Uint8Array.of(STATUS_CONTINUE);
|
||||
case "stop":
|
||||
return Uint8Array.of(STATUS_STOP);
|
||||
case "activeSensing":
|
||||
return Uint8Array.of(STATUS_ACTIVE_SENSING);
|
||||
case "reset":
|
||||
return Uint8Array.of(STATUS_RESET);
|
||||
}
|
||||
}
|
||||
|
||||
/** Status byte constants exposed for the decoder + UMP bridge. */
|
||||
export const STATUS = Object.freeze({
|
||||
NOTE_OFF: STATUS_NOTE_OFF,
|
||||
NOTE_ON: STATUS_NOTE_ON,
|
||||
POLY_PRESSURE: STATUS_POLY_PRESSURE,
|
||||
CONTROL_CHANGE: STATUS_CONTROL_CHANGE,
|
||||
PROGRAM_CHANGE: STATUS_PROGRAM_CHANGE,
|
||||
CHANNEL_PRESSURE: STATUS_CHANNEL_PRESSURE,
|
||||
PITCH_BEND: STATUS_PITCH_BEND,
|
||||
SYSEX_START: STATUS_SYSEX_START,
|
||||
TIME_CODE: STATUS_TIME_CODE,
|
||||
SONG_POSITION: STATUS_SONG_POSITION,
|
||||
SONG_SELECT: STATUS_SONG_SELECT,
|
||||
TUNE_REQUEST: STATUS_TUNE_REQUEST,
|
||||
SYSEX_END: STATUS_SYSEX_END,
|
||||
CLOCK: STATUS_CLOCK,
|
||||
START: STATUS_START,
|
||||
CONTINUE: STATUS_CONTINUE,
|
||||
STOP: STATUS_STOP,
|
||||
ACTIVE_SENSING: STATUS_ACTIVE_SENSING,
|
||||
RESET: STATUS_RESET,
|
||||
});
|
||||
29
packages/media/audio/midi/src/message/index.ts
Normal file
29
packages/media/audio/midi/src/message/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export { type DecodeResult, decodeMessage } from "./decode.js";
|
||||
export { encodeMessage, STATUS } from "./encode.js";
|
||||
export type {
|
||||
ActiveSensingMessage,
|
||||
ChannelPressureMessage,
|
||||
ClockMessage,
|
||||
ContinueMessage,
|
||||
ControlChangeMessage,
|
||||
Midi7,
|
||||
Midi14,
|
||||
MidiChannel,
|
||||
MidiChannelNumber,
|
||||
MidiMessage,
|
||||
MidiMessageType,
|
||||
NoteOffMessage,
|
||||
NoteOnMessage,
|
||||
PitchBendMessage,
|
||||
PolyPressureMessage,
|
||||
ProgramChangeMessage,
|
||||
ResetMessage,
|
||||
SongPositionMessage,
|
||||
SongSelectMessage,
|
||||
StartMessage,
|
||||
StopMessage,
|
||||
SystemExclusiveMessage,
|
||||
TimeCodeMessage,
|
||||
TuneRequestMessage,
|
||||
} from "./message.types.js";
|
||||
export { MIDI_MESSAGE_TYPES } from "./message.types.js";
|
||||
190
packages/media/audio/midi/src/message/message.types.ts
Normal file
190
packages/media/audio/midi/src/message/message.types.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Channel number for channel-voice messages — 0-based per the MIDI 1.0 spec
|
||||
* status nibble (status byte 0x91 yields channel 1). User-facing labels add 1.
|
||||
*/
|
||||
export type MidiChannel =
|
||||
| 0
|
||||
| 1
|
||||
| 2
|
||||
| 3
|
||||
| 4
|
||||
| 5
|
||||
| 6
|
||||
| 7
|
||||
| 8
|
||||
| 9
|
||||
| 10
|
||||
| 11
|
||||
| 12
|
||||
| 13
|
||||
| 14
|
||||
| 15;
|
||||
|
||||
/** Helper: tag for runtime channel-number narrowing without the union literal. */
|
||||
export type MidiChannelNumber = number;
|
||||
|
||||
/** A 7-bit MIDI data byte (0..127). */
|
||||
export type Midi7 = number;
|
||||
|
||||
/** A 14-bit MIDI value (0..16383) — pitch bend, NRPN/RPN data, paired CC. */
|
||||
export type Midi14 = number;
|
||||
|
||||
/* --- Channel voice messages (8 variants) --- */
|
||||
|
||||
export interface NoteOnMessage {
|
||||
readonly type: "noteOn";
|
||||
readonly channel: MidiChannelNumber;
|
||||
readonly note: Midi7;
|
||||
readonly velocity: Midi7;
|
||||
}
|
||||
|
||||
export interface NoteOffMessage {
|
||||
readonly type: "noteOff";
|
||||
readonly channel: MidiChannelNumber;
|
||||
readonly note: Midi7;
|
||||
readonly velocity: Midi7;
|
||||
}
|
||||
|
||||
export interface PolyPressureMessage {
|
||||
readonly type: "polyPressure";
|
||||
readonly channel: MidiChannelNumber;
|
||||
readonly note: Midi7;
|
||||
readonly pressure: Midi7;
|
||||
}
|
||||
|
||||
export interface ControlChangeMessage {
|
||||
readonly type: "controlChange";
|
||||
readonly channel: MidiChannelNumber;
|
||||
readonly controller: Midi7;
|
||||
readonly value: Midi7;
|
||||
}
|
||||
|
||||
export interface ProgramChangeMessage {
|
||||
readonly type: "programChange";
|
||||
readonly channel: MidiChannelNumber;
|
||||
readonly program: Midi7;
|
||||
}
|
||||
|
||||
export interface ChannelPressureMessage {
|
||||
readonly type: "channelPressure";
|
||||
readonly channel: MidiChannelNumber;
|
||||
readonly pressure: Midi7;
|
||||
}
|
||||
|
||||
export interface PitchBendMessage {
|
||||
readonly type: "pitchBend";
|
||||
readonly channel: MidiChannelNumber;
|
||||
/** Combined 14-bit value, 0..16383, centre = 8192. */
|
||||
readonly value: Midi14;
|
||||
}
|
||||
|
||||
/* --- System common messages --- */
|
||||
|
||||
export interface SystemExclusiveMessage {
|
||||
readonly type: "systemExclusive";
|
||||
readonly channel: null;
|
||||
/** Payload between 0xF0 and 0xF7 (exclusive). First byte is manufacturer id. */
|
||||
readonly payload: Uint8Array;
|
||||
}
|
||||
|
||||
export interface TimeCodeMessage {
|
||||
readonly type: "timeCode";
|
||||
readonly channel: null;
|
||||
readonly value: Midi7;
|
||||
}
|
||||
|
||||
export interface SongPositionMessage {
|
||||
readonly type: "songPosition";
|
||||
readonly channel: null;
|
||||
/** MIDI beats since song start, 14-bit. */
|
||||
readonly position: Midi14;
|
||||
}
|
||||
|
||||
export interface SongSelectMessage {
|
||||
readonly type: "songSelect";
|
||||
readonly channel: null;
|
||||
readonly song: Midi7;
|
||||
}
|
||||
|
||||
export interface TuneRequestMessage {
|
||||
readonly type: "tuneRequest";
|
||||
readonly channel: null;
|
||||
}
|
||||
|
||||
/* --- System realtime messages --- */
|
||||
|
||||
export interface ClockMessage {
|
||||
readonly type: "clock";
|
||||
readonly channel: null;
|
||||
}
|
||||
|
||||
export interface StartMessage {
|
||||
readonly type: "start";
|
||||
readonly channel: null;
|
||||
}
|
||||
|
||||
export interface ContinueMessage {
|
||||
readonly type: "continue";
|
||||
readonly channel: null;
|
||||
}
|
||||
|
||||
export interface StopMessage {
|
||||
readonly type: "stop";
|
||||
readonly channel: null;
|
||||
}
|
||||
|
||||
export interface ActiveSensingMessage {
|
||||
readonly type: "activeSensing";
|
||||
readonly channel: null;
|
||||
}
|
||||
|
||||
export interface ResetMessage {
|
||||
readonly type: "reset";
|
||||
readonly channel: null;
|
||||
}
|
||||
|
||||
/* --- Discriminated union --- */
|
||||
|
||||
export type MidiMessage =
|
||||
| NoteOnMessage
|
||||
| NoteOffMessage
|
||||
| PolyPressureMessage
|
||||
| ControlChangeMessage
|
||||
| ProgramChangeMessage
|
||||
| ChannelPressureMessage
|
||||
| PitchBendMessage
|
||||
| SystemExclusiveMessage
|
||||
| TimeCodeMessage
|
||||
| SongPositionMessage
|
||||
| SongSelectMessage
|
||||
| TuneRequestMessage
|
||||
| ClockMessage
|
||||
| StartMessage
|
||||
| ContinueMessage
|
||||
| StopMessage
|
||||
| ActiveSensingMessage
|
||||
| ResetMessage;
|
||||
|
||||
export type MidiMessageType = MidiMessage["type"];
|
||||
|
||||
/** All 18 MIDI message types in canonical order — useful for OTel keys. */
|
||||
export const MIDI_MESSAGE_TYPES: readonly MidiMessageType[] = [
|
||||
"noteOn",
|
||||
"noteOff",
|
||||
"polyPressure",
|
||||
"controlChange",
|
||||
"programChange",
|
||||
"channelPressure",
|
||||
"pitchBend",
|
||||
"systemExclusive",
|
||||
"timeCode",
|
||||
"songPosition",
|
||||
"songSelect",
|
||||
"tuneRequest",
|
||||
"clock",
|
||||
"start",
|
||||
"continue",
|
||||
"stop",
|
||||
"activeSensing",
|
||||
"reset",
|
||||
] as const;
|
||||
5
packages/media/audio/midi/src/port/index.ts
Normal file
5
packages/media/audio/midi/src/port/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
MidiPortLike,
|
||||
MidiPortState,
|
||||
Unsubscribe,
|
||||
} from "./port.types.js";
|
||||
71
packages/media/audio/midi/src/port/port.test.ts
Normal file
71
packages/media/audio/midi/src/port/port.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { RunningStatusDecoder } from "../dispatch/running-status.js";
|
||||
import { encodeMessage } from "../message/encode.js";
|
||||
import type { MidiMessage } from "../message/message.types.js";
|
||||
import type { MidiPortLike, Unsubscribe } from "./port.types.js";
|
||||
|
||||
function createFakePort(): MidiPortLike & {
|
||||
feed(bytes: Uint8Array): void;
|
||||
written: Uint8Array[];
|
||||
} {
|
||||
const decoder = new RunningStatusDecoder();
|
||||
const handlers: Array<(msg: MidiMessage) => void> = [];
|
||||
const written: Uint8Array[] = [];
|
||||
return {
|
||||
id: "fake-1",
|
||||
name: "Fake Port",
|
||||
manufacturer: "Suaveplan",
|
||||
state: "connected",
|
||||
send(bytes) {
|
||||
written.push(bytes);
|
||||
},
|
||||
onMessage(handler): Unsubscribe {
|
||||
handlers.push(handler);
|
||||
return () => {
|
||||
const idx = handlers.indexOf(handler);
|
||||
if (idx >= 0) handlers.splice(idx, 1);
|
||||
};
|
||||
},
|
||||
feed(bytes) {
|
||||
decoder.push(bytes);
|
||||
for (const msg of decoder.drain()) {
|
||||
for (const h of handlers) h(msg);
|
||||
}
|
||||
},
|
||||
written,
|
||||
};
|
||||
}
|
||||
|
||||
describe("MidiPortLike structural contract", () => {
|
||||
it("delivers parsed MidiMessages to subscribed handlers", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const port = createFakePort();
|
||||
const received: MidiMessage[] = [];
|
||||
const unsub = port.onMessage((m) => received.push(m));
|
||||
port.feed(Uint8Array.of(0x91, 0x40, 0x64));
|
||||
expect(received).toEqual([
|
||||
{ type: "noteOn", channel: 1, note: 0x40, velocity: 0x64 },
|
||||
]);
|
||||
unsub();
|
||||
port.feed(Uint8Array.of(0x91, 0x41, 0x64));
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("accepts outgoing bytes via send", { timeout: 5000 }, () => {
|
||||
const port = createFakePort();
|
||||
port.send(encodeMessage({ type: "stop", channel: null }));
|
||||
expect(port.written).toHaveLength(1);
|
||||
expect(port.written[0]?.[0]).toBe(0xfc);
|
||||
});
|
||||
|
||||
it("exposes id, name, manufacturer, state on the structural shape", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const port = createFakePort();
|
||||
expect(port.id).toBe("fake-1");
|
||||
expect(port.name).toBe("Fake Port");
|
||||
expect(port.manufacturer).toBe("Suaveplan");
|
||||
expect(port.state).toBe("connected");
|
||||
});
|
||||
});
|
||||
27
packages/media/audio/midi/src/port/port.types.ts
Normal file
27
packages/media/audio/midi/src/port/port.types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { MidiMessage } from "../message/message.types.js";
|
||||
|
||||
/** Subscription handle returned by {@link MidiPortLike.onMessage}. */
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
/** Connection lifecycle states a port can report. */
|
||||
export type MidiPortState = "connected" | "disconnected";
|
||||
|
||||
/**
|
||||
* Structural contract for a MIDI input/output endpoint. Browser adapters
|
||||
* (Web MIDI) and server adapters (e.g. node-midi) implement this shape so
|
||||
* higher-level code can subscribe to parsed `MidiMessage` events without
|
||||
* caring about transport.
|
||||
*
|
||||
* Implementations MUST decode incoming bytes into `MidiMessage`s before
|
||||
* delivering them to handlers (typically by composing
|
||||
* {@link RunningStatusDecoder}). Output is symmetrical: `send` accepts raw
|
||||
* encoded bytes — callers wrap a `MidiMessage` via `encodeMessage` first.
|
||||
*/
|
||||
export interface MidiPortLike {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly manufacturer?: string;
|
||||
readonly state: MidiPortState;
|
||||
send(bytes: Uint8Array): void;
|
||||
onMessage(handler: (msg: MidiMessage) => void): Unsubscribe;
|
||||
}
|
||||
9
packages/media/audio/midi/src/sysex/index.ts
Normal file
9
packages/media/audio/midi/src/sysex/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
lookupManufacturer,
|
||||
MANUFACTURERS,
|
||||
type ManufacturerEntry,
|
||||
type ManufacturerId,
|
||||
SYSEX_UNIVERSAL_NON_REALTIME,
|
||||
SYSEX_UNIVERSAL_REALTIME,
|
||||
} from "./manufacturers.js";
|
||||
export { buildSysEx, parseSysEx, type SysExFrame } from "./sysex.js";
|
||||
63
packages/media/audio/midi/src/sysex/manufacturers.md
Normal file
63
packages/media/audio/midi/src/sysex/manufacturers.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# sysex/manufacturers
|
||||
|
||||
## Purpose
|
||||
|
||||
The manufacturer-id registry maps the bytes that follow `F0` in a SysEx frame to a human-readable name. The MIDI Manufacturers Association assigns each vendor either a one-byte id (legacy, range 0x01..0x7D) or a three-byte id starting with `0x00` (modern allocations, the bulk of post-1995 vendors). This module owns the canonical mapping plus a `lookupManufacturer` helper that handles both forms.
|
||||
|
||||
The registry is intentionally a representative subset of the well-known assignments — synth makers, DAW makers, accessory makers — not the complete MMA database. Downstream packages that need additional ids can layer them via `Object.assign({}, MANUFACTURERS, { CUSTOM: { name, id } })` since the base object is frozen.
|
||||
|
||||
## Features
|
||||
|
||||
- One-byte ids for legacy vendors (Roland 0x41, Korg 0x42, Yamaha 0x43, etc.).
|
||||
- Three-byte ids for modern vendors (Native Instruments `00 21 09`, Ableton `00 21 1D`).
|
||||
- `SYSEX_UNIVERSAL_NON_REALTIME = 0x7E` and `SYSEX_UNIVERSAL_REALTIME = 0x7F` constants.
|
||||
- `lookupManufacturer(idBytes)` for runtime resolution from arbitrary byte arrays.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { lookupManufacturer } from "@suaveplan/midi";
|
||||
|
||||
console.log(lookupManufacturer([0x41])); // "Roland"
|
||||
console.log(lookupManufacturer([0x00, 0x21, 0x09])); // "Native Instruments"
|
||||
console.log(lookupManufacturer([0x6F])); // undefined
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { MANUFACTURERS, lookupManufacturer, SYSEX_UNIVERSAL_REALTIME } from "@suaveplan/midi";
|
||||
|
||||
// Filter sysex messages by manufacturer.
|
||||
function isUniversalRealtime(idBytes: readonly number[]): boolean {
|
||||
return idBytes.length === 1 && idBytes[0] === SYSEX_UNIVERSAL_REALTIME;
|
||||
}
|
||||
|
||||
// Extend the registry for a private vendor id.
|
||||
const EXTENDED = Object.freeze({
|
||||
...MANUFACTURERS,
|
||||
MY_BRAND: { name: "My Brand", id: [0x00, 0x7F, 0x01] as const },
|
||||
});
|
||||
|
||||
function nameOf(idBytes: readonly number[]): string | undefined {
|
||||
for (const entry of Object.values(EXTENDED)) {
|
||||
if (
|
||||
entry.id.length === idBytes.length &&
|
||||
entry.id.every((b, i) => b === idBytes[i])
|
||||
) return entry.name;
|
||||
}
|
||||
return lookupManufacturer(idBytes);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `MANUFACTURERS: Readonly<Record<string, ManufacturerEntry>>` — frozen registry keyed by friendly constant.
|
||||
- `ManufacturerEntry { name: string; id: ManufacturerId }`.
|
||||
- `ManufacturerId = readonly [number] | readonly [0x00, number, number]`.
|
||||
- `lookupManufacturer(idBytes: readonly number[]): string | undefined`.
|
||||
- `SYSEX_UNIVERSAL_NON_REALTIME: 0x7E`, `SYSEX_UNIVERSAL_REALTIME: 0x7F`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`lookupManufacturer` walks `Object.values(MANUFACTURERS)` linearly because the registry is small (under 100 entries) and the cost of building / maintaining a `Map<string, string>` keyed by stringified bytes would dwarf the lookup itself. The function uses `continue` to skip length mismatches and a labelled `match` boolean to track byte equality — avoiding a nested `every` call keeps the function within the strict-mode statement budget and produces no garbage objects per lookup.
|
||||
43
packages/media/audio/midi/src/sysex/manufacturers.test.ts
Normal file
43
packages/media/audio/midi/src/sysex/manufacturers.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
lookupManufacturer,
|
||||
MANUFACTURERS,
|
||||
SYSEX_UNIVERSAL_NON_REALTIME,
|
||||
SYSEX_UNIVERSAL_REALTIME,
|
||||
} from "./manufacturers.js";
|
||||
|
||||
describe("manufacturers registry", () => {
|
||||
it("registry is frozen", { timeout: 5000 }, () => {
|
||||
expect(Object.isFrozen(MANUFACTURERS)).toBe(true);
|
||||
});
|
||||
|
||||
it("looks up well-known one-byte ids", { timeout: 5000 }, () => {
|
||||
expect(lookupManufacturer([0x41])).toBe("Roland");
|
||||
expect(lookupManufacturer([0x42])).toBe("Korg");
|
||||
expect(lookupManufacturer([0x43])).toBe("Yamaha");
|
||||
});
|
||||
|
||||
it("looks up three-byte extended ids", { timeout: 5000 }, () => {
|
||||
expect(lookupManufacturer([0x00, 0x21, 0x09])).toBe(
|
||||
"Native Instruments"
|
||||
);
|
||||
expect(lookupManufacturer([0x00, 0x21, 0x1d])).toBe("Ableton");
|
||||
});
|
||||
|
||||
it("recognises universal non-realtime + realtime", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(lookupManufacturer([SYSEX_UNIVERSAL_NON_REALTIME])).toBe(
|
||||
"Universal Non-Realtime"
|
||||
);
|
||||
expect(lookupManufacturer([SYSEX_UNIVERSAL_REALTIME])).toBe(
|
||||
"Universal Realtime"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for unknown ids", { timeout: 5000 }, () => {
|
||||
expect(lookupManufacturer([0x6f])).toBeUndefined();
|
||||
expect(lookupManufacturer([0x00, 0x00, 0x00])).toBeUndefined();
|
||||
expect(lookupManufacturer([])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
113
packages/media/audio/midi/src/sysex/manufacturers.ts
Normal file
113
packages/media/audio/midi/src/sysex/manufacturers.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Registry of well-known MIDI manufacturer System-Exclusive identifiers.
|
||||
*
|
||||
* One-byte ids (0x01..0x7D) are listed as a single-element tuple. Three-byte
|
||||
* ids start with 0x00 followed by two assignment bytes — these belong to
|
||||
* later allocations from the MIDI Manufacturers Association.
|
||||
*
|
||||
* Values 0x7E (Universal Non-Realtime) and 0x7F (Universal Realtime) are
|
||||
* reserved by the spec and exposed as their own constants.
|
||||
*/
|
||||
export type ManufacturerId =
|
||||
| readonly [number]
|
||||
| readonly [0x00, number, number];
|
||||
|
||||
export interface ManufacturerEntry {
|
||||
readonly name: string;
|
||||
readonly id: ManufacturerId;
|
||||
}
|
||||
|
||||
/** A representative — not exhaustive — registry of common manufacturers. */
|
||||
export const MANUFACTURERS: Readonly<Record<string, ManufacturerEntry>> =
|
||||
Object.freeze({
|
||||
SEQUENTIAL: { name: "Sequential Circuits", id: [0x01] as const },
|
||||
IDP: { name: "IDP", id: [0x02] as const },
|
||||
OCTAVE_PLATEAU: { name: "Octave / Plateau", id: [0x03] as const },
|
||||
MOOG: { name: "Moog Music", id: [0x04] as const },
|
||||
PASSPORT_DESIGNS: { name: "Passport Designs", id: [0x05] as const },
|
||||
LEXICON: { name: "Lexicon", id: [0x06] as const },
|
||||
KURZWEIL: { name: "Kurzweil / Young Chang", id: [0x07] as const },
|
||||
FENDER: { name: "Fender", id: [0x08] as const },
|
||||
GULBRANSEN: { name: "Gulbransen", id: [0x09] as const },
|
||||
AKG: { name: "AKG Acoustics", id: [0x0a] as const },
|
||||
VOYCE_MUSIC: { name: "Voyce Music", id: [0x0b] as const },
|
||||
WAVEFRAME: { name: "WaveFrame (Timeline)", id: [0x0c] as const },
|
||||
ADA: { name: "ADA Signal Processors", id: [0x0d] as const },
|
||||
GARFIELD: { name: "Garfield Electronics", id: [0x0e] as const },
|
||||
ENSONIQ: { name: "Ensoniq", id: [0x0f] as const },
|
||||
OBERHEIM: { name: "Oberheim", id: [0x10] as const },
|
||||
APPLE: { name: "Apple", id: [0x11] as const },
|
||||
GREY_MATTER: { name: "Grey Matter Response", id: [0x12] as const },
|
||||
DIGIDESIGN: { name: "Digidesign", id: [0x13] as const },
|
||||
PALM_TREE: { name: "Palmtree Instruments", id: [0x14] as const },
|
||||
JL_COOPER: { name: "JLCooper Electronics", id: [0x15] as const },
|
||||
LOWREY: { name: "Lowrey Organ Company", id: [0x16] as const },
|
||||
ADAMS_SMITH: { name: "Adams-Smith", id: [0x17] as const },
|
||||
EMU: { name: "E-mu Systems", id: [0x18] as const },
|
||||
HARMONY_SYSTEMS: { name: "Harmony Systems", id: [0x19] as const },
|
||||
ART: { name: "ART", id: [0x1a] as const },
|
||||
BALDWIN: { name: "Baldwin", id: [0x1b] as const },
|
||||
EVENTIDE: { name: "Eventide", id: [0x1c] as const },
|
||||
INVENTRONICS: { name: "Inventronics", id: [0x1d] as const },
|
||||
CLARITY: { name: "Clarity", id: [0x1f] as const },
|
||||
PASSAC: { name: "Passac", id: [0x20] as const },
|
||||
SIEL: { name: "SIEL", id: [0x21] as const },
|
||||
SYNTHAXE: { name: "Synthaxe", id: [0x22] as const },
|
||||
HOHNER: { name: "Hohner", id: [0x24] as const },
|
||||
CRUMAR: { name: "Crumar", id: [0x25] as const },
|
||||
SOLTON: { name: "Solton", id: [0x26] as const },
|
||||
JELLINGHAUS_MS: { name: "Jellinghaus MS", id: [0x27] as const },
|
||||
CTM: { name: "CTM", id: [0x28] as const },
|
||||
WERSI: { name: "Wersi", id: [0x29] as const },
|
||||
EAGLE: { name: "Eagle Electronics", id: [0x2a] as const },
|
||||
ELKA: { name: "Elka", id: [0x2b] as const },
|
||||
DYNACORD: { name: "Dynacord", id: [0x2c] as const },
|
||||
JOMOX: { name: "JoMoX", id: [0x31] as const },
|
||||
BEHRINGER: { name: "Behringer", id: [0x32] as const },
|
||||
ACCESS: { name: "Access Music", id: [0x33] as const },
|
||||
NATIVE_INSTRUMENTS: {
|
||||
name: "Native Instruments",
|
||||
id: [0x00, 0x21, 0x09] as const,
|
||||
},
|
||||
ABLETON: { name: "Ableton", id: [0x00, 0x21, 0x1d] as const },
|
||||
KAWAI: { name: "Kawai Musical Instruments", id: [0x40] as const },
|
||||
ROLAND: { name: "Roland", id: [0x41] as const },
|
||||
KORG: { name: "Korg", id: [0x42] as const },
|
||||
YAMAHA: { name: "Yamaha", id: [0x43] as const },
|
||||
CASIO: { name: "Casio", id: [0x44] as const },
|
||||
AKAI: { name: "Akai", id: [0x47] as const },
|
||||
JAPAN_VICTOR: { name: "Victor Company of Japan", id: [0x48] as const },
|
||||
FOSTEX: { name: "Fostex", id: [0x51] as const },
|
||||
ZOOM: { name: "Zoom", id: [0x52] as const },
|
||||
MATSUSHITA: { name: "Matsushita", id: [0x54] as const },
|
||||
SUZUKI: { name: "Suzuki", id: [0x55] as const },
|
||||
FUJI_SOUND: { name: "Fuji Sound", id: [0x56] as const },
|
||||
});
|
||||
|
||||
export const SYSEX_UNIVERSAL_NON_REALTIME = 0x7e;
|
||||
export const SYSEX_UNIVERSAL_REALTIME = 0x7f;
|
||||
|
||||
/** Lookup a registered manufacturer by raw id bytes. Returns the name or `undefined`. */
|
||||
export function lookupManufacturer(
|
||||
idBytes: readonly number[]
|
||||
): string | undefined {
|
||||
for (const entry of Object.values(MANUFACTURERS)) {
|
||||
const id = entry.id;
|
||||
if (id.length !== idBytes.length) continue;
|
||||
let match = true;
|
||||
for (let i = 0; i < id.length; i += 1) {
|
||||
if (id[i] !== idBytes[i]) {
|
||||
match = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (match) return entry.name;
|
||||
}
|
||||
if (idBytes.length === 1 && idBytes[0] === SYSEX_UNIVERSAL_NON_REALTIME) {
|
||||
return "Universal Non-Realtime";
|
||||
}
|
||||
if (idBytes.length === 1 && idBytes[0] === SYSEX_UNIVERSAL_REALTIME) {
|
||||
return "Universal Realtime";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
56
packages/media/audio/midi/src/sysex/sysex.md
Normal file
56
packages/media/audio/midi/src/sysex/sysex.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# sysex/sysex
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`parseSysEx` and `buildSysEx` are the canonical helpers for working with MIDI System Exclusive payloads — the manufacturer-defined byte streams that sit between an `F0` prefix and an `F7` terminator. Every vendor uses SysEx for patch dumps, parameter sysex, MMC (MIDI Machine Control), and identity-request handshakes; standardising the parse / build code in one place prevents off-by-one errors with the one-byte vs three-byte manufacturer-id forms.
|
||||
|
||||
`parseSysEx` expects the *body* of the frame (i.e. everything between `F0` and `F7`, exclusive of both endpoints). `buildSysEx` produces a complete `F0...F7` frame; callers feed the result straight to `MidiPortLike.send`.
|
||||
|
||||
## Features
|
||||
|
||||
- Auto-detects one-byte vs three-byte manufacturer ids (`0x00` prefix signals the extended form).
|
||||
- Looks up the manufacturer name from the registry; returns it on `SysExFrame.manufacturerName` when known.
|
||||
- Validates that every byte in the manufacturer id, device id, and body has bit 7 clear — surfaces wire corruption instead of silently masking.
|
||||
- Rejects short payloads and missing device-id bytes loudly.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { buildSysEx, parseSysEx } from "@suaveplan/midi";
|
||||
|
||||
const frame = buildSysEx([0x43], 0x10, [0x4c, 0x00]);
|
||||
// Uint8Array(6) [0xF0, 0x43, 0x10, 0x4C, 0x00, 0xF7]
|
||||
|
||||
const parsed = parseSysEx(frame.slice(1, -1));
|
||||
// parsed.manufacturer = [0x43], parsed.manufacturerName = "Yamaha"
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { buildSysEx, MANUFACTURERS, parseSysEx } from "@suaveplan/midi";
|
||||
|
||||
// Build a Native Instruments device-identity request.
|
||||
const ni = MANUFACTURERS.NATIVE_INSTRUMENTS.id;
|
||||
const req = buildSysEx(ni, 0x00, [0x01, 0x00]);
|
||||
await outputPort.send(req);
|
||||
|
||||
// Wait for the device's response and parse it.
|
||||
const responseBytes = await readNextSysEx(outputPort);
|
||||
const response = parseSysEx(responseBytes);
|
||||
console.log(response.manufacturerName, response.body);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseSysEx(payload: Uint8Array): SysExFrame` — parses the body between `F0` and `F7`. Throws `MidiError("MIDI.INVALID_SYSEX")` for short, malformed, or extended-id-truncated payloads.
|
||||
- `buildSysEx(manufacturer: ManufacturerId, deviceId: number, body: Uint8Array | readonly number[]): Uint8Array` — produces a full F0/F7-framed byte sequence; throws on high-bit-set inputs.
|
||||
- `SysExFrame { manufacturer; manufacturerName?; deviceId; body }` — the parsed shape.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The build path walks the input three times: once for manufacturer validation, once for body validation, once for the final write. The cost is three pure-numeric loops over typically very short arrays (manufacturer ids are 1 or 3 bytes; bodies are usually <64 bytes for parameter messages), so the constant is negligible compared to the IO that follows. Parse uses `Uint8Array.slice` to detach the body from the input buffer — callers that mutate the source buffer afterwards do not affect already-parsed frames.
|
||||
80
packages/media/audio/midi/src/sysex/sysex.test.ts
Normal file
80
packages/media/audio/midi/src/sysex/sysex.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MidiError } from "../error-codes/error-codes.js";
|
||||
import { buildSysEx, parseSysEx } from "./sysex.js";
|
||||
|
||||
describe("sysex", () => {
|
||||
it("builds + parses round-trip for a one-byte manufacturer id", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = buildSysEx([0x43], 0x10, Uint8Array.of(0x4c, 0x00));
|
||||
expect(out[0]).toBe(0xf0);
|
||||
expect(out[out.length - 1]).toBe(0xf7);
|
||||
const frame = parseSysEx(out.slice(1, -1));
|
||||
expect(frame.manufacturer).toEqual([0x43]);
|
||||
expect(frame.deviceId).toBe(0x10);
|
||||
expect(Array.from(frame.body)).toEqual([0x4c, 0x00]);
|
||||
expect(frame.manufacturerName).toBe("Yamaha");
|
||||
});
|
||||
|
||||
it("builds + parses three-byte extended manufacturer id", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = buildSysEx([0x00, 0x21, 0x09], 0x00, []);
|
||||
const frame = parseSysEx(out.slice(1, -1));
|
||||
expect(frame.manufacturer).toEqual([0x00, 0x21, 0x09]);
|
||||
expect(frame.manufacturerName).toBe("Native Instruments");
|
||||
expect(frame.body.length).toBe(0);
|
||||
});
|
||||
|
||||
it("recognises the Universal Non-Realtime / Realtime ids", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const nrt = parseSysEx(Uint8Array.of(0x7e, 0x7f, 0x06, 0x01));
|
||||
expect(nrt.manufacturerName).toBe("Universal Non-Realtime");
|
||||
const rt = parseSysEx(Uint8Array.of(0x7f, 0x7f, 0x04, 0x01));
|
||||
expect(rt.manufacturerName).toBe("Universal Realtime");
|
||||
});
|
||||
|
||||
it("returns undefined manufacturerName for unknown ids", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const frame = parseSysEx(Uint8Array.of(0x6f, 0x00));
|
||||
expect(frame.manufacturerName).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects short payloads", { timeout: 5000 }, () => {
|
||||
expect(() => parseSysEx(Uint8Array.of(0x43))).toThrow(MidiError);
|
||||
expect(() => parseSysEx(new Uint8Array())).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("rejects extended ids that are too short", { timeout: 5000 }, () => {
|
||||
expect(() => parseSysEx(Uint8Array.of(0x00, 0x21))).toThrow(MidiError);
|
||||
expect(() => parseSysEx(Uint8Array.of(0x00, 0x21, 0x09))).toThrow(
|
||||
MidiError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects manufacturer id with the high bit set", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => buildSysEx([0x83], 0x00, [])).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("rejects device id with the high bit set", { timeout: 5000 }, () => {
|
||||
expect(() => buildSysEx([0x43], 0x80, [])).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("rejects body bytes with the high bit set", { timeout: 5000 }, () => {
|
||||
expect(() => buildSysEx([0x43], 0x00, [0x90])).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("accepts plain number[] as body", { timeout: 5000 }, () => {
|
||||
const out = buildSysEx([0x41], 0x00, [0x42, 0x43]);
|
||||
expect(out[0]).toBe(0xf0);
|
||||
expect(out[out.length - 1]).toBe(0xf7);
|
||||
});
|
||||
|
||||
it("looks up a NI three-byte id directly", { timeout: 5000 }, () => {
|
||||
expect(() => parseSysEx(Uint8Array.of(0x00, 0x21))).toThrow(MidiError);
|
||||
});
|
||||
});
|
||||
103
packages/media/audio/midi/src/sysex/sysex.ts
Normal file
103
packages/media/audio/midi/src/sysex/sysex.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { MidiError } from "../error-codes/error-codes.js";
|
||||
import { STATUS } from "../message/encode.js";
|
||||
import { lookupManufacturer, type ManufacturerId } from "./manufacturers.js";
|
||||
|
||||
/**
|
||||
* Parsed System Exclusive frame: manufacturer id, optional device id, and
|
||||
* the body bytes (everything after the device id, before the F7 terminator).
|
||||
*/
|
||||
export interface SysExFrame {
|
||||
readonly manufacturer: ManufacturerId;
|
||||
readonly manufacturerName?: string;
|
||||
readonly deviceId: number;
|
||||
readonly body: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the body of a SysEx message (the payload between F0 and F7,
|
||||
* exclusive of both endpoints). The first byte (or three, when the id starts
|
||||
* with 0x00) is the manufacturer id; the next byte is the device id; the
|
||||
* remainder is the manufacturer-specific body.
|
||||
*/
|
||||
export function parseSysEx(payload: Uint8Array): SysExFrame {
|
||||
let manufacturer: ManufacturerId;
|
||||
let cursor: number;
|
||||
if (payload.length > 0 && payload[0] === 0x00) {
|
||||
if (payload.length < 4) {
|
||||
throw new MidiError(
|
||||
"MIDI.INVALID_SYSEX",
|
||||
"midi.sysex.short_extended_id"
|
||||
);
|
||||
}
|
||||
manufacturer = [0x00, payload[1] as number, payload[2] as number];
|
||||
cursor = 3;
|
||||
} else {
|
||||
if (payload.length < 2) {
|
||||
throw new MidiError(
|
||||
"MIDI.INVALID_SYSEX",
|
||||
"midi.sysex.short_payload"
|
||||
);
|
||||
}
|
||||
manufacturer = [payload[0] as number];
|
||||
cursor = 1;
|
||||
}
|
||||
const deviceId = payload[cursor] as number;
|
||||
const body = payload.slice(cursor + 1);
|
||||
const manufacturerName = lookupManufacturer(manufacturer);
|
||||
const frame: SysExFrame =
|
||||
manufacturerName === undefined
|
||||
? { manufacturer, deviceId, body }
|
||||
: { manufacturer, manufacturerName, deviceId, body };
|
||||
return frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a complete SysEx byte sequence including the F0 prefix and F7
|
||||
* terminator. Validates the manufacturer id, device id, and body bytes are
|
||||
* all 7-bit clean (high bit unset).
|
||||
*/
|
||||
export function buildSysEx(
|
||||
manufacturer: ManufacturerId,
|
||||
deviceId: number,
|
||||
body: Uint8Array | readonly number[]
|
||||
): Uint8Array {
|
||||
for (const b of manufacturer) {
|
||||
if ((b & 0x80) !== 0) {
|
||||
throw new MidiError(
|
||||
"MIDI.INVALID_SYSEX",
|
||||
"midi.sysex.high_bit_in_id"
|
||||
);
|
||||
}
|
||||
}
|
||||
if ((deviceId & 0x80) !== 0) {
|
||||
throw new MidiError(
|
||||
"MIDI.INVALID_SYSEX",
|
||||
"midi.sysex.high_bit_in_device_id"
|
||||
);
|
||||
}
|
||||
const bodyArr = body instanceof Uint8Array ? body : Uint8Array.from(body);
|
||||
for (const b of bodyArr) {
|
||||
if ((b & 0x80) !== 0) {
|
||||
throw new MidiError(
|
||||
"MIDI.INVALID_SYSEX",
|
||||
"midi.sysex.high_bit_in_body"
|
||||
);
|
||||
}
|
||||
}
|
||||
const out = new Uint8Array(
|
||||
1 + manufacturer.length + 1 + bodyArr.length + 1
|
||||
);
|
||||
let idx = 0;
|
||||
out[idx] = STATUS.SYSEX_START;
|
||||
idx += 1;
|
||||
for (const b of manufacturer) {
|
||||
out[idx] = b;
|
||||
idx += 1;
|
||||
}
|
||||
out[idx] = deviceId;
|
||||
idx += 1;
|
||||
out.set(bodyArr, idx);
|
||||
idx += bodyArr.length;
|
||||
out[idx] = STATUS.SYSEX_END;
|
||||
return out;
|
||||
}
|
||||
13
packages/media/audio/midi/src/telemetry/index.ts
Normal file
13
packages/media/audio/midi/src/telemetry/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type {
|
||||
CounterLike,
|
||||
HistogramLike,
|
||||
InMemoryTelemetry,
|
||||
MidiTelemetry,
|
||||
MidiTelemetrySnapshot,
|
||||
} from "./telemetry.js";
|
||||
export {
|
||||
createInMemoryTelemetry,
|
||||
MIDI_TELEMETRY_NAMES,
|
||||
nowNanos,
|
||||
recordDecode,
|
||||
} from "./telemetry.js";
|
||||
63
packages/media/audio/midi/src/telemetry/telemetry.md
Normal file
63
packages/media/audio/midi/src/telemetry/telemetry.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# telemetry/telemetry
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
OpenTelemetry-shaped instrumentation for `@suaveplan/midi` without taking a runtime dependency on `@opentelemetry/api`. The module exposes structural `CounterLike` / `HistogramLike` interfaces (compatible with the real OTel counter/histogram shapes) and a default in-memory recorder for tests and embedded use. Consumers wire real OTel by passing `meter.createCounter(...)` / `meter.createHistogram(...)` directly.
|
||||
|
||||
The two instruments the package emits are: `midi_messages_decoded_total{type}` (Counter) and `midi_decode_duration_ns{type}` (Histogram). Both carry the MIDI message type as a single attribute — enough to slice dashboards by note-on rate, SysEx frequency, decode-latency outliers per category, etc.
|
||||
|
||||
## Features
|
||||
|
||||
- Zero runtime dep on `@opentelemetry/api` — structural typing only.
|
||||
- `createInMemoryTelemetry` recorder for tests, with snapshot + reset.
|
||||
- `nowNanos()` cross-runtime high-resolution timestamp.
|
||||
- `recordDecode(telemetry, type, startNs, endNs)` one-call helper that stamps both counter and histogram with the message-type attribute.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createInMemoryTelemetry, nowNanos, recordDecode } from "@suaveplan/midi";
|
||||
|
||||
const telemetry = createInMemoryTelemetry();
|
||||
const start = nowNanos();
|
||||
// ...decode...
|
||||
recordDecode(telemetry, "noteOn", start, nowNanos());
|
||||
|
||||
console.log(telemetry.snapshot().counters.noteOn);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import type { MidiTelemetry } from "@suaveplan/midi";
|
||||
|
||||
// Bridge to real OpenTelemetry without a hard dep.
|
||||
function createOtelTelemetry(meter: {
|
||||
createCounter(name: string): { add: (n: number, attrs?: object) => void };
|
||||
createHistogram(name: string): { record: (n: number, attrs?: object) => void };
|
||||
}): MidiTelemetry {
|
||||
return {
|
||||
messagesDecoded: meter.createCounter("midi_messages_decoded_total"),
|
||||
decodeDurationNs: meter.createHistogram("midi_decode_duration_ns"),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `CounterLike { add(value, attributes?): void }`.
|
||||
- `HistogramLike { record(value, attributes?): void }`.
|
||||
- `MidiTelemetry { messagesDecoded: CounterLike; decodeDurationNs: HistogramLike }`.
|
||||
- `createInMemoryTelemetry(): InMemoryTelemetry` — extends `MidiTelemetry` with `snapshot()` and `reset()`.
|
||||
- `MidiTelemetrySnapshot { counters: Record<string, number>; histograms: Record<string, number[]> }`.
|
||||
- `nowNanos(): number` — `performance.now() * 1e6` when available; falls back to `Date.now() * 1e6`.
|
||||
- `recordDecode(telemetry, type, startNs, endNs): void`.
|
||||
- `MIDI_TELEMETRY_NAMES` — frozen object with the canonical instrument names.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The in-memory recorder uses `Object.create(null)` for the counter / histogram backing objects so accidental `__proto__` keys cannot poison the snapshot. `nowNanos` multiplies milliseconds by `1_000_000` rather than using `process.hrtime.bigint()` because the latter is Node-only — the package is universal and must run identically in browsers. The slight precision loss (sub-microsecond) is acceptable for a coarse decode-duration histogram.
|
||||
84
packages/media/audio/midi/src/telemetry/telemetry.test.ts
Normal file
84
packages/media/audio/midi/src/telemetry/telemetry.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
createInMemoryTelemetry,
|
||||
MIDI_TELEMETRY_NAMES,
|
||||
nowNanos,
|
||||
recordDecode,
|
||||
} from "./telemetry.js";
|
||||
|
||||
describe("telemetry", () => {
|
||||
it("exposes canonical instrument names", { timeout: 5000 }, () => {
|
||||
expect(MIDI_TELEMETRY_NAMES.COUNTER_MESSAGES_DECODED).toBe(
|
||||
"midi_messages_decoded_total"
|
||||
);
|
||||
expect(MIDI_TELEMETRY_NAMES.HISTOGRAM_DECODE_DURATION).toBe(
|
||||
"midi_decode_duration_ns"
|
||||
);
|
||||
});
|
||||
|
||||
it("records counter increments keyed by message type", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = createInMemoryTelemetry();
|
||||
for (let i = 0; i < 100; i += 1) recordDecode(t, "noteOn", 0, 50);
|
||||
for (let i = 0; i < 10; i += 1) recordDecode(t, "controlChange", 0, 20);
|
||||
const snap = t.snapshot();
|
||||
expect(snap.counters.noteOn).toBe(100);
|
||||
expect(snap.counters.controlChange).toBe(10);
|
||||
expect(snap.histograms.noteOn?.length).toBe(100);
|
||||
expect(snap.histograms.controlChange?.length).toBe(10);
|
||||
});
|
||||
|
||||
it("snapshot is a stable copy not a live view", { timeout: 5000 }, () => {
|
||||
const t = createInMemoryTelemetry();
|
||||
recordDecode(t, "clock", 0, 1);
|
||||
const snap1 = t.snapshot();
|
||||
recordDecode(t, "clock", 0, 2);
|
||||
const snap2 = t.snapshot();
|
||||
expect(snap1.counters.clock).toBe(1);
|
||||
expect(snap2.counters.clock).toBe(2);
|
||||
});
|
||||
|
||||
it("reset clears counters and histograms", { timeout: 5000 }, () => {
|
||||
const t = createInMemoryTelemetry();
|
||||
recordDecode(t, "noteOn", 0, 1);
|
||||
t.reset();
|
||||
const snap = t.snapshot();
|
||||
expect(snap.counters).toEqual({});
|
||||
expect(snap.histograms).toEqual({});
|
||||
});
|
||||
|
||||
it("counter.add without attributes falls back to 'unknown'", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const t = createInMemoryTelemetry();
|
||||
t.messagesDecoded.add(3);
|
||||
t.decodeDurationNs.record(7);
|
||||
const snap = t.snapshot();
|
||||
expect(snap.counters.unknown).toBe(3);
|
||||
expect(snap.histograms.unknown).toEqual([7]);
|
||||
});
|
||||
|
||||
it("nowNanos is monotonic-ish (non-decreasing across two reads)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = nowNanos();
|
||||
const b = nowNanos();
|
||||
expect(typeof a).toBe("number");
|
||||
expect(b).toBeGreaterThanOrEqual(a);
|
||||
});
|
||||
|
||||
it("nowNanos falls back to Date.now when performance.now is unavailable", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const g = globalThis as { performance?: { now?: () => number } };
|
||||
const original = g.performance;
|
||||
g.performance = undefined;
|
||||
try {
|
||||
const t = nowNanos();
|
||||
expect(typeof t).toBe("number");
|
||||
} finally {
|
||||
g.performance = original;
|
||||
}
|
||||
});
|
||||
});
|
||||
128
packages/media/audio/midi/src/telemetry/telemetry.ts
Normal file
128
packages/media/audio/midi/src/telemetry/telemetry.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { MidiMessageType } from "../message/message.types.js";
|
||||
|
||||
/**
|
||||
* Structural counter shape — compatible with `@opentelemetry/api` Counters but
|
||||
* carries no runtime dep. Consumers wiring real OTel pass `meter.createCounter(...)`
|
||||
* directly; tests use the in-memory recorder from {@link createInMemoryTelemetry}.
|
||||
*/
|
||||
export interface CounterLike {
|
||||
add(
|
||||
value: number,
|
||||
attributes?: Readonly<Record<string, string | number>>
|
||||
): void;
|
||||
}
|
||||
|
||||
export interface HistogramLike {
|
||||
record(
|
||||
value: number,
|
||||
attributes?: Readonly<Record<string, string | number>>
|
||||
): void;
|
||||
}
|
||||
|
||||
export interface MidiTelemetry {
|
||||
readonly messagesDecoded: CounterLike;
|
||||
readonly decodeDurationNs: HistogramLike;
|
||||
}
|
||||
|
||||
const COUNTER_MESSAGES_DECODED = "midi_messages_decoded_total";
|
||||
const HISTOGRAM_DECODE_DURATION = "midi_decode_duration_ns";
|
||||
|
||||
/** Names exposed for OTel attribute keys / dashboards. */
|
||||
export const MIDI_TELEMETRY_NAMES = Object.freeze({
|
||||
COUNTER_MESSAGES_DECODED,
|
||||
HISTOGRAM_DECODE_DURATION,
|
||||
});
|
||||
|
||||
/**
|
||||
* In-memory snapshot recorded by {@link createInMemoryTelemetry}. Maps each
|
||||
* MIDI message type to its counter total and the list of histogram samples
|
||||
* recorded under that type attribute.
|
||||
*/
|
||||
export interface MidiTelemetrySnapshot {
|
||||
readonly counters: Readonly<Record<string, number>>;
|
||||
readonly histograms: Readonly<Record<string, readonly number[]>>;
|
||||
}
|
||||
|
||||
export interface InMemoryTelemetry extends MidiTelemetry {
|
||||
snapshot(): MidiTelemetrySnapshot;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an in-memory `MidiTelemetry` recorder. Used by tests and by the
|
||||
* default decode-instrumentation when the caller does not supply a real OTel
|
||||
* meter. Records totals keyed by the `type` attribute that
|
||||
* {@link instrumentedDecode} stamps on every emission.
|
||||
*/
|
||||
export function createInMemoryTelemetry(): InMemoryTelemetry {
|
||||
const counterTotals: Record<string, number> = Object.create(null) as Record<
|
||||
string,
|
||||
number
|
||||
>;
|
||||
const histogramSamples: Record<string, number[]> = Object.create(
|
||||
null
|
||||
) as Record<string, number[]>;
|
||||
const messagesDecoded: CounterLike = {
|
||||
add(value, attributes) {
|
||||
const key = String(attributes?.["type"] ?? "unknown");
|
||||
counterTotals[key] = (counterTotals[key] ?? 0) + value;
|
||||
},
|
||||
};
|
||||
const decodeDurationNs: HistogramLike = {
|
||||
record(value, attributes) {
|
||||
const key = String(attributes?.["type"] ?? "unknown");
|
||||
const arr = histogramSamples[key] ?? (histogramSamples[key] = []);
|
||||
arr.push(value);
|
||||
},
|
||||
};
|
||||
return {
|
||||
messagesDecoded,
|
||||
decodeDurationNs,
|
||||
snapshot(): MidiTelemetrySnapshot {
|
||||
const counters: Record<string, number> = Object.create(
|
||||
null
|
||||
) as Record<string, number>;
|
||||
const histograms: Record<string, readonly number[]> = Object.create(
|
||||
null
|
||||
) as Record<string, readonly number[]>;
|
||||
for (const k of Object.keys(counterTotals)) {
|
||||
counters[k] = counterTotals[k] as number;
|
||||
}
|
||||
for (const k of Object.keys(histogramSamples)) {
|
||||
histograms[k] = (histogramSamples[k] as number[]).slice();
|
||||
}
|
||||
return { counters, histograms };
|
||||
},
|
||||
reset() {
|
||||
for (const k of Object.keys(counterTotals)) delete counterTotals[k];
|
||||
for (const k of Object.keys(histogramSamples))
|
||||
delete histogramSamples[k];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns a monotonically increasing high-resolution timestamp in nanoseconds. */
|
||||
export function nowNanos(): number {
|
||||
const perf = (globalThis as { performance?: { now?: () => number } })
|
||||
.performance;
|
||||
if (perf?.now) {
|
||||
return perf.now() * 1_000_000;
|
||||
}
|
||||
return Date.now() * 1_000_000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a decoded message into the telemetry: increments the counter (with
|
||||
* the message type as an attribute) and records the elapsed decode duration
|
||||
* on the histogram. Callers obtain `startNs` from {@link nowNanos} just
|
||||
* before they decode.
|
||||
*/
|
||||
export function recordDecode(
|
||||
telemetry: MidiTelemetry,
|
||||
type: MidiMessageType,
|
||||
startNs: number,
|
||||
endNs: number
|
||||
): void {
|
||||
telemetry.messagesDecoded.add(1, { type });
|
||||
telemetry.decodeDurationNs.record(endNs - startNs, { type });
|
||||
}
|
||||
12
packages/media/audio/midi/src/ump/index.ts
Normal file
12
packages/media/audio/midi/src/ump/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
UMP32,
|
||||
UMP64,
|
||||
UMP96,
|
||||
UMP128,
|
||||
UmpPacket,
|
||||
} from "./ump.js";
|
||||
export {
|
||||
midi1ToUmp,
|
||||
UMP_MESSAGE_TYPE,
|
||||
umpToMidi1,
|
||||
} from "./ump.js";
|
||||
63
packages/media/audio/midi/src/ump/ump.md
Normal file
63
packages/media/audio/midi/src/ump/ump.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# ump/ump
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
MIDI 2.0 introduces the Universal MIDI Packet (UMP) format — fixed-width 32, 64, 96, or 128 bit packets that carry MIDI messages alongside high-resolution per-note controllers, per-note pitch, and 32-bit velocity. This module exposes the four packet types, the message-type nibble constants, and a *lossless* bridge between MIDI 1.0 messages and their UMP equivalents.
|
||||
|
||||
The bridge is deliberately one-way for any MIDI 2.0 feature that exceeds MIDI 1.0's expressivity: a MIDI 2.0 channel-voice packet with 32-bit velocity cannot be downgraded to MIDI 1.0 without losing precision, so `umpToMidi1` rejects it with `MidiError("MIDI.UNSUPPORTED_UMP_VARIANT")` rather than silently truncating. Engines that want to "best-effort downgrade" wrap this bridge with their own approximation policy.
|
||||
|
||||
## Features
|
||||
|
||||
- `UMP32` / `UMP64` / `UMP96` / `UMP128` shape types — each carries a `Uint32Array` of words.
|
||||
- `midi1ToUmp` lifts any MIDI 1.0 `MidiMessage` to its UMP equivalent.
|
||||
- `umpToMidi1` reverses the lift when lossless; throws for MIDI 2.0-only features.
|
||||
- Short SysEx (≤ 6 bytes) bridges through a single UMP64 data-64 packet.
|
||||
- `UMP_MESSAGE_TYPE` constant exposed for downstream dispatchers.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { midi1ToUmp, umpToMidi1 } from "@suaveplan/midi";
|
||||
|
||||
const packet = midi1ToUmp({
|
||||
type: "noteOn",
|
||||
channel: 0,
|
||||
note: 60,
|
||||
velocity: 100,
|
||||
});
|
||||
const back = umpToMidi1(packet);
|
||||
// back is identical to the input.
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { midi1ToUmp, type UmpPacket } from "@suaveplan/midi";
|
||||
|
||||
// Lift an entire stream onto group 3.
|
||||
function liftToGroup(stream: MidiMessage[], group: number): UmpPacket[] {
|
||||
return stream.map((m) => midi1ToUmp(m, group));
|
||||
}
|
||||
|
||||
// Filter packets by message-type nibble.
|
||||
function isChannelVoice(packet: UmpPacket): boolean {
|
||||
const mt = (packet.words[0]! >>> 28) & 0xf;
|
||||
return mt === 0x2 || mt === 0x4;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `UMP32 / UMP64 / UMP96 / UMP128` — discriminated shape types; `words` is `Uint32Array` of length 1/2/3/4.
|
||||
- `UmpPacket = UMP32 | UMP64 | UMP96 | UMP128`.
|
||||
- `UMP_MESSAGE_TYPE` — frozen object exposing the MT nibble constants.
|
||||
- `midi1ToUmp(msg: MidiMessage, group?: number): UmpPacket` — defaults `group` to 0.
|
||||
- `umpToMidi1(packet: UmpPacket): MidiMessage` — throws `MidiError("MIDI.UNSUPPORTED_UMP_VARIANT")` for MIDI 2.0-only packets.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Channel-voice packets pack the full status byte (`statusHigh | channel`) into bits 16..23 of word 0; the encoder builds the byte first and shifts it as a unit rather than ORing the two nibbles separately at different shift counts (which was an earlier source of mis-encoding on channel != 0). The SysEx bridge supports only the single-packet "complete" form (status nibble 0) because multi-packet sysex-7 / sysex-8 streams require an explicit codec with start/continue/end framing — that codec lives in a dedicated downstream package.
|
||||
168
packages/media/audio/midi/src/ump/ump.test.ts
Normal file
168
packages/media/audio/midi/src/ump/ump.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { MidiError } from "../error-codes/error-codes.js";
|
||||
import type { MidiMessage } from "../message/message.types.js";
|
||||
import { midi1ToUmp, type UmpPacket, umpToMidi1 } from "./ump.js";
|
||||
|
||||
function roundtrip(msg: MidiMessage): MidiMessage {
|
||||
return umpToMidi1(midi1ToUmp(msg, 0));
|
||||
}
|
||||
|
||||
describe("UMP bridge", () => {
|
||||
it("MIDI 1.0 NoteOn lifts to UMP32 with MT=2, group=0, status=0x9, channel=0", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const packet = midi1ToUmp(
|
||||
{ type: "noteOn", channel: 0, note: 60, velocity: 100 },
|
||||
0
|
||||
);
|
||||
expect(packet.type).toBe("ump32");
|
||||
const w0 = packet.words[0] as number;
|
||||
expect((w0 >>> 28) & 0xf).toBe(2);
|
||||
expect((w0 >>> 24) & 0xf).toBe(0);
|
||||
expect((w0 >>> 20) & 0xf).toBe(0x9);
|
||||
expect((w0 >>> 16) & 0xf).toBe(0);
|
||||
expect((w0 >>> 8) & 0x7f).toBe(60);
|
||||
expect(w0 & 0x7f).toBe(100);
|
||||
});
|
||||
|
||||
it("round-trips every channel-voice variant losslessly", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const variants: MidiMessage[] = [
|
||||
{ type: "noteOff", channel: 4, note: 60, velocity: 1 },
|
||||
{ type: "noteOn", channel: 5, note: 70, velocity: 127 },
|
||||
{ type: "polyPressure", channel: 6, note: 40, pressure: 80 },
|
||||
{ type: "controlChange", channel: 7, controller: 64, value: 127 },
|
||||
{ type: "programChange", channel: 8, program: 12 },
|
||||
{ type: "channelPressure", channel: 9, pressure: 50 },
|
||||
{ type: "pitchBend", channel: 10, value: 12345 },
|
||||
];
|
||||
for (const m of variants) {
|
||||
expect(roundtrip(m)).toEqual(m);
|
||||
}
|
||||
});
|
||||
|
||||
it("round-trips system common and realtime", { timeout: 5000 }, () => {
|
||||
const variants: MidiMessage[] = [
|
||||
{ type: "timeCode", channel: null, value: 7 },
|
||||
{ type: "songPosition", channel: null, position: 999 },
|
||||
{ type: "songSelect", channel: null, song: 3 },
|
||||
{ type: "tuneRequest", channel: null },
|
||||
{ type: "clock", channel: null },
|
||||
{ type: "start", channel: null },
|
||||
{ type: "continue", channel: null },
|
||||
{ type: "stop", channel: null },
|
||||
{ type: "activeSensing", channel: null },
|
||||
{ type: "reset", channel: null },
|
||||
];
|
||||
for (const m of variants) {
|
||||
expect(roundtrip(m)).toEqual(m);
|
||||
}
|
||||
});
|
||||
|
||||
it("SysEx with empty payload still produces a valid UMP64", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = midi1ToUmp({
|
||||
type: "systemExclusive",
|
||||
channel: null,
|
||||
payload: new Uint8Array(),
|
||||
});
|
||||
expect(out.type).toBe("ump64");
|
||||
const back = umpToMidi1(out);
|
||||
if (back.type !== "systemExclusive") throw new Error("type mismatch");
|
||||
expect(back.payload.length).toBe(0);
|
||||
});
|
||||
|
||||
it("SysEx short payload round-trips via UMP64 (data-64 packet)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const payload = Uint8Array.of(0x43, 0x10, 0x4c);
|
||||
const out = midi1ToUmp({
|
||||
type: "systemExclusive",
|
||||
channel: null,
|
||||
payload,
|
||||
});
|
||||
expect(out.type).toBe("ump64");
|
||||
const back = umpToMidi1(out);
|
||||
expect(back.type).toBe("systemExclusive");
|
||||
if (back.type !== "systemExclusive") throw new Error("type guard");
|
||||
expect(Array.from(back.payload)).toEqual([0x43, 0x10, 0x4c]);
|
||||
});
|
||||
|
||||
it("rejects long SysEx with UNSUPPORTED_UMP_VARIANT", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const payload = Uint8Array.of(1, 2, 3, 4, 5, 6, 7);
|
||||
expect(() =>
|
||||
midi1ToUmp({ type: "systemExclusive", channel: null, payload })
|
||||
).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("rejects MIDI 2.0 channel-voice (MT=4) downgrades", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const words = new Uint32Array(2);
|
||||
words[0] = (0x4 << 28) >>> 0;
|
||||
const packet: UmpPacket = { type: "ump64", words };
|
||||
expect(() => umpToMidi1(packet)).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("rejects DATA_128 / utility downgrades", { timeout: 5000 }, () => {
|
||||
const w128 = new Uint32Array(4);
|
||||
w128[0] = (0x5 << 28) >>> 0;
|
||||
expect(() => umpToMidi1({ type: "ump128", words: w128 })).toThrow(
|
||||
MidiError
|
||||
);
|
||||
|
||||
const wUtil = new Uint32Array(1);
|
||||
wUtil[0] = (0x0 << 28) >>> 0;
|
||||
expect(() => umpToMidi1({ type: "ump32", words: wUtil })).toThrow(
|
||||
MidiError
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown UMP MT", { timeout: 5000 }, () => {
|
||||
const words = new Uint32Array(1);
|
||||
words[0] = (0xf << 28) >>> 0;
|
||||
expect(() => umpToMidi1({ type: "ump32", words })).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("rejects unknown MIDI 1.0 voice status nibble inside MT=2", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const words = new Uint32Array(1);
|
||||
// MT=2 with status nibble 0xf (not a real channel-voice status)
|
||||
words[0] = ((0x2 << 28) | (0xf0 << 16)) >>> 0;
|
||||
expect(() => umpToMidi1({ type: "ump32", words })).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("rejects unknown system status inside MT=1", { timeout: 5000 }, () => {
|
||||
const words = new Uint32Array(1);
|
||||
words[0] = ((0x1 << 28) | (0xf5 << 16)) >>> 0;
|
||||
expect(() => umpToMidi1({ type: "ump32", words })).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("rejects sysex multipacket marker (status != 0)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const words = new Uint32Array(2);
|
||||
// MT=3, status=1 (start of multi-packet sysex)
|
||||
words[0] = ((0x3 << 28) | (0x1 << 20)) >>> 0;
|
||||
expect(() => umpToMidi1({ type: "ump64", words })).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("rejects sysex on wrong packet size", { timeout: 5000 }, () => {
|
||||
const words = new Uint32Array(1);
|
||||
words[0] = (0x3 << 28) >>> 0;
|
||||
expect(() => umpToMidi1({ type: "ump32", words })).toThrow(MidiError);
|
||||
});
|
||||
|
||||
it("uses a non-zero group when provided", { timeout: 5000 }, () => {
|
||||
const packet = midi1ToUmp(
|
||||
{ type: "noteOn", channel: 1, note: 60, velocity: 64 },
|
||||
5
|
||||
);
|
||||
expect(((packet.words[0] as number) >>> 24) & 0xf).toBe(5);
|
||||
});
|
||||
});
|
||||
364
packages/media/audio/midi/src/ump/ump.ts
Normal file
364
packages/media/audio/midi/src/ump/ump.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { MidiError } from "../error-codes/error-codes.js";
|
||||
import { STATUS } from "../message/encode.js";
|
||||
import type { MidiMessage } from "../message/message.types.js";
|
||||
|
||||
/**
|
||||
* MIDI 2.0 Universal MIDI Packet — fixed-width 32/64/96/128 bit frames
|
||||
* represented as `Uint32Array`s of 1/2/3/4 words. Higher-level interpretation
|
||||
* is left to dispatch code; the bridge functions here translate the subset
|
||||
* that is loss-free against MIDI 1.0.
|
||||
*/
|
||||
export interface UMP32 {
|
||||
readonly type: "ump32";
|
||||
readonly words: Uint32Array; // length 1
|
||||
}
|
||||
export interface UMP64 {
|
||||
readonly type: "ump64";
|
||||
readonly words: Uint32Array; // length 2
|
||||
}
|
||||
export interface UMP96 {
|
||||
readonly type: "ump96";
|
||||
readonly words: Uint32Array; // length 3
|
||||
}
|
||||
export interface UMP128 {
|
||||
readonly type: "ump128";
|
||||
readonly words: Uint32Array; // length 4
|
||||
}
|
||||
export type UmpPacket = UMP32 | UMP64 | UMP96 | UMP128;
|
||||
|
||||
const MT_UTILITY = 0x0;
|
||||
const MT_SYSTEM_REALTIME = 0x1;
|
||||
const MT_MIDI1_CHANNEL_VOICE = 0x2;
|
||||
const MT_DATA_64 = 0x3; // 64-bit SysEx 7
|
||||
const MT_MIDI2_CHANNEL_VOICE = 0x4;
|
||||
const MT_DATA_128 = 0x5; // 128-bit data (SysEx 8 / mixed data set)
|
||||
|
||||
/**
|
||||
* UMP message-type nibbles. Exposed for downstream dispatchers that need to
|
||||
* branch on the high nibble of word 0 without hard-coding magic numbers.
|
||||
*/
|
||||
export const UMP_MESSAGE_TYPE = Object.freeze({
|
||||
UTILITY: MT_UTILITY,
|
||||
SYSTEM_REALTIME: MT_SYSTEM_REALTIME,
|
||||
MIDI1_CHANNEL_VOICE: MT_MIDI1_CHANNEL_VOICE,
|
||||
DATA_64: MT_DATA_64,
|
||||
MIDI2_CHANNEL_VOICE: MT_MIDI2_CHANNEL_VOICE,
|
||||
DATA_128: MT_DATA_128,
|
||||
});
|
||||
|
||||
function packMidi1Voice(
|
||||
group: number,
|
||||
statusHigh: number,
|
||||
channel: number,
|
||||
d1: number,
|
||||
d2: number
|
||||
): UMP32 {
|
||||
const statusByte = (statusHigh & 0xf0) | (channel & 0xf);
|
||||
const word0 =
|
||||
((MT_MIDI1_CHANNEL_VOICE & 0xf) << 28) |
|
||||
((group & 0xf) << 24) |
|
||||
((statusByte & 0xff) << 16) |
|
||||
((d1 & 0x7f) << 8) |
|
||||
(d2 & 0x7f);
|
||||
const words = new Uint32Array(1);
|
||||
words[0] = word0 >>> 0;
|
||||
return { type: "ump32", words };
|
||||
}
|
||||
|
||||
function packMidi1OneByte(group: number, status: number): UMP32 {
|
||||
const word0 =
|
||||
((MT_SYSTEM_REALTIME & 0xf) << 28) |
|
||||
((group & 0xf) << 24) |
|
||||
((status & 0xff) << 16);
|
||||
const words = new Uint32Array(1);
|
||||
words[0] = word0 >>> 0;
|
||||
return { type: "ump32", words };
|
||||
}
|
||||
|
||||
function packMidi1SystemTwoByte(
|
||||
group: number,
|
||||
status: number,
|
||||
d1: number
|
||||
): UMP32 {
|
||||
const word0 =
|
||||
((MT_SYSTEM_REALTIME & 0xf) << 28) |
|
||||
((group & 0xf) << 24) |
|
||||
((status & 0xff) << 16) |
|
||||
((d1 & 0x7f) << 8);
|
||||
const words = new Uint32Array(1);
|
||||
words[0] = word0 >>> 0;
|
||||
return { type: "ump32", words };
|
||||
}
|
||||
|
||||
function packMidi1SystemThreeByte(
|
||||
group: number,
|
||||
status: number,
|
||||
d1: number,
|
||||
d2: number
|
||||
): UMP32 {
|
||||
const word0 =
|
||||
((MT_SYSTEM_REALTIME & 0xf) << 28) |
|
||||
((group & 0xf) << 24) |
|
||||
((status & 0xff) << 16) |
|
||||
((d1 & 0x7f) << 8) |
|
||||
(d2 & 0x7f);
|
||||
const words = new Uint32Array(1);
|
||||
words[0] = word0 >>> 0;
|
||||
return { type: "ump32", words };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge a MIDI 1.0 `MidiMessage` into the equivalent UMP packet on the given
|
||||
* group (0..15, default 0). SysEx maps to a 64-bit data packet sequence —
|
||||
* here we only emit the single-packet form for short payloads (≤ 6 bytes);
|
||||
* longer SysEx is rejected with `MIDI.UNSUPPORTED_UMP_VARIANT` since it
|
||||
* requires multi-packet sysex-7 streams better suited to a dedicated codec.
|
||||
*/
|
||||
export function midi1ToUmp(msg: MidiMessage, group = 0): UmpPacket {
|
||||
switch (msg.type) {
|
||||
case "noteOff":
|
||||
return packMidi1Voice(
|
||||
group,
|
||||
STATUS.NOTE_OFF,
|
||||
msg.channel,
|
||||
msg.note,
|
||||
msg.velocity
|
||||
);
|
||||
case "noteOn":
|
||||
return packMidi1Voice(
|
||||
group,
|
||||
STATUS.NOTE_ON,
|
||||
msg.channel,
|
||||
msg.note,
|
||||
msg.velocity
|
||||
);
|
||||
case "polyPressure":
|
||||
return packMidi1Voice(
|
||||
group,
|
||||
STATUS.POLY_PRESSURE,
|
||||
msg.channel,
|
||||
msg.note,
|
||||
msg.pressure
|
||||
);
|
||||
case "controlChange":
|
||||
return packMidi1Voice(
|
||||
group,
|
||||
STATUS.CONTROL_CHANGE,
|
||||
msg.channel,
|
||||
msg.controller,
|
||||
msg.value
|
||||
);
|
||||
case "programChange":
|
||||
return packMidi1Voice(
|
||||
group,
|
||||
STATUS.PROGRAM_CHANGE,
|
||||
msg.channel,
|
||||
msg.program,
|
||||
0
|
||||
);
|
||||
case "channelPressure":
|
||||
return packMidi1Voice(
|
||||
group,
|
||||
STATUS.CHANNEL_PRESSURE,
|
||||
msg.channel,
|
||||
msg.pressure,
|
||||
0
|
||||
);
|
||||
case "pitchBend":
|
||||
return packMidi1Voice(
|
||||
group,
|
||||
STATUS.PITCH_BEND,
|
||||
msg.channel,
|
||||
msg.value & 0x7f,
|
||||
(msg.value >> 7) & 0x7f
|
||||
);
|
||||
case "timeCode":
|
||||
return packMidi1SystemTwoByte(group, STATUS.TIME_CODE, msg.value);
|
||||
case "songPosition":
|
||||
return packMidi1SystemThreeByte(
|
||||
group,
|
||||
STATUS.SONG_POSITION,
|
||||
msg.position & 0x7f,
|
||||
(msg.position >> 7) & 0x7f
|
||||
);
|
||||
case "songSelect":
|
||||
return packMidi1SystemTwoByte(group, STATUS.SONG_SELECT, msg.song);
|
||||
case "tuneRequest":
|
||||
return packMidi1OneByte(group, STATUS.TUNE_REQUEST);
|
||||
case "clock":
|
||||
return packMidi1OneByte(group, STATUS.CLOCK);
|
||||
case "start":
|
||||
return packMidi1OneByte(group, STATUS.START);
|
||||
case "continue":
|
||||
return packMidi1OneByte(group, STATUS.CONTINUE);
|
||||
case "stop":
|
||||
return packMidi1OneByte(group, STATUS.STOP);
|
||||
case "activeSensing":
|
||||
return packMidi1OneByte(group, STATUS.ACTIVE_SENSING);
|
||||
case "reset":
|
||||
return packMidi1OneByte(group, STATUS.RESET);
|
||||
case "systemExclusive":
|
||||
return sysexToUmp64(group, msg.payload);
|
||||
}
|
||||
}
|
||||
|
||||
function sysexToUmp64(group: number, payload: Uint8Array): UMP64 {
|
||||
if (payload.length > 6) {
|
||||
throw new MidiError(
|
||||
"MIDI.UNSUPPORTED_UMP_VARIANT",
|
||||
"midi.ump.sysex_too_long_for_single_packet"
|
||||
);
|
||||
}
|
||||
// MT=3 (data 64), group, status=0x00 (complete sysex in one packet), n=length, then up to 6 data bytes.
|
||||
const status = 0x00; // complete
|
||||
const word0 =
|
||||
((MT_DATA_64 & 0xf) << 28) |
|
||||
((group & 0xf) << 24) |
|
||||
((status & 0xf) << 20) |
|
||||
((payload.length & 0xf) << 16) |
|
||||
(((payload[0] ?? 0) & 0x7f) << 8) |
|
||||
((payload[1] ?? 0) & 0x7f);
|
||||
const word1 =
|
||||
(((payload[2] ?? 0) & 0x7f) << 24) |
|
||||
(((payload[3] ?? 0) & 0x7f) << 16) |
|
||||
(((payload[4] ?? 0) & 0x7f) << 8) |
|
||||
((payload[5] ?? 0) & 0x7f);
|
||||
const words = new Uint32Array(2);
|
||||
words[0] = word0 >>> 0;
|
||||
words[1] = word1 >>> 0;
|
||||
return { type: "ump64", words };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse-bridge a UMP packet back to a MIDI 1.0 `MidiMessage` when lossless.
|
||||
* MIDI 2.0 channel-voice (MT=4) packets cannot be losslessly downgraded; the
|
||||
* function throws `MIDI.UNSUPPORTED_UMP_VARIANT` for those, as well as any
|
||||
* data-128 / mixed-data-set packet.
|
||||
*/
|
||||
export function umpToMidi1(packet: UmpPacket): MidiMessage {
|
||||
const word0 = packet.words[0] as number;
|
||||
const mt = (word0 >>> 28) & 0xf;
|
||||
switch (mt) {
|
||||
case MT_MIDI1_CHANNEL_VOICE:
|
||||
return decodeMidi1Voice(word0);
|
||||
case MT_SYSTEM_REALTIME:
|
||||
return decodeSystem(word0);
|
||||
case MT_DATA_64:
|
||||
return decodeSysex64(packet);
|
||||
case MT_MIDI2_CHANNEL_VOICE:
|
||||
case MT_DATA_128:
|
||||
case MT_UTILITY:
|
||||
throw new MidiError(
|
||||
"MIDI.UNSUPPORTED_UMP_VARIANT",
|
||||
"midi.ump.lossy_downgrade"
|
||||
);
|
||||
default:
|
||||
throw new MidiError(
|
||||
"MIDI.UNSUPPORTED_UMP_VARIANT",
|
||||
"midi.ump.unknown_mt"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeMidi1Voice(word0: number): MidiMessage {
|
||||
const statusByte = (word0 >>> 16) & 0xff;
|
||||
const statusHigh = statusByte & 0xf0;
|
||||
const channel = statusByte & 0x0f;
|
||||
const d1 = (word0 >>> 8) & 0x7f;
|
||||
const d2 = word0 & 0x7f;
|
||||
switch (statusHigh) {
|
||||
case STATUS.NOTE_OFF:
|
||||
return { type: "noteOff", channel, note: d1, velocity: d2 };
|
||||
case STATUS.NOTE_ON:
|
||||
return { type: "noteOn", channel, note: d1, velocity: d2 };
|
||||
case STATUS.POLY_PRESSURE:
|
||||
return { type: "polyPressure", channel, note: d1, pressure: d2 };
|
||||
case STATUS.CONTROL_CHANGE:
|
||||
return {
|
||||
type: "controlChange",
|
||||
channel,
|
||||
controller: d1,
|
||||
value: d2,
|
||||
};
|
||||
case STATUS.PROGRAM_CHANGE:
|
||||
return { type: "programChange", channel, program: d1 };
|
||||
case STATUS.CHANNEL_PRESSURE:
|
||||
return { type: "channelPressure", channel, pressure: d1 };
|
||||
case STATUS.PITCH_BEND:
|
||||
return { type: "pitchBend", channel, value: (d2 << 7) | d1 };
|
||||
default:
|
||||
throw new MidiError(
|
||||
"MIDI.UNSUPPORTED_UMP_VARIANT",
|
||||
"midi.ump.unknown_voice_status"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeSystem(word0: number): MidiMessage {
|
||||
const status = (word0 >>> 16) & 0xff;
|
||||
const d1 = (word0 >>> 8) & 0x7f;
|
||||
const d2 = word0 & 0x7f;
|
||||
switch (status) {
|
||||
case STATUS.TIME_CODE:
|
||||
return { type: "timeCode", channel: null, value: d1 };
|
||||
case STATUS.SONG_POSITION:
|
||||
return {
|
||||
type: "songPosition",
|
||||
channel: null,
|
||||
position: (d2 << 7) | d1,
|
||||
};
|
||||
case STATUS.SONG_SELECT:
|
||||
return { type: "songSelect", channel: null, song: d1 };
|
||||
case STATUS.TUNE_REQUEST:
|
||||
return { type: "tuneRequest", channel: null };
|
||||
case STATUS.CLOCK:
|
||||
return { type: "clock", channel: null };
|
||||
case STATUS.START:
|
||||
return { type: "start", channel: null };
|
||||
case STATUS.CONTINUE:
|
||||
return { type: "continue", channel: null };
|
||||
case STATUS.STOP:
|
||||
return { type: "stop", channel: null };
|
||||
case STATUS.ACTIVE_SENSING:
|
||||
return { type: "activeSensing", channel: null };
|
||||
case STATUS.RESET:
|
||||
return { type: "reset", channel: null };
|
||||
default:
|
||||
throw new MidiError(
|
||||
"MIDI.UNSUPPORTED_UMP_VARIANT",
|
||||
"midi.ump.unknown_system_status"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeSysex64(packet: UmpPacket): MidiMessage {
|
||||
if (packet.type !== "ump64") {
|
||||
throw new MidiError(
|
||||
"MIDI.UNSUPPORTED_UMP_VARIANT",
|
||||
"midi.ump.sysex_wrong_packet_size"
|
||||
);
|
||||
}
|
||||
const word0 = packet.words[0] as number;
|
||||
const word1 = packet.words[1] as number;
|
||||
const status = (word0 >>> 20) & 0xf;
|
||||
const n = (word0 >>> 16) & 0xf;
|
||||
if (status !== 0x00) {
|
||||
throw new MidiError(
|
||||
"MIDI.UNSUPPORTED_UMP_VARIANT",
|
||||
"midi.ump.sysex_multipacket"
|
||||
);
|
||||
}
|
||||
const out = new Uint8Array(n);
|
||||
const candidates = [
|
||||
(word0 >>> 8) & 0x7f,
|
||||
word0 & 0x7f,
|
||||
(word1 >>> 24) & 0x7f,
|
||||
(word1 >>> 16) & 0x7f,
|
||||
(word1 >>> 8) & 0x7f,
|
||||
word1 & 0x7f,
|
||||
];
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
out[i] = candidates[i] as number;
|
||||
}
|
||||
return { type: "systemExclusive", channel: null, payload: out };
|
||||
}
|
||||
86
packages/media/audio/midi/tests/conformance.test.ts
Normal file
86
packages/media/audio/midi/tests/conformance.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
GM_LEVEL1_FAMILIES,
|
||||
GM_LEVEL1_PATCHES,
|
||||
GM_LEVEL1_PERCUSSION,
|
||||
GM_LEVEL2_VARIATIONS,
|
||||
GS_VARIATIONS,
|
||||
gm2Key,
|
||||
XG_VARIATIONS,
|
||||
} from "../src/gm/index.js";
|
||||
import { MIDI_MESSAGE_TYPES } from "../src/message/message.types.js";
|
||||
import { midi1ToUmp, umpToMidi1 } from "../src/ump/ump.js";
|
||||
|
||||
describe("General MIDI Level 1 table integrity", () => {
|
||||
it("has all 128 programs", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(GM_LEVEL1_PATCHES).length).toBe(128);
|
||||
for (let i = 0; i < 128; i += 1) {
|
||||
expect(typeof GM_LEVEL1_PATCHES[i]).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("GM patch 0 is Acoustic Grand Piano", { timeout: 5000 }, () => {
|
||||
expect(GM_LEVEL1_PATCHES[0]).toBe("Acoustic Grand Piano");
|
||||
});
|
||||
|
||||
it("has percussion notes 35..81 (47 entries)", { timeout: 5000 }, () => {
|
||||
expect(Object.keys(GM_LEVEL1_PERCUSSION).length).toBe(81 - 35 + 1);
|
||||
expect(GM_LEVEL1_PERCUSSION[36]).toBe("Bass Drum 1");
|
||||
expect(GM_LEVEL1_PERCUSSION[42]).toBe("Closed Hi Hat");
|
||||
expect(GM_LEVEL1_PERCUSSION[81]).toBe("Open Triangle");
|
||||
});
|
||||
|
||||
it("families list has 16 entries", { timeout: 5000 }, () => {
|
||||
expect(GM_LEVEL1_FAMILIES.length).toBe(16);
|
||||
});
|
||||
|
||||
it("GM2 / GS / XG dialects expose at least one variation each", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(Object.keys(GM_LEVEL2_VARIATIONS).length).toBeGreaterThan(0);
|
||||
expect(Object.keys(GS_VARIATIONS).length).toBeGreaterThan(0);
|
||||
expect(Object.keys(XG_VARIATIONS).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("gm2Key composes the same way both tables encode", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(gm2Key(4, 1)).toBe(4 * 256 + 1);
|
||||
expect(GM_LEVEL2_VARIATIONS[gm2Key(4, 1)]).toBe(
|
||||
"Detuned Electric Piano 1"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("UMP framing invariants", () => {
|
||||
it("group bits map to bits 24..27 of word 0", { timeout: 5000 }, () => {
|
||||
const packet = midi1ToUmp(
|
||||
{ type: "noteOn", channel: 0, note: 60, velocity: 100 },
|
||||
3
|
||||
);
|
||||
expect(((packet.words[0] as number) >>> 24) & 0xf).toBe(3);
|
||||
});
|
||||
|
||||
it("channel-voice round-trip preserves all 16 channels", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
for (let ch = 0; ch < 16; ch += 1) {
|
||||
const msg = {
|
||||
type: "noteOn" as const,
|
||||
channel: ch,
|
||||
note: 60,
|
||||
velocity: 64,
|
||||
};
|
||||
expect(umpToMidi1(midi1ToUmp(msg, 0))).toEqual(msg);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Message-type registry", () => {
|
||||
it("MIDI_MESSAGE_TYPES contains all 18 variants exactly once", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(MIDI_MESSAGE_TYPES.length).toBe(18);
|
||||
expect(new Set(MIDI_MESSAGE_TYPES).size).toBe(18);
|
||||
});
|
||||
});
|
||||
130
packages/media/audio/midi/tests/property.test.ts
Normal file
130
packages/media/audio/midi/tests/property.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { decodeMessage } from "../src/message/decode.js";
|
||||
import { encodeMessage } from "../src/message/encode.js";
|
||||
import type { MidiMessage } from "../src/message/message.types.js";
|
||||
|
||||
const channel = fc.integer({ min: 0, max: 15 });
|
||||
const data7 = fc.integer({ min: 0, max: 127 });
|
||||
const data14 = fc.integer({ min: 0, max: 16383 });
|
||||
|
||||
const channelVoiceArbs: fc.Arbitrary<MidiMessage>[] = [
|
||||
fc.record({
|
||||
type: fc.constant("noteOff" as const),
|
||||
channel,
|
||||
note: data7,
|
||||
velocity: data7,
|
||||
}),
|
||||
fc.record({
|
||||
type: fc.constant("noteOn" as const),
|
||||
channel,
|
||||
note: data7,
|
||||
velocity: data7,
|
||||
}),
|
||||
fc.record({
|
||||
type: fc.constant("polyPressure" as const),
|
||||
channel,
|
||||
note: data7,
|
||||
pressure: data7,
|
||||
}),
|
||||
fc.record({
|
||||
type: fc.constant("controlChange" as const),
|
||||
channel,
|
||||
controller: data7,
|
||||
value: data7,
|
||||
}),
|
||||
fc.record({
|
||||
type: fc.constant("programChange" as const),
|
||||
channel,
|
||||
program: data7,
|
||||
}),
|
||||
fc.record({
|
||||
type: fc.constant("channelPressure" as const),
|
||||
channel,
|
||||
pressure: data7,
|
||||
}),
|
||||
fc.record({
|
||||
type: fc.constant("pitchBend" as const),
|
||||
channel,
|
||||
value: data14,
|
||||
}),
|
||||
];
|
||||
|
||||
const systemArbs: fc.Arbitrary<MidiMessage>[] = [
|
||||
fc.record({
|
||||
type: fc.constant("timeCode" as const),
|
||||
channel: fc.constant(null),
|
||||
value: data7,
|
||||
}),
|
||||
fc.record({
|
||||
type: fc.constant("songPosition" as const),
|
||||
channel: fc.constant(null),
|
||||
position: data14,
|
||||
}),
|
||||
fc.record({
|
||||
type: fc.constant("songSelect" as const),
|
||||
channel: fc.constant(null),
|
||||
song: data7,
|
||||
}),
|
||||
fc.record({
|
||||
type: fc.constant("tuneRequest" as const),
|
||||
channel: fc.constant(null),
|
||||
}),
|
||||
fc.record({
|
||||
type: fc.constant("clock" as const),
|
||||
channel: fc.constant(null),
|
||||
}),
|
||||
fc.record({
|
||||
type: fc.constant("start" as const),
|
||||
channel: fc.constant(null),
|
||||
}),
|
||||
fc.record({
|
||||
type: fc.constant("continue" as const),
|
||||
channel: fc.constant(null),
|
||||
}),
|
||||
fc.record({
|
||||
type: fc.constant("stop" as const),
|
||||
channel: fc.constant(null),
|
||||
}),
|
||||
fc.record({
|
||||
type: fc.constant("activeSensing" as const),
|
||||
channel: fc.constant(null),
|
||||
}),
|
||||
fc.record({
|
||||
type: fc.constant("reset" as const),
|
||||
channel: fc.constant(null),
|
||||
}),
|
||||
];
|
||||
|
||||
const sysexArb: fc.Arbitrary<MidiMessage> = fc
|
||||
.uint8Array({ minLength: 0, maxLength: 32, min: 0, max: 0x7f })
|
||||
.map((payload) => ({
|
||||
type: "systemExclusive" as const,
|
||||
channel: null,
|
||||
payload,
|
||||
}));
|
||||
|
||||
const anyMessageArb = fc.oneof(...channelVoiceArbs, ...systemArbs, sysexArb);
|
||||
|
||||
describe("property: encode/decode round-trip", () => {
|
||||
it("every variant round-trips", { timeout: 30000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(anyMessageArb, (msg) => {
|
||||
const bytes = encodeMessage(msg);
|
||||
const { msg: decoded, next } = decodeMessage(bytes, 0);
|
||||
expect(next).toBe(bytes.length);
|
||||
if (
|
||||
msg.type === "systemExclusive" &&
|
||||
decoded.type === "systemExclusive"
|
||||
) {
|
||||
expect(Array.from(decoded.payload)).toEqual(
|
||||
Array.from(msg.payload)
|
||||
);
|
||||
} else {
|
||||
expect(decoded).toEqual(msg);
|
||||
}
|
||||
}),
|
||||
{ numRuns: 1000 }
|
||||
);
|
||||
});
|
||||
});
|
||||
12
packages/media/audio/midi/tsconfig.lib.json
Normal file
12
packages/media/audio/midi/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
35
packages/media/audio/midi/vite.config.ts
Normal file
35
packages/media/audio/midi/vite.config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../../node_modules/.vite/packages/midi",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"tslib",
|
||||
"zod",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
3
packages/media/audio/midi/vitest.config.ts
Normal file
3
packages/media/audio/midi/vitest.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig();
|
||||
Reference in New Issue
Block a user