Initial Commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { type MidiOutputHandle, useMidiOutput } from "./use-midi-output.js";
|
||||
@@ -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.
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user