euterpe 0.1.1 → 0.1.2
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 +19 -1
- package/cli/index.js +4 -0
- package/cli/smoke.js +17 -3
- package/docs/hosting.md +34 -0
- package/docs/models.md +85 -0
- package/docs/page-code.md +56 -0
- package/docs/testing.md +60 -0
- package/package.json +2 -1
- package/src/agent.js +5 -2
- package/src/components/Settings.vue +25 -7
- package/src/index.js +14 -2
- package/src/store/modules/global-settings.js +9 -4
- package/src/utils/pageBridge.js +54 -0
- package/src/utils/types.js +1 -0
- package/src/views/Euterpe.vue +135 -122
- package/src/worker/agentWorker.js +30 -0
- package/src/worker/clock.js +6 -1
- package/src/worker/context.js +1 -1
- package/types/agent.d.ts +8 -1
- package/types/index.d.ts +16 -2
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.
|
|
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) =>
|
|
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));
|
package/docs/hosting.md
ADDED
|
@@ -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).
|
package/docs/testing.md
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.1.2",
|
|
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
|
],
|
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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:
|
|
249
|
-
selectedMIDIOutputDevice:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
86
|
-
|
|
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
|
+
}
|
package/src/utils/types.js
CHANGED
|
@@ -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
|
};
|
package/src/views/Euterpe.vue
CHANGED
|
@@ -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:
|
|
142
|
-
selectedMIDIOutputDevice:
|
|
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,
|
|
@@ -373,6 +379,8 @@ export default {
|
|
|
373
379
|
sabLiveCanvas: vm.liveCanvas?.sab ?? null,
|
|
374
380
|
},
|
|
375
381
|
});
|
|
382
|
+
// the page code's sendToAgent (mount()'s handle): from now on straight to the worker, after the config
|
|
383
|
+
attachWorker(vm.agent);
|
|
376
384
|
if (audioOut) {
|
|
377
385
|
vm.startAudioOut(audioOut).catch((error) => {
|
|
378
386
|
// e.g. a Content-Security-Policy that blocks the worklet: tell the agent, and the user
|
|
@@ -393,7 +401,8 @@ export default {
|
|
|
393
401
|
// Initialize Clock Worker (module)
|
|
394
402
|
vm.clockWorker = new Worker(new URL('../worker/clock.js', import.meta.url), {type: 'module'});
|
|
395
403
|
vm.clockWorker.onmessage = vm.tickBehavior;
|
|
396
|
-
vm.clockWorker.postMessage({action: 'setBPM', bpm: vm.localBPM
|
|
404
|
+
vm.clockWorker.postMessage({action: 'setBPM', bpm: vm.localBPM, ticksPerBeat: vm.config.clockSettings.ticksPerBeat,
|
|
405
|
+
clockPeriod: vm.config.clockSettings.clockPeriod ?? null});
|
|
397
406
|
vm.$store.commit('initializeClock');
|
|
398
407
|
|
|
399
408
|
if (vm.config.interactionMode.audioMode) {
|
|
@@ -497,16 +506,11 @@ export default {
|
|
|
497
506
|
// .then(vm.onEnabled)
|
|
498
507
|
// .catch((err) => console.debug('WebMIDI Error: ' + err));
|
|
499
508
|
// }
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
await this.initializeMIDI();
|
|
504
|
-
console.debug('MIDI initialized');
|
|
505
|
-
vm.permissionMIDI = true;
|
|
506
|
-
}
|
|
507
|
-
} catch (error) {
|
|
508
|
-
console.error('Error initializing MIDI: ', error);
|
|
509
|
+
// MIDI devices are optional: without Web MIDI (Safari, a denied permission) the page still starts
|
|
510
|
+
if (navigator.requestMIDIAccess) {
|
|
511
|
+
await this.initializeMIDI(); // never throws
|
|
509
512
|
}
|
|
513
|
+
vm.permissionMIDI = true;
|
|
510
514
|
|
|
511
515
|
/*
|
|
512
516
|
* Initialize computer keyboard logic
|
|
@@ -586,18 +590,20 @@ export default {
|
|
|
586
590
|
const vm = this;
|
|
587
591
|
if (vm.$store.getters.getClockStatus) {
|
|
588
592
|
vm.$store.commit('incrementTick');
|
|
593
|
+
// The side effects of a tick (the live canvas, the metronome and its MIDI out, the agent's
|
|
594
|
+
// sampler) run guarded: a throw in one of them must never stop the tick reaching the agent.
|
|
589
595
|
// the live canvas plays the user's note for this tick before the quantizer runs
|
|
590
|
-
vm.liveCanvas?.onTick(vm.$store.getters.getGlobalTick);
|
|
596
|
+
vm.guardedTickStep('liveCanvas', () => vm.liveCanvas?.onTick(vm.$store.getters.getGlobalTick));
|
|
591
597
|
|
|
592
598
|
// Trigger the metronome only if there is a "metronome" entry in config_players.yaml
|
|
593
599
|
if (vm.config.players.metronome) {
|
|
594
|
-
vm.metronomeTrigger();
|
|
600
|
+
vm.guardedTickStep('metronome', () => vm.metronomeTrigger());
|
|
595
601
|
}
|
|
596
602
|
|
|
597
|
-
vm.calculateMaxBPM();
|
|
603
|
+
vm.guardedTickStep('calculateMaxBPM', () => vm.calculateMaxBPM());
|
|
598
604
|
|
|
599
605
|
// in grid-based mode, the agent's sampler is triggered in sync with the clock
|
|
600
|
-
vm.triggerAgentSamplerSync();
|
|
606
|
+
vm.guardedTickStep('agentSampler', () => vm.triggerAgentSamplerSync());
|
|
601
607
|
|
|
602
608
|
|
|
603
609
|
// run the agent with a small delay of tick/4 in order to include
|
|
@@ -628,9 +634,23 @@ export default {
|
|
|
628
634
|
}
|
|
629
635
|
},
|
|
630
636
|
|
|
637
|
+
// runs one step of a tick; a throw is logged (the first time per step) instead of ending the tick
|
|
638
|
+
guardedTickStep(name, step) {
|
|
639
|
+
try {
|
|
640
|
+
step();
|
|
641
|
+
} catch (error) {
|
|
642
|
+
this.tickStepErrors ??= new Set();
|
|
643
|
+
if (!this.tickStepErrors.has(name)) {
|
|
644
|
+
this.tickStepErrors.add(name);
|
|
645
|
+
console.error(`[euterpe] ${name} failed on a tick (the tick still reached the agent)`, error);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
},
|
|
649
|
+
|
|
631
650
|
async toggleClock() {
|
|
632
651
|
const vm = this;
|
|
633
652
|
if (!vm.localSyncClockStatus) {
|
|
653
|
+
vm.clearUserInput();
|
|
634
654
|
vm.startRecording();
|
|
635
655
|
vm.clockWorker.postMessage({action: 'startClock'});
|
|
636
656
|
} else {
|
|
@@ -653,7 +673,14 @@ export default {
|
|
|
653
673
|
this.$store.commit('clearPianoState');
|
|
654
674
|
},
|
|
655
675
|
|
|
676
|
+
// Notes played while the clock was stopped must not reach the first ticks of the take
|
|
677
|
+
clearUserInput() {
|
|
678
|
+
this.$store.commit('clearContinuousBuffers');
|
|
679
|
+
this.noteOffEventForNextTick = null;
|
|
680
|
+
},
|
|
681
|
+
|
|
656
682
|
startClock() {
|
|
683
|
+
this.clearUserInput();
|
|
657
684
|
this.startRecording();
|
|
658
685
|
this.clockWorker.postMessage({action: 'startClock'});
|
|
659
686
|
this.localSyncClockStatus = true;
|
|
@@ -1106,6 +1133,10 @@ export default {
|
|
|
1106
1133
|
// the live canvas: the user's note for the next tick
|
|
1107
1134
|
vm.liveCanvas?.onHumanToken(messageValue);
|
|
1108
1135
|
break;
|
|
1136
|
+
case vm.messageType.PAGE:
|
|
1137
|
+
// {page: data}: for the project's page code (mount()'s onAgentMessage)
|
|
1138
|
+
deliverToPage(messageValue);
|
|
1139
|
+
break;
|
|
1109
1140
|
case vm.messageType.CLOCK_TIME:
|
|
1110
1141
|
break;
|
|
1111
1142
|
case vm.messageType.INFERENCE_TIME:
|
|
@@ -1315,90 +1346,72 @@ export default {
|
|
|
1315
1346
|
*/
|
|
1316
1347
|
async initializeMIDI() {
|
|
1317
1348
|
try {
|
|
1318
|
-
const access = await navigator.requestMIDIAccess();
|
|
1319
|
-
this.WebMIDISupport = true;
|
|
1320
|
-
|
|
1321
|
-
access.onstatechange = (event) => {
|
|
1322
|
-
this.onMidiStateChange(event);
|
|
1323
|
-
};
|
|
1324
|
-
|
|
1325
1349
|
await WebMidi.enable();
|
|
1326
|
-
this.
|
|
1350
|
+
this.WebMIDISupport = true;
|
|
1351
|
+
// a device plugged in (or out) later: update the menus, and pick up a keyboard if none is chosen
|
|
1352
|
+
WebMidi.addListener('portschanged', () => this.refreshMIDIDevices());
|
|
1353
|
+
this.refreshMIDIDevices();
|
|
1327
1354
|
} catch (err) {
|
|
1328
|
-
|
|
1355
|
+
// permission denied, a headless browser, or no Web MIDI (Safari): MIDI devices are off,
|
|
1356
|
+
// the laptop keyboard and the on-screen keyboard still work
|
|
1357
|
+
console.warn(`[euterpe] Web MIDI is not available (${err?.name ?? err}): MIDI devices are off`);
|
|
1329
1358
|
}
|
|
1330
1359
|
},
|
|
1331
1360
|
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1361
|
+
// the device menus from the connected devices. MIDI input: the device the user chose in Settings
|
|
1362
|
+
// (when it is connected), or, until the user chooses one, the first connected input.
|
|
1363
|
+
refreshMIDIDevices() {
|
|
1364
|
+
const vm = this;
|
|
1365
|
+
vm.activeDevices = [{id: NO_MIDI_DEVICE, name: 'None'},
|
|
1366
|
+
...WebMidi.inputs.map((device) => ({id: device.id, name: device.name}))];
|
|
1367
|
+
vm.activeMidiOutputDevices = [{id: NO_MIDI_DEVICE, name: 'None'},
|
|
1368
|
+
...WebMidi.outputs.map((device) => ({id: device.id, name: device.name}))];
|
|
1369
|
+
|
|
1370
|
+
const inputIds = WebMidi.inputs.map((device) => device.id);
|
|
1371
|
+
const choice = vm.midiInputChoice;
|
|
1372
|
+
let input;
|
|
1373
|
+
if (choice === null) input = inputIds[0] ?? NO_MIDI_DEVICE;
|
|
1374
|
+
else input = inputIds.includes(choice) ? choice : NO_MIDI_DEVICE;
|
|
1375
|
+
vm.selectMIDIInput(input);
|
|
1376
|
+
|
|
1377
|
+
if (vm.selectedMIDIOutputDevice != NO_MIDI_DEVICE &&
|
|
1378
|
+
!WebMidi.outputs.some((device) => device.id === vm.selectedMIDIOutputDevice)) {
|
|
1379
|
+
vm.selectedMIDIOutputDevice = NO_MIDI_DEVICE;
|
|
1337
1380
|
}
|
|
1338
|
-
// this.onEnabled();
|
|
1339
|
-
// Find a non-buggy way to correctly update the MIDI devices
|
|
1340
1381
|
},
|
|
1341
1382
|
|
|
1342
|
-
|
|
1383
|
+
// listen to this MIDI input (an id, or NO_MIDI_DEVICE for none), and stop listening to the previous one
|
|
1384
|
+
selectMIDIInput(id) {
|
|
1343
1385
|
const vm = this;
|
|
1344
|
-
|
|
1345
|
-
vm.
|
|
1346
|
-
|
|
1347
|
-
vm.
|
|
1348
|
-
}
|
|
1349
|
-
vm.selectedMIDIDevice =
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
});
|
|
1356
|
-
vm.selectedMIDIOutputDevice = vm.activeMidiOutputDevices[0].id;
|
|
1357
|
-
|
|
1386
|
+
const device = id == NO_MIDI_DEVICE ? null : WebMidi.getInputById(id);
|
|
1387
|
+
if (vm.midiInputPort && vm.midiInputPort !== device) {
|
|
1388
|
+
vm.midiInputPort.removeListener();
|
|
1389
|
+
vm.midiInputPort = null;
|
|
1390
|
+
}
|
|
1391
|
+
vm.selectedMIDIDevice = device ? id : NO_MIDI_DEVICE;
|
|
1392
|
+
if (device && vm.midiInputPort !== device) {
|
|
1393
|
+
vm.midiInputPort = device;
|
|
1394
|
+
vm.messageListener(device);
|
|
1395
|
+
console.debug(`MIDI input: ${device.name}`);
|
|
1396
|
+
}
|
|
1358
1397
|
},
|
|
1359
1398
|
|
|
1399
|
+
// the user chose a MIDI input in Settings: keep it (also when devices change), never auto-select over it
|
|
1360
1400
|
onMIDIDeviceSelectedChange(state) {
|
|
1361
|
-
const
|
|
1362
|
-
if (
|
|
1363
|
-
|
|
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
|
-
}
|
|
1401
|
+
const value = state.currentTarget.value;
|
|
1402
|
+
if (!value) return;
|
|
1403
|
+
this.midiInputChoice = value == NO_MIDI_DEVICE ? NO_MIDI_DEVICE : value;
|
|
1404
|
+
if (WebMidi.enabled) this.selectMIDIInput(this.midiInputChoice);
|
|
1382
1405
|
},
|
|
1383
1406
|
onMIDIOutputDeviceSelectedChange(state) {
|
|
1384
|
-
const
|
|
1385
|
-
if (
|
|
1386
|
-
|
|
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
|
-
}
|
|
1407
|
+
const value = state.currentTarget.value;
|
|
1408
|
+
if (!value) return;
|
|
1409
|
+
this.selectedMIDIOutputDevice = value == NO_MIDI_DEVICE ? NO_MIDI_DEVICE : value;
|
|
1396
1410
|
},
|
|
1397
1411
|
|
|
1398
1412
|
// listens for midi messages from a midi keyboard
|
|
1399
|
-
messageListener() {
|
|
1413
|
+
messageListener(inputDevice) {
|
|
1400
1414
|
const vm = this;
|
|
1401
|
-
const inputDevice = WebMidi.getInputById(this.selectedMIDIDevice);
|
|
1402
1415
|
inputDevice.addListener('noteon', (message) => {
|
|
1403
1416
|
const newNoteEvent = new NoteEvent();
|
|
1404
1417
|
newNoteEvent.player = vm.playerType.HUMAN;
|
|
@@ -1446,54 +1459,51 @@ export default {
|
|
|
1446
1459
|
});
|
|
1447
1460
|
},
|
|
1448
1461
|
|
|
1462
|
+
/*
|
|
1463
|
+
* MIDI out. Every path goes through withMIDIOutput: without Web MIDI (permission denied, a headless
|
|
1464
|
+
* browser, Safari) or without a selected output device it does nothing, and a failure to
|
|
1465
|
+
* send is logged once, never thrown (the metronome and the agent's notes call it on the clock's path).
|
|
1466
|
+
*/
|
|
1467
|
+
withMIDIOutput(send) {
|
|
1468
|
+
if (!WebMidi.enabled || this.selectedMIDIOutputDevice == NO_MIDI_DEVICE) return;
|
|
1469
|
+
try {
|
|
1470
|
+
const outputDevice = WebMidi.getOutputById(this.selectedMIDIOutputDevice);
|
|
1471
|
+
if (outputDevice) send(outputDevice);
|
|
1472
|
+
} catch (error) {
|
|
1473
|
+
if (!this.midiOutputWarned) {
|
|
1474
|
+
this.midiOutputWarned = true;
|
|
1475
|
+
console.warn('[euterpe] Web MIDI output failed; MIDI out is skipped', error);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
},
|
|
1479
|
+
|
|
1449
1480
|
// Method to send MIDI messages (for example, a Note On message)
|
|
1450
1481
|
sendNoteOn(noteEvent, channel = 1) {
|
|
1451
|
-
|
|
1452
|
-
if (outputDevice) {
|
|
1453
|
-
// Note On message is sent as [0x90 + channel, noteNumber, velocity]
|
|
1454
|
-
// outputDevice.send([0x90, noteNumber, velocity]);
|
|
1482
|
+
this.withMIDIOutput((outputDevice) => {
|
|
1455
1483
|
outputDevice.playNote(noteEvent.midi, {channels: [channel]});
|
|
1456
|
-
|
|
1457
|
-
// } else {
|
|
1458
|
-
// console.error('No output device selected.');
|
|
1459
|
-
}
|
|
1484
|
+
});
|
|
1460
1485
|
},
|
|
1461
1486
|
|
|
1462
1487
|
sendNoteOnOff(noteEvent, channel = 1, duration = 1000) {
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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
|
-
}
|
|
1488
|
+
this.withMIDIOutput((outputDevice) => {
|
|
1489
|
+
outputDevice.playNote(noteEvent.midi || noteEvent.name, {channels: [channel], duration: duration});
|
|
1490
|
+
});
|
|
1475
1491
|
},
|
|
1476
1492
|
|
|
1477
1493
|
// Method to send MIDI messages (for example, a Note Off message)
|
|
1478
|
-
sendNoteOff(noteEvent, channel = 1, after =
|
|
1479
|
-
|
|
1480
|
-
if (outputDevice) {
|
|
1481
|
-
// Note Off message is sent as [0x80 + channel, noteNumber, 0]
|
|
1494
|
+
sendNoteOff(noteEvent, channel = 1, after = '+0') {
|
|
1495
|
+
this.withMIDIOutput((outputDevice) => {
|
|
1482
1496
|
outputDevice.stopNote(noteEvent.midi, {time: after, channels: [channel]});
|
|
1483
|
-
|
|
1484
|
-
// } else {
|
|
1485
|
-
// console.error('No output device selected.');
|
|
1486
|
-
}
|
|
1497
|
+
});
|
|
1487
1498
|
},
|
|
1488
1499
|
|
|
1489
1500
|
// kill all notes on the external midi device
|
|
1490
1501
|
killAllNotes() {
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
}
|
|
1496
|
-
console.debug('Killed all notes on the external midi device');
|
|
1502
|
+
this.withMIDIOutput((outputDevice) => {
|
|
1503
|
+
for (let i = 0; i < 128; i++) {
|
|
1504
|
+
outputDevice.stopNote(i, {time: '+200', channels: [1]});
|
|
1505
|
+
}
|
|
1506
|
+
});
|
|
1497
1507
|
},
|
|
1498
1508
|
|
|
1499
1509
|
startRecording() {
|
|
@@ -1650,9 +1660,9 @@ export default {
|
|
|
1650
1660
|
this.keyboardMinRangeDisplayed = false;
|
|
1651
1661
|
},
|
|
1652
1662
|
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
this.$store.commit('
|
|
1663
|
+
// a dialog's before-open / closed event: the store knows which dialogs are open (Space is off then)
|
|
1664
|
+
setModalOpen(name, open) {
|
|
1665
|
+
this.$store.commit('setModalOpen', {name, open});
|
|
1656
1666
|
},
|
|
1657
1667
|
|
|
1658
1668
|
calculateMaxBPM() {
|
|
@@ -1673,7 +1683,7 @@ export default {
|
|
|
1673
1683
|
this.stopClock();
|
|
1674
1684
|
}
|
|
1675
1685
|
revertToDefault = true;
|
|
1676
|
-
this.clockWorker.postMessage({action: 'setBPM', bpm: value});
|
|
1686
|
+
this.clockWorker.postMessage({action: 'setBPM', bpm: value, ticksPerBeat: this.config.clockSettings.ticksPerBeat});
|
|
1677
1687
|
if (revertToDefault) {
|
|
1678
1688
|
this.startClock();
|
|
1679
1689
|
}
|
|
@@ -1933,8 +1943,8 @@ export default {
|
|
|
1933
1943
|
:minHeight="100"
|
|
1934
1944
|
:resizable="true"
|
|
1935
1945
|
:scrollable="true"
|
|
1936
|
-
@
|
|
1937
|
-
@closed="
|
|
1946
|
+
@before-open="setModalOpen('introModal', true)"
|
|
1947
|
+
@closed="setModalOpen('introModal', false)">
|
|
1938
1948
|
<div class="babyParent">
|
|
1939
1949
|
<div class="window-header">
|
|
1940
1950
|
<p class="modalTitle">
|
|
@@ -1959,11 +1969,14 @@ export default {
|
|
|
1959
1969
|
:WebMIDISupport="WebMIDISupport"
|
|
1960
1970
|
:activeDevices="activeDevices"
|
|
1961
1971
|
:activeMidiOutputDevices="activeMidiOutputDevices"
|
|
1972
|
+
:selectedMIDIInput="selectedMIDIDevice"
|
|
1973
|
+
:selectedMIDIOutput="selectedMIDIOutputDevice"
|
|
1962
1974
|
@button-click="buttonAction"
|
|
1963
1975
|
@text-change="textFieldChanged"
|
|
1964
1976
|
@midi-device-change="onMIDIDeviceSelectedChange"
|
|
1965
1977
|
@midi-output-device-change="onMIDIOutputDeviceSelectedChange"
|
|
1966
1978
|
@bpm-change="bpmValueChanged"
|
|
1979
|
+
@modal-open="(open) => setModalOpen('settingsModal', open)"
|
|
1967
1980
|
></SettingsModal>
|
|
1968
1981
|
|
|
1969
1982
|
<!-- <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));
|
package/src/worker/clock.js
CHANGED
|
@@ -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 /
|
|
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();
|
package/src/worker/context.js
CHANGED
|
@@ -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/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')
|
|
5
|
-
export declare function mount(el?: string | Element):
|
|
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 {
|