Shape notes with an Envelope and move numbers with its curve #335
Without an envelope a note set in hard and broke off 10 ms after its end. An instrument now has an Envelope node with attack, decay, sustain and release. On the way of the sound it is the volume. Its output "value" moves a number of the note with the same curve, from "From" to "To", for a filter sweep among others. A new Filter node gives that sweep something to move. - Envelope (instrument only): attack a straight line, decay and release ease out and are 60 dB of the way at their time. A release takes 10 ms at least. Defaults 0.005 / 0.1 / 1 / 0.1. - The end of a note goes to the Envelope instead of the 10 ms fade. A Gate or Loop Sample under it plays on through the release, "Loop, then release" leaves its loop too, and the voice ends with the release. A One-shot under an Envelope of its own plays through. - A note ending before attack and decay releases from the level it has reached. Chrome holds it with cancelAndHoldAtTime, Firefox gets the level worked out from the shape. - New port type "envelope" (coral). It fits only a number input that says it can follow one (PortDef.followsEnvelope): Filter cutoff and resonance, Pitch, Gain, the EQ bands and Mix. Not the Delay, not the Envelope's own numbers. portsMeet() decides it for ConnectCommand, the drag and the drawn edges. - On the audio thread every Envelope of a note is one ConstantSourceNode. A number it moves gets the curve through a WaveShaper, in equal ratios for a cutoff and a gain. A number an Envelope moves is no longer reached by a parameter. - Filter (sound FX and instrument): low-, high- or band-pass, cutoff in Hz, resonance 0 to 1 as a Q. A low- or high-pass is flat at 0 and rises 20 dB at 1, a band-pass goes from Q 0.7 to 20. - Stop sound with "let ring" lets a note with an Envelope fall away in its release. Without it, and with Stop in the tab, it is cut. - The same note again during a release is a voice of its own. - The Envelope's numbers are set when the note starts. They can come from velocity or a parameter, later changes do not bend the note. Tests: - envelope.test.ts (new, 12): curve, ratios, Filter Q, compiling an Envelope, its value into each number, a Delay refused - sound-library +7: attack and decay on the clock, release from the level reached, One-shot held, Stop sound with and without let ring, a cutoff and a pitch moved by the curve - e2e 34: ALL PASSED, 50 checks (+2). Offline in Chrome the curve rendered 0.5 / 1 / 0.5005 at 0.05 / 0.1 / 0.3 s, released at 0.5 s it was 0.0158 at 0.65 s and 0.0005 at 0.8 s, released mid-attack it fell from 0.5 to 0.05 in 0.1 s. A Gain moved 0.25 to 1 was 0.5 halfway. A looped note of 0.2 s with a release of 1 s was gone after 1.32 s. - Five counter-checks (release end, One-shot hold, the follows check, ratios, pitch sum) failed 3, 1, 1, 2 and 2 tests. - Gate on melektaus-atx: CHECK EXIT 0, 245 files, 3812 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fb4d65a2ab
commit
9c71eb1a0d
19 files changed
+1118
-105
No files matched your search
@@ -6,7 +6,7 @@
|
||||
* The device is stubbed – nothing makes a noise in a unit test, and Web Audio does not exist here.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { FxPlan, FxStage, FxStagePlan, FxVoice } from '../../src/core/soundfx/nodes';
|
||||
import type { FxEnvelopePlan, FxPlan, FxStage, FxStagePlan, FxVoice } from '../../src/core/soundfx/nodes';
|
||||
import type { PitchOptions } from '../../src/viewport/pitchWorklet';
|
||||
import { SoundLibrary } from '../../src/viewport/SoundLibrary';
|
||||
|
||||
@@ -307,6 +307,7 @@ function planOf(chain: Chain): FxPlan {
|
||||
const stages = chain.stages.map((stage): FxStagePlan => {
|
||||
if (stage.kind === 'gain') return { kind: 'gain', db: { value: 20 * Math.log10(stage.gain) } };
|
||||
if (stage.kind === 'filter') return { ...stage, gain: { value: stage.gain } };
|
||||
if (stage.kind !== 'delay') throw new Error(`no plan for a ${stage.kind} here`);
|
||||
return {
|
||||
...stage,
|
||||
time: { value: stage.time },
|
||||
@@ -412,6 +413,7 @@ describe('the sound library', () => {
|
||||
stages: 0,
|
||||
duration: 1.5,
|
||||
at: 0,
|
||||
envelopes: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1164,3 +1166,152 @@ describe('notes of an instrument (#331)', () => {
|
||||
expect(library.playNote('instrument:i', [{ pitch: [], stages: [] }], {}, 1, '', 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notes with an Envelope (#335)', () => {
|
||||
const PLANNED: FxEnvelopePlan = {
|
||||
id: 'env',
|
||||
attack: { value: 0.1 },
|
||||
decay: { value: 0.2 },
|
||||
sustain: { value: 0.5 },
|
||||
release: { value: 0.3 },
|
||||
};
|
||||
|
||||
/** A Sample's way, under the Envelope where `shaped`, with more stages after it. */
|
||||
const way = (gate: boolean, shaped = true, stages: FxStagePlan[] = []): FxPlan => ({
|
||||
pitch: [],
|
||||
stages: [...(shaped ? [{ kind: 'envelope' as const, envelope: PLANNED }] : []), ...stages],
|
||||
source: { file: FILE, volume: 0.5, loop: false, loopStart: 0, loopEnd: 0, gate },
|
||||
});
|
||||
|
||||
async function note(library: SoundLibrary, plan: FxPlan, duration = 0): Promise<number> {
|
||||
const voice = library.playNote('instrument:i', [plan], {}, 1, '', duration);
|
||||
await settle();
|
||||
await arrive();
|
||||
return voice;
|
||||
}
|
||||
|
||||
/** What carries the curve: the first silent source made, before any timer. */
|
||||
const driver = () => clocks[0]!;
|
||||
|
||||
it('starts its curve with the note: straight up in the attack, then easing towards the sustain', async () => {
|
||||
const library = new SoundLibrary();
|
||||
await note(library, way(true));
|
||||
expect(library.stats().last?.envelopes).toBe(1);
|
||||
expect(driver().started).toBe(true);
|
||||
expect(driver().offset.ramps).toEqual([['linear', 1, 0.1]]);
|
||||
expect(driver().offset.glides).toEqual([0.5]);
|
||||
// Voice gain, the Sample's volume, and the Envelope's gain, which is all the curve.
|
||||
expect(gains.map((gain) => gain.value)).toEqual([1, 0.5, 0]);
|
||||
expect(wires).toContain('clock > shaper');
|
||||
expect([shapers[0]!.curve![512], shapers[0]!.curve![1024]]).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it('a gated source plays on through the release, and the note ends with it', async () => {
|
||||
const library = new SoundLibrary();
|
||||
const voice = await note(library, way(true));
|
||||
library.release(voice);
|
||||
// Released at 0, in the attack still: from 0, falling to silence in 0.3 s.
|
||||
expect(driver().offset.ramps).toEqual([
|
||||
['linear', 1, 0.1],
|
||||
['linear', 0, 0],
|
||||
]);
|
||||
expect(driver().offset.glides).toEqual([0.5, 0]);
|
||||
expect(gains[1]!.ramps).toEqual([
|
||||
['set', 0.5, 0.3],
|
||||
['linear', 0, 0.31],
|
||||
]);
|
||||
clockTo(0.3);
|
||||
expect(sources[0]!.stopped).toBe(false);
|
||||
expect(library.playing(voice)).toBe(true);
|
||||
clockTo();
|
||||
expect(sources[0]!.stopped).toBe(true);
|
||||
expect(library.playing(voice)).toBe(false);
|
||||
expect(driver().stopped).toBe(true);
|
||||
});
|
||||
|
||||
it('a note ending mid-decay releases from the level its curve has by then', async () => {
|
||||
const library = new SoundLibrary();
|
||||
await note(library, way(true), 0.2);
|
||||
const level = 0.5 + 0.5 * Math.exp((-0.1 * Math.log(1000)) / 0.2);
|
||||
expect(driver().offset.ramps).toEqual([
|
||||
['linear', 1, 0.1],
|
||||
['set', level, 0.2],
|
||||
]);
|
||||
expect(gains[1]!.ramps).toEqual([
|
||||
['set', 0.5, 0.5],
|
||||
['linear', 0, 0.51],
|
||||
]);
|
||||
});
|
||||
|
||||
it('a one-shot under an Envelope alone plays through, its curve held', async () => {
|
||||
const library = new SoundLibrary();
|
||||
const voice = await note(library, way(false));
|
||||
library.release(voice);
|
||||
expect(driver().offset.glides).toEqual([0.5]);
|
||||
clockTo();
|
||||
expect(sources[0]!.stopped).toBe(false);
|
||||
sources[0]!.onended?.();
|
||||
expect(library.playing(voice)).toBe(false);
|
||||
});
|
||||
|
||||
it('Stop sound lets it fall away in its release with "let ring", and cuts it without', async () => {
|
||||
const library = new SoundLibrary();
|
||||
const ringing = await note(library, way(false));
|
||||
library.stopVoice(ringing, true);
|
||||
expect(driver().offset.glides).toEqual([0.5, 0]);
|
||||
expect(gains[1]!.ramps).toEqual([
|
||||
['set', 0.5, 0.3],
|
||||
['linear', 0, 0.31],
|
||||
]);
|
||||
expect(gains[0]!.ramps).toEqual([]);
|
||||
expect(library.stats().stopped).toBe(1);
|
||||
clockTo();
|
||||
expect(library.playing(ringing)).toBe(false);
|
||||
|
||||
const cut = await note(library, way(true));
|
||||
library.stopVoice(cut);
|
||||
expect(gains[3]!.ramps).toEqual([['linear', 0, 0.01]]);
|
||||
expect(library.playing(cut)).toBe(false);
|
||||
});
|
||||
|
||||
it('one that only moves a cutoff ends nothing: the gated source fades with the note as before', async () => {
|
||||
const library = new SoundLibrary();
|
||||
const sweep: FxStagePlan = {
|
||||
kind: 'pass',
|
||||
filter: 'lowpass',
|
||||
cutoff: { value: 200, mod: { envelope: PLANNED, from: { value: 200 }, to: { value: 5000 } } },
|
||||
resonance: { value: 0 },
|
||||
};
|
||||
const voice = await note(library, way(true, false, [sweep]));
|
||||
// The cutoff is all the curve; the Q stays as it is.
|
||||
expect(filters[0]!.frequency.value).toBe(0);
|
||||
expect(filters[0]!.Q.value).toBeCloseTo(20 * Math.log10(Math.SQRT1_2), 9);
|
||||
const curve = shapers[0]!.curve!;
|
||||
expect([curve[0], curve[512], curve[768], curve[1024]]).toEqual([
|
||||
200,
|
||||
200,
|
||||
expect.closeTo(1000, 1),
|
||||
expect.closeTo(5000, 1),
|
||||
]);
|
||||
library.release(voice);
|
||||
expect(gains[1]!.ramps).toEqual([['linear', 0, 0.01]]);
|
||||
expect(driver().offset.glides).toEqual([0.5, 0]);
|
||||
// A parameter does not reach a number an Envelope moves.
|
||||
expect(library.levels('instrument:i')).toEqual([[200, expect.closeTo(-3.01, 2)]]);
|
||||
});
|
||||
|
||||
it('moves a pitch on top of the one its way has, through the worklet', async () => {
|
||||
const library = new SoundLibrary();
|
||||
const plan: FxPlan = {
|
||||
...way(true, false),
|
||||
pitch: [
|
||||
{ value: 3 },
|
||||
{ value: -12, mod: { envelope: PLANNED, from: { value: -12 }, to: { value: 0 } } },
|
||||
],
|
||||
};
|
||||
await note(library, plan);
|
||||
expect(worklets[0]!.pitch.value).toBe(3);
|
||||
expect(wires).toContain('shaper > ?');
|
||||
expect([shapers[0]!.curve![512], shapers[0]!.curve![1024]]).toEqual([-12, 0]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user