entangled-worlds-orbiters-shared 2.11.0 → 2.12.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,72 @@
1
+ export type NoteEventType = 'on' | 'off';
2
+ export interface NoteEvent {
3
+ type: NoteEventType;
4
+ /** Unique per sounding note; an `off` carries the id of the `on` it ends. */
5
+ id: string;
6
+ /** MIDI note number, 0 … 127 (integers today; the field allows fractions later). */
7
+ pitch: number;
8
+ /** 0 … 1. An `off` may carry a release velocity; nothing reads it yet. */
9
+ velocity: number;
10
+ /** Audio-context seconds when it should sound; at or before now means now. */
11
+ time: number;
12
+ /** Who sent it: a device, the keyboard, a MIDI effect's slot, a room client. */
13
+ source: string;
14
+ }
15
+ export declare const NOTE_PITCH_MIN = 0;
16
+ export declare const NOTE_PITCH_MAX = 127;
17
+ /** Whether `value` is a well-formed note event (the shape a room peer or an agent must send). */
18
+ export declare function isNoteEvent(value: unknown): value is NoteEvent;
19
+ /** What a channel message means to a note player. Channels are 0 … 15, as on the wire. */
20
+ export type MidiNoteMessage = {
21
+ kind: 'on';
22
+ channel: number;
23
+ pitch: number;
24
+ velocity: number;
25
+ } | {
26
+ kind: 'off';
27
+ channel: number;
28
+ pitch: number;
29
+ velocity: number;
30
+ }
31
+ /** Pitch bend, −1 … 1 (0 is the centre). */
32
+ | {
33
+ kind: 'bend';
34
+ channel: number;
35
+ value: number;
36
+ }
37
+ /** A control change, its value 0 … 1. */
38
+ | {
39
+ kind: 'cc';
40
+ channel: number;
41
+ controller: number;
42
+ value: number;
43
+ };
44
+ /** The mod wheel. */
45
+ export declare const MIDI_CC_MOD_WHEEL = 1;
46
+ /**
47
+ * Decode a channel message's bytes. A note-on with velocity 0 is a note-off (running status sends
48
+ * them that way). Anything a note player has no use for (aftertouch, program change, system) is null.
49
+ */
50
+ export declare function decodeMidiMessage(status: number, data1?: number, data2?: number): MidiNoteMessage | null;
51
+ /**
52
+ * A MIDI note-off names only a channel and a pitch. This gives each held key its note id, so the
53
+ * off pairs with its on by id like every other source.
54
+ *
55
+ * - `on` mints a fresh id. If that key was already held (a device that sends two ons without an off)
56
+ * the old note's id comes back as `replaced`, so the caller can end it first: nothing sticks.
57
+ * - `off` returns the id the key was playing, or null when it was never down (an off arriving after
58
+ * a panic, or from before the page listened).
59
+ * - `releaseDevice` hands back every id a device holds: it went away, and they all end.
60
+ */
61
+ export interface MidiNoteIds {
62
+ on(device: string, channel: number, pitch: number): {
63
+ id: string;
64
+ replaced: string | null;
65
+ };
66
+ off(device: string, channel: number, pitch: number): string | null;
67
+ releaseDevice(device: string): string[];
68
+ releaseAll(): string[];
69
+ /** How many keys are down, across devices. */
70
+ readonly size: number;
71
+ }
72
+ export declare function createMidiNoteIds(prefix?: string): MidiNoteIds;
@@ -0,0 +1,94 @@
1
+ // Notes as events, shared by every note source (MIDI in, the on-screen keyboard, a capability call, a
2
+ // MIDI effect, a room peer) and everything that routes or sounds them.
3
+ //
4
+ // On and off are separate events with no duration: a live key has no known length when it goes
5
+ // down. They pair by `id`, never by pitch, since two sources can hold the same pitch at once and a
6
+ // chord or an echo plays the same pitch more than once. Pairing by pitch alone gives stuck notes.
7
+ //
8
+ // App-agnostic: nothing here knows about Orbiters, dimensions or the audio graph.
9
+ export const NOTE_PITCH_MIN = 0;
10
+ export const NOTE_PITCH_MAX = 127;
11
+ const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
12
+ /** Whether `value` is a well-formed note event (the shape a room peer or an agent must send). */
13
+ export function isNoteEvent(value) {
14
+ if (!value || typeof value !== 'object')
15
+ return false;
16
+ const event = value;
17
+ return ((event.type === 'on' || event.type === 'off') &&
18
+ typeof event.id === 'string' &&
19
+ event.id.length > 0 &&
20
+ isFiniteNumber(event.pitch) &&
21
+ event.pitch >= NOTE_PITCH_MIN &&
22
+ event.pitch <= NOTE_PITCH_MAX &&
23
+ isFiniteNumber(event.velocity) &&
24
+ event.velocity >= 0 &&
25
+ event.velocity <= 1 &&
26
+ isFiniteNumber(event.time) &&
27
+ typeof event.source === 'string');
28
+ }
29
+ /** The mod wheel. */
30
+ export const MIDI_CC_MOD_WHEEL = 1;
31
+ const BEND_CENTRE = 8192;
32
+ /**
33
+ * Decode a channel message's bytes. A note-on with velocity 0 is a note-off (running status sends
34
+ * them that way). Anything a note player has no use for (aftertouch, program change, system) is null.
35
+ */
36
+ export function decodeMidiMessage(status, data1 = 0, data2 = 0) {
37
+ const channel = status & 0x0f;
38
+ switch (status & 0xf0) {
39
+ case 0x90:
40
+ return data2 > 0
41
+ ? { kind: 'on', channel, pitch: data1 & 0x7f, velocity: (data2 & 0x7f) / 127 }
42
+ : { kind: 'off', channel, pitch: data1 & 0x7f, velocity: 0 };
43
+ case 0x80:
44
+ return { kind: 'off', channel, pitch: data1 & 0x7f, velocity: (data2 & 0x7f) / 127 };
45
+ case 0xe0: {
46
+ // 14 bits, least significant first. The top is 8191 above the centre and the bottom 8192 below
47
+ // it, so each side is scaled on its own and both ends reach ±1.
48
+ const raw = ((data2 & 0x7f) << 7) | (data1 & 0x7f);
49
+ const offset = raw - BEND_CENTRE;
50
+ return { kind: 'bend', channel, value: offset >= 0 ? offset / (BEND_CENTRE - 1) : offset / BEND_CENTRE };
51
+ }
52
+ case 0xb0:
53
+ return { kind: 'cc', channel, controller: data1 & 0x7f, value: (data2 & 0x7f) / 127 };
54
+ default:
55
+ return null;
56
+ }
57
+ }
58
+ export function createMidiNoteIds(prefix = 'midi') {
59
+ const held = new Map();
60
+ let next = 0;
61
+ const keyOf = (device, channel, pitch) => `${device}\u0000${channel}\u0000${pitch}`;
62
+ const deviceOf = (key) => key.slice(0, key.indexOf('\u0000'));
63
+ const take = (predicate) => {
64
+ const ids = [];
65
+ for (const [key, id] of held) {
66
+ if (!predicate(key))
67
+ continue;
68
+ ids.push(id);
69
+ held.delete(key);
70
+ }
71
+ return ids;
72
+ };
73
+ return {
74
+ on(device, channel, pitch) {
75
+ const key = keyOf(device, channel, pitch);
76
+ const replaced = held.get(key) ?? null;
77
+ next += 1;
78
+ const id = `${prefix}:${next}`;
79
+ held.set(key, id);
80
+ return { id, replaced };
81
+ },
82
+ off(device, channel, pitch) {
83
+ const key = keyOf(device, channel, pitch);
84
+ const id = held.get(key) ?? null;
85
+ held.delete(key);
86
+ return id;
87
+ },
88
+ releaseDevice: (device) => take((key) => deviceOf(key) === device),
89
+ releaseAll: () => take(() => true),
90
+ get size() {
91
+ return held.size;
92
+ },
93
+ };
94
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entangled-worlds-orbiters-shared",
3
- "version": "2.11.0",
3
+ "version": "2.12.0",
4
4
  "description": "Shared library for entangled worlds orbiters - contains core world rendering, moons system, and UI components",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -42,6 +42,10 @@
42
42
  "types": "./dist/capabilities/index.d.ts",
43
43
  "import": "./dist/capabilities/index.js"
44
44
  },
