euterpe 0.1.1 → 0.1.3

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.
package/README.md CHANGED
@@ -61,20 +61,38 @@ The agent runs in a Web Worker, so models (TF.js, onnxruntime-web, your own WebG
61
61
  | `processAudioBuffer(frame, ctx)` | every microphone frame |
62
62
  | `renderAudio(frames, ctx)` | audio output |
63
63
  | `updateParameter(change, ctx)` | a Settings widget changed |
64
+ | `processPageMessage(data, ctx)` | the project's page code called `sendToAgent(data)` |
64
65
 
65
66
  `agent/config.yaml`, `widgets.yaml` and `players.yaml` choose the interaction mode, the clock, the UI and the instruments. They're validated against the JSON schemas in `schema/` at dev and build time.
66
67
 
68
+ ## Page code
69
+
70
+ The project owns `index.html`, so a widget of its own (a custom controller, a canvas) can talk to the agent through the handle `mount()` returns:
71
+
72
+ ```js
73
+ import {mount} from 'euterpe';
74
+
75
+ const euterpe = mount('#app');
76
+ euterpe.sendToAgent({button: 3}); // -> the agent's processPageMessage(data, ctx)
77
+ euterpe.onAgentMessage((data) => {}); // <- {page: data} from a hook or ctx.send
78
+ ```
79
+
80
+ See [docs/page-code.md](docs/page-code.md).
81
+
67
82
  ## What's in the package
68
83
 
69
84
  | Export / command | What it is |
70
85
  |---|---|
71
86
  | `euterpe/vite` | The Vite plugin. It loads the agent and its YAML, serves and copies the instrument samples, and sets the cross-origin isolation headers. |
87
+ | `euterpe` | `mount()`, which returns `{vm, sendToAgent, onAgentMessage}` |
72
88
  | `euterpe/agent` | `defineAgent`, `NoteEvent`, small helpers and the type enums |
73
89
  | `npx euterpe smoke [--build]` | A headless-browser check: the page is isolated, the agent is ready, the clock ticks, the agent answers a phrase, and there are no errors |
74
90
  | `npx euterpe eject` | Copies Euterpe into the project, to change it freely |
75
91
 
76
92
  TypeScript types ship in `types/`, including the YAML config types (generated from the schemas).
77
93
 
94
+ Short guides ship in `docs/` (`node_modules/euterpe/docs/`): [models.md](docs/models.md) (TF.js and onnxruntime-web in the agent worker), [hosting.md](docs/hosting.md) (cross-origin isolation), [testing.md](docs/testing.md) (`npm run smoke`, Playwright, `window.__euterpe`, harmless console messages), [page-code.md](docs/page-code.md) (the page's own widgets and the agent).
95
+
78
96
  ## Deploying
79
97
 
80
98
  A built project (`npm run build`, then `dist/`) is a static site. It must be served with these headers:
@@ -84,7 +102,7 @@ Cross-Origin-Opener-Policy: same-origin
84
102
  Cross-Origin-Embedder-Policy: require-corp
85
103
  ```
86
104
 
87
- Euterpe uses SharedArrayBuffer between the page, the agent worker and the audio thread. The templates include `netlify.toml` and `vercel.json` with these headers.
105
+ Euterpe uses SharedArrayBuffer between the page, the agent worker and the audio thread. The templates include `netlify.toml` and `vercel.json` with these headers; for hosts without custom headers see [docs/hosting.md](docs/hosting.md).
88
106
 
89
107
  ## Requirements
90
108
 
package/cli/index.js CHANGED
@@ -89,6 +89,10 @@ async function smokeCommand() {
89
89
  ` ${options.expectAnswer ? mark(false) : yellow('-')} agent played nothing in answer to the phrase`);
90
90
  console.log(` ${mark(report.failedRequests.length === 0)} ${report.samplesLoaded} samples loaded, ${report.failedRequests.length} failed requests`);
91
91
  for (const f of report.failedRequests) console.log(` ${f}`);
92
+ if (!report.midi) {
93
+ console.log(` ${dim('i')} no Web MIDI in the headless browser (not counted as errors: ` +
94
+ `${report.midiMessages.join('; ')})`);
95
+ }
92
96
  console.log(` ${mark(report.errors.length === 0)} ${report.errors.length} console errors`);
93
97
  for (const e of report.errors) console.log(` ${e}`);
94
98
  for (const w of [...new Set(report.warnings)]) console.log(` ${yellow('!')} ${w.replace(/^\[\w+\] warning: /, '')}`);
package/cli/smoke.js CHANGED
@@ -5,6 +5,12 @@
5
5
  * or Playwright's own Chromium if Chrome isn't installed.
6
6
  */
7
7
 
8
+ // A headless browser may have no Web MIDI (no devices, or the permission refused): not the agent's fault, so
9
+ // these messages are not counted as errors (the report says report.midi = false instead).
10
+ const MIDI_UNAVAILABLE = [/WebMidi is not enabled/i, /NotAllowedError.*MIDI/i, /WebMIDI Error/i,
11
+ /\[euterpe\] Web MIDI is not available/, /\[euterpe\] Web MIDI output failed/];
12
+ const midiUnavailable = (text) => MIDI_UNAVAILABLE.some((pattern) => pattern.test(text));
13
+
8
14
  /** Playwright's chromium, from the project's playwright or playwright-core. */
9
15
  async function loadChromium() {
10
16
  for (const name of ['playwright', 'playwright-core']) {
@@ -51,12 +57,17 @@ export async function smoke(url, {expectAnswer = false, set = [], wait = 4000, s
51
57
  const logs = [];
52
58
  const failed = [];
53
59
  let samples = 0;
60
+ const midiMessages = [];
54
61
  const onConsole = (source) => (m) => {
55
62
  logs.push(`[${source}] ${m.type()}: ${m.text()}`);
56
- if (m.type() === 'error') errors.push(`[${source}] ${m.text()} ${m.location()?.url ?? ''}`.trim());
63
+ if (midiUnavailable(m.text())) midiMessages.push(m.text());
64
+ else if (m.type() === 'error') errors.push(`[${source}] ${m.text()} ${m.location()?.url ?? ''}`.trim());
57
65
  };
58
66
  page.on('console', onConsole('page'));
59
- page.on('pageerror', (e) => errors.push('[pageerror] ' + e.message));
67
+ page.on('pageerror', (e) => {
68
+ if (midiUnavailable(e.message)) midiMessages.push(e.message);
69
+ else errors.push('[pageerror] ' + e.message);
70
+ });
60
71
  page.on('worker', (w) => w.on('console', onConsole('worker')));
61
72
  context.on('response', (r) => {
62
73
  if (r.status() >= 400) failed.push(`${r.status()} ${r.url()}`);
@@ -115,7 +126,10 @@ export async function smoke(url, {expectAnswer = false, set = [], wait = 4000, s
115
126
  report.samplesLoaded = samples;
116
127
  report.failedRequests = failed;
117
128
  report.errors = errors;
118
- report.warnings = logs.filter((l) => l.includes('[euterpe]'));
129
+ report.warnings = logs.filter((l) => l.includes('[euterpe]') && !midiUnavailable(l));
130
+ // whether the page had Web MIDI; without it, the MIDI messages that were not counted as errors
131
+ report.midi = midiMessages.length === 0;
132
+ report.midiMessages = [...new Set(midiMessages)];
119
133
  report.ok = Boolean(report.crossOriginIsolated && report.started && report.ticksPerSecond > 0 &&
120
134
  failed.length === 0 && errors.length === 0 &&
121
135
  (!expectAnswer || report.agentNoteOns.length > 0 || (report.audioOut?.peakDuringPhrase ?? 0) > 0.01));
@@ -0,0 +1,34 @@
1
+ # Hosting: cross-origin isolation
2
+
3
+ Euterpe passes audio, widget values and the live canvas between the page, the agent worker and the audio thread through `SharedArrayBuffer`s, and threaded WASM (TF.js, onnxruntime-web) needs them too. Browsers allow `SharedArrayBuffer` only on a **cross-origin isolated** page, one served with:
4
+
5
+ ```text
6
+ Cross-Origin-Opener-Policy: same-origin
7
+ Cross-Origin-Embedder-Policy: require-corp
8
+ ```
9
+
10
+ Check in the browser console: `self.crossOriginIsolated` must be `true`. Without it `SharedArrayBuffer` is undefined and the UI fails to start.
11
+
12
+ ## Who sets the headers
13
+
14
+ | Where | Headers |
15
+ |---|---|
16
+ | `npm run dev`, `npm run preview` | set by `euterpe/vite` |
17
+ | `npm run smoke -- --build` | set by its static server |
18
+ | Netlify | the project's `netlify.toml` (or a `public/_headers` file) |
19
+ | Vercel | the project's `vercel.json` |
20
+ | GitHub Pages, `python3 -m http.server`, any host without custom headers | not set: use coi-serviceworker (below) |
21
+
22
+ ## Hosts that can't set headers: coi-serviceworker
23
+
24
+ [coi-serviceworker](https://github.com/gzuidhof/coi-serviceworker) is a small service worker that adds the two headers to every response. Copy `coi-serviceworker.js` (from the npm package `coi-serviceworker` or its repository) into the project's `public/`, and load it first in `index.html`'s `<head>`:
25
+
26
+ ```html
27
+ <script src="/coi-serviceworker.js"></script>
28
+ ```
29
+
30
+ The first visit installs the worker and reloads the page once; from then on the page is isolated. It needs HTTPS (or `localhost`).
31
+
32
+ ## Cross-origin files under COEP
33
+
34
+ With `require-corp`, files from another origin (a model on a CDN or a bucket, an image) load only if that server allows it: CORS (`Access-Control-Allow-Origin`) for `fetch`, or `Cross-Origin-Resource-Policy: cross-origin`. Files in the project's `public/` are same-origin and always load. A blocked file shows as a network error in the console (`ERR_BLOCKED_BY_RESPONSE`).
package/docs/models.md ADDED
@@ -0,0 +1,85 @@
1
+ # Loading a model in the agent
2
+
3
+ The hooks run in a module Web Worker, so a model never blocks the UI.
4
+
5
+ - **Worker, not page.** `fetch`, `WebAssembly` and (in Chrome) WebGPU work; the DOM, `window` and `document` don't.
6
+ - **Files.** Put weights and data in the project's `public/`. They are served from the site root: `public/model/model.json` is `fetch('/model/model.json')`. (Under a Vite `base` other than `/`, prefix it: `import.meta.env.BASE_URL`.)
7
+ - **When.** Load in `loadAlgorithm(ctx)`. The start button appears only when it resolves; if it throws, the error is shown on the loading screen. Send progress with `ctx.send({text: 'Loading...'})` (shown on the loading screen). Run one warm-up inference there too: the first call is much slower (compilation, buffer allocation).
8
+ - **Per tick.** `processClockEvent` has one tick to answer (150 ms at 100 BPM in 16ths). Keep the model's state (RNN hidden state, KV cache) in module-level variables between ticks. The console warns when the hook is slower than a tick.
9
+ - **Dependencies.** `npm install` the runtime and import it in `agent/`. Euterpe's Vite plugin pre-bundles what the agent imports.
10
+
11
+ ## WASM files: let Vite serve them
12
+
13
+ ML runtimes fetch their `.wasm` binaries at run time, by default from a CDN or next to the script, which breaks in a bundled worker. Import each binary with Vite's `?url` suffix (Vite copies it into the build and gives you its URL) and hand the URLs to the runtime, before the first model loads.
14
+
15
+ ### TF.js (WASM backend)
16
+
17
+ ```bash
18
+ npm install @tensorflow/tfjs @tensorflow/tfjs-backend-wasm # the same version for both (tested: 4.22.0)
19
+ ```
20
+
21
+ ```js
22
+ // agent/hooks.js
23
+ import {defineAgent} from 'euterpe/agent';
24
+ import * as tf from '@tensorflow/tfjs';
25
+ import {setWasmPaths} from '@tensorflow/tfjs-backend-wasm';
26
+ import wasm from '@tensorflow/tfjs-backend-wasm/dist/tfjs-backend-wasm.wasm?url';
27
+ import wasmSimd from '@tensorflow/tfjs-backend-wasm/dist/tfjs-backend-wasm-simd.wasm?url';
28
+ import wasmThreadedSimd from '@tensorflow/tfjs-backend-wasm/dist/tfjs-backend-wasm-threaded-simd.wasm?url';
29
+
30
+ setWasmPaths({
31
+ 'tfjs-backend-wasm.wasm': wasm,
32
+ 'tfjs-backend-wasm-simd.wasm': wasmSimd,
33
+ 'tfjs-backend-wasm-threaded-simd.wasm': wasmThreadedSimd,
34
+ });
35
+
36
+ let model = null;
37
+
38
+ export default defineAgent({
39
+ async loadAlgorithm(ctx) {
40
+ ctx.send({text: 'Loading the model...'});
41
+ // WASM, or plain JS if it can't start
42
+ let ok = false;
43
+ try {
44
+ ok = await tf.setBackend('wasm');
45
+ } catch (error) {
46
+ console.warn('TF.js wasm backend failed', error);
47
+ }
48
+ if (!ok) await tf.setBackend('cpu');
49
+ await tf.ready();
50
+ model = await tf.loadLayersModel('/model/model.json');
51
+ tf.tidy(() => model.predict(tf.zeros([1, 16]))); // warm-up
52
+ },
53
+ processClockEvent(clock, ctx) {
54
+ // tf.tidy frees the step's intermediate tensors; dispose() what you keep across ticks
55
+ const next = tf.tidy(() => model.predict(input(clock)).argMax(-1).dataSync()[0]);
56
+ // ...
57
+ },
58
+ });
59
+ ```
60
+
61
+ The threaded binary is used only when the page is cross-origin isolated (see [hosting.md](hosting.md)); Euterpe's dev server and deploy configs make sure it is.
62
+
63
+ ### onnxruntime-web
64
+
65
+ ```bash
66
+ npm install onnxruntime-web # tested: 1.30.0
67
+ ```
68
+
69
+ ```js
70
+ // agent/ort.js: ORT's WASM settings are global and fixed by the first session, so set them in one module
71
+ import * as ort from 'onnxruntime-web/wasm';
72
+ import ortWasm from 'onnxruntime-web/ort-wasm-simd-threaded.wasm?url';
73
+
74
+ ort.env.wasm.wasmPaths = {wasm: ortWasm};
75
+ ort.env.wasm.numThreads = 1; // more threads need a cross-origin isolated page
76
+ ort.env.wasm.proxy = false; // already in a worker
77
+ export {ort};
78
+ ```
79
+
80
+ ```js
81
+ // in loadAlgorithm
82
+ session = await ort.InferenceSession.create('/model/model.onnx');
83
+ ```
84
+
85
+ Tensors: `new ort.Tensor('float32', data, dims)`; `await session.run({input: tensor})`.
@@ -0,0 +1,56 @@
1
+ # Your own page code: widgets that talk to the agent
2
+
3
+ The project owns `index.html`, which calls `mount()` from `euterpe`. Anything else on that page (a custom controller, buttons, a canvas, a camera feed) is the project's own code, and can talk to the agent without changing Euterpe:
4
+
5
+ ```js
6
+ // index.html (or a module it imports)
7
+ import {mount} from 'euterpe';
8
+
9
+ const euterpe = mount('#app');
10
+ euterpe.sendToAgent({button: 3, down: true}); // -> the agent's processPageMessage(data, ctx)
11
+ const stop = euterpe.onAgentMessage((data) => { // <- what the agent sends as {page: data}
12
+ console.log('from the agent', data);
13
+ });
14
+ ```
15
+
16
+ ```js
17
+ // agent/hooks.js
18
+ export default defineAgent({
19
+ processPageMessage(data, ctx) {
20
+ if (!data.down) return [ctx.noteOff(60 + data.button)];
21
+ return {
22
+ notes: [ctx.noteOn(60 + data.button)],
23
+ page: {lit: data.button}, // to the page's onAgentMessage listeners
24
+ };
25
+ },
26
+ });
27
+ ```
28
+
29
+ | API | |
30
+ |---|---|
31
+ | `mount(el = '#app')` | Returns `{vm, sendToAgent, onAgentMessage}` (`EuterpeApp` in the types); `vm` is the root Vue instance |
32
+ | `sendToAgent(data)` | `data`: anything structured-cloneable (plain objects, arrays, numbers, strings, typed arrays). Delivered in order. Sent before the agent has loaded, it waits until `loadAlgorithm` has finished |
33
+ | `processPageMessage(data, ctx)` | The agent's hook. Returns what any hook returns (notes, `{notes, text, ...}`), plus `page` |
34
+ | `{page: data}` | From any hook's return value, or `ctx.send({page: data})` at any time (e.g. `{page: {ready: true}}` at the end of `loadAlgorithm`) |
35
+ | `onAgentMessage(listener)` | Calls `listener(data)` for each `{page: data}`. Returns a function that removes the listener |
36
+
37
+ Agents written the original way (named exports using `self.*`) get `processPageMessage(data)` without `ctx` and return MICP messages; the page key is `self.messageType.PAGE`.
38
+
39
+ Notes the agent plays show on the keyboard and piano roll as the agent's. Input sent this way is not a user note: it doesn't light the on-screen keyboard or appear in the piano roll or score as the user's. (`window.__euterpe.noteOn` is a test handle that plays a user note, see [testing.md](testing.md).)
40
+
41
+ ## Keys
42
+
43
+ Euterpe's laptop keyboard listens on `document`: the bottom row `Z S X D C V G B H N J M , L . ; /` plays C4 to E5 and the top row `Q 2 W 3 E R 5 T 6 Y 7 U I 9 O 0 P [ = ]` plays C5 to G6; Space starts and stops the clock. Of the digits, `2 3 5 6 7 9 0` are notes; `1 4 8` are free. To use a key Euterpe also uses, take it first with a capture-phase listener on `window` and stop it there:
44
+
45
+ ```js
46
+ window.addEventListener('keydown', (e) => {
47
+ if (e.code !== 'Digit2' || e.target.closest?.('input, textarea')) return;
48
+ e.stopPropagation(); // Euterpe's listener on document never sees it
49
+ if (!e.repeat) euterpe.sendToAgent({button: 1, down: true});
50
+ }, true);
51
+ // and the same for keyup
52
+ ```
53
+
54
+ ## Layout
55
+
56
+ Euterpe's UI fills the page. Place your widget with `position: fixed` and a `z-index` above it (e.g. 1000).
@@ -0,0 +1,60 @@
1
+ # Testing an agent in a browser
2
+
3
+ ## npm run smoke
4
+
5
+ ```bash
6
+ npm run smoke # starts the dev server, checks the page in headless Chrome
7
+ npm run smoke -- --build # the same on the production build, served with the COOP/COEP headers
8
+ npm run smoke -- --json # the full report (add --logs for the console)
9
+ npm run smoke -- --set=slider:1:40 --screenshot=shot.png --wait=6000
10
+ ```
11
+
12
+ It checks: the page is cross-origin isolated, the agent loads, the start button works, the clock ticks; then it plays C5 D5 E5 F5 G5 F5 E5 D5 C5 on the laptop keyboard (`q w e r t`) and reports what the agent played, console errors and Euterpe's `[euterpe]` warnings. `--expect-answer` fails if the agent plays nothing. It uses the system Chrome; without one, run `npx playwright install chromium` once.
13
+
14
+ ## Your own tests: Playwright + window.__euterpe
15
+
16
+ Use the system Chrome (no browser download) and drive the page through `window.__euterpe` rather than clicking through the UI:
17
+
18
+ ```js
19
+ import {chromium} from 'playwright-core'; // already in the project's devDependencies
20
+
21
+ const browser = await chromium.launch({channel: 'chrome'});
22
+ const page = await browser.newPage();
23
+ await page.goto('http://localhost:5173/');
24
+ await page.waitForFunction(() => window.__euterpe);
25
+ await page.evaluate(() => __euterpe.whenReady()); // the agent loaded (loadAlgorithm resolved)
26
+ await page.evaluate(() => __euterpe.enter()); // what the start button does; it opens the intro dialog
27
+ await page.keyboard.press('Escape'); // close the intro dialog
28
+ await page.evaluate(() => __euterpe.startClock());
29
+ const n = await page.evaluate(() => __euterpe.agentMessageCount);
30
+ await page.evaluate(() => __euterpe.noteOn(60));
31
+ await page.waitForTimeout(500);
32
+ await page.evaluate(() => __euterpe.noteOff(60));
33
+ const notes = await page.evaluate((k) => __euterpe.agentMessages(k).flatMap((m) => m.message[4] ?? []), n);
34
+ ```
35
+
36
+ `window.__euterpe` (types: `EuterpeHandle` in `euterpe`):
37
+
38
+ | | |
39
+ |---|---|
40
+ | `ready`, `whenReady(timeoutMs = 180000)` | The agent has loaded and the permissions are granted |
41
+ | `entered`, `enter()` | The start button was pressed / press it |
42
+ | `clockRunning`, `startClock()`, `stopClock()` | The clock (grid mode) |
43
+ | `tick` | `{local, global}`: the position in the bar, ticks since the start |
44
+ | `bpm`, `setBPM(bpm)` | The tempo |
45
+ | `params`, `setParam(kind, id, value)` | Widget values as the agent's `ctx.params` sees them; `kind`: `'slider'`, `'switch'`, `'dropdown'`, `'button'` (presses it), `'text'` |
46
+ | `noteOn(midi, velocity = 127)`, `noteOff(midi)` | A user note, as from the laptop keyboard (lights the on-screen key) |
47
+ | `agentMessages(since = 0)`, `agentMessageCount` | What the agent sent: `[{t, hookType, message}]`, numbered from the first message. `message` is keyed by `messageType` number: `message[4]` is the note list, `message[13]` the text |
48
+ | `audioOut` | The agent's audio output: `{played, underruns, playing, quantum, peak}`, or `null` |
49
+ | `config` | The merged YAML config |
50
+
51
+ Notes in `agentMessages` are serialized `NoteEvent`s: read `n._midi`, `n._type` (1 on, -1 off), `n._playAfter`.
52
+
53
+ The controls carry `data-testid`s: `euterpe-start`, `euterpe-clock`, `euterpe-settings`, `euterpe-slider-1`, `euterpe-dropdown-1`, `euterpe-text-1`, ...
54
+
55
+ ## Messages that look like errors but aren't
56
+
57
+ - **`WebMidi is not enabled`, `NotAllowedError ... MIDI`, `[euterpe] Web MIDI is not available`.** Headless Chrome has no Web MIDI (no devices, or the permission refused). MIDI devices are simply off; the laptop keyboard, the on-screen keyboard and `__euterpe.noteOn` still work. `npm run smoke` doesn't count these as errors.
58
+ - **`The AudioContext was not allowed to start`.** The browser's autoplay policy, before the start button (the user gesture that resumes audio).
59
+ - **The intro dialog.** `enter()` (the start button) opens it. While it is open it covers the middle of the page and Space doesn't start or stop the clock (Euterpe's shortcuts are off while any dialog is open); laptop-keyboard notes still play. Close it with Escape before clicking or pressing Space in a test.
60
+ - **Icons shown as words** (e.g. `settings`, `play_arrow`). Euterpe's icon font comes from Google Fonts; offline it doesn't load and the icon names show as text. Nothing else is affected.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euterpe",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "A browser framework for real-time interactive music agents: write the agent's hooks and YAML, Euterpe does MIDI and audio input, the clock, quantization, samplers and the UI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,6 +27,7 @@
27
27
  "types",
28
28
  "schema",
29
29
  "assets",
30
+ "docs",
30
31
  "LICENSE",
31
32
  "README.md"
32
33
  ],
@@ -65,11 +65,19 @@
65
65
  "inKey": {"type": "boolean", "description": "ONNX: pitches only from the current key's scale (the agent's key estimate)."}
66
66
  }
67
67
  },
68
+ "mixer": {
69
+ "type": "object",
70
+ "additionalProperties": false,
71
+ "properties": {
72
+ "open": {"type": "boolean", "description": "Show the mixer panel when the page opens (default false: its button opens it)."}
73
+ }
74
+ },
68
75
  "monitor": {
69
76
  "type": "object",
70
77
  "additionalProperties": false,
71
78
  "properties": {
72
79
  "status": {"type": "boolean"},
80
+ "open": {"type": "boolean", "description": "Show the monitor panel when the page opens (default false: its button opens it)."},
73
81
  "title": {"type": "string"},
74
82
  "structure": {
75
83
  "type": ["array", "null"],
package/src/agent.js CHANGED
@@ -16,7 +16,7 @@ export {liveCanvasState} from './utils/liveCanvasState.js';
16
16
  export const AGENT_MARK = Symbol.for('euterpe.agent');
17
17
 
18
18
  export const HOOK_NAMES = ['loadAlgorithm', 'processClockEvent', 'processNoteEvent', 'processAudioBuffer',
19
- 'updateParameter', 'renderAudio'];
19
+ 'updateParameter', 'renderAudio', 'processPageMessage'];
20
20
 
21
21
  /**
22
22
  * Declares the agent. agent/hooks.js does `export default defineAgent({...})`; every hook is optional and may be
@@ -31,8 +31,11 @@ export const HOOK_NAMES = ['loadAlgorithm', 'processClockEvent', 'processNoteEve
31
31
  * updated)
32
32
  * renderAudio(frames, ctx) audio output (interactionMode.audioOut): return `frames` frames,
33
33
  * called whenever the output queue holds less than audioOut.latency
34
+ * processPageMessage(data, ctx) the project's page code called sendToAgent(data) (the handle mount()
35
+ * returns), e.g. from its own widget
34
36
  *
35
- * A hook returns the agent's output: an array of notes, or {notes, text, label, vector, key, audio}, or nothing.
37
+ * A hook returns the agent's output: an array of notes, or {notes, text, label, vector, key, audio, page}, or
38
+ * nothing. `page` goes to the page code's onAgentMessage listeners.
36
39
  * @param {Object} hooks
37
40
  * @return {Object} The agent, as the worker runtime calls it.
38
41
  */
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <div>
3
3
  <modal name="settingsModal" width="90%" height="85%" :maxWidth="1200" :minWidth="320" :minHeight="300"
4
- :adaptive="true">
4
+ :adaptive="true" @before-open="$emit('modal-open', true)" @closed="$emit('modal-open', false)">
5
5
  <div class="settingsContainer">
6
6
  <div class="settingsHeader">
7
7
  <p class="settingsTitle">
@@ -71,10 +71,10 @@
71
71
  </div>
72
72
  </div>
73
73
  <span v-else>
74
- Currently, Using MIDI devices in browser is only supported by Google
75
- Chrome v43+, Opera v30+ and Microsoft Edge v79+. Please update to
76
- one of those browsers if you want to use Web MIDI
77
- functionalities.</span>
74
+ Web MIDI is not available: this browser does not support it, or the
75
+ permission to use MIDI devices was not given. Chrome, Edge, Opera and
76
+ Firefox support MIDI devices; the laptop and on-screen keyboards
77
+ work everywhere.</span>
78
78
  </div>
79
79
  <!-- <modal name="settingsModal" :minWidth="700" :minHeight="600" :adaptive="true"> -->
80
80
  <div style="display: flex; flex-direction:column;">
@@ -238,6 +238,15 @@ export default {
238
238
  type: Boolean,
239
239
  default: false,
240
240
  },
241
+ // the devices in use (Euterpe.vue picks the first MIDI input until the user chooses one)
242
+ selectedMIDIInput: {
243
+ type: [String, Number],
244
+ default: 1123581321, // the "None" option
245
+ },
246
+ selectedMIDIOutput: {
247
+ type: [String, Number],
248
+ default: 1123581321,
249
+ },
241
250
  maxBPM: Number,
242
251
  initBPM: Number
243
252
  },
@@ -245,11 +254,20 @@ export default {
245
254
  data() {
246
255
  return {
247
256
  currentBPM: this.$props.initBPM,
248
- selectedMIDIInputDevice: 1123581321, // id for "None" option
249
- selectedMIDIOutputDevice: 1123581321 // id for "None" option
257
+ selectedMIDIInputDevice: this.$props.selectedMIDIInput,
258
+ selectedMIDIOutputDevice: this.$props.selectedMIDIOutput,
250
259
  };
251
260
  },
252
261
 
262
+ watch: {
263
+ selectedMIDIInput(id) {
264
+ this.selectedMIDIInputDevice = id;
265
+ },
266
+ selectedMIDIOutput(id) {
267
+ this.selectedMIDIOutputDevice = id;
268
+ },
269
+ },
270
+
253
271
  // TODO: Use the sortedCollection to render the controls in the correct order
254
272
  computed: {
255
273
  sortedCollections() {
package/src/index.js CHANGED
@@ -12,6 +12,7 @@ import 'vue-material/dist/theme/default.css';
12
12
  import 'remixicon/fonts/remixicon.css';
13
13
  import Dropdown from 'vue-simple-search-dropdown';
14
14
  import MarkdownItVue from 'markdown-it-vue'
15
+ import {sendToAgent, onAgentMessage} from './utils/pageBridge.js';
15
16
 
16
17
  Vue.use(MdButton);
17
18
  Vue.use(MdIcon);
@@ -42,8 +43,18 @@ const FONT_STYLESHEETS = [
42
43
 
43
44
  /**
44
45
  * Mounts the Euterpe UI, running the agent in the project's agent/ folder (bundled by euterpe/vite).
46
+ *
47
+ * The handle it returns lets the project's own page code (e.g. a widget of its own in index.html) talk to the
48
+ * agent:
49
+ *
50
+ * const euterpe = mount('#app');
51
+ * euterpe.sendToAgent({button: 3}); // the agent's processPageMessage(data, ctx)
52
+ * euterpe.onAgentMessage((data) => {...}); // what the agent sends as {page: data}; returns an unsubscribe
53
+ *
54
+ * Messages sent before the agent has loaded wait for it.
45
55
  * @param {string|Element} [el='#app'] - The element to mount into.
46
- * @return {Vue} The root Vue instance.
56
+ * @return {{vm: Vue, sendToAgent: function(*): void, onAgentMessage: function(function(*): void): function(): void}}
57
+ * The handle; `vm` is the root Vue instance.
47
58
  */
48
59
  export function mount(el = '#app') {
49
60
  // problems in the agent's YAML files (also printed by the dev server and the build)
@@ -55,8 +66,9 @@ export function mount(el = '#app') {
55
66
  link.href = href;
56
67
  document.head.appendChild(link);
57
68
  }
58
- return new Vue({
69
+ const vm = new Vue({
59
70
  store,
60
71
  render: (h) => h(App),
61
72
  }).$mount(el);
73
+ return {vm, sendToAgent, onAgentMessage};
62
74
  }
@@ -6,7 +6,8 @@ const state = {
6
6
  currentBPM: null,
7
7
  frequency: null,
8
8
  // randomness: null,
9
- modalStatus: false,
9
+ // the names of the dialogs (vue-js-modal) that are open; the keyboard shortcuts are off while any is
10
+ openModals: [],
10
11
  // Key estimate from the agent ('E♭ major'), used to spell notes (C♯ vs D♭); null spells with sharps
11
12
  estimatedKey: null,
12
13
  };
@@ -27,8 +28,9 @@ const getters = {
27
28
  // getRandomness(state){
28
29
  // return state.randomness;
29
30
  // },
31
+ // true while a dialog is open
30
32
  getModalStatus(state) {
31
- return state.modalStatus;
33
+ return state.openModals.length > 0;
32
34
  },
33
35
  getEstimatedKey(state) {
34
36
  return state.estimatedKey;
@@ -82,8 +84,11 @@ const mutations = {
82
84
  console.debug('setBPM: ' + bpm);
83
85
  state.currentBPM = bpm;
84
86
  },
85
- changeModalStatus(state) {
86
- state.modalStatus = !state.modalStatus;
87
+ // a dialog opened or closed: set from its before-open and closed events, idempotent (a dialog closed
88
+ // during its opening transition gets no "opened" event, so a toggle would go out of step)
89
+ setModalOpen(state, {name, open}) {
90
+ const others = state.openModals.filter((n) => n !== name);
91
+ state.openModals = open ? [...others, name] : others;
87
92
  },
88
93
  };
89
94
 
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Page messages: the project's own page code (its index.html, its widgets) talking to the agent, outside
3
+ * Euterpe's UI. mount() returns sendToAgent / onAgentMessage (src/index.js); the agent gets the data in its
4
+ * processPageMessage hook and answers with {page: data}, which reaches the onAgentMessage listeners.
5
+ */
6
+
7
+ // messages sent before the agent worker exists (mount() returns before the UI's chunk has loaded)
8
+ const pending = [];
9
+ const listeners = new Set();
10
+ let worker = null;
11
+
12
+ /**
13
+ * Sends data to the agent's processPageMessage hook.
14
+ * @param {*} data - Anything structured-cloneable (plain objects, arrays, numbers, strings, typed arrays).
15
+ */
16
+ export function sendToAgent(data) {
17
+ // a copy now, as postMessage would make: the data can't change while it waits
18
+ if (worker) worker.postMessage({control: 'pageMessage', data});
19
+ else pending.push(structuredClone(data));
20
+ }
21
+
22
+ /**
23
+ * Calls `listener(data)` for every {page: data} the agent sends.
24
+ * @param {function(*): void} listener
25
+ * @return {function(): void} Removes the listener.
26
+ */
27
+ export function onAgentMessage(listener) {
28
+ if (typeof listener !== 'function') throw new TypeError('onAgentMessage: the listener must be a function');
29
+ listeners.add(listener);
30
+ return () => listeners.delete(listener);
31
+ }
32
+
33
+ /**
34
+ * Connects the agent worker (Euterpe.vue, after the INIT_AGENT message) and sends what was waiting.
35
+ * @param {Worker} agentWorker
36
+ */
37
+ export function attachWorker(agentWorker) {
38
+ worker = agentWorker;
39
+ for (const data of pending.splice(0)) worker.postMessage({control: 'pageMessage', data});
40
+ }
41
+
42
+ /**
43
+ * Passes a page message from the agent (messageType.PAGE) to the listeners.
44
+ * @param {*} data
45
+ */
46
+ export function deliverToPage(data) {
47
+ for (const listener of listeners) {
48
+ try {
49
+ listener(data);
50
+ } catch (error) {
51
+ console.error('[euterpe] an onAgentMessage listener failed', error);
52
+ }
53
+ }
54
+ }
@@ -36,6 +36,7 @@ export const messageType = {
36
36
  SESSION: 16, // a recorded session (see src/agent/sessionLog.js), downloaded by the UI
37
37
  TIMING_RESET: 18, // the agent switched to another model: the UI restarts its max-BPM estimate
38
38
  HUMAN_TOKEN: 19, // the live canvas: the user's note for the next tick, {tick, midi, artic, candidate, ...}
39
+ PAGE: 20, // data for the project's page code (mount()'s onAgentMessage listeners)
39
40
  CLOCK_TIME: 1,
40
41
  INFERENCE_TIME: 2,
41
42
  };
@@ -23,6 +23,8 @@ import '../css/main.css';
23
23
  import * as Tone from 'tone';
24
24
  import {Midi} from '@tonaljs/tonal';
25
25
  import {WebMidi} from 'webmidi';
26
+ // the id of the "None" entry of the MIDI device menus
27
+ const NO_MIDI_DEVICE = 1123581321;
26
28
  import AudioKeys from 'audiokeys';
27
29
 
28
30
  import * as rb from 'ringbuf.js';
@@ -36,6 +38,7 @@ import {
36
38
  import {isMobile, isNotChrome} from '../utils/helpers.js';
37
39
  import {NoteEvent} from '../utils/NoteEvent.js';
38
40
  import {installHandle} from '../utils/handle.js';
41
+ import {attachWorker, deliverToPage} from '../utils/pageBridge.js';
39
42
  // import { selectedAgent } from '../agent-examples/selectedAgent.js'
40
43
 
41
44
  // the agent's YAML configs and intro text, from the project's agent/ folder (see euterpe/vite)
@@ -138,8 +141,11 @@ export default {
138
141
  pageLoadTime: null,
139
142
  modelLoadTime: null,
140
143
  activeDevices: [],
141
- selectedMIDIDevice: 1123581321, // id for the "None" device
142
- selectedMIDIOutputDevice: 1123581321, // id for the "None" device
144
+ selectedMIDIDevice: NO_MIDI_DEVICE,
145
+ selectedMIDIOutputDevice: NO_MIDI_DEVICE,
146
+ // the MIDI input the user chose in Settings (an id, or NO_MIDI_DEVICE); null: not chosen yet, so
147
+ // Euterpe uses the first connected input
148
+ midiInputChoice: null,
143
149
  activeMidiOutputDevices: [], // To store MIDI output devices
144
150
 
145
151
  noteOffEventForNextTick: null,
@@ -287,8 +293,12 @@ export default {
287
293
  if (vm.showMonitor) {
288
294
  console.debug('MA DEN EPREPE');
289
295
  vm.$root.$refs.monitor.loadMonitorConfig(vm.config.gui.monitor);
296
+ // closed until its button is pressed, unless gui.monitor.open
297
+ vm.$root.$refs.monitor.pane.hidden = !vm.config.gui.monitor.open;
290
298
  }
291
299
  vm.$root.$refs.mixer.loadMixerConfig(vm.config.players);
300
+ // closed until its button is pressed, unless gui.mixer.open
301
+ vm.$root.$refs.mixer.pane.hidden = !vm.config.gui.mixer?.open;
292
302
 
293
303
  /*
294
304
  * Loading Animation: set initial status of both div
@@ -373,6 +383,8 @@ export default {
373
383
  sabLiveCanvas: vm.liveCanvas?.sab ?? null,
374
384
  },
375
385
  });
386
+ // the page code's sendToAgent (mount()'s handle): from now on straight to the worker, after the config
387
+ attachWorker(vm.agent);
376
388
  if (audioOut) {
377
389
  vm.startAudioOut(audioOut).catch((error) => {
378
390
  // e.g. a Content-Security-Policy that blocks the worklet: tell the agent, and the user
@@ -393,7 +405,8 @@ export default {
393
405
  // Initialize Clock Worker (module)
394
406
  vm.clockWorker = new Worker(new URL('../worker/clock.js', import.meta.url), {type: 'module'});
395
407
  vm.clockWorker.onmessage = vm.tickBehavior;
396
- vm.clockWorker.postMessage({action: 'setBPM', bpm: vm.localBPM});
408
+ vm.clockWorker.postMessage({action: 'setBPM', bpm: vm.localBPM, ticksPerBeat: vm.config.clockSettings.ticksPerBeat,
409
+ clockPeriod: vm.config.clockSettings.clockPeriod ?? null});
397
410
  vm.$store.commit('initializeClock');
398
411
 
399
412
  if (vm.config.interactionMode.audioMode) {
@@ -497,16 +510,11 @@ export default {
497
510
  // .then(vm.onEnabled)
498
511
  // .catch((err) => console.debug('WebMIDI Error: ' + err));
499
512
  // }
500
- try {
501
- if (navigator.requestMIDIAccess) {
502
- // Await MIDI permission
503
- await this.initializeMIDI();
504
- console.debug('MIDI initialized');
505
- vm.permissionMIDI = true;
506
- }
507
- } catch (error) {
508
- console.error('Error initializing MIDI: ', error);
513
+ // MIDI devices are optional: without Web MIDI (Safari, a denied permission) the page still starts
514
+ if (navigator.requestMIDIAccess) {
515
+ await this.initializeMIDI(); // never throws
509
516
  }
517
+ vm.permissionMIDI = true;
510
518
 
511
519
  /*
512
520
  * Initialize computer keyboard logic
@@ -586,18 +594,20 @@ export default {
586
594
  const vm = this;
587
595
  if (vm.$store.getters.getClockStatus) {
588
596
  vm.$store.commit('incrementTick');
597
+ // The side effects of a tick (the live canvas, the metronome and its MIDI out, the agent's
598
+ // sampler) run guarded: a throw in one of them must never stop the tick reaching the agent.
589
599
  // the live canvas plays the user's note for this tick before the quantizer runs
590
- vm.liveCanvas?.onTick(vm.$store.getters.getGlobalTick);
600
+ vm.guardedTickStep('liveCanvas', () => vm.liveCanvas?.onTick(vm.$store.getters.getGlobalTick));
591
601
 
592
602
  // Trigger the metronome only if there is a "metronome" entry in config_players.yaml
593
603
  if (vm.config.players.metronome) {
594
- vm.metronomeTrigger();
604
+ vm.guardedTickStep('metronome', () => vm.metronomeTrigger());
595
605
  }
596
606
 
597
- vm.calculateMaxBPM();
607
+ vm.guardedTickStep('calculateMaxBPM', () => vm.calculateMaxBPM());
598
608
 
599
609
  // in grid-based mode, the agent's sampler is triggered in sync with the clock
600
- vm.triggerAgentSamplerSync(); // UNCOMMENT
610
+ vm.guardedTickStep('agentSampler', () => vm.triggerAgentSamplerSync());
601
611
 
602
612
 
603
613
  // run the agent with a small delay of tick/4 in order to include
@@ -628,9 +638,23 @@ export default {
628
638
  }
629
639
  },
630
640
 
641
+ // runs one step of a tick; a throw is logged (the first time per step) instead of ending the tick
642
+ guardedTickStep(name, step) {
643
+ try {
644
+ step();
645
+ } catch (error) {
646
+ this.tickStepErrors ??= new Set();
647
+ if (!this.tickStepErrors.has(name)) {
648
+ this.tickStepErrors.add(name);
649
+ console.error(`[euterpe] ${name} failed on a tick (the tick still reached the agent)`, error);
650
+ }
651
+ }
652
+ },
653
+
631
654
  async toggleClock() {
632
655
  const vm = this;
633
656
  if (!vm.localSyncClockStatus) {
657
+ vm.clearUserInput();
634
658
  vm.startRecording();
635
659
  vm.clockWorker.postMessage({action: 'startClock'});
636
660
  } else {
@@ -653,7 +677,14 @@ export default {
653
677
  this.$store.commit('clearPianoState');
654
678
  },
655
679
 
680
+ // Notes played while the clock was stopped must not reach the first ticks of the take
681
+ clearUserInput() {
682
+ this.$store.commit('clearContinuousBuffers');
683
+ this.noteOffEventForNextTick = null;
684
+ },
685
+
656
686
  startClock() {
687
+ this.clearUserInput();
657
688
  this.startRecording();
658
689
  this.clockWorker.postMessage({action: 'startClock'});
659
690
  this.localSyncClockStatus = true;
@@ -1106,6 +1137,10 @@ export default {
1106
1137
  // the live canvas: the user's note for the next tick
1107
1138
  vm.liveCanvas?.onHumanToken(messageValue);
1108
1139
  break;
1140
+ case vm.messageType.PAGE:
1141
+ // {page: data}: for the project's page code (mount()'s onAgentMessage)
1142
+ deliverToPage(messageValue);
1143
+ break;
1109
1144
  case vm.messageType.CLOCK_TIME:
1110
1145
  break;
1111
1146
  case vm.messageType.INFERENCE_TIME:
@@ -1315,90 +1350,72 @@ export default {
1315
1350
  */
1316
1351
  async initializeMIDI() {
1317
1352
  try {
1318
- const access = await navigator.requestMIDIAccess();
1319
- this.WebMIDISupport = true;
1320
-
1321
- access.onstatechange = (event) => {
1322
- this.onMidiStateChange(event);
1323
- };
1324
-
1325
1353
  await WebMidi.enable();
1326
- this.onEnabled();
1354
+ this.WebMIDISupport = true;
1355
+ // a device plugged in (or out) later: update the menus, and pick up a keyboard if none is chosen
1356
+ WebMidi.addListener('portschanged', () => this.refreshMIDIDevices());
1357
+ this.refreshMIDIDevices();
1327
1358
  } catch (err) {
1328
- console.error('WebMIDI Error:', err);
1359
+ // permission denied, a headless browser, or no Web MIDI (Safari): MIDI devices are off,
1360
+ // the laptop keyboard and the on-screen keyboard still work
1361
+ console.warn(`[euterpe] Web MIDI is not available (${err?.name ?? err}): MIDI devices are off`);
1329
1362
  }
1330
1363
  },
1331
1364
 
1332
- onMidiStateChange(state) {
1333
- if (state.port.state === 'connected') {
1334
- console.debug('MIDI device connected:', state.port.name);
1335
- } else if (state.port.state === 'disconnected') {
1336
- console.debug('MIDI device disconnected:', state.port.name);
1365
+ // the device menus from the connected devices. MIDI input: the device the user chose in Settings
1366
+ // (when it is connected), or, until the user chooses one, the first connected input.
1367
+ refreshMIDIDevices() {
1368
+ const vm = this;
1369
+ vm.activeDevices = [{id: NO_MIDI_DEVICE, name: 'None'},
1370
+ ...WebMidi.inputs.map((device) => ({id: device.id, name: device.name}))];
1371
+ vm.activeMidiOutputDevices = [{id: NO_MIDI_DEVICE, name: 'None'},
1372
+ ...WebMidi.outputs.map((device) => ({id: device.id, name: device.name}))];
1373
+
1374
+ const inputIds = WebMidi.inputs.map((device) => device.id);
1375
+ const choice = vm.midiInputChoice;
1376
+ let input;
1377
+ if (choice === null) input = inputIds[0] ?? NO_MIDI_DEVICE;
1378
+ else input = inputIds.includes(choice) ? choice : NO_MIDI_DEVICE;
1379
+ vm.selectMIDIInput(input);
1380
+
1381
+ if (vm.selectedMIDIOutputDevice != NO_MIDI_DEVICE &&
1382
+ !WebMidi.outputs.some((device) => device.id === vm.selectedMIDIOutputDevice)) {
1383
+ vm.selectedMIDIOutputDevice = NO_MIDI_DEVICE;
1337
1384
  }
1338
- // this.onEnabled();
1339
- // Find a non-buggy way to correctly update the MIDI devices
1340
1385
  },
1341
1386
 
1342
- onEnabled() {
1387
+ // listen to this MIDI input (an id, or NO_MIDI_DEVICE for none), and stop listening to the previous one
1388
+ selectMIDIInput(id) {
1343
1389
  const vm = this;
1344
- vm.activeDevices = [];
1345
- vm.activeDevices.push({id: 1123581321, name: 'None'})
1346
- WebMidi.inputs.forEach((device) => {
1347
- vm.activeDevices.push({id: device.id, name: device.name});
1348
- });
1349
- vm.selectedMIDIDevice = vm.activeDevices[0].id;
1350
-
1351
- vm.activeMidiOutputDevices = [];
1352
- vm.activeMidiOutputDevices.push({id: 1123581321, name: 'None'})
1353
- WebMidi.outputs.forEach((device) => {
1354
- vm.activeMidiOutputDevices.push({id: device.id, name: device.name});
1355
- });
1356
- vm.selectedMIDIOutputDevice = vm.activeMidiOutputDevices[0].id;
1357
-
1390
+ const device = id == NO_MIDI_DEVICE ? null : WebMidi.getInputById(id);
1391
+ if (vm.midiInputPort && vm.midiInputPort !== device) {
1392
+ vm.midiInputPort.removeListener();
1393
+ vm.midiInputPort = null;
1394
+ }
1395
+ vm.selectedMIDIDevice = device ? id : NO_MIDI_DEVICE;
1396
+ if (device && vm.midiInputPort !== device) {
1397
+ vm.midiInputPort = device;
1398
+ vm.messageListener(device);
1399
+ console.debug(`MIDI input: ${device.name}`);
1400
+ }
1358
1401
  },
1359
1402
 
1403
+ // the user chose a MIDI input in Settings: keep it (also when devices change), never auto-select over it
1360
1404
  onMIDIDeviceSelectedChange(state) {
1361
- const vm = this;
1362
- if (state.currentTarget.value) {
1363
- if (vm.selectedMIDIDevice !== state.currentTarget.value) {
1364
-
1365
- if (vm.selectedMIDIDevice != 1123581321) {
1366
- const inputDevice = WebMidi.getInputById(this.selectedMIDIDevice);
1367
- // iterate over all channels in inputDevice.channels
1368
- // and do channel.removeListener();
1369
- for (let i = 1; i <= 16; i++) {
1370
- let channel = inputDevice.channels[i];
1371
- if (channel)
1372
- channel.removeListener();
1373
- }
1374
- }
1375
- vm.selectedMIDIDevice = state.currentTarget.value;
1376
- if (state.currentTarget.value != 1123581321) {
1377
- vm.messageListener();
1378
- }
1379
-
1380
- }
1381
- }
1405
+ const value = state.currentTarget.value;
1406
+ if (!value) return;
1407
+ this.midiInputChoice = value == NO_MIDI_DEVICE ? NO_MIDI_DEVICE : value;
1408
+ if (WebMidi.enabled) this.selectMIDIInput(this.midiInputChoice);
1382
1409
  },
1383
1410
  onMIDIOutputDeviceSelectedChange(state) {
1384
- const vm = this;
1385
- if (state.currentTarget.value) {
1386
- if (vm.selectedMIDIOutputDevice !== state.currentTarget.value) {
1387
- if (state.currentTarget.value == 1123581321) {
1388
- vm.selectedMIDIOutputDevice = 1123581321;
1389
- }
1390
- else {
1391
- vm.selectedMIDIOutputDevice = state.currentTarget.value;
1392
- }
1393
- // vm.sendNoteOn();
1394
- }
1395
- }
1411
+ const value = state.currentTarget.value;
1412
+ if (!value) return;
1413
+ this.selectedMIDIOutputDevice = value == NO_MIDI_DEVICE ? NO_MIDI_DEVICE : value;
1396
1414
  },
1397
1415
 
1398
1416
  // listens for midi messages from a midi keyboard
1399
- messageListener() {
1417
+ messageListener(inputDevice) {
1400
1418
  const vm = this;
1401
- const inputDevice = WebMidi.getInputById(this.selectedMIDIDevice);
1402
1419
  inputDevice.addListener('noteon', (message) => {
1403
1420
  const newNoteEvent = new NoteEvent();
1404
1421
  newNoteEvent.player = vm.playerType.HUMAN;
@@ -1446,54 +1463,51 @@ export default {
1446
1463
  });
1447
1464
  },
1448
1465
 
1466
+ /*
1467
+ * MIDI out. Every path goes through withMIDIOutput: without Web MIDI (permission denied, a headless
1468
+ * browser, Safari) or without a selected output device it does nothing, and a failure to
1469
+ * send is logged once, never thrown (the metronome and the agent's notes call it on the clock's path).
1470
+ */
1471
+ withMIDIOutput(send) {
1472
+ if (!WebMidi.enabled || this.selectedMIDIOutputDevice == NO_MIDI_DEVICE) return;
1473
+ try {
1474
+ const outputDevice = WebMidi.getOutputById(this.selectedMIDIOutputDevice);
1475
+ if (outputDevice) send(outputDevice);
1476
+ } catch (error) {
1477
+ if (!this.midiOutputWarned) {
1478
+ this.midiOutputWarned = true;
1479
+ console.warn('[euterpe] Web MIDI output failed; MIDI out is skipped', error);
1480
+ }
1481
+ }
1482
+ },
1483
+
1449
1484
  // Method to send MIDI messages (for example, a Note On message)
1450
1485
  sendNoteOn(noteEvent, channel = 1) {
1451
- const outputDevice = WebMidi.getOutputById(this.selectedMIDIOutputDevice);
1452
- if (outputDevice) {
1453
- // Note On message is sent as [0x90 + channel, noteNumber, velocity]
1454
- // outputDevice.send([0x90, noteNumber, velocity]);
1486
+ this.withMIDIOutput((outputDevice) => {
1455
1487
  outputDevice.playNote(noteEvent.midi, {channels: [channel]});
1456
- console.debug(`Sent Note On to external device`);
1457
- // } else {
1458
- // console.error('No output device selected.');
1459
- }
1488
+ });
1460
1489
  },
1461
1490
 
1462
1491
  sendNoteOnOff(noteEvent, channel = 1, duration = 1000) {
1463
- const outputDevice = WebMidi.getOutputById(this.selectedMIDIOutputDevice);
1464
- if (outputDevice) {
1465
- // Note On message is sent as [0x90 + channel, noteNumber, velocity]
1466
- // outputDevice.send([0x90, noteNumber, velocity]);
1467
- if (noteEvent.midi)
1468
- outputDevice.playNote(noteEvent.midi, {channels: [channel], duration: duration});
1469
- else
1470
- outputDevice.playNote(noteEvent.name, {channels: [channel], duration: duration});
1471
- console.debug(`Sent Note On to external device`);
1472
- // } else {
1473
- // console.error('No output device selected.');
1474
- }
1492
+ this.withMIDIOutput((outputDevice) => {
1493
+ outputDevice.playNote(noteEvent.midi || noteEvent.name, {channels: [channel], duration: duration});
1494
+ });
1475
1495
  },
1476
1496
 
1477
1497
  // Method to send MIDI messages (for example, a Note Off message)
1478
- sendNoteOff(noteEvent, channel = 1, after = "+0") {
1479
- const outputDevice = WebMidi.getOutputById(this.selectedMIDIOutputDevice);
1480
- if (outputDevice) {
1481
- // Note Off message is sent as [0x80 + channel, noteNumber, 0]
1498
+ sendNoteOff(noteEvent, channel = 1, after = '+0') {
1499
+ this.withMIDIOutput((outputDevice) => {
1482
1500
  outputDevice.stopNote(noteEvent.midi, {time: after, channels: [channel]});
1483
- // console.debug(`Sent Note Off: Note ${noteEvent.midi} to external device`);
1484
- // } else {
1485
- // console.error('No output device selected.');
1486
- }
1501
+ });
1487
1502
  },
1488
1503
 
1489
1504
  // kill all notes on the external midi device
1490
1505
  killAllNotes() {
1491
- // use the sendNoteOff method to send a note off message to all notes
1492
- // that are currently playing on the external midi device
1493
- for (let i = 0; i < 128; i++) {
1494
- this.sendNoteOff({midi: i}, 1, "+200");
1495
- }
1496
- console.debug('Killed all notes on the external midi device');
1506
+ this.withMIDIOutput((outputDevice) => {
1507
+ for (let i = 0; i < 128; i++) {
1508
+ outputDevice.stopNote(i, {time: '+200', channels: [1]});
1509
+ }
1510
+ });
1497
1511
  },
1498
1512
 
1499
1513
  startRecording() {
@@ -1650,9 +1664,9 @@ export default {
1650
1664
  this.keyboardMinRangeDisplayed = false;
1651
1665
  },
1652
1666
 
1653
- modalCallback(eventName) {
1654
- console.debug('modal callback', eventName);
1655
- this.$store.commit('changeModalStatus');
1667
+ // a dialog's before-open / closed event: the store knows which dialogs are open (Space is off then)
1668
+ setModalOpen(name, open) {
1669
+ this.$store.commit('setModalOpen', {name, open});
1656
1670
  },
1657
1671
 
1658
1672
  calculateMaxBPM() {
@@ -1673,7 +1687,7 @@ export default {
1673
1687
  this.stopClock();
1674
1688
  }
1675
1689
  revertToDefault = true;
1676
- this.clockWorker.postMessage({action: 'setBPM', bpm: value});
1690
+ this.clockWorker.postMessage({action: 'setBPM', bpm: value, ticksPerBeat: this.config.clockSettings.ticksPerBeat});
1677
1691
  if (revertToDefault) {
1678
1692
  this.startClock();
1679
1693
  }
@@ -1933,8 +1947,8 @@ export default {
1933
1947
  :minHeight="100"
1934
1948
  :resizable="true"
1935
1949
  :scrollable="true"
1936
- @opened="modalCallback"
1937
- @closed="modalCallback">
1950
+ @before-open="setModalOpen('introModal', true)"
1951
+ @closed="setModalOpen('introModal', false)">
1938
1952
  <div class="babyParent">
1939
1953
  <div class="window-header">
1940
1954
  <p class="modalTitle">
@@ -1959,11 +1973,14 @@ export default {
1959
1973
  :WebMIDISupport="WebMIDISupport"
1960
1974
  :activeDevices="activeDevices"
1961
1975
  :activeMidiOutputDevices="activeMidiOutputDevices"
1976
+ :selectedMIDIInput="selectedMIDIDevice"
1977
+ :selectedMIDIOutput="selectedMIDIOutputDevice"
1962
1978
  @button-click="buttonAction"
1963
1979
  @text-change="textFieldChanged"
1964
1980
  @midi-device-change="onMIDIDeviceSelectedChange"
1965
1981
  @midi-output-device-change="onMIDIOutputDeviceSelectedChange"
1966
1982
  @bpm-change="bpmValueChanged"
1983
+ @modal-open="(open) => setModalOpen('settingsModal', open)"
1967
1984
  ></SettingsModal>
1968
1985
 
1969
1986
  <!-- <div v-if="config.gui.pianoRoll.status"> -->
@@ -25,6 +25,8 @@ let ctx = null;
25
25
  let renderLoop = null;
26
26
  // widget changes made while loadAlgorithm runs, for updateParameter once it has finished
27
27
  const pendingChanges = [];
28
+ // page messages (sendToAgent) that arrive while loadAlgorithm runs, for processPageMessage once it has finished
29
+ const pendingPageMessages = [];
28
30
  const missing = (name) => () => console.warn(`agent/hooks.js does not define ${name}()`);
29
31
  const hook = (name, fallback) => (typeof agent[name] === 'function' ? agent[name] : fallback ?? missing(name));
30
32
  const loadExternalFiles = hook('loadExternalFiles', () => undefined);
@@ -37,6 +39,8 @@ const updateParameter = hook('updateParameter', () => undefined);
37
39
  const processClockEvent = hook('processClockEvent', () => ({}));
38
40
  const processNoteEvent = hook('processNoteEvent', () => undefined);
39
41
  const processAudioBuffer = hook('processAudioBuffer', () => undefined);
42
+ const processPageMessage = hook('processPageMessage', () => warnOnce('the page sent the agent a message ' +
43
+ '(sendToAgent), but agent/hooks.js defines no processPageMessage'));
40
44
 
41
45
  import {LIFOQueue} from '../utils/dataStructures.js';
42
46
  import {NoteEvent} from '../utils/NoteEvent.js';
@@ -79,6 +83,8 @@ let uiParameterIntervalID = null;
79
83
  let clockQueue = Promise.resolve();
80
84
  // and note events too
81
85
  let noteQueue = Promise.resolve();
86
+ // and page messages
87
+ let pageQueue = Promise.resolve();
82
88
 
83
89
  self.param_reader = null;
84
90
  self.param_writer = null;
@@ -147,6 +153,22 @@ function runUpdateParameter(change) {
147
153
  }).catch((error) => console.error('updateParameter failed', error));
148
154
  }
149
155
 
156
+ /**
157
+ * Passes a page message (sendToAgent on the page) to processPageMessage, in order; what the hook returns (notes,
158
+ * text, page, ...) is sent like any hook's.
159
+ * @param {*} data
160
+ * @return {Promise<void>}
161
+ */
162
+ function runPageMessage(data) {
163
+ pageQueue = pageQueue.then(async () => {
164
+ const message = defined ?
165
+ toMessage(await processPageMessage(data, ctx), 'processPageMessage', ctx) :
166
+ await processPageMessage(data);
167
+ if (message && Object.keys(message).length) postMessage({hookType: self.agentHookType.NOTE_EVENT, message});
168
+ }).catch((error) => console.error('processPageMessage failed', error));
169
+ return pageQueue;
170
+ }
171
+
150
172
  /**
151
173
  * This function is called regularily to check if there are any
152
174
  * new parameters from the UI.
@@ -264,6 +286,12 @@ async function onMessageFunction(obj) {
264
286
  if (defined) dispatchChange({kind: 'text', id, value});
265
287
  return;
266
288
  }
289
+ if (obj.data?.control === 'pageMessage') {
290
+ // the page's sendToAgent(data): held while a defineAgent agent loads, like widget changes
291
+ if (!ctx || (defined && ctx._loading)) pendingPageMessages.push(obj.data.data);
292
+ else runPageMessage(obj.data.data);
293
+ return;
294
+ }
267
295
  if (obj.data?.control === 'audioInStart') {
268
296
  audioInStart = obj.data.time;
269
297
  return;
@@ -376,6 +404,7 @@ async function initAgent(content) {
376
404
  } else {
377
405
  const externalFilesLoaded = loadExternalFiles();
378
406
  loadAlgorithm(externalFilesLoaded);
407
+ for (const data of pendingPageMessages.splice(0)) runPageMessage(data);
379
408
  initAudio(content);
380
409
  initParameterSharing(content);
381
410
  }
@@ -400,6 +429,7 @@ async function loadDefinedAgent() {
400
429
  if (typeof agent.loadAlgorithm === 'function') await agent.loadAlgorithm(ctx);
401
430
  ctx._loading = false;
402
431
  for (const change of pendingChanges.splice(0)) await runUpdateParameter(change);
432
+ for (const data of pendingPageMessages.splice(0)) runPageMessage(data);
403
433
  if (typeof agent.renderAudio === 'function') {
404
434
  if (ctx.audioOut) {
405
435
  renderLoop = startRenderLoop(agent.renderAudio, ctx, (error) => console.error('renderAudio failed', error));
@@ -1,12 +1,15 @@
1
1
  /* eslint-disable require-jsdoc */
2
2
  let bpm = 100;
3
+ // clockSettings.ticksPerBeat, and clockSettings.clockPeriod (ms, free time: overrides bpm and ticksPerBeat)
4
+ let ticksPerBeat = 4;
5
+ let clockPeriod = null;
3
6
  let isRunning = false;
4
7
  let intervalId = null;
5
8
 
6
9
  function startClock() {
7
10
  if (!isRunning) {
8
11
  isRunning = true;
9
- intervalId = setInterval(tick, 1000 * 60 / bpm / 4);
12
+ intervalId = setInterval(tick, clockPeriod ?? 1000 * 60 / bpm / ticksPerBeat);
10
13
  }
11
14
  }
12
15
 
@@ -26,6 +29,8 @@ self.onmessage = function(msg) {
26
29
  const data = msg.data;
27
30
  if (data.action === 'setBPM') {
28
31
  bpm = data.bpm;
32
+ if (data.ticksPerBeat) ticksPerBeat = data.ticksPerBeat;
33
+ if (data.clockPeriod !== undefined) clockPeriod = data.clockPeriod || null;
29
34
  // if (isRunning) {
30
35
  // stopClock();
31
36
  // startClock();
@@ -14,7 +14,7 @@ const INSTRUMENTS = {piano: instrumentType.PIANO, drums: instrumentType.DRUMS, b
14
14
  const MESSAGE_KEYS = {
15
15
  notes: messageType.NOTE_LIST, text: messageType.TEXT, label: messageType.LABEL, vector: messageType.VECTOR,
16
16
  key: messageType.KEY, session: messageType.SESSION, timingReset: messageType.TIMING_RESET,
17
- status: messageType.STATUS, humanToken: messageType.HUMAN_TOKEN,
17
+ status: messageType.STATUS, humanToken: messageType.HUMAN_TOKEN, page: messageType.PAGE,
18
18
  };
19
19
 
20
20
  // uiParameterType index -> {kind, id}, e.g. 0 -> {kind: 'slider', id: 1}
package/types/agent.d.ts CHANGED
@@ -15,7 +15,7 @@ export declare const messageType: {
15
15
  readonly STATUS: 0; readonly CLOCK_TIME: 1; readonly INFERENCE_TIME: 2; readonly NOTE_LIST: 4;
16
16
  readonly AUDIO_BUFFER: 7; readonly CHROMA_VECTOR: 10; readonly VECTOR: 11; readonly CHORD_LABEL: 12;
17
17
  readonly TEXT: 13; readonly LABEL: 14; readonly KEY: 15; readonly SESSION: 16; readonly TIMING_RESET: 18;
18
- readonly HUMAN_TOKEN: 19;
18
+ readonly HUMAN_TOKEN: 19; readonly PAGE: 20;
19
19
  };
20
20
  export declare const statusType: {
21
21
  readonly LOADED: 0; readonly WARMUP: 1; readonly SUCCESS: 2; readonly ERROR: 3; readonly REPORT: 4;
@@ -98,6 +98,8 @@ export type HookOutput = void | null | undefined | Array<NoteEvent | PlainNote>
98
98
  humanToken?: HumanToken;
99
99
  /** A recorded session for the UI to download. */
100
100
  session?: unknown;
101
+ /** Data for the project's page code: the onAgentMessage listeners of the handle mount() returns. */
102
+ page?: unknown;
101
103
  /** The original protocol: values keyed by messageType number. */
102
104
  [messageType: number]: unknown;
103
105
  };
@@ -260,6 +262,11 @@ export interface AgentHooks {
260
262
  * all). Called whenever less than audioOut.latency seconds are queued, one call at a time.
261
263
  */
262
264
  renderAudio?(frames: number, ctx: Ctx): Awaitable<Float32Array | Float32Array[] | void>;
265
+ /**
266
+ * The project's page code sent `data` (sendToAgent on the handle mount() returns), e.g. from a widget of its
267
+ * own. In order; held until loadAlgorithm has finished. Return `{page: data}` to answer the page.
268
+ */
269
+ processPageMessage?(data: any, ctx: Ctx): Awaitable<HookOutput>;
263
270
  }
264
271
 
265
272
  export declare const AGENT_MARK: unique symbol;
package/types/config.d.ts CHANGED
@@ -241,8 +241,18 @@ export interface WidgetsYaml {
241
241
  */
242
242
  inKey?: boolean;
243
243
  };
244
+ mixer?: {
245
+ /**
246
+ * Show the mixer panel when the page opens (default false: its button opens it).
247
+ */
248
+ open?: boolean;
249
+ };
244
250
  monitor?: {
245
251
  status?: boolean;
252
+ /**
253
+ * Show the monitor panel when the page opens (default false: its button opens it).
254
+ */
255
+ open?: boolean;
246
256
  title?: string;
247
257
  structure?:
248
258
  | {
package/types/index.d.ts CHANGED
@@ -1,8 +1,22 @@
1
1
  /**
2
2
  * euterpe: the UI. A project's index.html calls mount(); the agent comes from agent/ through euterpe/vite.
3
3
  */
4
- /** Mounts the Euterpe UI into `el` (default '#app') and returns the root Vue instance. */
5
- export declare function mount(el?: string | Element): unknown;
4
+ /** Mounts the Euterpe UI into `el` (default '#app'). */
5
+ export declare function mount(el?: string | Element): EuterpeApp;
6
+
7
+ /** What mount() returns: the project's page code talks to the agent through it. */
8
+ export interface EuterpeApp {
9
+ /** The root Vue instance. */
10
+ readonly vm: unknown;
11
+ /**
12
+ * Sends `data` to the agent's processPageMessage(data, ctx) hook, e.g. a press on the page's own widget.
13
+ * `data` must be structured-cloneable (plain objects, arrays, numbers, strings, typed arrays). Messages sent
14
+ * before the agent has loaded wait for it.
15
+ */
16
+ sendToAgent(data: unknown): void;
17
+ /** Calls `listener` with every `{page: data}` the agent sends (a hook's return value or ctx.send). Returns an unsubscribe. */
18
+ onAgentMessage(listener: (data: any) => void): () => void;
19
+ }
6
20
 
7
21
  /** window.__euterpe: the running UI's handle for tests and scripts. */
8
22
  export interface EuterpeHandle {