@grame/faustwasm 0.18.0 → 0.18.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 +28 -1
- package/assets/standalone/faustwasm/index.d.ts +22 -5
- package/assets/standalone/faustwasm/index.js +35 -5
- package/assets/standalone/faustwasm/index.js.map +2 -2
- package/dist/cjs/index.d.ts +22 -5
- package/dist/cjs/index.js +35 -5
- package/dist/cjs/index.js.map +2 -2
- package/dist/cjs-bundle/index.d.ts +22 -5
- package/dist/cjs-bundle/index.js +36 -6
- package/dist/cjs-bundle/index.js.map +2 -2
- package/dist/esm/index.d.ts +22 -5
- package/dist/esm/index.js +35 -5
- package/dist/esm/index.js.map +2 -2
- package/dist/esm-bundle/index.d.ts +22 -5
- package/dist/esm-bundle/index.js +36 -6
- package/dist/esm-bundle/index.js.map +2 -2
- package/libfaust-wasm/libfaust-wasm.wasm +0 -0
- package/package.json +1 -1
- package/src/FaustWebAudioDsp.ts +41 -5
- package/test/faustlive-wasm/faustwasm/index.d.ts +22 -5
- package/test/faustlive-wasm/faustwasm/index.js +35 -5
- package/test/faustlive-wasm/faustwasm/index.js.map +2 -2
- package/test/unit/voice-steal-fade.test.mjs +139 -0
- package/test/web/poly-schedule.html +97 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hand-over on a stolen voice fades in as well as out.
|
|
3
|
+
*
|
|
4
|
+
* `renderStolenVoices` plays the outgoing note over the first half of the
|
|
5
|
+
* block and fades that half out through the mixer. The new note then plays
|
|
6
|
+
* the second half from the DSP's live state -- `keyOn` does not reset a
|
|
7
|
+
* voice, and an envelope that never reached silence is still up -- so that
|
|
8
|
+
* half has to be faded in too, or every stolen note starts with a step.
|
|
9
|
+
*/
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { FaustPolyWebAudioDsp } from '../../dist/esm/index.js';
|
|
13
|
+
|
|
14
|
+
const BLOCK = 128;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A polyphonic DSP whose single voice renders a constant 1.
|
|
18
|
+
*
|
|
19
|
+
* The mixer is inert -- `fadeOut` is the wasm side's business -- so what
|
|
20
|
+
* ends up in the mixing buffer is the voice's output shaped only by the
|
|
21
|
+
* JavaScript side of the hand-over.
|
|
22
|
+
*/
|
|
23
|
+
function polyDsp(block = BLOCK) {
|
|
24
|
+
const memory = new WebAssembly.Memory({ initial: 1 });
|
|
25
|
+
const meta = {
|
|
26
|
+
name: 'probe',
|
|
27
|
+
inputs: 0,
|
|
28
|
+
outputs: 1,
|
|
29
|
+
size: 8,
|
|
30
|
+
ui: [
|
|
31
|
+
{
|
|
32
|
+
type: 'vgroup',
|
|
33
|
+
label: 'probe',
|
|
34
|
+
items: [
|
|
35
|
+
{ type: 'hslider', label: 'freq', address: '/probe/freq', index: 0, init: 440, min: 20, max: 2000, step: 1 },
|
|
36
|
+
{ type: 'hslider', label: 'gain', address: '/probe/gain', index: 4, init: 0.5, min: 0, max: 1, step: 0.01 },
|
|
37
|
+
{ type: 'button', label: 'gate', address: '/probe/gate', index: 8 }
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
]
|
|
41
|
+
};
|
|
42
|
+
const voiceAPI = {
|
|
43
|
+
init: () => {},
|
|
44
|
+
instanceInit: () => {},
|
|
45
|
+
instanceClear: () => {},
|
|
46
|
+
instanceConstants: () => {},
|
|
47
|
+
instanceResetUserInterface: () => {},
|
|
48
|
+
setParamValue: () => {},
|
|
49
|
+
getParamValue: () => 0,
|
|
50
|
+
compute: (dsp, count, $inputs, $outputs) => {
|
|
51
|
+
const HEAP32 = new Int32Array(memory.buffer);
|
|
52
|
+
const HEAPF = new Float32Array(memory.buffer);
|
|
53
|
+
const start = HEAP32[$outputs >> 2] >> 2;
|
|
54
|
+
for (let i = 0; i < count; i++) HEAPF[start + i] = 1;
|
|
55
|
+
},
|
|
56
|
+
getNumInputs: () => 0,
|
|
57
|
+
getNumOutputs: () => 1
|
|
58
|
+
};
|
|
59
|
+
const instance = {
|
|
60
|
+
memory,
|
|
61
|
+
voices: 1,
|
|
62
|
+
voiceAPI,
|
|
63
|
+
effectAPI: undefined,
|
|
64
|
+
effectJSON: undefined,
|
|
65
|
+
mixerAPI: {
|
|
66
|
+
clearOutput: () => {},
|
|
67
|
+
mixCheckVoice: () => 1,
|
|
68
|
+
fadeOut: () => {}
|
|
69
|
+
},
|
|
70
|
+
voiceJSON: JSON.stringify(meta)
|
|
71
|
+
};
|
|
72
|
+
const dsp = new FaustPolyWebAudioDsp(instance, 48000, 4, block, []);
|
|
73
|
+
return { dsp, memory };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The mixing buffer of channel 0 after a block. */
|
|
77
|
+
function mixing(dsp, memory, block = BLOCK) {
|
|
78
|
+
const HEAP32 = new Int32Array(memory.buffer);
|
|
79
|
+
const HEAPF = new Float32Array(memory.buffer);
|
|
80
|
+
const start = HEAP32[dsp.fAudioMixing >> 2] >> 2;
|
|
81
|
+
return Array.from(HEAPF.subarray(start, start + block));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
test('the second half of a stolen block fades in from 1/count', () => {
|
|
85
|
+
const { dsp, memory } = polyDsp();
|
|
86
|
+
dsp.start();
|
|
87
|
+
const out = [new Float32Array(BLOCK)];
|
|
88
|
+
dsp.keyOn(0, 60, 100);
|
|
89
|
+
dsp.compute([], out);
|
|
90
|
+
// The pool has one voice, so the next note steals it.
|
|
91
|
+
dsp.keyOn(0, 67, 100);
|
|
92
|
+
dsp.compute([], out);
|
|
93
|
+
|
|
94
|
+
const half = BLOCK >> 1;
|
|
95
|
+
const buf = mixing(dsp, memory);
|
|
96
|
+
// First half: the outgoing note, left to the mixer's fadeOut (inert here).
|
|
97
|
+
assert.ok(buf.slice(0, half).every((v) => v === 1), 'first half untouched by the JS side');
|
|
98
|
+
// Second half: a ramp from 1/64 to 1, the mirror of fadeOut.
|
|
99
|
+
const ramp = buf.slice(half);
|
|
100
|
+
assert.equal(ramp.length, BLOCK - half);
|
|
101
|
+
assert.ok(Math.abs(ramp[0] - 1 / ramp.length) < 1e-6, `starts at 1/count, got ${ramp[0]}`);
|
|
102
|
+
assert.equal(ramp[ramp.length - 1], 1, 'ends at 1');
|
|
103
|
+
for (let i = 1; i < ramp.length; i++) {
|
|
104
|
+
assert.ok(ramp[i] > ramp[i - 1], `rises at frame ${i}`);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('an odd block gives the extra frame to the second half, and the ramp covers it', () => {
|
|
109
|
+
const block = 127;
|
|
110
|
+
const { dsp, memory } = polyDsp(block);
|
|
111
|
+
dsp.start();
|
|
112
|
+
const out = [new Float32Array(block)];
|
|
113
|
+
dsp.keyOn(0, 60, 100);
|
|
114
|
+
dsp.compute([], out);
|
|
115
|
+
dsp.keyOn(0, 67, 100);
|
|
116
|
+
dsp.compute([], out);
|
|
117
|
+
|
|
118
|
+
// computeLegato splits at count >> 1 = 63 and renders the remaining 64
|
|
119
|
+
// frames as the second half; the fade-in is measured from the split.
|
|
120
|
+
const half = block >> 1;
|
|
121
|
+
const buf = mixing(dsp, memory, block);
|
|
122
|
+
assert.ok(buf.slice(0, half).every((v) => v === 1), 'first half untouched by the JS side');
|
|
123
|
+
const ramp = buf.slice(half);
|
|
124
|
+
assert.equal(ramp.length, block - half);
|
|
125
|
+
assert.ok(Math.abs(ramp[0] - 1 / ramp.length) < 1e-6, `starts at 1/count, got ${ramp[0]}`);
|
|
126
|
+
assert.equal(ramp[ramp.length - 1], 1, 'ends at 1 on the last frame of the block');
|
|
127
|
+
for (let i = 1; i < ramp.length; i++) {
|
|
128
|
+
assert.ok(ramp[i] > ramp[i - 1], `rises at frame ${i}`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('a note on a free voice is not faded', () => {
|
|
133
|
+
const { dsp, memory } = polyDsp();
|
|
134
|
+
dsp.start();
|
|
135
|
+
const out = [new Float32Array(BLOCK)];
|
|
136
|
+
dsp.keyOn(0, 60, 100);
|
|
137
|
+
dsp.compute([], out);
|
|
138
|
+
assert.ok(mixing(dsp, memory).every((v) => v === 1), 'no ramp on a fresh voice');
|
|
139
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<title>Sample-accurate scheduling test</title>
|
|
6
|
+
<script src="../../dist/cjs-bundle/index.js"></script>
|
|
7
|
+
</head>
|
|
8
|
+
<body style="font-family: sans-serif; margin: 1.5rem">
|
|
9
|
+
<h1>Sample-accurate scheduling</h1>
|
|
10
|
+
<p>
|
|
11
|
+
Compares the same arpeggio played twice: first scheduled with
|
|
12
|
+
<code>setTimeout</code> (the pre-timestamp way, jitter of up to a
|
|
13
|
+
block plus whatever the main thread is doing), then with
|
|
14
|
+
<code>keyOn(channel, pitch, velocity, time)</code> against the
|
|
15
|
+
AudioContext clock (each note lands on its exact sample). A gate
|
|
16
|
+
retrigger inside a single block, inaudible before, closes the demo.
|
|
17
|
+
</p>
|
|
18
|
+
<button id="start">Start</button>
|
|
19
|
+
<pre id="log" style="background: #f6f6f6; padding: 1rem; border-radius: 4px"></pre>
|
|
20
|
+
</body>
|
|
21
|
+
<script>
|
|
22
|
+
const polycode = `
|
|
23
|
+
import("stdfaust.lib");
|
|
24
|
+
process = ba.pulsen(1, ba.hz2midikey(freq) * 1000) : pm.marimba(freq, 0, 7000, 0.5, 0.8) * gate * gain with {
|
|
25
|
+
freq = hslider("freq", 440, 40, 8000, 1);
|
|
26
|
+
gain = hslider("gain", 0.5, 0, 1, 0.01);
|
|
27
|
+
gate = button("gate");
|
|
28
|
+
};
|
|
29
|
+
effect = dm.freeverb_demo;`;
|
|
30
|
+
|
|
31
|
+
const PATTERN = [60, 64, 67, 71, 72, 71, 67, 64];
|
|
32
|
+
const STEP = 0.125; // seconds between notes
|
|
33
|
+
const log = (msg) => {
|
|
34
|
+
document.getElementById("log").textContent += msg + "\n";
|
|
35
|
+
console.log(msg);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// The old way: the main thread fires each note when its timer
|
|
39
|
+
// happens to run.
|
|
40
|
+
const playWithTimeouts = (node) => {
|
|
41
|
+
log("Pattern 1: setTimeout scheduling (jitter audible on a busy page)");
|
|
42
|
+
PATTERN.forEach((pitch, i) => {
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
node.keyOn(0, pitch, 100);
|
|
45
|
+
setTimeout(() => node.keyOff(0, pitch, 0), STEP * 900);
|
|
46
|
+
}, i * STEP * 1000);
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// The new way: everything is scheduled up front against the
|
|
51
|
+
// AudioContext clock, and each note lands on its exact sample.
|
|
52
|
+
const playWithTimestamps = (node, audioCtx) => {
|
|
53
|
+
log("Pattern 2: keyOn(..., time) scheduling (sample-accurate)");
|
|
54
|
+
const t0 = audioCtx.currentTime + 0.1;
|
|
55
|
+
PATTERN.forEach((pitch, i) => {
|
|
56
|
+
node.keyOn(0, pitch, 100, t0 + i * STEP);
|
|
57
|
+
node.keyOff(0, pitch, 0, t0 + (i + 0.9) * STEP);
|
|
58
|
+
});
|
|
59
|
+
return t0 + PATTERN.length * STEP;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// A 1 -> 0 -> 1 gate retrigger inside one 128-frame block: both
|
|
63
|
+
// edges land, so the note audibly restarts. Before timestamps the
|
|
64
|
+
// whole excursion was lost.
|
|
65
|
+
const retriggerInsideOneBlock = (node, audioCtx) => {
|
|
66
|
+
const gatePath = node.getParams().find((p) => p.endsWith("/gate"));
|
|
67
|
+
log(`Retrigger: ${gatePath} 1 -> 0 -> 1 within a single block`);
|
|
68
|
+
const t0 = audioCtx.currentTime + 0.1;
|
|
69
|
+
const frame = 1 / audioCtx.sampleRate;
|
|
70
|
+
node.keyOn(0, 55, 100, t0);
|
|
71
|
+
node.keyOff(0, 55, 0, t0 + 1.0);
|
|
72
|
+
node.setParamValue(gatePath, 0, t0 + 0.5);
|
|
73
|
+
node.setParamValue(gatePath, 1, t0 + 0.5 + 32 * frame);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const { instantiateFaustModule, LibFaust, FaustCompiler, FaustPolyDspGenerator } = faustwasm;
|
|
77
|
+
document.getElementById("start").onclick = async () => {
|
|
78
|
+
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
79
|
+
await audioCtx.resume();
|
|
80
|
+
const faustModule = await instantiateFaustModule();
|
|
81
|
+
const libFaust = new LibFaust(faustModule);
|
|
82
|
+
const compiler = new FaustCompiler(libFaust);
|
|
83
|
+
const generator = new FaustPolyDspGenerator();
|
|
84
|
+
await generator.compile(compiler, "dsp", polycode, "");
|
|
85
|
+
const node = await generator.createNode(audioCtx, 4);
|
|
86
|
+
node.connect(audioCtx.destination);
|
|
87
|
+
log("Node ready");
|
|
88
|
+
|
|
89
|
+
playWithTimeouts(node);
|
|
90
|
+
setTimeout(() => {
|
|
91
|
+
const end = playWithTimestamps(node, audioCtx);
|
|
92
|
+
const msLeft = (end - audioCtx.currentTime) * 1000 + 500;
|
|
93
|
+
setTimeout(() => retriggerInsideOneBlock(node, audioCtx), msLeft);
|
|
94
|
+
}, PATTERN.length * STEP * 1000 + 500);
|
|
95
|
+
};
|
|
96
|
+
</script>
|
|
97
|
+
</html>
|