45
+ "./notes": {
46
+ "types": "./dist/notes/index.d.ts",
47
+ "import": "./dist/notes/index.js"
48
+ },
45
49
  "./studio": {
46
50
  "types": "./dist/studio/index.d.ts",
47
51
  "import": "./dist/studio/index.js"
@@ -109,10 +113,11 @@
109
113
  "typecheck": "tsc -p tsconfig.json --noEmit",
110
114
  "test:clock": "node --test --import tsx src/clock/*.test.ts",
111
115
  "test:capabilities": "node --test --import tsx src/capabilities/*.test.js",
116
+ "test:notes": "node --test --import tsx src/notes/*.test.ts",
112
117
  "test:studio": "node --test --import tsx src/studio/*.test.js",
113
118
  "test:uploads": "node --test --import tsx src/uploads/*.test.js",
114
119
  "test:render": "node --test src/render/*.test.js",
115
- "test": "npm run test:clock && npm run test:capabilities && npm run test:studio && npm run test:uploads && npm run test:render && npm run test:moons && npm run test:star",
120
+ "test": "npm run test:clock && npm run test:capabilities && npm run test:notes && npm run test:studio && npm run test:uploads && npm run test:render && npm run test:moons && npm run test:star",
116
121
  "test:moons": "node --test src/moons/*.test.js",
117
122
  "test:star": "node --test --import tsx src/star/*.test.ts",
118
123
  "bench:star:build": "esbuild bench/star/main.ts --bundle --format=esm --target=es2022 --outfile=bench/star/build/main.js --log-level=warning",
@@ -0,0 +1,146 @@
1
+ // Notes as events, shared by every note source (MIDI in, the on-screen keyboard, a capability call, a
2
+ // MIDI effect, a room peer) and everything that routes or sounds them.
3
+ //
4
+ // On and off are separate events with no duration: a live key has no known length when it goes
5
+ // down. They pair by `id`, never by pitch, since two sources can hold the same pitch at once and a
6
+ // chord or an echo plays the same pitch more than once. Pairing by pitch alone gives stuck notes.
7
+ //
8
+ // App-agnostic: nothing here knows about Orbiters, dimensions or the audio graph.
9
+
10
+ export type NoteEventType = 'on' | 'off'
11
+
12
+ export interface NoteEvent {
13
+ type: NoteEventType
14
+ /** Unique per sounding note; an `off` carries the id of the `on` it ends. */
15
+ id: string
16
+ /** MIDI note number, 0 … 127 (integers today; the field allows fractions later). */
17
+ pitch: number
18
+ /** 0 … 1. An `off` may carry a release velocity; nothing reads it yet. */
19
+ velocity: number
20
+ /** Audio-context seconds when it should sound; at or before now means now. */
21
+ time: number
22
+ /** Who sent it: a device, the keyboard, a MIDI effect's slot, a room client. */
23
+ source: string
24
+ }
25
+
26
+ export const NOTE_PITCH_MIN = 0
27
+ export const NOTE_PITCH_MAX = 127
28
+
29
+ const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
30
+
31
+ /** Whether `value` is a well-formed note event (the shape a room peer or an agent must send). */
32
+ export function isNoteEvent(value: unknown): value is NoteEvent {
33
+ if (!value || typeof value !== 'object') return false
34
+ const event = value as Record<string, unknown>
35
+ return (
36
+ (event.type === 'on' || event.type === 'off') &&
37
+ typeof event.id === 'string' &&
38
+ event.id.length > 0 &&
39
+ isFiniteNumber(event.pitch) &&
40
+ event.pitch >= NOTE_PITCH_MIN &&
41
+ event.pitch <= NOTE_PITCH_MAX &&
42
+ isFiniteNumber(event.velocity) &&
43
+ event.velocity >= 0 &&
44
+ event.velocity <= 1 &&
45
+ isFiniteNumber(event.time) &&
46
+ typeof event.source === 'string'
47
+ )
48
+ }
49
+
50
+ // ── MIDI ────────────────────────────────────────────────────────────────────
51
+
52
+ /** What a channel message means to a note player. Channels are 0 … 15, as on the wire. */
53
+ export type MidiNoteMessage =
54
+ | { kind: 'on'; channel: number; pitch: number; velocity: number }
55
+ | { kind: 'off'; channel: number; pitch: number; velocity: number }
56
+ /** Pitch bend, −1 … 1 (0 is the centre). */
57
+ | { kind: 'bend'; channel: number; value: number }
58
+ /** A control change, its value 0 … 1. */
59
+ | { kind: 'cc'; channel: number; controller: number; value: number }
60
+
61
+ /** The mod wheel. */
62
+ export const MIDI_CC_MOD_WHEEL = 1
63
+ const BEND_CENTRE = 8192
64
+
65
+ /**
66
+ * Decode a channel message's bytes. A note-on with velocity 0 is a note-off (running status sends
67
+ * them that way). Anything a note player has no use for (aftertouch, program change, system) is null.
68
+ */
69
+ export function decodeMidiMessage(status: number, data1 = 0, data2 = 0): MidiNoteMessage | null {
70
+ const channel = status & 0x0f
71
+ switch (status & 0xf0) {
72
+ case 0x90:
73
+ return data2 > 0
74
+ ? { kind: 'on', channel, pitch: data1 & 0x7f, velocity: (data2 & 0x7f) / 127 }
75
+ : { kind: 'off', channel, pitch: data1 & 0x7f, velocity: 0 }
76
+ case 0x80:
77
+ return { kind: 'off', channel, pitch: data1 & 0x7f, velocity: (data2 & 0x7f) / 127 }
78
+ case 0xe0: {
79
+ // 14 bits, least significant first. The top is 8191 above the centre and the bottom 8192 below
80
+ // it, so each side is scaled on its own and both ends reach ±1.
81
+ const raw = ((data2 & 0x7f) << 7) | (data1 & 0x7f)
82
+ const offset = raw - BEND_CENTRE
83
+ return { kind: 'bend', channel, value: offset >= 0 ? offset / (BEND_CENTRE - 1) : offset / BEND_CENTRE }
84
+ }
85
+ case 0xb0:
86
+ return { kind: 'cc', channel, controller: data1 & 0x7f, value: (data2 & 0x7f) / 127 }
87
+ default:
88
+ return null
89
+ }
90
+ }
91
+
92
+ /**
93
+ * A MIDI note-off names only a channel and a pitch. This gives each held key its note id, so the
94
+ * off pairs with its on by id like every other source.
95
+ *
96
+ * - `on` mints a fresh id. If that key was already held (a device that sends two ons without an off)
97
+ * the old note's id comes back as `replaced`, so the caller can end it first: nothing sticks.
98
+ * - `off` returns the id the key was playing, or null when it was never down (an off arriving after
99
+ * a panic, or from before the page listened).
100
+ * - `releaseDevice` hands back every id a device holds: it went away, and they all end.
101
+ */
102
+ export interface MidiNoteIds {
103
+ on(device: string, channel: number, pitch: number): { id: string; replaced: string | null }
104
+ off(device: string, channel: number, pitch: number): string | null
105
+ releaseDevice(device: string): string[]
106
+ releaseAll(): string[]
107
+ /** How many keys are down, across devices. */
108
+ readonly size: number
109
+ }
110
+
111
+ export function createMidiNoteIds(prefix = 'midi'): MidiNoteIds {
112
+ const held = new Map<string, string>()
113
+ let next = 0
114
+ const keyOf = (device: string, channel: number, pitch: number) => `${device}\u0000${channel}\u0000${pitch}`
115
+ const deviceOf = (key: string) => key.slice(0, key.indexOf('\u0000'))
116
+ const take = (predicate: (key: string) => boolean) => {
117
+ const ids: string[] = []
118
+ for (const [key, id] of held) {
119
+ if (!predicate(key)) continue
120
+ ids.push(id)
121
+ held.delete(key)
122
+ }
123
+ return ids
124
+ }
125
+ return {
126
+ on(device, channel, pitch) {
127
+ const key = keyOf(device, channel, pitch)
128
+ const replaced = held.get(key) ?? null
129
+ next += 1
130
+ const id = `${prefix}:${next}`
131
+ held.set(key, id)
132
+ return { id, replaced }
133
+ },
134
+ off(device, channel, pitch) {
135
+ const key = keyOf(device, channel, pitch)
136
+ const id = held.get(key) ?? null
137
+ held.delete(key)
138
+ return id
139
+ },
140
+ releaseDevice: (device) => take((key) => deviceOf(key) === device),
141
+ releaseAll: () => take(() => true),
142
+ get size() {
143
+ return held.size
144
+ },
145
+ }
146
+ }