@wcstack/midi 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,757 @@
1
+ const _config = {
2
+ tagNames: {
3
+ midi: "wcs-midi",
4
+ },
5
+ };
6
+ function deepFreeze(obj) {
7
+ if (obj === null || typeof obj !== "object")
8
+ return obj;
9
+ Object.freeze(obj);
10
+ for (const key of Object.keys(obj)) {
11
+ deepFreeze(obj[key]);
12
+ }
13
+ return obj;
14
+ }
15
+ function deepClone(obj) {
16
+ if (obj === null || typeof obj !== "object")
17
+ return obj;
18
+ const clone = {};
19
+ for (const key of Object.keys(obj)) {
20
+ clone[key] = deepClone(obj[key]);
21
+ }
22
+ return clone;
23
+ }
24
+ let frozenConfig = null;
25
+ const config = _config;
26
+ function getConfig() {
27
+ if (!frozenConfig) {
28
+ frozenConfig = deepFreeze(deepClone(_config));
29
+ }
30
+ return frozenConfig;
31
+ }
32
+ function setConfig(partialConfig) {
33
+ if (partialConfig.tagNames) {
34
+ Object.assign(_config.tagNames, partialConfig.tagNames);
35
+ }
36
+ frozenConfig = null;
37
+ }
38
+
39
+ /**
40
+ * midiCapabilities.ts
41
+ *
42
+ * Web MIDI 固有の error code(taxonomy)と derivation。汎用の error info 型は
43
+ * `./platformCapability.js`(/io-core/ から copy-distribution される生成ファイル)
44
+ * から import する。
45
+ *
46
+ * この node の失敗は 2 箇所からしか来ない:
47
+ * 1. `requestMIDIAccess()` の rejection — 権限拒否・API 不在・sysex 拒否。
48
+ * 2. `MIDIOutput.send()` の throw — 不正なメッセージ・切断済みポート。
49
+ * `_setError` は screen-orientation と同じ discriminator 技法で、synthetic な
50
+ * `"unsupported"` / `"send"` ヒントと caught された `Error.name` を弁別する
51
+ * (message の文言に依存した分岐を作らない)。
52
+ */
53
+ /** 安定した midi error code(taxonomy)。値は公開キーとして固定。 */
54
+ const WCS_MIDI_ERROR_CODE = {
55
+ /** `navigator.requestMIDIAccess` 自体が不在(synthetic "unsupported")。 */
56
+ CapabilityMissing: "capability-missing",
57
+ /**
58
+ * `SecurityError` / `NotAllowedError` — ユーザーが拒否した、あるいは
59
+ * permissions policy / 非 secure context で許可されない。retry では回復しない。
60
+ */
61
+ NotAllowed: "not-allowed",
62
+ /** その他の `requestMIDIAccess()` 失敗。fresh な request は成功しうる。 */
63
+ AccessError: "access-error",
64
+ /** `MIDIOutput.send()` の失敗(不正なメッセージ / 切断済みポート)。 */
65
+ SendFailed: "send-failed",
66
+ };
67
+ /**
68
+ * Web MIDI の失敗を serializable な error taxonomy に写す。
69
+ *
70
+ * `name` は呼び出し側が渡す discriminator:
71
+ * - `"unsupported"` — API 不在 → phase="probe" / capability-missing。
72
+ * - `"send"` — 送信失敗 → phase="execute" / send-failed。recoverable(ポートが
73
+ * 戻れば成功しうる)。
74
+ * - `SecurityError` / `NotAllowedError` — 拒否 → phase="start" / not-allowed。
75
+ * - それ以外(`AbortError`、生の throw、`.name` 欠如等) → phase="start" /
76
+ * access-error。
77
+ */
78
+ function deriveMidiErrorInfo(name, message) {
79
+ if (name === "unsupported") {
80
+ return { code: WCS_MIDI_ERROR_CODE.CapabilityMissing, phase: "probe", recoverable: false, message };
81
+ }
82
+ if (name === "send") {
83
+ return { code: WCS_MIDI_ERROR_CODE.SendFailed, phase: "execute", recoverable: true, message };
84
+ }
85
+ if (name === "SecurityError" || name === "NotAllowedError") {
86
+ return { code: WCS_MIDI_ERROR_CODE.NotAllowed, phase: "start", recoverable: false, message };
87
+ }
88
+ return { code: WCS_MIDI_ERROR_CODE.AccessError, phase: "start", recoverable: true, message };
89
+ }
90
+
91
+ // Channel voice message commands (high nibble of the status byte).
92
+ const NOTE_OFF = 0x80;
93
+ const NOTE_ON = 0x90;
94
+ const POLY_AFTERTOUCH = 0xa0;
95
+ const CONTROL_CHANGE = 0xb0;
96
+ const PROGRAM_CHANGE = 0xc0;
97
+ const AFTERTOUCH = 0xd0;
98
+ const SYSTEM = 0xf0;
99
+ const SYSEX_START = 0xf0;
100
+ const EMPTY = {
101
+ type: "other", channel: null, note: null, velocity: null, control: null, value: null,
102
+ };
103
+ /**
104
+ * Decode a raw MIDI message into the fields bindings actually want.
105
+ *
106
+ * Two normalizations are deliberate, because every consumer would otherwise
107
+ * repeat them:
108
+ *
109
+ * - **A note-on with velocity 0 is reported as `"noteoff"`.** Many controllers
110
+ * send running-status note-ons and never emit 0x8n at all; treating them as
111
+ * note-ons leaves stuck notes. The raw status byte is still available in
112
+ * `data[0]` for callers that care.
113
+ * - **Velocity is normalized to 0-1**, so it multiplies straight into a gain
114
+ * without the caller knowing about the 7-bit MIDI range. Controller values
115
+ * stay raw 0-127 (`value`), since their meaning is per-controller.
116
+ *
117
+ * Never throws: a truncated or nonsensical buffer decodes to `type: "other"`
118
+ * with every field `null` (async-io-node-guidelines.md §3.6).
119
+ */
120
+ function parseMessage(data) {
121
+ const status = data[0];
122
+ if (typeof status !== "number")
123
+ return EMPTY;
124
+ const d1 = typeof data[1] === "number" ? data[1] : 0;
125
+ const d2 = typeof data[2] === "number" ? data[2] : 0;
126
+ // System messages (0xF0-0xFF) carry no channel nibble.
127
+ if (status >= SYSTEM) {
128
+ return { ...EMPTY, type: status === SYSEX_START ? "sysex" : "other" };
129
+ }
130
+ // Below 0x80 is a data byte, not a status byte: the buffer is malformed or
131
+ // running-status (which the Web MIDI API already expands for us).
132
+ if (status < NOTE_OFF)
133
+ return EMPTY;
134
+ const command = status & 0xf0;
135
+ const channel = (status & 0x0f) + 1;
136
+ switch (command) {
137
+ case NOTE_OFF:
138
+ return { type: "noteoff", channel, note: d1, velocity: d2 / 127, control: null, value: null };
139
+ case NOTE_ON:
140
+ // velocity 0 == note off (see doc comment).
141
+ return d2 > 0
142
+ ? { type: "noteon", channel, note: d1, velocity: d2 / 127, control: null, value: null }
143
+ : { type: "noteoff", channel, note: d1, velocity: 0, control: null, value: null };
144
+ case POLY_AFTERTOUCH:
145
+ return { type: "polyaftertouch", channel, note: d1, velocity: null, control: null, value: d2 };
146
+ case CONTROL_CHANGE:
147
+ return { type: "controlchange", channel, note: null, velocity: null, control: d1, value: d2 };
148
+ case PROGRAM_CHANGE:
149
+ return { type: "programchange", channel, note: null, velocity: null, control: null, value: d1 };
150
+ case AFTERTOUCH:
151
+ return { type: "aftertouch", channel, note: null, velocity: null, control: null, value: d1 };
152
+ default:
153
+ // 0xE0 pitch bend — the only high nibble left between 0x80 and 0xEF once
154
+ // the six cases above are taken. 14-bit little-endian, centered at 8192,
155
+ // normalized to -1..1.
156
+ return {
157
+ type: "pitchbend", channel, note: null, velocity: null, control: null,
158
+ value: (((d2 << 7) | d1) - 8192) / 8192,
159
+ };
160
+ }
161
+ }
162
+ /** Message kinds a caller may want to filter on, exported for tooling. */
163
+ const MIDI_MESSAGE_TYPES = [
164
+ "noteon", "noteoff", "polyaftertouch", "controlchange",
165
+ "programchange", "aftertouch", "pitchbend", "sysex", "other",
166
+ ];
167
+
168
+ const UNSUPPORTED = "unsupported";
169
+ const detailOf = (event) => event.detail;
170
+ const messageField = (key) => (event) => detailOf(event)?.[key] ?? null;
171
+ /**
172
+ * Headless Web MIDI primitive: a framework-agnostic wrapper around
173
+ * `navigator.requestMIDIAccess()` exposed through the wc-bindable protocol.
174
+ *
175
+ * Direction is genuinely two-way, unlike `@wcstack/permission` (monitor only):
176
+ * incoming messages flow element → state as `event`-semantics occurrences, and
177
+ * `send()` is a command going the other way. Both live on one node because a
178
+ * page holds exactly one `MIDIAccess` — splitting input and output into two tags
179
+ * would only force them to coordinate over the same shared handle.
180
+ *
181
+ * Nothing starts on construction. `requestMIDIAccess()` can raise a permission
182
+ * prompt, so it is deliberately command-driven (`request()`), matching the
183
+ * `idle` / Generic Sensor family. `observe({ auto: true })` opts back into
184
+ * requesting immediately.
185
+ *
186
+ * The `MIDIAccess` handle itself is never published: the Core owns it and drops
187
+ * it in `close()` / `dispose()`, as `worker` / `websocket` / `broadcast` do with
188
+ * theirs. Only values cross the protocol boundary.
189
+ */
190
+ class MidiCore extends EventTarget {
191
+ static wcBindable = {
192
+ protocol: "wc-bindable",
193
+ version: 1,
194
+ properties: [
195
+ // One occurrence event, decomposed into derived getters
196
+ // (async-io-node-guidelines.md §4.2). Never same-value guarded: two
197
+ // identical note-ons are two distinct presses.
198
+ { name: "message", event: "wcs-midi:message", semantics: "event" },
199
+ { name: "type", event: "wcs-midi:message", semantics: "event", getter: messageField("type") },
200
+ { name: "channel", event: "wcs-midi:message", semantics: "event", getter: messageField("channel") },
201
+ { name: "note", event: "wcs-midi:message", semantics: "event", getter: messageField("note") },
202
+ { name: "velocity", event: "wcs-midi:message", semantics: "event", getter: messageField("velocity") },
203
+ { name: "control", event: "wcs-midi:message", semantics: "event", getter: messageField("control") },
204
+ { name: "value", event: "wcs-midi:message", semantics: "event", getter: messageField("value") },
205
+ { name: "devices", event: "wcs-midi:devices", semantics: "state" },
206
+ { name: "connected", event: "wcs-midi:statechange", semantics: "state" },
207
+ { name: "permission", event: "wcs-midi:permission", semantics: "state" },
208
+ { name: "granted", event: "wcs-midi:permission", semantics: "state", getter: (e) => detailOf(e) === "granted" },
209
+ { name: "denied", event: "wcs-midi:permission", semantics: "state", getter: (e) => detailOf(e) === "denied" },
210
+ { name: "unsupported", event: "wcs-midi:permission", semantics: "state", getter: (e) => detailOf(e) === UNSUPPORTED },
211
+ { name: "error", event: "wcs-midi:error", semantics: "state" },
212
+ { name: "errorInfo", event: "wcs-midi:error-info-changed", semantics: "state" },
213
+ ],
214
+ commands: [
215
+ { name: "request", async: true },
216
+ { name: "close" },
217
+ { name: "send" },
218
+ ],
219
+ };
220
+ _target;
221
+ _options = {};
222
+ _access = null;
223
+ _hookedInputs = new Set();
224
+ _message = null;
225
+ _devices = [];
226
+ _connected = false;
227
+ _permission = "prompt";
228
+ _error = null;
229
+ _errorInfo = null;
230
+ _permissionStatus = null;
231
+ // Monotonic id bumped by every request(), close() and dispose(). An in-flight
232
+ // requestMIDIAccess() captures it and bails on resolve when superseded, so a
233
+ // rapid disconnect→reconnect (or a close() mid-flight) never hooks listeners
234
+ // onto a torn-down instance.
235
+ _gen = 0;
236
+ _ready = Promise.resolve();
237
+ constructor(options, target) {
238
+ super();
239
+ this._target = target ?? this;
240
+ if (options)
241
+ this._options = { ...options };
242
+ }
243
+ // --- Observable getters ---
244
+ get message() { return this._message; }
245
+ get type() { return this._message?.type ?? null; }
246
+ get channel() { return this._message?.channel ?? null; }
247
+ get note() { return this._message?.note ?? null; }
248
+ get velocity() { return this._message?.velocity ?? null; }
249
+ get control() { return this._message?.control ?? null; }
250
+ get value() { return this._message?.value ?? null; }
251
+ get devices() { return this._devices; }
252
+ get connected() { return this._connected; }
253
+ get permission() { return this._permission; }
254
+ get granted() { return this._permission === "granted"; }
255
+ get denied() { return this._permission === "denied"; }
256
+ get unsupported() { return this._permission === UNSUPPORTED; }
257
+ get error() { return this._error; }
258
+ get errorInfo() { return this._errorInfo; }
259
+ /** Resolves once the current (or initial) access attempt settles. */
260
+ get ready() { return this._ready; }
261
+ // --- Public API ---
262
+ /**
263
+ * Store the options and, when `auto` is set, request access. Idempotent:
264
+ * calling it again with new options re-applies port selection to the live
265
+ * access without re-requesting.
266
+ */
267
+ observe(options) {
268
+ const wasIdle = this._access === null;
269
+ this.setOptions(options);
270
+ if (options.auto && wasIdle) {
271
+ this._ready = this.request();
272
+ }
273
+ return this._ready;
274
+ }
275
+ /**
276
+ * Re-apply port selection / channel filtering. Cheap and safe at any time —
277
+ * with no access held it only records the options for a later `request()`.
278
+ */
279
+ setOptions(options) {
280
+ this._options = { ...this._options, ...options };
281
+ if (this._access)
282
+ this._hookInputs(this._access);
283
+ }
284
+ /**
285
+ * Acquire MIDI access. Never rejects: a refusal or a missing API surfaces as
286
+ * `permission` / `error` state (async-io-node-guidelines.md §3.6).
287
+ */
288
+ request() {
289
+ const requestAccess = this._resolveApi();
290
+ if (!requestAccess) {
291
+ this._setPermission(UNSUPPORTED);
292
+ this._setError(UNSUPPORTED, UNSUPPORTED);
293
+ this._ready = Promise.resolve();
294
+ return this._ready;
295
+ }
296
+ const gen = ++this._gen;
297
+ this._probePermission();
298
+ this._ready = requestAccess({ sysex: this._options.sysex === true }).then((access) => {
299
+ if (gen !== this._gen)
300
+ return;
301
+ this._access = access;
302
+ access.onstatechange = this._onStateChange;
303
+ this._setError(null);
304
+ this._setPermission("granted");
305
+ this._hookInputs(access);
306
+ this._publishDevices(access);
307
+ this._setConnected(true);
308
+ }, (error) => {
309
+ if (gen !== this._gen)
310
+ return;
311
+ this._setPermission("denied");
312
+ this._setError(this._messageOf(error), error?.name);
313
+ });
314
+ return this._ready;
315
+ }
316
+ /**
317
+ * Release the access handle and detach every listener, keeping the observed
318
+ * permission state. A later `request()` re-acquires.
319
+ */
320
+ close() {
321
+ this._gen++;
322
+ this._unhookInputs();
323
+ if (this._access) {
324
+ this._access.onstatechange = null;
325
+ this._access = null;
326
+ }
327
+ this._setConnected(false);
328
+ }
329
+ /**
330
+ * Send a raw MIDI message to the selected output port (or the first available
331
+ * one). No-op when no output is reachable; a failing `send()` surfaces on
332
+ * `error` rather than throwing.
333
+ *
334
+ * Positional arguments pass through verbatim from command-token
335
+ * (docs/spec-proposal-command-token-arguments.md).
336
+ */
337
+ send(data, timestamp) {
338
+ const port = this._resolveOutput();
339
+ if (!port)
340
+ return;
341
+ try {
342
+ port.send(data, timestamp);
343
+ }
344
+ catch (error) {
345
+ this._setError(this._messageOf(error), "send");
346
+ }
347
+ }
348
+ /**
349
+ * Detach everything. Headless callers own this lifecycle themselves: without
350
+ * it the live `onmidimessage` handlers keep this instance reachable for as
351
+ * long as the ports are alive.
352
+ */
353
+ dispose() {
354
+ this.close();
355
+ if (this._permissionStatus) {
356
+ this._permissionStatus.removeEventListener("change", this._onPermissionChange);
357
+ this._permissionStatus = null;
358
+ }
359
+ }
360
+ // --- Internal: API resolution ---
361
+ // Resolved at call time, never cached: keeps the unsupported branch honest and
362
+ // lets tests swap the global (async-io-node-guidelines.md §3.7). Read off
363
+ // globalThis rather than the bare identifier so an SSR/worker context with no
364
+ // navigator at all takes the same "unsupported" path as Safari.
365
+ _resolveApi() {
366
+ const nav = globalThis.navigator;
367
+ if (!nav || typeof nav.requestMIDIAccess !== "function")
368
+ return null;
369
+ return (options) => nav.requestMIDIAccess(options);
370
+ }
371
+ _messageOf(error) {
372
+ return typeof error?.message === "string" && error.message !== "" ? error.message : "MIDI error";
373
+ }
374
+ // --- Internal: permission monitoring ---
375
+ // Optional extra: browsers that answer query({name:"midi"}) let a grant flipped
376
+ // in site settings flow into the declarative state without a re-request. Where
377
+ // the descriptor is rejected we simply keep inferring from request() outcomes.
378
+ _probePermission() {
379
+ if (this._permissionStatus)
380
+ return;
381
+ // Only ever reached after _resolveApi() found navigator.requestMIDIAccess,
382
+ // so navigator itself is known to exist here.
383
+ const permissions = globalThis.navigator.permissions;
384
+ if (!permissions || typeof permissions.query !== "function")
385
+ return;
386
+ const gen = this._gen;
387
+ permissions.query({ name: "midi", sysex: this._options.sysex === true }).then((status) => {
388
+ if (gen !== this._gen)
389
+ return;
390
+ this._permissionStatus = status;
391
+ this._setPermission(status.state);
392
+ status.addEventListener("change", this._onPermissionChange);
393
+ }, () => { });
394
+ }
395
+ _onPermissionChange = (event) => {
396
+ this._setPermission(event.target.state);
397
+ };
398
+ // --- Internal: ports ---
399
+ _matches(port, selector) {
400
+ if (port.id === selector)
401
+ return true;
402
+ const name = port.name ?? "";
403
+ return name.toLowerCase().startsWith(selector.toLowerCase());
404
+ }
405
+ // Takes the access explicitly rather than reading the field: every call site
406
+ // already holds a non-null handle, and a defensive re-check here would be a
407
+ // branch no test could ever reach.
408
+ _selectedInputs(access) {
409
+ const all = [...access.inputs.values()];
410
+ const selector = this._options.input;
411
+ // No selector means "whatever is plugged in" — the expectation for MIDI is
412
+ // that a controller just works, not that the page names it first.
413
+ if (selector === undefined || selector === null || selector === "")
414
+ return all;
415
+ return all.filter((port) => this._matches(port, selector));
416
+ }
417
+ _resolveOutput() {
418
+ if (!this._access)
419
+ return null;
420
+ const all = [...this._access.outputs.values()];
421
+ const selector = this._options.output;
422
+ if (selector === undefined || selector === null || selector === "")
423
+ return all[0] ?? null;
424
+ return all.find((port) => this._matches(port, selector)) ?? null;
425
+ }
426
+ _hookInputs(access) {
427
+ const wanted = new Set(this._selectedInputs(access));
428
+ for (const port of this._hookedInputs) {
429
+ if (!wanted.has(port)) {
430
+ port.onmidimessage = null;
431
+ this._hookedInputs.delete(port);
432
+ }
433
+ }
434
+ for (const port of wanted) {
435
+ if (this._hookedInputs.has(port))
436
+ continue;
437
+ port.onmidimessage = this._onMidiMessage;
438
+ this._hookedInputs.add(port);
439
+ }
440
+ }
441
+ _unhookInputs() {
442
+ for (const port of this._hookedInputs)
443
+ port.onmidimessage = null;
444
+ this._hookedInputs.clear();
445
+ }
446
+ _onStateChange = () => {
447
+ // Port list changed: re-apply selection (a newly plugged input must start
448
+ // delivering) and republish. `onstatechange` is only installed while an
449
+ // access is held and cleared in close(), so the handle is non-null here.
450
+ const access = this._access;
451
+ this._hookInputs(access);
452
+ this._publishDevices(access);
453
+ };
454
+ _onMidiMessage = (event) => {
455
+ const raw = event.data;
456
+ if (!raw)
457
+ return;
458
+ const parsed = parseMessage(raw);
459
+ const channelFilter = this._options.channel;
460
+ if (channelFilter != null && parsed.channel !== null && parsed.channel !== channelFilter)
461
+ return;
462
+ const port = event.target;
463
+ this._publishMessage({
464
+ ...parsed,
465
+ // Fresh copy every time: the platform buffer must never be handed out
466
+ // (producer snapshot contract).
467
+ data: new Uint8Array(raw),
468
+ port: port?.id ?? "",
469
+ portName: port?.name ?? "",
470
+ timestamp: event.timeStamp ?? 0,
471
+ });
472
+ };
473
+ // --- Internal: state setters ---
474
+ // Occurrence, not state: identical payloads are distinct presses, so there is
475
+ // deliberately no same-value guard here.
476
+ _publishMessage(message) {
477
+ this._message = message;
478
+ this._target.dispatchEvent(new CustomEvent("wcs-midi:message", { detail: message, bubbles: true }));
479
+ }
480
+ _publishDevices(access) {
481
+ const next = this._snapshotDevices(access);
482
+ if (this._sameDevices(next))
483
+ return;
484
+ // Fresh array assigned before notifying (producer snapshot contract).
485
+ this._devices = next;
486
+ this._target.dispatchEvent(new CustomEvent("wcs-midi:devices", { detail: next, bubbles: true }));
487
+ }
488
+ _snapshotDevices(access) {
489
+ const collect = (ports, direction) => [...ports.values()].map((port) => ({
490
+ id: port.id,
491
+ name: port.name ?? "",
492
+ manufacturer: port.manufacturer ?? "",
493
+ direction,
494
+ state: port.state === "connected" ? "connected" : "disconnected",
495
+ }));
496
+ return [
497
+ ...collect(access.inputs, "input"),
498
+ ...collect(access.outputs, "output"),
499
+ ];
500
+ }
501
+ // Content comparison, not reference: statechange fires separately for the
502
+ // input and the output side of one physical device, so an unfiltered
503
+ // republish would double-notify on every plug.
504
+ _sameDevices(next) {
505
+ if (next.length !== this._devices.length)
506
+ return false;
507
+ return next.every((device, i) => {
508
+ const prev = this._devices[i];
509
+ return prev.id === device.id && prev.state === device.state
510
+ && prev.direction === device.direction && prev.name === device.name;
511
+ });
512
+ }
513
+ _setConnected(connected) {
514
+ if (this._connected === connected)
515
+ return;
516
+ this._connected = connected;
517
+ this._target.dispatchEvent(new CustomEvent("wcs-midi:statechange", { detail: connected, bubbles: true }));
518
+ }
519
+ _setPermission(permission) {
520
+ if (this._permission === permission)
521
+ return;
522
+ this._permission = permission;
523
+ this._target.dispatchEvent(new CustomEvent("wcs-midi:permission", { detail: permission, bubbles: true }));
524
+ }
525
+ _setError(error, name) {
526
+ if (this._error !== error) {
527
+ this._error = error;
528
+ this._target.dispatchEvent(new CustomEvent("wcs-midi:error", { detail: error, bubbles: true }));
529
+ }
530
+ this._commitErrorInfo(error === null ? null : deriveMidiErrorInfo(name, error));
531
+ }
532
+ // errorInfo transitions exactly when error does (no independent same-value
533
+ // guard: a fresh object is built per transition).
534
+ _commitErrorInfo(info) {
535
+ if (this._errorInfo === null && info === null)
536
+ return;
537
+ this._errorInfo = info;
538
+ this._target.dispatchEvent(new CustomEvent("wcs-midi:error-info-changed", { detail: info, bubbles: true }));
539
+ }
540
+ }
541
+
542
+ // ===========================================================================
543
+ // AUTO-GENERATED FILE - DO NOT EDIT.
544
+ // Generated from /protocol/upgrade-properties.ts by scripts/sync-protocol-types.mjs.
545
+ // Run `node scripts/sync-protocol-types.mjs` after editing the source.
546
+ // ===========================================================================
547
+ function hasAccessorOnPrototype(target, name) {
548
+ let proto = Object.getPrototypeOf(target);
549
+ while (proto !== null) {
550
+ const descriptor = Object.getOwnPropertyDescriptor(proto, name);
551
+ if (descriptor !== undefined) {
552
+ return typeof descriptor.get === "function" || typeof descriptor.set === "function";
553
+ }
554
+ proto = Object.getPrototypeOf(proto);
555
+ }
556
+ return false;
557
+ }
558
+ /**
559
+ * `connectedCallback` の先頭で呼ぶ。宣言済み input のうち upgrade 前の代入で
560
+ * accessor をシャドウしている own プロパティを、delete → 再代入で setter に通し直す。
561
+ *
562
+ * - 冪等: 再代入は accessor を通るので own プロパティは残らず、2 回目以降は no-op。
563
+ * - 宣言に `inputs` が無い要素、`wcBindable` を持たない要素では何もしない。
564
+ * - 値の意味は変えない。今まで捨てられていた代入が届くようになる一方向の変化。
565
+ */
566
+ function upgradeProperties(element) {
567
+ const declaration = element.constructor?.wcBindable;
568
+ const inputs = declaration?.inputs;
569
+ if (inputs === undefined)
570
+ return;
571
+ for (const input of inputs) {
572
+ const name = input.name;
573
+ if (!Object.prototype.hasOwnProperty.call(element, name))
574
+ continue;
575
+ if (!hasAccessorOnPrototype(element, name))
576
+ continue;
577
+ const record = element;
578
+ const value = record[name];
579
+ delete record[name];
580
+ record[name] = value;
581
+ }
582
+ }
583
+
584
+ /**
585
+ * `<wcs-midi>` — declarative Web MIDI input/output.
586
+ *
587
+ * Nothing happens on connect: `requestMIDIAccess()` can raise a permission
588
+ * prompt, so access is command-driven (`command.request`). Add the `auto`
589
+ * attribute to opt into requesting as soon as the element connects.
590
+ */
591
+ class WcsMidi extends HTMLElement {
592
+ static hasConnectedCallbackPromise = true;
593
+ static observedAttributes = ["input", "output", "channel"];
594
+ static wcBindable = {
595
+ ...MidiCore.wcBindable,
596
+ inputs: [
597
+ { name: "input", attribute: "input" },
598
+ { name: "output", attribute: "output" },
599
+ { name: "channel", attribute: "channel" },
600
+ { name: "sysex", attribute: "sysex" },
601
+ { name: "auto", attribute: "auto" },
602
+ ],
603
+ };
604
+ _core;
605
+ _connectedCallbackPromise = Promise.resolve();
606
+ _internals = null;
607
+ constructor() {
608
+ super();
609
+ this._core = new MidiCore(null, this);
610
+ this._internals = this._initInternals();
611
+ this._wireStates({
612
+ "wcs-midi:permission": (d) => ({
613
+ granted: d === "granted",
614
+ denied: d === "denied",
615
+ unsupported: d === "unsupported",
616
+ }),
617
+ "wcs-midi:statechange": (d) => ({ connected: d === true }),
618
+ "wcs-midi:error": (d) => ({ error: d !== null }),
619
+ });
620
+ }
621
+ // CSS state reflection (:state()) — debug-only snapshot getter. NOT part of
622
+ // wc-bindable (not a bind target); see README "CSS styling with :state()".
623
+ get debugStates() {
624
+ return this._internals ? [...this._internals.states] : [];
625
+ }
626
+ _initInternals() {
627
+ // never-throw (async-io-node-guidelines.md §3.6): attachInternals is absent
628
+ // in happy-dom / older environments, and pre-125 Chromium rejects non-dashed
629
+ // state names. Either case silently disables reflection.
630
+ try {
631
+ if (typeof this.attachInternals !== "function")
632
+ return null;
633
+ const internals = this.attachInternals();
634
+ internals.states.add("wcs-probe");
635
+ internals.states.delete("wcs-probe");
636
+ return internals;
637
+ }
638
+ catch {
639
+ return null;
640
+ }
641
+ }
642
+ _wireStates(map) {
643
+ if (this._internals === null)
644
+ return;
645
+ const states = this._internals.states;
646
+ for (const [event, toStates] of Object.entries(map)) {
647
+ this.addEventListener(event, (e) => {
648
+ const debug = this.hasAttribute("debug-states");
649
+ for (const [name, on] of Object.entries(toStates(e.detail))) {
650
+ try {
651
+ if (on) {
652
+ states.add(name);
653
+ }
654
+ else {
655
+ states.delete(name);
656
+ }
657
+ }
658
+ catch { /* never-throw */ }
659
+ if (debug)
660
+ this.toggleAttribute(`data-wcs-state-${name}`, on);
661
+ }
662
+ });
663
+ }
664
+ }
665
+ // --- Attribute accessors ---
666
+ get input() { return this.getAttribute("input") ?? ""; }
667
+ set input(value) { this.setAttribute("input", value); }
668
+ get output() { return this.getAttribute("output") ?? ""; }
669
+ set output(value) { this.setAttribute("output", value); }
670
+ get channel() {
671
+ const raw = this.getAttribute("channel");
672
+ if (raw === null)
673
+ return null;
674
+ const n = Number(raw);
675
+ return Number.isFinite(n) ? n : null;
676
+ }
677
+ set channel(value) {
678
+ if (value === null) {
679
+ this.removeAttribute("channel");
680
+ }
681
+ else {
682
+ this.setAttribute("channel", String(value));
683
+ }
684
+ }
685
+ get sysex() { return this.hasAttribute("sysex"); }
686
+ set sysex(value) { this.toggleAttribute("sysex", value); }
687
+ get auto() { return this.hasAttribute("auto"); }
688
+ set auto(value) { this.toggleAttribute("auto", value); }
689
+ // --- Core delegated getters ---
690
+ get message() { return this._core.message; }
691
+ get type() { return this._core.type; }
692
+ get note() { return this._core.note; }
693
+ get velocity() { return this._core.velocity; }
694
+ get control() { return this._core.control; }
695
+ get value() { return this._core.value; }
696
+ get devices() { return this._core.devices; }
697
+ get connected() { return this._core.connected; }
698
+ get permission() { return this._core.permission; }
699
+ get granted() { return this._core.granted; }
700
+ get denied() { return this._core.denied; }
701
+ get unsupported() { return this._core.unsupported; }
702
+ get error() { return this._core.error; }
703
+ get errorInfo() { return this._core.errorInfo; }
704
+ /** Headless escape hatch: the Core backing this element. */
705
+ get core() { return this._core; }
706
+ get connectedCallbackPromise() {
707
+ return this._connectedCallbackPromise;
708
+ }
709
+ // --- Commands ---
710
+ request() { return this._core.request(); }
711
+ close() { this._core.close(); }
712
+ send(data, timestamp) { this._core.send(data, timestamp); }
713
+ // --- Internal ---
714
+ _options() {
715
+ return {
716
+ input: this.input,
717
+ output: this.output,
718
+ channel: this.channel,
719
+ sysex: this.sysex,
720
+ auto: this.auto,
721
+ };
722
+ }
723
+ // --- Lifecycle ---
724
+ connectedCallback() {
725
+ // upgrade 前に代入された input を取り込み直す(doc 13 §1.2 / Phase A1)
726
+ upgradeProperties(this);
727
+ this.style.display = "none";
728
+ this._connectedCallbackPromise = this._core.observe(this._options());
729
+ }
730
+ disconnectedCallback() {
731
+ this._core.dispose();
732
+ }
733
+ attributeChangedCallback(_name, oldValue, newValue) {
734
+ if (oldValue === newValue)
735
+ return;
736
+ // Port selection and channel filtering are live: they re-hook the existing
737
+ // access rather than re-requesting it (no second permission prompt).
738
+ if (this.isConnected)
739
+ this._core.setOptions(this._options());
740
+ }
741
+ }
742
+
743
+ function registerComponents() {
744
+ if (!customElements.get(config.tagNames.midi)) {
745
+ customElements.define(config.tagNames.midi, WcsMidi);
746
+ }
747
+ }
748
+
749
+ function bootstrapMidi(userConfig) {
750
+ if (userConfig) {
751
+ setConfig(userConfig);
752
+ }
753
+ registerComponents();
754
+ }
755
+
756
+ export { MIDI_MESSAGE_TYPES, MidiCore, WCS_MIDI_ERROR_CODE, WcsMidi, bootstrapMidi, deriveMidiErrorInfo, getConfig, parseMessage };
757
+ //# sourceMappingURL=index.esm.js.map