first commit
This commit is contained in:
67
docs/media/audio/midi/src/clock/clock.md
Normal file
67
docs/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.
|
||||
53
docs/media/audio/midi/src/dispatch/cc14.md
Normal file
53
docs/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.
|
||||
59
docs/media/audio/midi/src/dispatch/nrpn.md
Normal file
59
docs/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.
|
||||
58
docs/media/audio/midi/src/dispatch/rpn.md
Normal file
58
docs/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.
|
||||
53
docs/media/audio/midi/src/dispatch/running-status.md
Normal file
53
docs/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.
|
||||
57
docs/media/audio/midi/src/error-codes/error-codes.md
Normal file
57
docs/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.
|
||||
49
docs/media/audio/midi/src/gm/gs.md
Normal file
49
docs/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.
|
||||
51
docs/media/audio/midi/src/gm/level1.md
Normal file
51
docs/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.
|
||||
51
docs/media/audio/midi/src/gm/level2.md
Normal file
51
docs/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.
|
||||
48
docs/media/audio/midi/src/gm/xg.md
Normal file
48
docs/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.
|
||||
57
docs/media/audio/midi/src/message/decode.md
Normal file
57
docs/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.
|
||||
61
docs/media/audio/midi/src/message/encode.md
Normal file
61
docs/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.
|
||||
63
docs/media/audio/midi/src/sysex/manufacturers.md
Normal file
63
docs/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.
|
||||
56
docs/media/audio/midi/src/sysex/sysex.md
Normal file
56
docs/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.
|
||||
63
docs/media/audio/midi/src/telemetry/telemetry.md
Normal file
63
docs/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.
|
||||
63
docs/media/audio/midi/src/ump/ump.md
Normal file
63
docs/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.
|
||||
Reference in New Issue
Block a user