Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1 @@
export { type MidiOutputHandle, useMidiOutput } from "./use-midi-output.js";

View File

@@ -0,0 +1,84 @@
# `hooks/use-midi-output`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useMidiOutput(selector)` returns a typed handle for a single Web
MIDI output. The handle exposes both `send(bytes)` (raw bytes,
typically for SysEx pass-through) and `sendMessage(msg)`, which
encodes a `MidiMessage` via `encodeMessage` before forwarding. The
selector mirrors `useMidiInput`: pass an explicit `MIDIOutput.id` or
the literal `"first"` to pick whichever output the access enumerates
first. Returns `null` when access is loading, denied, unsupported,
or the selector misses.
## Features
- Two selector forms — id string or `"first"`.
- Typed `sendMessage(msg: MidiMessage)` plus raw `send(bytes)`.
- Telemetry counter fires on every send.
- Handle identity memoised against access + stateVersion + selector.
## Usage: Basic
```tsx
import { useMidiOutput } from "@suaveplan/react-midi";
import { useCallback } from "react";
function NoteButton() {
const out = useMidiOutput("first");
const onClick = useCallback(() => {
out?.sendMessage({
type: "noteOn",
channel: 0,
note: 60,
velocity: 100,
});
}, [out]);
return (
<button type="button" onClick={onClick} disabled={out === null}>
Play C4
</button>
);
}
```
## Usage: Advanced
```tsx
function SysExSender({ outputId }: { outputId: string }) {
const out = useMidiOutput(outputId);
const send = () => {
// Universal device-inquiry (real-time bypass).
out?.send(new Uint8Array([0xf0, 0x7e, 0x7f, 0x06, 0x01, 0xf7]));
};
return (
<button type="button" onClick={send} disabled={out === null}>
Inquire device
</button>
);
}
```
## API Reference
- `useMidiOutput(selector: string | "first"): MidiOutputHandle | null`
- `interface MidiOutputHandle { id, name, manufacturer?, state, send, sendMessage }`.
## Implementation Notes
`sendMessage` is intentionally distinct from `send`: the encoder
prepends/appends framing for SysEx and constructs a fresh
`Uint8Array` per call. Hosts that ship one bus-master encoder elsewhere
can stick with `send(bytes)` and avoid the per-call allocation. The
hook does not currently gate SysEx behind the provider's `sysex`
prop — Web MIDI itself enforces that boundary on `port.send` and
will surface a `DOMException` if a SysEx-bearing buffer is forwarded
from a non-SysEx access. The returned handle is memoised against
`(access, stateVersion, selector)` so its identity is stable until
the underlying port actually changes; callers that capture the
handle inside a `useCallback` will see the dependency change at the
right moments (port plug/unplug, selector switch) and at no others.

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import {
FakeMidiProvider,
makeFakeAccess,
makeFakeOutput,
} from "../../test-utils.js";
import { useMidiOutput } from "./use-midi-output.js";
describe("useMidiOutput", () => {
it("returns null while loading", { timeout: 5000 }, () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider
access={makeFakeAccess()}
requestImpl={() => new Promise(() => undefined)}
>
{children}
</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiOutput("first"), {
wrapper,
});
expect(result.current).toBeNull();
});
it("picks first output with 'first' selector", {
timeout: 30000,
}, async () => {
const outputs = [makeFakeOutput("a"), makeFakeOutput("b")];
const access = makeFakeAccess({ outputs });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiOutput("first"), {
wrapper,
});
await waitFor(() => {
expect(result.current?.id).toBe("a");
});
});
it("picks by id", { timeout: 30000 }, async () => {
const outputs = [makeFakeOutput("a"), makeFakeOutput("b")];
const access = makeFakeAccess({ outputs });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiOutput("b"), { wrapper });
await waitFor(() => {
expect(result.current?.id).toBe("b");
});
});
it("returns null on unknown id", { timeout: 30000 }, async () => {
const outputs = [makeFakeOutput("a")];
const access = makeFakeAccess({ outputs });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiOutput("zzz"), {
wrapper,
});
await new Promise((r) => setTimeout(r, 10));
expect(result.current).toBeNull();
});
it("returns null when no outputs exist and 'first' is used", {
timeout: 30000,
}, async () => {
const access = makeFakeAccess();
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiOutput("first"), {
wrapper,
});
await new Promise((r) => setTimeout(r, 10));
expect(result.current).toBeNull();
});
it("send forwards raw bytes", { timeout: 30000 }, async () => {
const output = makeFakeOutput("a");
const access = makeFakeAccess({ outputs: [output] });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiOutput("first"), {
wrapper,
});
await waitFor(() => {
expect(result.current).not.toBeNull();
});
result.current?.send(new Uint8Array([0x90, 0x40, 0x40]));
expect(output.sent).toEqual([[0x90, 0x40, 0x40]]);
});
it("sendMessage encodes via encodeMessage", {
timeout: 30000,
}, async () => {
const output = makeFakeOutput("a");
const access = makeFakeAccess({ outputs: [output] });
const wrapper = ({ children }: { children: ReactNode }) => (
<FakeMidiProvider access={access}>{children}</FakeMidiProvider>
);
const { result } = renderHook(() => useMidiOutput("first"), {
wrapper,
});
await waitFor(() => {
expect(result.current).not.toBeNull();
});
result.current?.sendMessage({
type: "noteOn",
channel: 0,
note: 60,
velocity: 100,
});
expect(output.sent).toEqual([[0x90, 0x3c, 0x64]]);
});
});

View File

@@ -0,0 +1,39 @@
import { useMemo } from "react";
import type { MidiMessage } from "../../midi-contract/midi-message.types.js";
import { adaptOutput } from "../../port/web-midi-port.js";
import { useMidiContextOrThrow } from "../use-midi-context.js";
import type { PortSelector } from "../use-midi-input/use-midi-input.js";
export interface MidiOutputHandle {
readonly id: string;
readonly name: string;
readonly manufacturer: string | undefined;
readonly state: "connected" | "disconnected";
send(bytes: Uint8Array): void;
sendMessage(msg: MidiMessage): void;
}
/**
* Returns a typed output handle for the selected Web MIDI output, or
* `null` while access is loading / the selector misses. The handle's
* `sendMessage` encodes a `MidiMessage` via `encodeMessage` and
* forwards the resulting bytes to the underlying `MIDIOutput.send`;
* `send(bytes)` is the raw escape-hatch (typically used for SysEx
* pass-through).
*/
export function useMidiOutput(selector: PortSelector): MidiOutputHandle | null {
const ctx = useMidiContextOrThrow();
const stateVersion = ctx.stateVersion;
return useMemo<MidiOutputHandle | null>(() => {
void stateVersion;
if (ctx.access === null) {
return null;
}
if (selector === "first") {
const next = ctx.access.outputs.values().next();
return next.done === true ? null : adaptOutput(next.value);
}
const target = ctx.access.outputs.get(selector);
return target === undefined ? null : adaptOutput(target);
}, [ctx.access, stateVersion, selector]);
}