Send a track, a pattern item or a whole song through a sound FX (#346)
A sound FX could only be put on a single sound. Now a track, a pattern item, a Play pattern and a Play song send everything they play through one chain of it, as an insert in a DAW. A Play note gets one of its own. - Track: an FX box in the head next to M and S opens a menu of the project's sound FX, and the track's inspector has the row too. Order: items, track FX, volume, pan. - Pattern item: Sound FX in its inspector. Its notes play on a bus of their own into the track's bus. - Play pattern and Play song take fx and fx params. The chain sits on the master of the playing, after the tracks and before the level and the fades. Set sound FX parameters reaches it by the playback. - Play note takes fx and fx params next to the instrument's params. The FX parameters go by "fx:" so the two sets stay apart. Set sound FX parameters on a note changes a name both have in both. - A Pitch on a bus is a new PitchShifter: the phase vocoder of the PitchPlayer, fed while it plays. It cannot look ahead and is exactly one frame late at every pitch, 2048 samples. - That latency is made up. The other way of a Mix is held back by a delay of the same length. Every note and clip starts as much earlier as the buses it passes are late. The scheduler plans that much further ahead, and the first note waits that much after Play. - The Song tab waits for the chains before it plays and lets its buses go a moment after Stop. A playing in a game lets them go after its echoes and what the shifters still hold. - songBuses moved to its own file. Kept in song.ts it pulled the FX compiler into the node registry through an import cycle. Decisions of the user: one chain for the whole (1a), FX everywhere asked for (2a), Set sound FX parameters on playbacks (3a), and a pitch shifter of its own with exact latency compensation for a Pitch on a bus. Tests: - Unit pitch-shifter (new): 11 green. At 0 semitones the output is the input 2048 samples later, off by at most 1.2e-7. - Unit sound-library: 84 green, 8 new. Counter-checks (no early start, no Mix delay, chain made every time, tune misses the master) each turned their test red. - Unit bus-fx (new): 6 green, 4 counter-checks red. Scheduler ahead: 1 new, its counter-check red. - e2e 65 (melektaus-atx): ALL PASSED, 35 checks, 4 new, first run. Chrome at 44.1 kHz: the bus was 0.046440 s late, the note started exactly that much early. Offline the shifter moved 1 kHz to 2000 Hz and passed a click 2048 samples later. - Gate (melektaus-atx): CHECK EXIT 0, 259 files, 4021 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
01fd6e6464
commit
b519cd192f
32 files changed
+1666
-226
No files matched your search
@@ -256,8 +256,9 @@ export interface NodeContext {
|
|||||||
* Plays one note of an instrument asset (#331): `note` as MIDI counts it (60 is C4), struck with
|
* Plays one note of an instrument asset (#331): `note` as MIDI counts it (60 is C4), struck with
|
||||||
* `velocity` (0 to 1, how loud it is next to `volume`) and released after `duration` seconds, or
|
* `velocity` (0 to 1, how loud it is next to `volume`) and released after `duration` seconds, or
|
||||||
* held until it is stopped where that is 0. Owned like a `playSound` voice, `instrumentParams` are
|
* held until it is stopped where that is 0. Owned like a `playSound` voice, `instrumentParams` are
|
||||||
* values for the instrument's parameters, `tempo` as for `playSound`. Returns the number of the
|
* values for the instrument's parameters, `tempo` as for `playSound`. `fx` sends the note through a
|
||||||
* voice, 0 where nothing plays.
|
* sound FX of its own (#346), `fxParams` with values for its parameters, apart from the instrument's.
|
||||||
|
* Returns the number of the voice, 0 where nothing plays.
|
||||||
*/
|
*/
|
||||||
playNote(
|
playNote(
|
||||||
instrumentId: string,
|
instrumentId: string,
|
||||||
@@ -268,22 +269,41 @@ export interface NodeContext {
|
|||||||
detached: boolean,
|
detached: boolean,
|
||||||
instrumentParams: FxParams,
|
instrumentParams: FxParams,
|
||||||
tempo: number,
|
tempo: number,
|
||||||
|
fx: string,
|
||||||
|
fxParams: FxParams,
|
||||||
): number;
|
): number;
|
||||||
/**
|
/**
|
||||||
* Plays a pattern asset through its instrument, at its own tempo, once or round and round, every
|
* Plays a pattern asset through its instrument, at its own tempo, once or round and round, every
|
||||||
* note at `volume`. Owned like a `playSound` voice. Returns its number, for `stopVoice`; 0 where
|
* note at `volume`. Owned like a `playSound` voice. `fx` is a sound FX all of it goes through as
|
||||||
* there is nothing to play.
|
* one chain (#346), `fxParams` values for its parameters. Returns its number, for `stopVoice` and
|
||||||
|
* `tuneSound`; 0 where there is nothing to play.
|
||||||
*/
|
*/
|
||||||
playPattern(patternId: string, volume: number, loop: boolean, detached: boolean): number;
|
playPattern(
|
||||||
|
patternId: string,
|
||||||
|
volume: number,
|
||||||
|
loop: boolean,
|
||||||
|
detached: boolean,
|
||||||
|
fx: string,
|
||||||
|
fxParams: FxParams,
|
||||||
|
): number;
|
||||||
/** Stops every playing of that pattern, `''` every pattern; `letRing` lets its notes fall away in their release. */
|
/** Stops every playing of that pattern, `''` every pattern; `letRing` lets its notes fall away in their release. */
|
||||||
stopPattern(patternId: string, letRing: boolean): void;
|
stopPattern(patternId: string, letRing: boolean): void;
|
||||||
/**
|
/**
|
||||||
* Plays a song asset (#336) at its tempo, its tracks as they are set: from its start, then round
|
* Plays a song asset (#336) at its tempo, its tracks as they are set: from its start, then round
|
||||||
* the loop of its ruler with `loop`, else once to its end. `volume` (0 to 1) is the level of the
|
* the loop of its ruler with `loop`, else once to its end. `volume` (0 to 1) is the level of the
|
||||||
* whole of it, rising there from silence over `fadeIn` seconds. Owned like a `playSound` voice.
|
* whole of it, rising there from silence over `fadeIn` seconds. Owned like a `playSound` voice.
|
||||||
* Returns its number; 0 where there is nothing to play.
|
* `fx` and `fxParams` as for `playPattern`, after its tracks. Returns its number; 0 where there is
|
||||||
|
* nothing to play.
|
||||||
*/
|
*/
|
||||||
playSong(songId: string, volume: number, fadeIn: number, loop: boolean, detached: boolean): number;
|
playSong(
|
||||||
|
songId: string,
|
||||||
|
volume: number,
|
||||||
|
fadeIn: number,
|
||||||
|
loop: boolean,
|
||||||
|
detached: boolean,
|
||||||
|
fx: string,
|
||||||
|
fxParams: FxParams,
|
||||||
|
): number;
|
||||||
/** Stops every playing of that song, `''` every song, falling silent over `fadeOut` seconds. */
|
/** Stops every playing of that song, `''` every song, falling silent over `fadeOut` seconds. */
|
||||||
stopSong(songId: string, fadeOut: number): void;
|
stopSong(songId: string, fadeOut: number): void;
|
||||||
/** Stops one playing of a song or pattern by its number, falling silent over `fadeOut` seconds. */
|
/** Stops one playing of a song or pattern by its number, falling silent over `fadeOut` seconds. */
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { MIDDLE_C } from '../../../instrument/notes';
|
|||||||
import { heldTempo } from '../../../music/time';
|
import { heldTempo } from '../../../music/time';
|
||||||
import type { NodeDef } from '../../NodeRegistry';
|
import type { NodeDef } from '../../NodeRegistry';
|
||||||
import { asBoolean, asNumber, asString } from '../../values';
|
import { asBoolean, asNumber, asString } from '../../values';
|
||||||
import { exec, num, withDefault } from '../shared';
|
import { exec, num, soundFxPort, withDefault } from '../shared';
|
||||||
import { fxParamsOf, tempoPort } from './PlaySound';
|
import { fxParamsOf, tempoPort } from './PlaySound';
|
||||||
|
|
||||||
export const PlayNote: NodeDef = {
|
export const PlayNote: NodeDef = {
|
||||||
@@ -10,7 +10,7 @@ export const PlayNote: NodeDef = {
|
|||||||
label: 'Play note',
|
label: 'Play note',
|
||||||
category: 'action',
|
category: 'action',
|
||||||
description:
|
description:
|
||||||
'Plays one note of an instrument asset. "note" is its number as MIDI counts it: 60 is C4, 61 C#4, 72 C5. "velocity" is how hard it is struck, 0 to 1 – it makes the note quieter and is handed to what the instrument does with it. "duration" is how many seconds until the note is released; at 0 it is held until a Stop sound ends it. A note belongs to this object like a sound and stops when the object is destroyed, unless "detached" is on. "params" takes values for the parameters of the instrument from an Instrument parameters node. "voice" is the number of this note, for Set sound FX parameters and Stop sound; 0 where the instrument plays nothing for it. "tempo" is what a Delay in the instrument synced to beats counts by, in quarter notes a minute.',
|
'Plays one note of an instrument asset. "note" is its number as MIDI counts it: 60 is C4, 61 C#4, 72 C5. "velocity" is how hard it is struck, 0 to 1 – it makes the note quieter and is handed to what the instrument does with it. "duration" is how many seconds until the note is released; at 0 it is held until a Stop sound ends it. A note belongs to this object like a sound and stops when the object is destroyed, unless "detached" is on. "params" takes values for the parameters of the instrument from an Instrument parameters node. "fx" sends the note through a sound FX on top of what the instrument does, "fx params" takes values for its parameters from a Sound FX parameters node – the two sets stay apart. "voice" is the number of this note, for Set sound FX parameters and Stop sound; 0 where the instrument plays nothing for it. "tempo" is what a Delay in the instrument synced to beats counts by, in quarter notes a minute.',
|
||||||
inputs: [
|
inputs: [
|
||||||
exec(),
|
exec(),
|
||||||
{
|
{
|
||||||
@@ -44,6 +44,8 @@ export const PlayNote: NodeDef = {
|
|||||||
},
|
},
|
||||||
{ key: 'fxParams', label: 'params', type: 'fxparams' },
|
{ key: 'fxParams', label: 'params', type: 'fxparams' },
|
||||||
tempoPort(),
|
tempoPort(),
|
||||||
|
{ ...soundFxPort(), key: 'soundFx' },
|
||||||
|
{ key: 'soundFxParams', label: 'fx params', type: 'fxparams' },
|
||||||
],
|
],
|
||||||
outputs: [exec(), num('voice')],
|
outputs: [exec(), num('voice')],
|
||||||
params: [],
|
params: [],
|
||||||
@@ -59,6 +61,8 @@ export const PlayNote: NodeDef = {
|
|||||||
asBoolean(inputs.detached),
|
asBoolean(inputs.detached),
|
||||||
fxParamsOf(inputs.fxParams),
|
fxParamsOf(inputs.fxParams),
|
||||||
heldTempo(asNumber(inputs.tempo)),
|
heldTempo(asNumber(inputs.tempo)),
|
||||||
|
asString(inputs.soundFx),
|
||||||
|
fxParamsOf(inputs.soundFxParams),
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
return { next: 'exec', outputs: { voice } };
|
return { next: 'exec', outputs: { voice } };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
||||||
import { asBoolean, asNumber, asString } from '../../values';
|
import { asBoolean, asNumber, asString } from '../../values';
|
||||||
import { exec, num, withDefault } from '../shared';
|
import { exec, num, soundFxPort, withDefault } from '../shared';
|
||||||
|
import { fxParamsOf } from './PlaySound';
|
||||||
|
|
||||||
export const patternPort = (): PortDef => ({
|
export const patternPort = (): PortDef => ({
|
||||||
key: 'pattern',
|
key: 'pattern',
|
||||||
@@ -19,8 +20,10 @@ export const PlayPattern: NodeDef = {
|
|||||||
'pattern ends it – the loop set on its ruler is only for editing. "volume" makes every note of ' +
|
'pattern ends it – the loop set on its ruler is only for editing. "volume" makes every note of ' +
|
||||||
'it quieter. Each play is a playing of its own: firing this twice plays it twice, overlapping. ' +
|
'it quieter. Each play is a playing of its own: firing this twice plays it twice, overlapping. ' +
|
||||||
'It belongs to this object like a sound and stops when the object is destroyed, unless ' +
|
'It belongs to this object like a sound and stops when the object is destroyed, unless ' +
|
||||||
'"detached" is on. "playback" is the number of this playing, for Stop pattern; 0 where the ' +
|
'"detached" is on. "fx" sends all of it through one sound FX, its notes together: an LFO runs on ' +
|
||||||
'pattern has no instrument or no notes.',
|
'across them, an echo repeats the whole. "fx params" takes values for its parameters from a Sound ' +
|
||||||
|
'FX parameters node. "playback" is the number of this playing, for Stop pattern and for Set sound ' +
|
||||||
|
'FX parameters; 0 where the pattern has no instrument or no notes.',
|
||||||
inputs: [
|
inputs: [
|
||||||
exec(),
|
exec(),
|
||||||
patternPort(),
|
patternPort(),
|
||||||
@@ -39,13 +42,22 @@ export const PlayPattern: NodeDef = {
|
|||||||
default: false,
|
default: false,
|
||||||
field: { key: 'detached', label: 'Detached (outlives this object)', kind: 'boolean', default: false },
|
field: { key: 'detached', label: 'Detached (outlives this object)', kind: 'boolean', default: false },
|
||||||
},
|
},
|
||||||
|
soundFxPort(),
|
||||||
|
{ key: 'fxParams', label: 'fx params', type: 'fxparams' },
|
||||||
],
|
],
|
||||||
outputs: [exec(), num('playback')],
|
outputs: [exec(), num('playback')],
|
||||||
params: [],
|
params: [],
|
||||||
run: (ctx, inputs) => {
|
run: (ctx, inputs) => {
|
||||||
const pattern = asString(inputs.pattern);
|
const pattern = asString(inputs.pattern);
|
||||||
const playback = pattern
|
const playback = pattern
|
||||||
? ctx.playPattern(pattern, asNumber(inputs.volume), asBoolean(inputs.loop), asBoolean(inputs.detached))
|
? ctx.playPattern(
|
||||||
|
pattern,
|
||||||
|
asNumber(inputs.volume),
|
||||||
|
asBoolean(inputs.loop),
|
||||||
|
asBoolean(inputs.detached),
|
||||||
|
asString(inputs.fx),
|
||||||
|
fxParamsOf(inputs.fxParams),
|
||||||
|
)
|
||||||
: 0;
|
: 0;
|
||||||
return { next: 'exec', outputs: { playback } };
|
return { next: 'exec', outputs: { playback } };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
||||||
import { asBoolean, asNumber, asString } from '../../values';
|
import { asBoolean, asNumber, asString } from '../../values';
|
||||||
import { exec, num, withDefault } from '../shared';
|
import { exec, num, soundFxPort, withDefault } from '../shared';
|
||||||
|
import { fxParamsOf } from './PlaySound';
|
||||||
|
|
||||||
export const songPort = (): PortDef => ({
|
export const songPort = (): PortDef => ({
|
||||||
key: 'song',
|
key: 'song',
|
||||||
@@ -20,8 +21,10 @@ export const PlaySong: NodeDef = {
|
|||||||
'level of the whole song, "fade in" the seconds it rises there from silence. Each play is a ' +
|
'level of the whole song, "fade in" the seconds it rises there from silence. Each play is a ' +
|
||||||
'playing of its own: firing this twice plays it twice, side by side. It belongs to this object ' +
|
'playing of its own: firing this twice plays it twice, side by side. It belongs to this object ' +
|
||||||
'like a sound and stops when the object is destroyed or the scene is left, unless "detached" ' +
|
'like a sound and stops when the object is destroyed or the scene is left, unless "detached" ' +
|
||||||
'is on. "playback" is the number of this playing, for Stop song; 0 where the song has nothing ' +
|
'is on. "fx" sends the whole song through one sound FX, after its tracks and before "volume"; ' +
|
||||||
'to play.',
|
'"fx params" takes values for its parameters from a Sound FX parameters node. "playback" is the ' +
|
||||||
|
'number of this playing, for Stop song and for Set sound FX parameters; 0 where the song has ' +
|
||||||
|
'nothing to play.',
|
||||||
inputs: [
|
inputs: [
|
||||||
exec(),
|
exec(),
|
||||||
songPort(),
|
songPort(),
|
||||||
@@ -41,6 +44,8 @@ export const PlaySong: NodeDef = {
|
|||||||
default: false,
|
default: false,
|
||||||
field: { key: 'detached', label: 'Detached (outlives this object)', kind: 'boolean', default: false },
|
field: { key: 'detached', label: 'Detached (outlives this object)', kind: 'boolean', default: false },
|
||||||
},
|
},
|
||||||
|
soundFxPort(),
|
||||||
|
{ key: 'fxParams', label: 'fx params', type: 'fxparams' },
|
||||||
],
|
],
|
||||||
outputs: [exec(), num('playback')],
|
outputs: [exec(), num('playback')],
|
||||||
params: [],
|
params: [],
|
||||||
@@ -53,6 +58,8 @@ export const PlaySong: NodeDef = {
|
|||||||
asNumber(inputs.fadeIn),
|
asNumber(inputs.fadeIn),
|
||||||
asBoolean(inputs.loop),
|
asBoolean(inputs.loop),
|
||||||
asBoolean(inputs.detached),
|
asBoolean(inputs.detached),
|
||||||
|
asString(inputs.fx),
|
||||||
|
fxParamsOf(inputs.fxParams),
|
||||||
)
|
)
|
||||||
: 0;
|
: 0;
|
||||||
return { next: 'exec', outputs: { playback } };
|
return { next: 'exec', outputs: { playback } };
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ export const SetSoundFxParameters: NodeDef = {
|
|||||||
label: 'Set sound FX parameters',
|
label: 'Set sound FX parameters',
|
||||||
category: 'action',
|
category: 'action',
|
||||||
description:
|
description:
|
||||||
'Changes the sound FX parameters of a sound while it plays: the voice a Play sound handed out ' +
|
'Changes the sound FX parameters of a sound while it plays: the voice a Play sound or Play note ' +
|
||||||
'glides to the values from a Sound FX parameters node. Keep the voice in a variable to reach it ' +
|
'handed out, or the playback of a Play pattern or Play song with an fx, glides to the values from ' +
|
||||||
|
'a Sound FX parameters node. On a note a name its instrument and its fx both have changes both. ' +
|
||||||
|
'Keep the voice in a variable to reach it ' +
|
||||||
'from another event. A parameter not given keeps its value, a voice that has ended is not ' +
|
'from another event. A parameter not given keeps its value, a voice that has ended is not ' +
|
||||||
'reached. A pitch keeps the length of the sound here too, but is heard about 20 ms after it is set. ' +
|
'reached. A pitch keeps the length of the sound here too, but is heard about 20 ms after it is set. ' +
|
||||||
'time: seconds the values take to get there, along the chosen easing and on the clock of the ' +
|
'time: seconds the values take to get there, along the chosen easing and on the clock of the ' +
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const SoundFxParameters: NodeDef = {
|
|||||||
label: 'Sound FX parameters',
|
label: 'Sound FX parameters',
|
||||||
category: 'data',
|
category: 'data',
|
||||||
description:
|
description:
|
||||||
'Values for the parameters of a sound FX, for the "fx params" input of a Play sound or a Set sound FX parameters. Pick the parameters to set; one not picked keeps the default its Input gives it, or on a playing sound the value it has.',
|
'Values for the parameters of a sound FX, for the "fx params" input of a Play sound, Play note, Play pattern, Play song or a Set sound FX parameters. Pick the parameters to set; one not picked keeps the default its Input gives it, or on a playing sound the value it has.',
|
||||||
inputs: [],
|
inputs: [],
|
||||||
outputs: [{ key: 'params', label: 'params', type: 'fxparams' }],
|
outputs: [{ key: 'params', label: 'params', type: 'fxparams' }],
|
||||||
params: [SOUND_FX_SETTING],
|
params: [SOUND_FX_SETTING],
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ function notesOutputs(node: GraphNode, note: number): Record<string, Value> {
|
|||||||
* a value handed to the FX itself would be. One left open keeps its default.
|
* a value handed to the FX itself would be. One left open keeps its default.
|
||||||
*/
|
*/
|
||||||
function throughFx(fx: FxPlan, sound: FxPlan, given: Readonly<Record<string, FxAmount>>): FxPlan {
|
function throughFx(fx: FxPlan, sound: FxPlan, given: Readonly<Record<string, FxAmount>>): FxPlan {
|
||||||
const bind = (amount: FxAmount): FxAmount => {
|
return throughFxWith(fx, sound, (amount) => {
|
||||||
if (amount.param === undefined) return amount;
|
if (amount.param === undefined) return amount;
|
||||||
const fed = given[amount.param];
|
const fed = given[amount.param];
|
||||||
if (!fed) return amount;
|
if (!fed) return amount;
|
||||||
@@ -333,14 +333,27 @@ function throughFx(fx: FxPlan, sound: FxPlan, given: Readonly<Record<string, FxA
|
|||||||
return fed.param === undefined
|
return fed.param === undefined
|
||||||
? { value }
|
? { value }
|
||||||
: { value, param: fed.param, min: amount.min, max: amount.max };
|
: { value, param: fed.param, min: amount.min, max: amount.max };
|
||||||
};
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A note sent through a sound FX of its own (#346), as a `Play note` with `fx` does: the parameters of
|
||||||
|
* the FX go by `scope` and their name, so they cannot meet the instrument's of the same name.
|
||||||
|
*/
|
||||||
|
export function throughScopedFx(fx: FxPlan, sound: FxPlan, scope: string): FxPlan {
|
||||||
|
return throughFxWith(fx, sound, (amount) =>
|
||||||
|
amount.param === undefined ? amount : { ...amount, param: `${scope}${amount.param}` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function throughFxWith(fx: FxPlan, sound: FxPlan, bind: (amount: FxAmount) => FxAmount): FxPlan {
|
||||||
const stages = fx.stages.map((stage) => bindStage(stage, bind));
|
const stages = fx.stages.map((stage) => bindStage(stage, bind));
|
||||||
if ('mix' in fx)
|
if ('mix' in fx)
|
||||||
return {
|
return {
|
||||||
...fx,
|
...fx,
|
||||||
mix: bind(fx.mix),
|
mix: bind(fx.mix),
|
||||||
a: throughFx(fx.a, sound, given),
|
a: throughFxWith(fx.a, sound, bind),
|
||||||
b: throughFx(fx.b, sound, given),
|
b: throughFxWith(fx.b, sound, bind),
|
||||||
stages,
|
stages,
|
||||||
};
|
};
|
||||||
const pitched = fx.pitch.reduce((plan, semitones) => withPitch(plan, bind(semitones)), sound);
|
const pitched = fx.pitch.reduce((plan, semitones) => withPitch(plan, bind(semitones)), sound);
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ export interface SchedulerHost {
|
|||||||
now(): number;
|
now(): number;
|
||||||
/** Seconds between the audio clock and what is heard. */
|
/** Seconds between the audio clock and what is heard. */
|
||||||
latency(): number;
|
latency(): number;
|
||||||
|
/**
|
||||||
|
* Seconds ahead of its time the player may start a note, and so how much further ahead it is
|
||||||
|
* planned: the latency of the buses it is heard through, made up by starting it that much earlier
|
||||||
|
* (#346). None where left out.
|
||||||
|
*/
|
||||||
|
ahead?(): number;
|
||||||
/** Starts the note, gives back the number of its voice; 0 where nothing plays. */
|
/** Starts the note, gives back the number of its voice; 0 where nothing plays. */
|
||||||
play(note: ScheduledNote): number;
|
play(note: ScheduledNote): number;
|
||||||
/** Starts a clip the same way; a host that plays no clips leaves it out. */
|
/** Starts a clip the same way; a host that plays no clips leaves it out. */
|
||||||
@@ -124,7 +130,7 @@ export class Scheduler {
|
|||||||
start(from = this.from): void {
|
start(from = this.from): void {
|
||||||
this.stop();
|
this.stop();
|
||||||
this.from = Math.max(0, Math.round(from));
|
this.from = Math.max(0, Math.round(from));
|
||||||
this.anchor = this.host.now() + LEAD;
|
this.anchor = this.host.now() + LEAD + this.ahead();
|
||||||
this.until = 0;
|
this.until = 0;
|
||||||
this.before = null;
|
this.before = null;
|
||||||
this.ended = false;
|
this.ended = false;
|
||||||
@@ -177,7 +183,9 @@ export class Scheduler {
|
|||||||
/** Plans the notes of the window ahead that are not planned yet. */
|
/** Plans the notes of the window ahead that are not planned yet. */
|
||||||
pump(): void {
|
pump(): void {
|
||||||
if (this.anchor === null) return;
|
if (this.anchor === null) return;
|
||||||
const to = Math.floor(ticksOfSeconds(this.host.now() + LOOKAHEAD - this.anchor, this.tempo));
|
const to = Math.floor(
|
||||||
|
ticksOfSeconds(this.host.now() + LOOKAHEAD + this.ahead() - this.anchor, this.tempo),
|
||||||
|
);
|
||||||
if (to <= this.until) return;
|
if (to <= this.until) return;
|
||||||
this.voices = this.voices.filter((voice) => this.host.playing(voice));
|
this.voices = this.voices.filter((voice) => this.host.playing(voice));
|
||||||
const sequence = this.sequence();
|
const sequence = this.sequence();
|
||||||
@@ -217,6 +225,11 @@ export class Scheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ahead(): number {
|
||||||
|
const ahead = this.host.ahead?.() ?? 0;
|
||||||
|
return Number.isFinite(ahead) && ahead > 0 ? ahead : 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** The playhead at a second of the audio clock; before the anchor, where it starts. */
|
/** The playhead at a second of the audio clock; before the anchor, where it starts. */
|
||||||
private headAt(time: number): number {
|
private headAt(time: number): number {
|
||||||
if (this.anchor === null) return this.from;
|
if (this.anchor === null) return this.from;
|
||||||
|
|||||||
+17
-4
@@ -2,15 +2,28 @@ import { instrumentBuildFor, instrumentSourcesOf } from '../instrument/compile';
|
|||||||
import { findAssetOf, type Project } from '../model/Project';
|
import { findAssetOf, type Project } from '../model/Project';
|
||||||
import { playedSoundById } from '../properties/playedSound';
|
import { playedSoundById } from '../properties/playedSound';
|
||||||
import { soundFxBuildFor } from '../soundfx/compile';
|
import { soundFxBuildFor } from '../soundfx/compile';
|
||||||
import { DRY_VOICE, type FxPlan, type FxVoice } from '../soundfx/nodes';
|
import { DRY_VOICE, type FxParams, type FxPlan, type FxVoice } from '../soundfx/nodes';
|
||||||
import type { SequenceNeeds } from './playbacks';
|
import type { SequenceNeeds } from './playbacks';
|
||||||
import type { Sequence } from './sequence';
|
import type { Sequence } from './sequence';
|
||||||
|
|
||||||
/** The sound FX a clip goes through; one that does not compile plays it dry, as `Play sound` does. */
|
/**
|
||||||
export function clipFx(project: Project, fxId: string, tempo: number): FxVoice {
|
* A sound FX by its asset id with values for its parameters: null where there is none, it is gone or
|
||||||
|
* it does not compile – what plays through it then plays dry, as with `Play sound`.
|
||||||
|
*/
|
||||||
|
export function fxVoiceOf(
|
||||||
|
project: Project,
|
||||||
|
fxId: string,
|
||||||
|
tempo: number,
|
||||||
|
params: FxParams = {},
|
||||||
|
): FxVoice | null {
|
||||||
const fx = fxId ? findAssetOf(project, fxId, 'soundfx') : undefined;
|
const fx = fxId ? findAssetOf(project, fxId, 'soundfx') : undefined;
|
||||||
const plan = fx && soundFxBuildFor(fx).compiled;
|
const plan = fx && soundFxBuildFor(fx).compiled;
|
||||||
return plan ? { plan, params: {}, tempo } : DRY_VOICE;
|
return plan ? { plan, params, tempo } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sound FX a clip goes through. */
|
||||||
|
export function clipFx(project: Project, fxId: string, tempo: number): FxVoice {
|
||||||
|
return fxVoiceOf(project, fxId, tempo) ?? DRY_VOICE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every layer of every note and every file and FX of every clip a sequence plays. */
|
/** Every layer of every note and every file and FX of every clip a sequence plays. */
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { FxPlan } from '../soundfx/nodes';
|
import type { FxPlan, FxVoice } from '../soundfx/nodes';
|
||||||
import { LEAD, type ScheduledClip, type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler';
|
import { LEAD, type ScheduledClip, type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler';
|
||||||
import type { LoopRange, Sequence } from './sequence';
|
import type { LoopRange, Sequence } from './sequence';
|
||||||
|
|
||||||
@@ -13,6 +13,10 @@ export interface BusLevel {
|
|||||||
name: string;
|
name: string;
|
||||||
gain: number;
|
gain: number;
|
||||||
pan: number;
|
pan: number;
|
||||||
|
/** The bus it goes into (#346): a pattern item's goes into its track's. The master where left out. */
|
||||||
|
into?: string;
|
||||||
|
/** The sound FX all of it goes through as one chain (#346), before its level and pan. */
|
||||||
|
fx?: FxVoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a game asks to hear of a pattern or a song: its notes and clips, how fast, how long, how round. */
|
/** What a game asks to hear of a pattern or a song: its notes and clips, how fast, how long, how round. */
|
||||||
@@ -29,13 +33,18 @@ export interface SequencePlay {
|
|||||||
fadeIn?: number;
|
fadeIn?: number;
|
||||||
/** The tracks of a song (#336): the buses its notes and clips name, each set to its level and pan. */
|
/** The tracks of a song (#336): the buses its notes and clips name, each set to its level and pan. */
|
||||||
buses?: readonly BusLevel[];
|
buses?: readonly BusLevel[];
|
||||||
|
/** The sound FX the whole playing goes through (#346), after its tracks and before its level. */
|
||||||
|
fx?: FxVoice;
|
||||||
/** Starts one note as the player was asked to, and gives back the number of its voice. */
|
/** Starts one note as the player was asked to, and gives back the number of its voice. */
|
||||||
strike(note: ScheduledNote): number;
|
strike(note: ScheduledNote): number;
|
||||||
strikeClip?(clip: ScheduledClip): number;
|
strikeClip?(clip: ScheduledClip): number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlaybackHost extends Omit<SchedulerHost, 'play' | 'playClip'> {
|
export interface PlaybackHost extends Omit<SchedulerHost, 'play' | 'playClip' | 'ahead'> {
|
||||||
prepare(needs: SequenceNeeds): Promise<void>;
|
/** Loads what that playing needs and makes its buses ready, their sound FX among them (#346). */
|
||||||
|
prepare(needs: SequenceNeeds, id: number): Promise<void>;
|
||||||
|
/** How far ahead of its time a note of that playing may be started (see `SchedulerHost.ahead`). */
|
||||||
|
ahead?(id: number): number;
|
||||||
/** The level of a playing, from where it is at `at` to `gain`, reached `seconds` later. */
|
/** The level of a playing, from where it is at `at` to `gain`, reached `seconds` later. */
|
||||||
level(id: number, gain: number, at: number, seconds: number): void;
|
level(id: number, gain: number, at: number, seconds: number): void;
|
||||||
/** Calls `then` once the audio clock reaches `at`. */
|
/** Calls `then` once the audio clock reaches `at`. */
|
||||||
@@ -85,6 +94,7 @@ export class Playbacks {
|
|||||||
const scheduler = new Scheduler(
|
const scheduler = new Scheduler(
|
||||||
{
|
{
|
||||||
...this.host,
|
...this.host,
|
||||||
|
ahead: () => this.host.ahead?.(id) ?? 0,
|
||||||
play: (note) => (keep(planned, note), play.strike(note)),
|
play: (note) => (keep(planned, note), play.strike(note)),
|
||||||
...(strikeClip && { playClip: (clip: ScheduledClip) => (keep(clips, clip), strikeClip(clip)) }),
|
...(strikeClip && { playClip: (clip: ScheduledClip) => (keep(clips, clip), strikeClip(clip)) }),
|
||||||
},
|
},
|
||||||
@@ -93,10 +103,12 @@ export class Playbacks {
|
|||||||
);
|
);
|
||||||
const playback: Playback = { id, name, owner, scheduler, planned, clips, fading: null };
|
const playback: Playback = { id, name, owner, scheduler, planned, clips, fading: null };
|
||||||
this.list.push(playback);
|
this.list.push(playback);
|
||||||
void this.host.prepare(play.needs).then(() => {
|
void this.host.prepare(play.needs, id).then(() => {
|
||||||
if (!this.list.includes(playback) || playback.fading !== null) return;
|
if (!this.list.includes(playback) || playback.fading !== null) return;
|
||||||
scheduler.start(0);
|
scheduler.start(0);
|
||||||
this.host.level(id, play.volume ?? 1, this.host.now() + LEAD, Math.max(0, play.fadeIn ?? 0));
|
// From the first note heard, which is later where the buses are (#346).
|
||||||
|
const at = scheduler.startedAt ?? this.host.now() + LEAD;
|
||||||
|
this.host.level(id, play.volume ?? 1, at, Math.max(0, play.fadeIn ?? 0));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-10
@@ -10,7 +10,6 @@ import type { SongAsset } from '../model/Asset';
|
|||||||
import { type PropertyBag, textOf } from '../model/Properties';
|
import { type PropertyBag, textOf } from '../model/Properties';
|
||||||
import { findAssetOf, type Project } from '../model/Project';
|
import { findAssetOf, type Project } from '../model/Project';
|
||||||
import { gridTicks, GRID_OPTIONS, patternInstrument, patternLength } from './pattern';
|
import { gridTicks, GRID_OPTIONS, patternInstrument, patternLength } from './pattern';
|
||||||
import type { BusLevel } from './playbacks';
|
|
||||||
import { heldLoop, type LoopRange, type Sequence, type SequenceClip, type SequenceNote } from './sequence';
|
import { heldLoop, type LoopRange, type Sequence, type SequenceClip, type SequenceNote } from './sequence';
|
||||||
import { heldMeter, heldTempo, type Meter, secondsOfTicks, ticksPerBar } from './time';
|
import { heldMeter, heldTempo, type Meter, secondsOfTicks, ticksPerBar } from './time';
|
||||||
|
|
||||||
@@ -23,6 +22,8 @@ export interface SongTrack {
|
|||||||
pan: number;
|
pan: number;
|
||||||
mute: boolean;
|
mute: boolean;
|
||||||
solo: boolean;
|
solo: boolean;
|
||||||
|
/** The sound FX everything on the track goes through, before its volume and pan (#346); `''` for none. */
|
||||||
|
fx: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SongItemKind = 'pattern' | 'sound';
|
export type SongItemKind = 'pattern' | 'sound';
|
||||||
@@ -44,7 +45,10 @@ export interface SongItem {
|
|||||||
transpose: number;
|
transpose: number;
|
||||||
/** The instrument a pattern is played with instead of its own, `''` for its own. */
|
/** The instrument a pattern is played with instead of its own, `''` for its own. */
|
||||||
instrument: string;
|
instrument: string;
|
||||||
/** The sound FX a sound goes through, `''` for none. */
|
/**
|
||||||
|
* The sound FX it goes through, `''` for none: a sound by itself, a pattern all its notes together
|
||||||
|
* on a bus of their own (#346).
|
||||||
|
*/
|
||||||
fx: string;
|
fx: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +111,7 @@ export function songLoop(song: SongAsset): LoopRange {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function newTrack(index: number): SongTrack {
|
export function newTrack(index: number): SongTrack {
|
||||||
return { id: newId(), name: `Track ${index}`, volume: 0, pan: 0, mute: false, solo: false };
|
return { id: newId(), name: `Track ${index}`, volume: 0, pan: 0, mute: false, solo: false, fx: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultTracks(): SongTrack[] {
|
export function defaultTracks(): SongTrack[] {
|
||||||
@@ -123,6 +127,11 @@ export function trackBus(songId: string, trackId: string): string {
|
|||||||
return `song:${songId}:${trackId}`;
|
return `song:${songId}:${trackId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The bus a pattern item with a sound FX is heard on (#346), into the bus of its track. */
|
||||||
|
export function itemBus(songId: string, item: Pick<SongItem, 'id' | 'track'>): string {
|
||||||
|
return `${trackBus(songId, item.track)}:${item.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function dbToGain(db: number): number {
|
export function dbToGain(db: number): number {
|
||||||
return db <= TRACK_MIN_DB ? 0 : 10 ** (db / 20);
|
return db <= TRACK_MIN_DB ? 0 : 10 ** (db / 20);
|
||||||
}
|
}
|
||||||
@@ -134,12 +143,9 @@ export function trackLevel(track: SongTrack, tracks: readonly SongTrack[]): { ga
|
|||||||
return { gain: heard ? dbToGain(track.volume) : 0, pan: track.pan };
|
return { gain: heard ? dbToGain(track.volume) : 0, pan: track.pan };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The level and pan each track's bus is set to. */
|
/** Whether a pattern item is heard on a bus of its own (#346): it names a sound FX that is there. */
|
||||||
export function songBuses(song: { id: string; tracks: readonly SongTrack[] }): BusLevel[] {
|
export function hasItemBus(project: Project, item: SongItem): boolean {
|
||||||
return song.tracks.map((track) => ({
|
return item.kind === 'pattern' && item.fx !== '' && findAssetOf(project, item.fx, 'soundfx') !== undefined;
|
||||||
name: trackBus(song.id, track.id),
|
|
||||||
...trackLevel(track, song.tracks),
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,8 +166,8 @@ export function songSequence(project: Project, song: SongAsset): Sequence {
|
|||||||
const tracks = new Set(song.tracks.map((t) => t.id));
|
const tracks = new Set(song.tracks.map((t) => t.id));
|
||||||
for (const item of song.items) {
|
for (const item of song.items) {
|
||||||
if (!tracks.has(item.track)) continue;
|
if (!tracks.has(item.track)) continue;
|
||||||
const bus = trackBus(song.id, item.track);
|
|
||||||
const volume = dbToGain(item.volume);
|
const volume = dbToGain(item.volume);
|
||||||
|
const bus = hasItemBus(project, item) ? itemBus(song.id, item) : trackBus(song.id, item.track);
|
||||||
if (item.kind === 'sound') {
|
if (item.kind === 'sound') {
|
||||||
if (!findAssetOf(project, item.asset, 'sound')?.file) continue;
|
if (!findAssetOf(project, item.asset, 'sound')?.file) continue;
|
||||||
clips.push({
|
clips.push({
|
||||||
@@ -218,6 +224,7 @@ export function heldSongTracks(raw: unknown): SongTrack[] {
|
|||||||
pan: clampTo(finite(t.pan, 0), -1, 1),
|
pan: clampTo(finite(t.pan, 0), -1, 1),
|
||||||
mute: t.mute === true,
|
mute: t.mute === true,
|
||||||
solo: t.solo === true,
|
solo: t.solo === true,
|
||||||
|
fx: text(t.fx),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return tracks.length > 0 ? tracks : [newTrack(1)];
|
return tracks.length > 0 ? tracks : [newTrack(1)];
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Project } from '../model/Project';
|
||||||
|
import type { PropertyBag } from '../model/Properties';
|
||||||
|
import { fxVoiceOf } from './needs';
|
||||||
|
import type { BusLevel } from './playbacks';
|
||||||
|
import { hasItemBus, itemBus, type SongItem, songTempo, type SongTrack, trackBus, trackLevel } from './song';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The level and pan each track's bus is set to, and the sound FX it has (#346). A pattern item with a
|
||||||
|
* sound FX has a bus of its own into its track's, at unity: its volume is its notes'. A sound FX that
|
||||||
|
* does not compile is none, and the item's bus then only passes it on.
|
||||||
|
*/
|
||||||
|
export function songBuses(
|
||||||
|
project: Project,
|
||||||
|
song: { id: string; tracks: readonly SongTrack[]; items: readonly SongItem[]; props: PropertyBag },
|
||||||
|
): BusLevel[] {
|
||||||
|
const tempo = songTempo(song.props);
|
||||||
|
const fxOf = (id: string) => {
|
||||||
|
const fx = fxVoiceOf(project, id, tempo);
|
||||||
|
return fx ? { fx } : {};
|
||||||
|
};
|
||||||
|
const tracks = song.tracks.map((track) => ({
|
||||||
|
name: trackBus(song.id, track.id),
|
||||||
|
...trackLevel(track, song.tracks),
|
||||||
|
...fxOf(track.fx),
|
||||||
|
}));
|
||||||
|
const known = new Set(song.tracks.map((t) => t.id));
|
||||||
|
const items = song.items
|
||||||
|
.filter((item) => known.has(item.track) && hasItemBus(project, item))
|
||||||
|
.map((item) => ({
|
||||||
|
name: itemBus(song.id, item),
|
||||||
|
gain: 1,
|
||||||
|
pan: 0,
|
||||||
|
into: trackBus(song.id, item.track),
|
||||||
|
...fxOf(item.fx),
|
||||||
|
}));
|
||||||
|
return [...tracks, ...items];
|
||||||
|
}
|
||||||
+174
-88
@@ -10,27 +10,37 @@ const PULL = 0.1;
|
|||||||
/** How close, in radians, the peaks have to be to the sound's phases before it is played as it is. */
|
/** How close, in radians, the peaks have to be to the sound's phases before it is played as it is. */
|
||||||
const ALIGNED = 0.05;
|
const ALIGNED = 0.05;
|
||||||
|
|
||||||
|
/** Samples in a frame at an output rate: about 43 ms, a few periods of a low note. */
|
||||||
|
export function pitchFrameSize(rate: number): number {
|
||||||
|
return 2 ** Math.round(Math.log2(rate * 0.043));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plays a sound higher or lower at its own length, the pitch free to change sample by sample
|
* Seconds a `PitchShifter` is late at an output rate (#346): one frame, at every pitch, 0 too.
|
||||||
* (#317). A phase vocoder: the sound is cut into overlapping frames, and each frame's spectrum is
|
*/
|
||||||
|
export function shifterLatency(rate: number): number {
|
||||||
|
return pitchFrameSize(rate) / rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A phase vocoder (#317): a sound is cut into overlapping frames, and each frame's spectrum is
|
||||||
* moved up or down by the pitch. It is moved peak by peak, each with the bins around it (Laroche and
|
* moved up or down by the pitch. It is moved peak by peak, each with the bins around it (Laroche and
|
||||||
* Dolson's phase locking): a peak's phase runs on at its new frequency, so a tone carries on from
|
* Dolson's phase locking): a peak's phase runs on at its new frequency, so a tone carries on from
|
||||||
* frame to frame without a jump, and the bins around it keep their phase to it, so it stays one tone
|
* frame to frame without a jump, and the bins around it keep their phase to it, so it stays one tone
|
||||||
* rather than beating against itself. It reads the sound itself rather than being fed it, so it can
|
* rather than beating against itself. A frame takes the pitch at the sample it starts on, so a change
|
||||||
* look ahead as far as a frame reaches and starts with the very first sample, no latency. A change of
|
* is heard about half a frame later. Where a frame's samples come from is up to `readFrame`.
|
||||||
* pitch is heard about half a frame later, though: a frame takes the pitch at the sample it starts on.
|
|
||||||
*
|
*
|
||||||
* At exactly 0 semitones a frame is the sound as it is, windowed in and out again, which the overlap
|
* At exactly 0 semitones a frame is the sound as it is, windowed in and out again, which the overlap
|
||||||
* adds back up to the samples themselves – and costs next to nothing.
|
* adds back up to the samples themselves – and costs next to nothing.
|
||||||
*/
|
*/
|
||||||
export class PitchPlayer {
|
abstract class Vocoder {
|
||||||
private readonly size: number;
|
protected readonly size: number;
|
||||||
private readonly hop: number;
|
protected readonly hop: number;
|
||||||
private readonly bins: number;
|
private readonly bins: number;
|
||||||
private readonly window: Float32Array;
|
private readonly window: Float32Array;
|
||||||
/** The window squared and weighed so four overlapping frames add up to the sound. */
|
/** The window squared and weighed so four overlapping frames add up to the sound. */
|
||||||
private readonly dry: Float32Array;
|
private readonly dry: Float32Array;
|
||||||
private readonly reading: Float64Array;
|
protected readonly reading: Float64Array;
|
||||||
/** A frame in time, turned by half its size: what goes into the transform and comes out of it. */
|
/** A frame in time, turned by half its size: what goes into the transform and comes out of it. */
|
||||||
private readonly real: Float64Array;
|
private readonly real: Float64Array;
|
||||||
private readonly fft: RealFft;
|
private readonly fft: RealFft;
|
||||||
@@ -47,39 +57,22 @@ export class PitchPlayer {
|
|||||||
private readonly lastPhase: Float64Array[];
|
private readonly lastPhase: Float64Array[];
|
||||||
private readonly runPhase: Float64Array[];
|
private readonly runPhase: Float64Array[];
|
||||||
/** Per channel: the samples of the frames still being added up, from the start of the newest frame on. */
|
/** Per channel: the samples of the frames still being added up, from the start of the newest frame on. */
|
||||||
private readonly sum: Float32Array[];
|
protected readonly sum: Float32Array[];
|
||||||
/** How far into the finished first hop of `sum` the output has been handed out. */
|
/** How far into the finished first hop of `sum` the output has been handed out. */
|
||||||
private given: number;
|
protected given: number;
|
||||||
/** Where the newest frame starts, in samples of the output. */
|
/** Where the newest frame starts, in samples of the output. */
|
||||||
private frameAt: number;
|
protected frameAt: number;
|
||||||
/** Whether `lastPhase` holds the frame before: a frame at 0 semitones does not need its phases. */
|
/** Whether `lastPhase` holds the frame before: a frame at 0 semitones does not need its phases. */
|
||||||
private phased = false;
|
private phased = false;
|
||||||
private primed = false;
|
private primed = false;
|
||||||
/** How many samples of the output the sound lasts; a looping one never ends until it leaves its loop. */
|
|
||||||
private end: number;
|
|
||||||
/** Where a loop runs in the samples of the sound; null for a sound that does not loop. */
|
|
||||||
private readonly loop: { start: number; end: number } | null;
|
|
||||||
/**
|
|
||||||
* Once the loop is left, how far the sound's samples lag behind the output from the end of the
|
|
||||||
* last round on: every round played before then. Infinity while it loops on.
|
|
||||||
*/
|
|
||||||
private unwound = Infinity;
|
|
||||||
|
|
||||||
/**
|
/** `rate` is the output's, which sets the frame; `start` is the output sample the first frame starts on. */
|
||||||
* `channels` are the samples of the sound, `step` how many of them pass for one sample of the
|
|
||||||
* output (the sound's rate over the output's) and `rate` the output's rate, which sets the frame.
|
|
||||||
* `loop` repeats the whole sound, or the samples from `start` up to `end` (#331) once it gets there.
|
|
||||||
* `skip` is how many samples of the sound lie before where it starts (#334).
|
|
||||||
*/
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly channels: readonly Float32Array[],
|
private readonly count: number,
|
||||||
private readonly step: number,
|
|
||||||
rate: number,
|
rate: number,
|
||||||
loop: boolean | { start: number; end: number },
|
start: number,
|
||||||
skip = 0,
|
|
||||||
) {
|
) {
|
||||||
// About 43 ms: a frame holds a few periods of a low note.
|
this.size = pitchFrameSize(rate);
|
||||||
this.size = 2 ** Math.round(Math.log2(rate * 0.043));
|
|
||||||
this.hop = this.size / OVERLAP;
|
this.hop = this.size / OVERLAP;
|
||||||
this.bins = this.size / 2 + 1;
|
this.bins = this.size / 2 + 1;
|
||||||
this.window = new Float32Array(this.size);
|
this.window = new Float32Array(this.size);
|
||||||
@@ -94,74 +87,35 @@ export class PitchPlayer {
|
|||||||
this.frequency = new Float64Array(this.bins);
|
this.frequency = new Float64Array(this.bins);
|
||||||
this.peaks = new Int32Array(this.bins);
|
this.peaks = new Int32Array(this.bins);
|
||||||
this.nextRun = new Float64Array(this.bins);
|
this.nextRun = new Float64Array(this.bins);
|
||||||
this.lastPhase = channels.map(() => new Float64Array(this.bins));
|
this.lastPhase = Array.from({ length: count }, () => new Float64Array(this.bins));
|
||||||
this.runPhase = channels.map(() => new Float64Array(this.bins));
|
this.runPhase = Array.from({ length: count }, () => new Float64Array(this.bins));
|
||||||
this.sum = channels.map(() => new Float32Array(this.size));
|
this.sum = Array.from({ length: count }, () => new Float32Array(this.size));
|
||||||
this.given = this.hop;
|
this.given = this.hop;
|
||||||
this.frameAt = Math.max(0, Math.round(skip / step)) - this.size;
|
this.frameAt = start - this.size;
|
||||||
const length = channels[0]?.length ?? 0;
|
|
||||||
this.end = loop ? Infinity : Math.ceil(length / step);
|
|
||||||
const region = loop === true ? { start: 0, end: length } : loop || null;
|
|
||||||
this.loop =
|
|
||||||
region && region.start >= 0 && region.end <= length && region.start < region.end
|
|
||||||
? region
|
|
||||||
: region && { start: 0, end: length };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The samples of channel `c` from output sample `from` on, a frame long, in `reading`. */
|
||||||
* Fills `out` – one array per channel, all as long – with the next samples. `semitones` holds the
|
protected abstract readFrame(c: number, from: number): Float64Array;
|
||||||
* pitch for each of them, or one value for all; a frame takes the pitch at the sample it starts on.
|
|
||||||
* Says whether the sound is still going: a sound that does not loop ends with its last sample.
|
|
||||||
*/
|
|
||||||
render(out: readonly Float32Array[], semitones: ArrayLike<number>): boolean {
|
|
||||||
const count = out[0]?.length ?? 0;
|
|
||||||
const length = this.channels[0]?.length ?? 0;
|
|
||||||
if (!this.primed) {
|
|
||||||
// The frames before the first sample: what it is made of, so the start is not faded in.
|
|
||||||
for (let k = 1; k < OVERLAP; k++) this.frame(semitones[0] ?? 0);
|
|
||||||
this.given = this.hop;
|
|
||||||
this.primed = true;
|
|
||||||
}
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const at = this.frameAt + this.given;
|
|
||||||
if (at >= this.end || length === 0) {
|
|
||||||
for (const channel of out) channel.fill(0, i);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.given === this.hop) this.frame(semitones.length > 1 ? semitones[i]! : (semitones[0] ?? 0));
|
|
||||||
for (let c = 0; c < out.length; c++)
|
|
||||||
out[c]![i] = this.sum[Math.min(c, this.sum.length - 1)]![this.given] ?? 0;
|
|
||||||
this.given += 1;
|
|
||||||
}
|
|
||||||
return this.frameAt + this.given < this.end;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/** The frames before the first sample: what it is made of, so the start is not faded in. */
|
||||||
* Lets the loop finish the round it is in and then plays on into what comes after it, to the end
|
protected prime(semitones: number): void {
|
||||||
* of the sound (#331). The round is the one the newest frame has read up to, so nothing read
|
if (this.primed) return;
|
||||||
* already changes under the frames still sounding.
|
for (let k = 1; k < OVERLAP; k++) this.frame(semitones);
|
||||||
*/
|
this.given = this.hop;
|
||||||
leaveLoop(): void {
|
this.primed = true;
|
||||||
const { loop } = this;
|
|
||||||
if (!loop || this.unwound !== Infinity) return;
|
|
||||||
const span = loop.end - loop.start;
|
|
||||||
const read = (this.frameAt + this.size) * this.step;
|
|
||||||
const rounds = read < loop.end ? 0 : Math.floor((read - loop.end) / span) + 1;
|
|
||||||
this.unwound = rounds * span;
|
|
||||||
this.end = Math.ceil(((this.channels[0]?.length ?? 0) + this.unwound) / this.step);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Adds the next frame, a hop on from the last, and hands out the hop it finishes. */
|
/** Adds the next frame, a hop on from the last, and hands out the hop it finishes. */
|
||||||
private frame(semitones: number): void {
|
protected frame(semitones: number): void {
|
||||||
this.frameAt += this.hop;
|
this.frameAt += this.hop;
|
||||||
this.given = 0;
|
this.given = 0;
|
||||||
const ratio = 2 ** (semitones / 12);
|
const ratio = 2 ** (semitones / 12);
|
||||||
if (ratio !== 1 && !this.phased) {
|
if (ratio !== 1 && !this.phased) {
|
||||||
// Coming from 0 semitones, the frequency of each bin is read against the frame before.
|
// Coming from 0 semitones, the frequency of each bin is read against the frame before.
|
||||||
for (let c = 0; c < this.channels.length; c++) this.analyse(c, this.frameAt - this.hop, true);
|
for (let c = 0; c < this.count; c++) this.analyse(c, this.frameAt - this.hop, true);
|
||||||
}
|
}
|
||||||
let apart = 0;
|
let apart = 0;
|
||||||
for (let c = 0; c < this.channels.length; c++) {
|
for (let c = 0; c < this.count; c++) {
|
||||||
const sum = this.sum[c]!;
|
const sum = this.sum[c]!;
|
||||||
sum.copyWithin(0, this.hop);
|
sum.copyWithin(0, this.hop);
|
||||||
sum.fill(0, this.size - this.hop);
|
sum.fill(0, this.size - this.hop);
|
||||||
@@ -278,12 +232,90 @@ export class PitchPlayer {
|
|||||||
frequency[k] = k + moved / advance;
|
frequency[k] = k + moved / advance;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays a sound higher or lower at its own length, the pitch free to change sample by sample
|
||||||
|
* (#317). It reads the sound itself rather than being fed it, so it can look ahead as far as a frame
|
||||||
|
* reaches and starts with the very first sample, no latency.
|
||||||
|
*/
|
||||||
|
export class PitchPlayer extends Vocoder {
|
||||||
|
/** How many samples of the output the sound lasts; a looping one never ends until it leaves its loop. */
|
||||||
|
private end: number;
|
||||||
|
/** Where a loop runs in the samples of the sound; null for a sound that does not loop. */
|
||||||
|
private readonly loop: { start: number; end: number } | null;
|
||||||
|
/**
|
||||||
|
* Once the loop is left, how far the sound's samples lag behind the output from the end of the
|
||||||
|
* last round on: every round played before then. Infinity while it loops on.
|
||||||
|
*/
|
||||||
|
private unwound = Infinity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `channels` are the samples of the sound, `step` how many of them pass for one sample of the
|
||||||
|
* output (the sound's rate over the output's) and `rate` the output's rate, which sets the frame.
|
||||||
|
* `loop` repeats the whole sound, or the samples from `start` up to `end` (#331) once it gets there.
|
||||||
|
* `skip` is how many samples of the sound lie before where it starts (#334).
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private readonly channels: readonly Float32Array[],
|
||||||
|
private readonly step: number,
|
||||||
|
rate: number,
|
||||||
|
loop: boolean | { start: number; end: number },
|
||||||
|
skip = 0,
|
||||||
|
) {
|
||||||
|
super(channels.length, rate, Math.max(0, Math.round(skip / step)));
|
||||||
|
const length = channels[0]?.length ?? 0;
|
||||||
|
this.end = loop ? Infinity : Math.ceil(length / step);
|
||||||
|
const region = loop === true ? { start: 0, end: length } : loop || null;
|
||||||
|
this.loop =
|
||||||
|
region && region.start >= 0 && region.end <= length && region.start < region.end
|
||||||
|
? region
|
||||||
|
: region && { start: 0, end: length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills `out` – one array per channel, all as long – with the next samples. `semitones` holds the
|
||||||
|
* pitch for each of them, or one value for all; a frame takes the pitch at the sample it starts on.
|
||||||
|
* Says whether the sound is still going: a sound that does not loop ends with its last sample.
|
||||||
|
*/
|
||||||
|
render(out: readonly Float32Array[], semitones: ArrayLike<number>): boolean {
|
||||||
|
const count = out[0]?.length ?? 0;
|
||||||
|
const length = this.channels[0]?.length ?? 0;
|
||||||
|
this.prime(semitones[0] ?? 0);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const at = this.frameAt + this.given;
|
||||||
|
if (at >= this.end || length === 0) {
|
||||||
|
for (const channel of out) channel.fill(0, i);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.given === this.hop) this.frame(semitones.length > 1 ? semitones[i]! : (semitones[0] ?? 0));
|
||||||
|
for (let c = 0; c < out.length; c++)
|
||||||
|
out[c]![i] = this.sum[Math.min(c, this.sum.length - 1)]![this.given] ?? 0;
|
||||||
|
this.given += 1;
|
||||||
|
}
|
||||||
|
return this.frameAt + this.given < this.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the loop finish the round it is in and then plays on into what comes after it, to the end
|
||||||
|
* of the sound (#331). The round is the one the newest frame has read up to, so nothing read
|
||||||
|
* already changes under the frames still sounding.
|
||||||
|
*/
|
||||||
|
leaveLoop(): void {
|
||||||
|
const { loop } = this;
|
||||||
|
if (!loop || this.unwound !== Infinity) return;
|
||||||
|
const span = loop.end - loop.start;
|
||||||
|
const read = (this.frameAt + this.size) * this.step;
|
||||||
|
const rounds = read < loop.end ? 0 : Math.floor((read - loop.end) / span) + 1;
|
||||||
|
this.unwound = rounds * span;
|
||||||
|
this.end = Math.ceil(((this.channels[0]?.length ?? 0) + this.unwound) / this.step);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Channel `c` for the frame starting at output sample `from`, each sample read between two of the
|
* Channel `c` for the frame starting at output sample `from`, each sample read between two of the
|
||||||
* sound's; outside the sound it is silence, or the loop again – or past the loop once it is left.
|
* sound's; outside the sound it is silence, or the loop again – or past the loop once it is left.
|
||||||
*/
|
*/
|
||||||
private readFrame(c: number, from: number): Float64Array {
|
protected readFrame(c: number, from: number): Float64Array {
|
||||||
const { reading, size, step, loop, unwound } = this;
|
const { reading, size, step, loop, unwound } = this;
|
||||||
const samples = this.channels[c]!;
|
const samples = this.channels[c]!;
|
||||||
const end = loop ? loop.end : samples.length;
|
const end = loop ? loop.end : samples.length;
|
||||||
@@ -318,6 +350,60 @@ export class PitchPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pitch for a sound that is fed in while it plays (#346): what a whole track or song goes through.
|
||||||
|
* It cannot look ahead, so it holds back a frame before it hands anything out and is that much late
|
||||||
|
* at every pitch – at 0 semitones it is the sound itself, exactly `latency` samples later. A sound
|
||||||
|
* played through it is started that much earlier, so it is heard where it was planned.
|
||||||
|
*/
|
||||||
|
export class PitchShifter extends Vocoder {
|
||||||
|
/** Per channel the last samples fed in, two frames of them, by their number modulo the length. */
|
||||||
|
private readonly ring: Float32Array[];
|
||||||
|
private written = 0;
|
||||||
|
|
||||||
|
constructor(channels: number, rate: number) {
|
||||||
|
super(channels, rate, -pitchFrameSize(rate));
|
||||||
|
this.ring = Array.from({ length: channels }, () => new Float32Array(2 * this.size));
|
||||||
|
}
|
||||||
|
|
||||||
|
get latency(): number {
|
||||||
|
return this.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes the next samples of `input` and fills `out` with as many: the sound a frame ago, moved by
|
||||||
|
* `semitones` as `PitchPlayer.render` takes them. A channel missing from the input is the one
|
||||||
|
* before it, a mono sound on both sides; no channel at all is silence.
|
||||||
|
*/
|
||||||
|
render(input: readonly Float32Array[], out: readonly Float32Array[], semitones: ArrayLike<number>): void {
|
||||||
|
const count = out[0]?.length ?? 0;
|
||||||
|
for (let c = 0; c < this.ring.length; c++) {
|
||||||
|
const ring = this.ring[c]!;
|
||||||
|
const from = input[Math.min(c, input.length - 1)];
|
||||||
|
for (let i = 0; i < count; i++) ring[(this.written + i) % ring.length] = from?.[i] ?? 0;
|
||||||
|
}
|
||||||
|
this.written += count;
|
||||||
|
this.prime(semitones[0] ?? 0);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
if (this.given === this.hop) this.frame(semitones.length > 1 ? semitones[i]! : (semitones[0] ?? 0));
|
||||||
|
for (let c = 0; c < out.length; c++)
|
||||||
|
out[c]![i] = this.sum[Math.min(c, this.sum.length - 1)]![this.given] ?? 0;
|
||||||
|
this.given += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Before the first sample fed in it is silence. */
|
||||||
|
protected readFrame(c: number, from: number): Float64Array {
|
||||||
|
const { reading, size } = this;
|
||||||
|
const ring = this.ring[c]!;
|
||||||
|
for (let j = 0; j < size; j++) {
|
||||||
|
const at = from + j;
|
||||||
|
reading[j] = at < 0 ? 0 : ring[at % ring.length]!;
|
||||||
|
}
|
||||||
|
return reading;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function wrap(phase: number): number {
|
function wrap(phase: number): number {
|
||||||
return phase - 2 * Math.PI * Math.round(phase / (2 * Math.PI));
|
return phase - 2 * Math.PI * Math.round(phase / (2 * Math.PI));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
playbacks: () => soundLibrary.playbacks(),
|
playbacks: () => soundLibrary.playbacks(),
|
||||||
/** Whether the audio clock is held by the pause of the toolbar or runs. */
|
/** Whether the audio clock is held by the pause of the toolbar or runs. */
|
||||||
clockState: () => soundLibrary.clockState(),
|
clockState: () => soundLibrary.clockState(),
|
||||||
|
/** The second of the audio clock a voice started at, and the rate that clock runs at (#346). */
|
||||||
|
startedAt: (voice: number) => soundLibrary.startedAt(voice),
|
||||||
|
sampleRate: () => soundLibrary.liveContext().sampleRate,
|
||||||
/**
|
/**
|
||||||
* The nodes a stage of a voice becomes, in any context – an offline one renders what it does.
|
* The nodes a stage of a voice becomes, in any context – an offline one renders what it does.
|
||||||
* `at` is when its voice starts, which a Flanger's sweep counts from (#341).
|
* `at` is when its voice starts, which a Flanger's sweep counts from (#341).
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ import { createFieldRow } from './fieldFactory';
|
|||||||
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
|
import { addNameField, addNote, addTitle, type InspectorHost } from './host';
|
||||||
import { previewingHandlers } from './previewHandlers';
|
import { previewingHandlers } from './previewHandlers';
|
||||||
|
|
||||||
|
const FX_ROW: PropertyDef = {
|
||||||
|
key: 'fx',
|
||||||
|
label: 'Sound FX',
|
||||||
|
kind: 'asset',
|
||||||
|
default: '',
|
||||||
|
assetTypes: ['soundfx'],
|
||||||
|
};
|
||||||
|
|
||||||
const TRACK_ROWS: readonly PropertyDef[] = [
|
const TRACK_ROWS: readonly PropertyDef[] = [
|
||||||
{
|
{
|
||||||
key: 'volume',
|
key: 'volume',
|
||||||
@@ -29,6 +37,7 @@ const TRACK_ROWS: readonly PropertyDef[] = [
|
|||||||
{ key: 'pan', label: 'Pan', kind: 'number', default: 0, min: -1, max: 1, step: 0.05 },
|
{ key: 'pan', label: 'Pan', kind: 'number', default: 0, min: -1, max: 1, step: 0.05 },
|
||||||
{ key: 'mute', label: 'Mute', kind: 'boolean', default: false },
|
{ key: 'mute', label: 'Mute', kind: 'boolean', default: false },
|
||||||
{ key: 'solo', label: 'Solo', kind: 'boolean', default: false },
|
{ key: 'solo', label: 'Solo', kind: 'boolean', default: false },
|
||||||
|
FX_ROW,
|
||||||
];
|
];
|
||||||
|
|
||||||
/** The rows of an item: what it plays, how loud, and per kind how. */
|
/** The rows of an item: what it plays, how loud, and per kind how. */
|
||||||
@@ -65,7 +74,7 @@ function itemRows(item: SongItem): PropertyDef[] {
|
|||||||
},
|
},
|
||||||
{ key: 'instrument', label: 'Instrument', kind: 'asset', default: '', assetTypes: ['instrument'] },
|
{ key: 'instrument', label: 'Instrument', kind: 'asset', default: '', assetTypes: ['instrument'] },
|
||||||
);
|
);
|
||||||
else rows.push({ key: 'fx', label: 'Sound FX', kind: 'asset', default: '', assetTypes: ['soundfx'] });
|
rows.push(FX_ROW);
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +143,7 @@ function buildTrack(host: InspectorHost, asset: SongAsset, track: SongTrack): vo
|
|||||||
});
|
});
|
||||||
const count = asset.items.filter((i) => i.track === track.id).length;
|
const count = asset.items.filter((i) => i.track === track.id).length;
|
||||||
addNote(host, `Track of the song "${asset.name}", ${count} ${count === 1 ? 'item' : 'items'}.`);
|
addNote(host, `Track of the song "${asset.name}", ${count} ${count === 1 ? 'item' : 'items'}.`);
|
||||||
|
addNote(host, 'Its Sound FX takes everything on it together, before its volume and pan.', 'small');
|
||||||
for (const def of TRACK_ROWS) {
|
for (const def of TRACK_ROWS) {
|
||||||
const handlers = previewingHandlers<SongTrack, SongTrack[keyof SongTrack]>(host, {
|
const handlers = previewingHandlers<SongTrack, SongTrack[keyof SongTrack]>(host, {
|
||||||
target: () => findAssetOf(state.project, asset.id, 'song')?.tracks.find((t) => t.id === track.id),
|
target: () => findAssetOf(state.project, asset.id, 'song')?.tracks.find((t) => t.id === track.id),
|
||||||
@@ -175,7 +185,12 @@ function buildItem(host: InspectorHost, asset: SongAsset, item: SongItem, picked
|
|||||||
host.fields.set(def.key, row);
|
host.fields.set(def.key, row);
|
||||||
host.body.add(row.row);
|
host.body.add(row.row);
|
||||||
}
|
}
|
||||||
if (item.kind === 'pattern') addNote(host, 'An empty Instrument plays the pattern with its own.', 'small');
|
if (item.kind === 'pattern')
|
||||||
|
addNote(
|
||||||
|
host,
|
||||||
|
'An empty Instrument plays the pattern with its own. A Sound FX takes all its notes together, before the one of the track.',
|
||||||
|
'small',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function itemOf(asset: SongAsset | undefined, itemIds: readonly string[]): SongItem | undefined {
|
function itemOf(asset: SongAsset | undefined, itemIds: readonly string[]): SongItem | undefined {
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import {
|
|||||||
newItem,
|
newItem,
|
||||||
SONG_GRID_KEY,
|
SONG_GRID_KEY,
|
||||||
SONG_GRID_OPTIONS,
|
SONG_GRID_OPTIONS,
|
||||||
songBuses,
|
|
||||||
songLoop,
|
songLoop,
|
||||||
songMeter,
|
songMeter,
|
||||||
songSequence,
|
songSequence,
|
||||||
songTempo,
|
songTempo,
|
||||||
} from '../../core/music/song';
|
} from '../../core/music/song';
|
||||||
|
import { songBuses } from '../../core/music/songBuses';
|
||||||
import { sampleCount, takeName, takeStart, takeTrack, takeTrackIndex, wavBytes } from '../../core/music/take';
|
import { sampleCount, takeName, takeStart, takeTrack, takeTrackIndex, wavBytes } from '../../core/music/take';
|
||||||
import { ticksOfSeconds } from '../../core/music/time';
|
import { ticksOfSeconds } from '../../core/music/time';
|
||||||
import type { Asset, SongAsset } from '../../core/model/Asset';
|
import type { Asset, SongAsset } from '../../core/model/Asset';
|
||||||
@@ -104,7 +104,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
};
|
};
|
||||||
timeline.onTracksPreview = (tracks) => {
|
timeline.onTracksPreview = (tracks) => {
|
||||||
const asset = this.asset();
|
const asset = this.asset();
|
||||||
if (asset && this.isPlaying) this.music.setBuses(songBuses({ id: asset.id, tracks }));
|
if (asset && this.isPlaying) this.music.setBuses(songBuses(this.state.project, { ...asset, tracks }));
|
||||||
};
|
};
|
||||||
timeline.onOpenPattern = (patternId) => activateAsset(state, patternId);
|
timeline.onOpenPattern = (patternId) => activateAsset(state, patternId);
|
||||||
timeline.onDiscardTake = () => this.stop({ discard: true });
|
timeline.onDiscardTake = () => this.stop({ discard: true });
|
||||||
@@ -148,7 +148,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
claimTransport(this.halt);
|
claimTransport(this.halt);
|
||||||
this.playingId = asset.id;
|
this.playingId = asset.id;
|
||||||
this.told = this.settingsOf(asset);
|
this.told = this.settingsOf(asset);
|
||||||
this.music.setBuses(songBuses(asset));
|
this.music.setBuses(songBuses(this.state.project, asset));
|
||||||
void this.music.play(songSequence(this.state.project, asset), {
|
void this.music.play(songSequence(this.state.project, asset), {
|
||||||
tempo: songTempo(asset.props),
|
tempo: songTempo(asset.props),
|
||||||
meter: songMeter(asset.props),
|
meter: songMeter(asset.props),
|
||||||
@@ -218,7 +218,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
this.showTake(take, false);
|
this.showTake(take, false);
|
||||||
this.syncPlayButton();
|
this.syncPlayButton();
|
||||||
ui().focus.focus(this.canvas);
|
ui().focus.focus(this.canvas);
|
||||||
this.music.setBuses(songBuses(asset));
|
this.music.setBuses(songBuses(this.state.project, asset));
|
||||||
await this.music.play(songSequence(this.state.project, asset), {
|
await this.music.play(songSequence(this.state.project, asset), {
|
||||||
tempo,
|
tempo,
|
||||||
meter: songMeter(asset.props),
|
meter: songMeter(asset.props),
|
||||||
@@ -413,7 +413,7 @@ export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.music.edit(songSequence(this.state.project, asset));
|
this.music.edit(songSequence(this.state.project, asset));
|
||||||
this.music.setBuses(songBuses(asset));
|
this.music.setBuses(songBuses(this.state.project, asset));
|
||||||
const settings = this.settingsOf(asset);
|
const settings = this.settingsOf(asset);
|
||||||
if (settings === this.told) return;
|
if (settings === this.told) return;
|
||||||
this.told = settings;
|
this.told = settings;
|
||||||
|
|||||||
@@ -113,9 +113,10 @@ const TAKE_COLOR = 0xb33a3a;
|
|||||||
/** The id the track of a take being recorded is drawn under, until it is made. */
|
/** The id the track of a take being recorded is drawn under, until it is made. */
|
||||||
const TAKE_TRACK = '__take';
|
const TAKE_TRACK = '__take';
|
||||||
const SOLO_COLOR = 0xc9c23a;
|
const SOLO_COLOR = 0xc9c23a;
|
||||||
|
const FX_COLOR = 0x8fb8de;
|
||||||
/** Height of an item's name strip; what it plays is drawn below. */
|
/** Height of an item's name strip; what it plays is drawn below. */
|
||||||
const LABEL_STRIP = 15;
|
const LABEL_STRIP = 15;
|
||||||
/** The letter on a lit mute or solo button. */
|
/** The letter on a lit mute, solo or FX button. */
|
||||||
const DARK_LABEL = textStyle('small').clone();
|
const DARK_LABEL = textStyle('small').clone();
|
||||||
DARK_LABEL.fill = 0x30343c;
|
DARK_LABEL.fill = 0x30343c;
|
||||||
|
|
||||||
@@ -673,7 +674,8 @@ export class SongTimeline extends Widget {
|
|||||||
}
|
}
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
const part = headPartAt(x, y - trackTop(this.view, row), this.view.trackHeight);
|
const part = headPartAt(x, y - trackTop(this.view, row), this.view.trackHeight);
|
||||||
if (part === 'mute' || part === 'solo') {
|
if (part === 'fx') this.openTrackFxMenu(asset, track, e.global);
|
||||||
|
else if (part === 'mute' || part === 'solo') {
|
||||||
const label =
|
const label =
|
||||||
part === 'mute'
|
part === 'mute'
|
||||||
? track.mute
|
? track.mute
|
||||||
@@ -1014,6 +1016,24 @@ export class SongTimeline extends Widget {
|
|||||||
new Menu([{ label: 'Delete', onSelect: () => this.deleteSelection() }]).open(global.x, global.y);
|
new Menu([{ label: 'Delete', onSelect: () => this.deleteSelection() }]).open(global.x, global.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The sound FX a track goes through (#346): none, or one of the project's, by name. */
|
||||||
|
private openTrackFxMenu(asset: SongAsset, track: SongTrack, global: Vec2): void {
|
||||||
|
const set = (fx: string) =>
|
||||||
|
this.commit(
|
||||||
|
asset,
|
||||||
|
{ tracks: asset.tracks.map((t) => (t.id === track.id ? { ...t, fx } : t)) },
|
||||||
|
'Set track sound FX',
|
||||||
|
);
|
||||||
|
const effects = this.state.project.assets
|
||||||
|
.filter((a) => a.type === 'soundfx')
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
new Menu([
|
||||||
|
{ label: 'No sound FX', current: track.fx === '', onSelect: () => set('') },
|
||||||
|
...(effects.length > 0 ? [{ separator: true as const }] : []),
|
||||||
|
...effects.map((fx) => ({ label: fx.name, current: fx.id === track.fx, onSelect: () => set(fx.id) })),
|
||||||
|
]).open(global.x, global.y);
|
||||||
|
}
|
||||||
|
|
||||||
private openTrackMenu(asset: SongAsset, row: number, global: Vec2): void {
|
private openTrackMenu(asset: SongAsset, row: number, global: Vec2): void {
|
||||||
const track = asset.tracks[row]!;
|
const track = asset.tracks[row]!;
|
||||||
const swap = (other: number) => {
|
const swap = (other: number) => {
|
||||||
@@ -1111,6 +1131,7 @@ export class SongTimeline extends Widget {
|
|||||||
pan: 0,
|
pan: 0,
|
||||||
mute: false,
|
mute: false,
|
||||||
solo: false,
|
solo: false,
|
||||||
|
fx: '',
|
||||||
});
|
});
|
||||||
return shown;
|
return shown;
|
||||||
}
|
}
|
||||||
@@ -1441,8 +1462,11 @@ export class SongTimeline extends Widget {
|
|||||||
this.headsG.rect(0, y, HEAD_WIDTH - 1, th).fill(lit ? { color: c.accent, alpha: 0.18 } : c.panel);
|
this.headsG.rect(0, y, HEAD_WIDTH - 1, th).fill(lit ? { color: c.accent, alpha: 0.18 } : c.panel);
|
||||||
if (i === 0) this.headsG.rect(0, y, HEAD_WIDTH - 1, 1).fill(c.border);
|
if (i === 0) this.headsG.rect(0, y, HEAD_WIDTH - 1, 1).fill(c.border);
|
||||||
this.headsG.rect(0, y + th - 1, HEAD_WIDTH - 1, 1).fill(c.border);
|
this.headsG.rect(0, y + th - 1, HEAD_WIDTH - 1, 1).fill(c.border);
|
||||||
const { name, mute, solo, volume, pan } = HEAD_BOXES;
|
const { name, fx, mute, solo, volume, pan } = HEAD_BOXES;
|
||||||
labels.push({ text: track.name, x: name.x, y: y + name.y, w: name.w });
|
labels.push({ text: track.name, x: name.x, y: y + name.y, w: name.w });
|
||||||
|
const hasFx = track.fx !== '';
|
||||||
|
this.toggle(fx, y, hasFx, FX_COLOR);
|
||||||
|
labels.push({ text: 'FX', x: fx.x, y: y + fx.y, w: fx.w, h: fx.h, dark: hasFx });
|
||||||
this.toggle(mute, y, track.mute, MUTE_COLOR);
|
this.toggle(mute, y, track.mute, MUTE_COLOR);
|
||||||
this.toggle(solo, y, track.solo, SOLO_COLOR);
|
this.toggle(solo, y, track.solo, SOLO_COLOR);
|
||||||
labels.push(
|
labels.push(
|
||||||
|
|||||||
@@ -157,11 +157,12 @@ export function indexOf(tracks: readonly SongTrack[]): Map<string, number> {
|
|||||||
return new Map(tracks.map((t, i) => [t.id, i]));
|
return new Map(tracks.map((t, i) => [t.id, i]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The parts of a track's head, in its own pixels: name, mute, solo, volume and pan. */
|
/** The parts of a track's head, in its own pixels: name, sound FX (#346), mute, solo, volume and pan. */
|
||||||
export type HeadPart = 'name' | 'mute' | 'solo' | 'volume' | 'pan';
|
export type HeadPart = 'name' | 'fx' | 'mute' | 'solo' | 'volume' | 'pan';
|
||||||
|
|
||||||
export const HEAD_BOXES: Readonly<Record<HeadPart, Box>> = {
|
export const HEAD_BOXES: Readonly<Record<HeadPart, Box>> = {
|
||||||
name: { x: 8, y: 4, w: 112, h: 18 },
|
name: { x: 8, y: 4, w: 88, h: 18 },
|
||||||
|
fx: { x: 102, y: 5, w: 22, h: 16 },
|
||||||
mute: { x: 128, y: 5, w: 18, h: 16 },
|
mute: { x: 128, y: 5, w: 18, h: 16 },
|
||||||
solo: { x: 150, y: 5, w: 18, h: 16 },
|
solo: { x: 150, y: 5, w: 18, h: 16 },
|
||||||
volume: { x: 8, y: 30, w: 100, h: 12 },
|
volume: { x: 8, y: 30, w: 100, h: 12 },
|
||||||
@@ -170,7 +171,7 @@ export const HEAD_BOXES: Readonly<Record<HeadPart, Box>> = {
|
|||||||
|
|
||||||
export function headPartAt(x: number, yInTrack: number, trackHeight = TRACK_HEIGHT): HeadPart {
|
export function headPartAt(x: number, yInTrack: number, trackHeight = TRACK_HEIGHT): HeadPart {
|
||||||
const sliders = trackHeight >= SLIDERS_MIN_HEIGHT;
|
const sliders = trackHeight >= SLIDERS_MIN_HEIGHT;
|
||||||
for (const part of ['mute', 'solo', 'volume', 'pan'] as const) {
|
for (const part of ['fx', 'mute', 'solo', 'volume', 'pan'] as const) {
|
||||||
if (!sliders && (part === 'volume' || part === 'pan')) continue;
|
if (!sliders && (part === 'volume' || part === 'pan')) continue;
|
||||||
const box = HEAD_BOXES[part];
|
const box = HEAD_BOXES[part];
|
||||||
if (x >= box.x - 2 && x <= box.x + box.w + 2 && yInTrack >= box.y - 3 && yInTrack <= box.y + box.h + 3)
|
if (x >= box.x - 2 && x <= box.x + box.w + 2 && yInTrack >= box.y - 3 && yInTrack <= box.y + box.h + 3)
|
||||||
|
|||||||
@@ -16,22 +16,22 @@ import { hasRenderLayer } from '../../core/properties/PropertySchema';
|
|||||||
import { resolveAssetProps } from '../../core/properties/resolve';
|
import { resolveAssetProps } from '../../core/properties/resolve';
|
||||||
import { loopsOf, volumeOf } from '../../core/properties/sounds';
|
import { loopsOf, volumeOf } from '../../core/properties/sounds';
|
||||||
import { soundFxBuildFor } from '../../core/soundfx/compile';
|
import { soundFxBuildFor } from '../../core/soundfx/compile';
|
||||||
import { instrumentBuildFor, instrumentSourcesOf } from '../../core/instrument/compile';
|
import { instrumentBuildFor, instrumentSourcesOf, throughScopedFx } from '../../core/instrument/compile';
|
||||||
import { VELOCITY_PARAM } from '../../core/instrument/nodes';
|
import { VELOCITY_PARAM } from '../../core/instrument/nodes';
|
||||||
import { heldNote } from '../../core/instrument/notes';
|
import { heldNote } from '../../core/instrument/notes';
|
||||||
import { clipFx, sequenceNeeds } from '../../core/music/needs';
|
import { clipFx, fxVoiceOf, sequenceNeeds } from '../../core/music/needs';
|
||||||
import { patternInstrument, patternLength, patternSequence, patternTempo } from '../../core/music/pattern';
|
import { patternInstrument, patternLength, patternSequence, patternTempo } from '../../core/music/pattern';
|
||||||
import type { ScheduledClip, ScheduledNote } from '../../core/music/Scheduler';
|
import type { ScheduledClip, ScheduledNote } from '../../core/music/Scheduler';
|
||||||
import {
|
import {
|
||||||
dbToGain,
|
dbToGain,
|
||||||
songBuses,
|
|
||||||
songLength,
|
songLength,
|
||||||
songLoop,
|
songLoop,
|
||||||
songSequence,
|
songSequence,
|
||||||
songTempo,
|
songTempo,
|
||||||
SOUND_ITEM_MAX_DB,
|
SOUND_ITEM_MAX_DB,
|
||||||
} from '../../core/music/song';
|
} from '../../core/music/song';
|
||||||
import { DRY_VOICE, type FxParams } from '../../core/soundfx/nodes';
|
import { songBuses } from '../../core/music/songBuses';
|
||||||
|
import { DRY_VOICE, type FxParams, type FxVoice } from '../../core/soundfx/nodes';
|
||||||
import type { Entity } from '../Entity';
|
import type { Entity } from '../Entity';
|
||||||
import { PhysicsSystem } from '../physics/PhysicsSystem';
|
import { PhysicsSystem } from '../physics/PhysicsSystem';
|
||||||
import type { World } from '../World';
|
import type { World } from '../World';
|
||||||
@@ -181,15 +181,21 @@ export function behaviorContext(
|
|||||||
},
|
},
|
||||||
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo) =>
|
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo) =>
|
||||||
playSound(run, assetId, volume, loop, detached ? '' : entity.id, fx, fxParams, tempo),
|
playSound(run, assetId, volume, loop, detached ? '' : entity.id, fx, fxParams, tempo),
|
||||||
playNote: (instrumentId, note, velocity, volume, duration, detached, params, tempo) =>
|
playNote: (instrumentId, note, velocity, volume, duration, detached, params, tempo, fx, fxParams) =>
|
||||||
playNote(run, instrumentId, { note, velocity, volume, duration }, detached ? '' : entity.id, params, {
|
playNote(
|
||||||
tempo,
|
run,
|
||||||
}),
|
instrumentId,
|
||||||
playPattern: (patternId, volume, loop, detached) =>
|
{ note, velocity, volume, duration },
|
||||||
playPattern(run, patternId, volume, loop, detached ? '' : entity.id),
|
detached ? '' : entity.id,
|
||||||
|
params,
|
||||||
|
{ tempo },
|
||||||
|
fxVoiceOf(project, fx, tempo, fxParams),
|
||||||
|
),
|
||||||
|
playPattern: (patternId, volume, loop, detached, fx, fxParams) =>
|
||||||
|
playPattern(run, patternId, volume, loop, detached ? '' : entity.id, fx, fxParams),
|
||||||
stopPattern: (patternId, letRing) => run.audio.stopSequence(patternVoiceName(patternId), letRing),
|
stopPattern: (patternId, letRing) => run.audio.stopSequence(patternVoiceName(patternId), letRing),
|
||||||
playSong: (songId, volume, fadeIn, loop, detached) =>
|
playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams) =>
|
||||||
playSong(run, songId, volume, fadeIn, loop, detached ? '' : entity.id),
|
playSong(run, songId, volume, fadeIn, loop, detached ? '' : entity.id, fx, fxParams),
|
||||||
stopSong: (songId, fadeOut) => run.audio.stopSequence(songVoiceName(songId), false, heldFade(fadeOut)),
|
stopSong: (songId, fadeOut) => run.audio.stopSequence(songVoiceName(songId), false, heldFade(fadeOut)),
|
||||||
stopPlayback: (playback, fadeOut) => run.audio.stopPlayback(playback, heldFade(fadeOut)),
|
stopPlayback: (playback, fadeOut) => run.audio.stopPlayback(playback, heldFade(fadeOut)),
|
||||||
soundLength: (assetId) => {
|
soundLength: (assetId) => {
|
||||||
@@ -204,7 +210,7 @@ export function behaviorContext(
|
|||||||
if (assetId === '' || file) run.audio.stop(file, letRing);
|
if (assetId === '' || file) run.audio.stop(file, letRing);
|
||||||
},
|
},
|
||||||
stopVoice: (voice, letRing) => run.audio.stopVoice(voice, letRing),
|
stopVoice: (voice, letRing) => run.audio.stopVoice(voice, letRing),
|
||||||
tuneSound: (voice, fxParams, time, ease) => run.audio.tune(voice, fxParams, time, ease),
|
tuneSound: (voice, fxParams, time, ease) => run.audio.tune(voice, withScoped(fxParams), time, ease),
|
||||||
spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null),
|
spawn: (assetId, position, parent) => world.spawn(assetId, position, resolve(parent) ?? null),
|
||||||
addJoint: (assetId, owner, connected, at) => {
|
addJoint: (assetId, owner, connected, at) => {
|
||||||
const on = resolve(owner);
|
const on = resolve(owner);
|
||||||
@@ -282,7 +288,8 @@ function playSound(
|
|||||||
* One note of an instrument (#331), as the sink understands it: the layers the instrument makes of
|
* One note of an instrument (#331), as the sink understands it: the layers the instrument makes of
|
||||||
* it, the velocity as loudness and as the value of its `velocity`, and the voice's volume held to
|
* it, the velocity as loudness and as the value of its `velocity`, and the voice's volume held to
|
||||||
* 0..1 like a sound's. An instrument that plays nothing for that note plays nothing at all.
|
* 0..1 like a sound's. An instrument that plays nothing for that note plays nothing at all.
|
||||||
* `timing` says when it starts and by which tempo (#332).
|
* `timing` says when it starts and by which tempo (#332). `fx` is a sound FX of the note's own
|
||||||
|
* (#346), its parameters in `FX_SCOPE`.
|
||||||
*/
|
*/
|
||||||
export function playNote(
|
export function playNote(
|
||||||
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
|
{ project, audio }: Pick<ContextSources, 'project' | 'audio'>,
|
||||||
@@ -291,18 +298,35 @@ export function playNote(
|
|||||||
owner: string,
|
owner: string,
|
||||||
params: FxParams,
|
params: FxParams,
|
||||||
timing: NoteTiming = {},
|
timing: NoteTiming = {},
|
||||||
|
fx: FxVoice | null = null,
|
||||||
): number {
|
): number {
|
||||||
const asset = findAssetOf(project, instrumentId, 'instrument');
|
const asset = findAssetOf(project, instrumentId, 'instrument');
|
||||||
if (!asset) return 0;
|
if (!asset) return 0;
|
||||||
const { layers } = instrumentBuildFor(asset, heldNote(struck.note), instrumentSourcesOf(project));
|
const built = instrumentBuildFor(asset, heldNote(struck.note), instrumentSourcesOf(project)).layers;
|
||||||
if (layers.length === 0) return 0;
|
if (built.length === 0) return 0;
|
||||||
|
const layers = fx ? built.map((layer) => throughScopedFx(fx.plan, layer, FX_SCOPE)) : built;
|
||||||
const velocity = Number.isFinite(struck.velocity) ? Math.max(0, Math.min(1, struck.velocity)) : 1;
|
const velocity = Number.isFinite(struck.velocity) ? Math.max(0, Math.min(1, struck.velocity)) : 1;
|
||||||
const level = Math.max(0, Math.min(1, struck.volume * velocity));
|
const level = Math.max(0, Math.min(1, struck.volume * velocity));
|
||||||
const duration = Number.isFinite(struck.duration) ? Math.max(0, struck.duration) : 0;
|
const duration = Number.isFinite(struck.duration) ? Math.max(0, struck.duration) : 0;
|
||||||
const values = { ...params, [VELOCITY_PARAM]: velocity };
|
const values = { ...params, [VELOCITY_PARAM]: velocity, ...scoped(fx?.params ?? {}) };
|
||||||
return audio.playNote(instrumentVoiceName(instrumentId), layers, values, level, owner, duration, timing);
|
return audio.playNote(instrumentVoiceName(instrumentId), layers, values, level, owner, duration, timing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where the parameters of a note's own sound FX go by (#346): no name of a parameter has a colon. */
|
||||||
|
export const FX_SCOPE = 'fx:';
|
||||||
|
|
||||||
|
function scoped(params: FxParams): FxParams {
|
||||||
|
return Object.fromEntries(Object.entries(params).map(([key, value]) => [`${FX_SCOPE}${key}`, value]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values handed to a playing voice: for a note with a sound FX of its own (#346) they reach both its
|
||||||
|
* instrument and its FX, a name both have changes both. Anything else has no scoped names to reach.
|
||||||
|
*/
|
||||||
|
function withScoped(params: FxParams): FxParams {
|
||||||
|
return { ...params, ...scoped(params) };
|
||||||
|
}
|
||||||
|
|
||||||
/** What a note's voice is named by, where a sound's is named by its file. */
|
/** What a note's voice is named by, where a sound's is named by its file. */
|
||||||
export function instrumentVoiceName(instrumentId: string): string {
|
export function instrumentVoiceName(instrumentId: string): string {
|
||||||
return `instrument:${instrumentId}`;
|
return `instrument:${instrumentId}`;
|
||||||
@@ -319,6 +343,8 @@ function playPattern(
|
|||||||
volume: number,
|
volume: number,
|
||||||
loop: boolean,
|
loop: boolean,
|
||||||
owner: string,
|
owner: string,
|
||||||
|
fxId: string,
|
||||||
|
fxParams: FxParams,
|
||||||
): number {
|
): number {
|
||||||
const asset = findAssetOf(project, patternId, 'pattern');
|
const asset = findAssetOf(project, patternId, 'pattern');
|
||||||
if (!asset) return 0;
|
if (!asset) return 0;
|
||||||
@@ -327,14 +353,17 @@ function playPattern(
|
|||||||
if (!instrument || sequence.notes.length === 0) return 0;
|
if (!instrument || sequence.notes.length === 0) return 0;
|
||||||
const level = heldLevel(volume);
|
const level = heldLevel(volume);
|
||||||
const length = patternLength(asset);
|
const length = patternLength(asset);
|
||||||
|
const tempo = patternTempo(asset.props);
|
||||||
|
const fx = fxVoiceOf(project, fxId, tempo, fxParams);
|
||||||
return audio.playSequence(
|
return audio.playSequence(
|
||||||
patternVoiceName(patternId),
|
patternVoiceName(patternId),
|
||||||
{
|
{
|
||||||
sequence,
|
sequence,
|
||||||
tempo: patternTempo(asset.props),
|
tempo,
|
||||||
length,
|
length,
|
||||||
loop: loop ? { start: 0, end: length } : null,
|
loop: loop ? { start: 0, end: length } : null,
|
||||||
needs: sequenceNeeds(project, sequence),
|
needs: sequenceNeeds(project, sequence),
|
||||||
|
...(fx && { fx }),
|
||||||
strike: (note) => strikeNote({ project, audio }, { ...note, volume: level }, owner),
|
strike: (note) => strikeNote({ project, audio }, { ...note, volume: level }, owner),
|
||||||
},
|
},
|
||||||
owner,
|
owner,
|
||||||
@@ -354,21 +383,26 @@ function playSong(
|
|||||||
fadeIn: number,
|
fadeIn: number,
|
||||||
loop: boolean,
|
loop: boolean,
|
||||||
owner: string,
|
owner: string,
|
||||||
|
fxId: string,
|
||||||
|
fxParams: FxParams,
|
||||||
): number {
|
): number {
|
||||||
const song = findAssetOf(project, songId, 'song');
|
const song = findAssetOf(project, songId, 'song');
|
||||||
if (!song) return 0;
|
if (!song) return 0;
|
||||||
const sequence = songSequence(project, song);
|
const sequence = songSequence(project, song);
|
||||||
|
const tempo = songTempo(song.props);
|
||||||
|
const fx = fxVoiceOf(project, fxId, tempo, fxParams);
|
||||||
return audio.playSequence(
|
return audio.playSequence(
|
||||||
songVoiceName(songId),
|
songVoiceName(songId),
|
||||||
{
|
{
|
||||||
sequence,
|
sequence,
|
||||||
tempo: songTempo(song.props),
|
tempo,
|
||||||
length: songLength(song),
|
length: songLength(song),
|
||||||
loop: loop ? songLoop(song) : null,
|
loop: loop ? songLoop(song) : null,
|
||||||
needs: sequenceNeeds(project, sequence),
|
needs: sequenceNeeds(project, sequence),
|
||||||
volume: heldLevel(volume),
|
volume: heldLevel(volume),
|
||||||
fadeIn: heldFade(fadeIn),
|
fadeIn: heldFade(fadeIn),
|
||||||
buses: songBuses(song),
|
buses: songBuses(project, song),
|
||||||
|
...(fx && { fx }),
|
||||||
strike: (note) => strikeNote({ project, audio }, note, owner),
|
strike: (note) => strikeNote({ project, audio }, note, owner),
|
||||||
strikeClip: (clip) => playClip({ project, audio }, clip),
|
strikeClip: (clip) => playClip({ project, audio }, clip),
|
||||||
},
|
},
|
||||||
|
|||||||
+357
-32
@@ -22,11 +22,12 @@ import {
|
|||||||
} from '../core/soundfx/nodes';
|
} from '../core/soundfx/nodes';
|
||||||
import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument/envelope';
|
import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument/envelope';
|
||||||
import type { EaseShare } from '../core/model/animation';
|
import type { EaseShare } from '../core/model/animation';
|
||||||
import { type BusLevel, Playbacks, type SequencePlay } from '../core/music/playbacks';
|
import { type BusLevel, Playbacks, type SequenceNeeds, type SequencePlay } from '../core/music/playbacks';
|
||||||
import type { ScheduledClip, ScheduledNote } from '../core/music/Scheduler';
|
import type { ScheduledClip, ScheduledNote } from '../core/music/Scheduler';
|
||||||
import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
|
import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
|
||||||
import { DEFAULT_TEMPO } from '../core/music/time';
|
import { DEFAULT_TEMPO } from '../core/music/time';
|
||||||
import { type CutRange, cutChannels, cutRange, parseCutName } from '../core/model/sound';
|
import { type CutRange, cutChannels, cutRange, parseCutName } from '../core/model/sound';
|
||||||
|
import { shifterLatency } from '../core/soundfx/pitchPlayer';
|
||||||
import type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio';
|
import type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio';
|
||||||
import type { ChipOptions, FlangerOptions, PhaserOptions, PitchOptions } from './fxWorklet';
|
import type { ChipOptions, FlangerOptions, PhaserOptions, PitchOptions } from './fxWorklet';
|
||||||
import fxWorkletUrl from './fxWorklet.ts?worker&url';
|
import fxWorkletUrl from './fxWorklet.ts?worker&url';
|
||||||
@@ -286,7 +287,8 @@ export class SoundLibrary implements AudioSink {
|
|||||||
for (const id of voices) this.stopVoice(id, letRing);
|
for (const id of voices) this.stopVoice(id, letRing);
|
||||||
},
|
},
|
||||||
wake,
|
wake,
|
||||||
prepare: ({ layers, files }) => this.prepare(layers, files),
|
prepare: (needs, id) => this.ready(needs, id),
|
||||||
|
ahead: (id) => this.groupAhead(id),
|
||||||
level: (id, gain, at, seconds) => this.level(id, gain, at, seconds),
|
level: (id, gain, at, seconds) => this.level(id, gain, at, seconds),
|
||||||
after: (at, then) => void this.after(at, then),
|
after: (at, then) => void this.after(at, then),
|
||||||
closed: () => this.sweep(),
|
closed: () => this.sweep(),
|
||||||
@@ -295,6 +297,8 @@ export class SoundLibrary implements AudioSink {
|
|||||||
private readonly groups = new Set<number>();
|
private readonly groups = new Set<number>();
|
||||||
/** Held by the pause of the toolbar: nothing that reads the clock may wake it then. */
|
/** Held by the pause of the toolbar: nothing that reads the clock may wake it then. */
|
||||||
private held = false;
|
private held = false;
|
||||||
|
/** Counts the times buses were let go, so a later use of one can keep it. */
|
||||||
|
private leavings = 0;
|
||||||
|
|
||||||
/** Where the files are served from. Set once at start-up, before anything plays. */
|
/** Where the files are served from. Set once at start-up, before anything plays. */
|
||||||
configure(base: string): void {
|
configure(base: string): void {
|
||||||
@@ -372,26 +376,252 @@ export class SoundLibrary implements AudioSink {
|
|||||||
bus.pan.pan.setTargetAtTime(Math.max(-1, Math.min(1, pan)), now, GLIDE / 3);
|
bus.pan.pan.setTargetAtTime(Math.max(-1, Math.min(1, pan)), now, GLIDE / 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What each track's bus was last set to (test hook). */
|
/**
|
||||||
busLevels(): { name: string; gain: number; pan: number }[] {
|
* The tracks of a song in the Song tab (#334), each set as `setBus` sets one, with the bus it goes
|
||||||
return [...this.buses].map(([name, bus]) => ({ name, ...bus.set }));
|
* into and its sound FX (#346). A chain is made again only where its FX has changed. Done once the
|
||||||
|
* chains are there – the worklet some of them need has to be loaded first.
|
||||||
|
*/
|
||||||
|
async setBuses(levels: readonly BusLevel[]): Promise<void> {
|
||||||
|
const waits: Promise<void>[] = [];
|
||||||
|
for (const { name, gain, pan, into, fx } of levels) {
|
||||||
|
if (into !== undefined) this.bus(into);
|
||||||
|
this.bus(name, into ?? null);
|
||||||
|
this.setBus(name, gain, pan);
|
||||||
|
waits.push(this.setFx(this.bus(name), fx ?? null));
|
||||||
|
}
|
||||||
|
await Promise.all(waits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets these buses go once what is on them now has faded (#346): their chains would otherwise run
|
||||||
|
* on for nothing. One used again before then stays.
|
||||||
|
*/
|
||||||
|
releaseBuses(names: readonly string[]): void {
|
||||||
|
const going = names.flatMap((name) => this.buses.get(name) ?? []);
|
||||||
|
if (going.length === 0) return;
|
||||||
|
const token = ++this.leavings;
|
||||||
|
for (const bus of going) bus.leaving = token;
|
||||||
|
const wait = Math.max(0, ...names.map((name) => this.ringOf(name)));
|
||||||
|
this.after(this.audio().currentTime + FADE + wait, () => {
|
||||||
|
for (const name of names) {
|
||||||
|
const bus = this.buses.get(name);
|
||||||
|
if (bus && bus.leaving === token) this.dropBus(name, bus);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seconds what goes in on that bus is late by the time it is heard (#346): its chain and those of
|
||||||
|
* the buses it goes into. 0 straight out.
|
||||||
|
*/
|
||||||
|
busLatency(name: string): number {
|
||||||
|
let late = 0;
|
||||||
|
const seen = new Set<Bus>();
|
||||||
|
for (let bus = this.buses.get(name); bus && !seen.has(bus); bus = this.buses.get(bus.into ?? '')) {
|
||||||
|
seen.add(bus);
|
||||||
|
late += bus.chain?.latency ?? 0;
|
||||||
|
}
|
||||||
|
return late;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What each track's bus was last set to, with whether its sound FX is there and how late (test hook). */
|
||||||
|
busLevels(): {
|
||||||
|
name: string;
|
||||||
|
gain: number;
|
||||||
|
pan: number;
|
||||||
|
into: string | null;
|
||||||
|
fx: boolean;
|
||||||
|
latency: number;
|
||||||
|
}[] {
|
||||||
|
return [...this.buses].map(([name, bus]) => ({
|
||||||
|
name,
|
||||||
|
...bus.set,
|
||||||
|
into: bus.into,
|
||||||
|
fx: bus.chain !== null,
|
||||||
|
latency: bus.chain?.latency ?? 0,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where the voices of a track go in; straight out for `''`. */
|
/** Where the voices of a track go in; straight out for `''`. */
|
||||||
private busInput(name: string): AudioNode {
|
private busInput(name: string): AudioNode {
|
||||||
return name === '' ? this.audio().destination : this.bus(name).gain;
|
return name === '' ? this.audio().destination : this.bus(name).input;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bus(name: string, out?: AudioNode): Bus {
|
/** A bus by its name, made on its first use into `into`, or straight out; it is not to go any more. */
|
||||||
|
private bus(name: string, into: string | null = null): Bus {
|
||||||
const known = this.buses.get(name);
|
const known = this.buses.get(name);
|
||||||
if (known) return known;
|
if (known) {
|
||||||
|
known.leaving = 0;
|
||||||
|
return known;
|
||||||
|
}
|
||||||
const context = this.audio();
|
const context = this.audio();
|
||||||
const made = { gain: context.createGain(), pan: context.createStereoPanner(), set: { gain: 1, pan: 0 } };
|
const made: Bus = {
|
||||||
made.gain.connect(made.pan).connect(out ?? context.destination);
|
input: context.createGain(),
|
||||||
|
gain: context.createGain(),
|
||||||
|
pan: context.createStereoPanner(),
|
||||||
|
set: { gain: 1, pan: 0 },
|
||||||
|
into,
|
||||||
|
fx: null,
|
||||||
|
chain: null,
|
||||||
|
leaving: 0,
|
||||||
|
};
|
||||||
|
made.input
|
||||||
|
.connect(made.gain)
|
||||||
|
.connect(made.pan)
|
||||||
|
.connect(into ? this.bus(into).input : context.destination);
|
||||||
this.buses.set(name, made);
|
this.buses.set(name, made);
|
||||||
return made;
|
return made;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the sound FX of a bus (#346), its chain made again only where the FX is another or counts by
|
||||||
|
* another tempo. Done once the chain is there, after the worklet it needs.
|
||||||
|
*/
|
||||||
|
private async setFx(bus: Bus, fx: FxVoice | null): Promise<void> {
|
||||||
|
if (bus.fx?.plan === fx?.plan && bus.fx?.tempo === fx?.tempo) return;
|
||||||
|
bus.fx = fx;
|
||||||
|
this.unchain(bus);
|
||||||
|
if (!fx) return;
|
||||||
|
if (needsWorklet(fx.plan)) await this.fxWorklet();
|
||||||
|
if (bus.fx === fx && !bus.chain) this.chain(bus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puts the chain of its sound FX between the input of a bus and its level. */
|
||||||
|
private chain(bus: Bus): void {
|
||||||
|
const fx = bus.fx;
|
||||||
|
if (!fx) return;
|
||||||
|
const context = this.audio();
|
||||||
|
const made: BusChain = { knobs: [], latency: 0, stops: [], nodes: [] };
|
||||||
|
bus.input.disconnect();
|
||||||
|
const { out, latency } = this.chainOf(context, fxChainOf(fx.plan, fx.params, fx.tempo), bus.input, made);
|
||||||
|
out.connect(bus.gain);
|
||||||
|
made.latency = latency;
|
||||||
|
bus.chain = made;
|
||||||
|
}
|
||||||
|
|
||||||
|
private unchain(bus: Bus): void {
|
||||||
|
const chain = bus.chain;
|
||||||
|
if (!chain) return;
|
||||||
|
bus.chain = null;
|
||||||
|
for (const stop of chain.stops) stop();
|
||||||
|
for (const node of chain.nodes) node.disconnect();
|
||||||
|
bus.input.disconnect();
|
||||||
|
bus.input.connect(bus.gain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The nodes a chain of a bus becomes, fed from `input`, in the order `wire` makes those of a voice
|
||||||
|
* so their knobs line up with `levelsOf`. A way has no source of its own: both ways of a Mix are fed
|
||||||
|
* the bus. A Pitch is a shifter of the sound going by (#346), late by a frame, and the other way of
|
||||||
|
* a Mix is held back as far, so the two stay together. Says where it comes out and how late.
|
||||||
|
*/
|
||||||
|
private chainOf(
|
||||||
|
context: AudioContext,
|
||||||
|
chain: FxChain,
|
||||||
|
input: AudioNode,
|
||||||
|
made: BusChain,
|
||||||
|
): { out: AudioNode; latency: number } {
|
||||||
|
const { knobs, nodes } = made;
|
||||||
|
let tail: AudioNode = input;
|
||||||
|
let latency = 0;
|
||||||
|
if ('mix' in chain) {
|
||||||
|
const both = chain.curve === 'both';
|
||||||
|
const a = this.chainOf(context, chain.a, input, made);
|
||||||
|
const shareA = gainNode(context, both ? 0 : 1 - chain.mix);
|
||||||
|
if (!both) knobs.push({ param: shareA.gain, target: 1 - chain.mix, even: 'value' });
|
||||||
|
const b = this.chainOf(context, chain.b, input, made);
|
||||||
|
const shareB = gainNode(context, both ? 0 : chain.mix);
|
||||||
|
if (!both) knobs.push({ param: shareB.gain, target: chain.mix, even: 'value' });
|
||||||
|
else {
|
||||||
|
const driver = bothAtCenter(context, chain.mix, shareA, shareB);
|
||||||
|
driver.start();
|
||||||
|
knobs.push({ param: driver.offset, target: chain.mix, even: 'value' });
|
||||||
|
made.stops.push(() => driver.stop());
|
||||||
|
nodes.push(driver);
|
||||||
|
}
|
||||||
|
latency = Math.max(a.latency, b.latency);
|
||||||
|
tail = context.createGain();
|
||||||
|
for (const [way, share] of [
|
||||||
|
[a, shareA],
|
||||||
|
[b, shareB],
|
||||||
|
] as const) {
|
||||||
|
let out = way.out;
|
||||||
|
if (way.latency < latency) {
|
||||||
|
const line = context.createDelay(latency);
|
||||||
|
line.delayTime.value = latency - way.latency;
|
||||||
|
out.connect(line);
|
||||||
|
nodes.push(line);
|
||||||
|
out = line;
|
||||||
|
}
|
||||||
|
out.connect(share).connect(tail);
|
||||||
|
nodes.push(share);
|
||||||
|
}
|
||||||
|
nodes.push(tail);
|
||||||
|
} else if (chain.shifted) {
|
||||||
|
const shifted = this.shifter(context, chain.pitch, input, made);
|
||||||
|
tail = shifted.out;
|
||||||
|
latency = shifted.latency;
|
||||||
|
}
|
||||||
|
for (const stage of chain.stages) {
|
||||||
|
// A sound FX has no Envelope; one would only silence the bus.
|
||||||
|
if (stage.kind === 'envelope') continue;
|
||||||
|
const stageMade = stageNodes(context, stage, undefined, context.currentTime);
|
||||||
|
tail.connect(stageMade.input);
|
||||||
|
tail = stageMade.output;
|
||||||
|
knobs.push(...stageMade.knobs);
|
||||||
|
if (stageMade.stop) made.stops.push(stageMade.stop);
|
||||||
|
nodes.push(stageMade.input, stageMade.output);
|
||||||
|
}
|
||||||
|
return { out: tail, latency };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Pitch of a way of a bus (#346); without the worklet the sound goes by as it is, on time. */
|
||||||
|
private shifter(
|
||||||
|
context: AudioContext,
|
||||||
|
pitch: number,
|
||||||
|
input: AudioNode,
|
||||||
|
made: BusChain,
|
||||||
|
): { out: AudioNode; latency: number } {
|
||||||
|
let node: AudioWorkletNode;
|
||||||
|
try {
|
||||||
|
if (!this.hasWorklet) throw new Error('no worklet');
|
||||||
|
node = new AudioWorkletNode(context, 'pitch-shifter', {
|
||||||
|
numberOfInputs: 1,
|
||||||
|
numberOfOutputs: 1,
|
||||||
|
outputChannelCount: [2],
|
||||||
|
channelCount: 2,
|
||||||
|
channelCountMode: 'explicit',
|
||||||
|
parameterData: { pitch },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
made.knobs.push({ param: null, target: pitch, even: 'value' });
|
||||||
|
return { out: input, latency: 0 };
|
||||||
|
}
|
||||||
|
input.connect(node);
|
||||||
|
made.nodes.push(node);
|
||||||
|
made.stops.push(() => node.port.postMessage('stop'));
|
||||||
|
made.knobs.push({ param: node.parameters.get('pitch')!, target: pitch, even: 'value' });
|
||||||
|
return { out: node, latency: shifterLatency(context.sampleRate) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seconds after its sound stops that a bus is still heard (#346): the echoes of its chain, after
|
||||||
|
* what it and the buses it goes into hold back.
|
||||||
|
*/
|
||||||
|
private ringOf(name: string): number {
|
||||||
|
const fx = this.buses.get(name)?.fx;
|
||||||
|
const tail = fx && fxRings(fx.plan) ? fxTailOf(fxChainOf(fx.plan, fx.params, fx.tempo)) : 0;
|
||||||
|
return tail + this.busLatency(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private dropBus(name: string, bus: Bus): void {
|
||||||
|
this.unchain(bus);
|
||||||
|
bus.input.disconnect();
|
||||||
|
bus.gain.disconnect();
|
||||||
|
bus.pan.disconnect();
|
||||||
|
this.buses.delete(name);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A sequence without notes or clips plays nothing, and is given no number. Each playing is heard
|
* A sequence without notes or clips plays nothing, and is given no number. Each playing is heard
|
||||||
* through a master of its own, silent until it starts (#336): its level and its fades. The tracks
|
* through a master of its own, silent until it starts (#336): its level and its fades. The tracks
|
||||||
@@ -401,12 +631,14 @@ export class SoundLibrary implements AudioSink {
|
|||||||
if (play.sequence.notes.length === 0 && !play.sequence.clips?.length) return 0;
|
if (play.sequence.notes.length === 0 && !play.sequence.clips?.length) return 0;
|
||||||
const id = this.nextId++;
|
const id = this.nextId++;
|
||||||
const buses = play.buses ?? [];
|
const buses = play.buses ?? [];
|
||||||
this.open(id, buses);
|
this.open(id, buses, play.fx ?? null);
|
||||||
const tracks = new Set(buses.map((bus) => bus.name));
|
const tracks = new Set(buses.map((bus) => bus.name));
|
||||||
const into = (bus: string | undefined) => groupBus(id, bus !== undefined && tracks.has(bus) ? bus : '');
|
const into = (bus: string | undefined) => groupBus(id, bus !== undefined && tracks.has(bus) ? bus : '');
|
||||||
const { strike, strikeClip } = play;
|
const { strike, strikeClip } = play;
|
||||||
|
const chains = [...buses.flatMap((bus) => bus.fx ?? []), ...(play.fx ? [play.fx] : [])];
|
||||||
const routed: SequencePlay = {
|
const routed: SequencePlay = {
|
||||||
...play,
|
...play,
|
||||||
|
needs: { ...play.needs, layers: [...play.needs.layers, ...chains.map((fx) => fx.plan)] },
|
||||||
strike: (note) => strike({ ...note, bus: into(note.bus) }),
|
strike: (note) => strike({ ...note, bus: into(note.bus) }),
|
||||||
...(strikeClip && {
|
...(strikeClip && {
|
||||||
strikeClip: (clip: ScheduledClip) => strikeClip({ ...clip, bus: into(clip.bus) }),
|
strikeClip: (clip: ScheduledClip) => strikeClip({ ...clip, bus: into(clip.bus) }),
|
||||||
@@ -446,19 +678,49 @@ export class SoundLibrary implements AudioSink {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private open(id: number, tracks: readonly BusLevel[]): void {
|
/**
|
||||||
const master = this.bus(groupBus(id, ''));
|
* The buses of a playing, its tracks into its master and a pattern item's into its track (#346),
|
||||||
|
* each with the sound FX it was given. Their chains are made once the worklet is there (`ready`).
|
||||||
|
*/
|
||||||
|
private open(id: number, tracks: readonly BusLevel[], fx: FxVoice | null): void {
|
||||||
|
const masterName = groupBus(id, '');
|
||||||
|
const master = this.bus(masterName);
|
||||||
master.gain.gain.value = 0;
|
master.gain.gain.value = 0;
|
||||||
master.set = { gain: 0, pan: 0 };
|
master.set = { gain: 0, pan: 0 };
|
||||||
for (const { name, gain, pan } of tracks) {
|
master.fx = fx;
|
||||||
const track = this.bus(groupBus(id, name), master.gain);
|
const names = new Set(tracks.map((track) => track.name));
|
||||||
|
const ordered = [...tracks].sort((a, b) => Number(a.into !== undefined) - Number(b.into !== undefined));
|
||||||
|
for (const { name, gain, pan, into, fx: trackFx } of ordered) {
|
||||||
|
const parent = into !== undefined && names.has(into) ? groupBus(id, into) : masterName;
|
||||||
|
const track = this.bus(groupBus(id, name), parent);
|
||||||
track.gain.gain.value = Math.max(0, gain);
|
track.gain.gain.value = Math.max(0, gain);
|
||||||
track.pan.pan.value = Math.max(-1, Math.min(1, pan));
|
track.pan.pan.value = Math.max(-1, Math.min(1, pan));
|
||||||
track.set = { gain: track.gain.gain.value, pan: track.pan.pan.value };
|
track.set = { gain: track.gain.gain.value, pan: track.pan.pan.value };
|
||||||
|
track.fx = trackFx ?? null;
|
||||||
}
|
}
|
||||||
this.groups.add(id);
|
this.groups.add(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a playing needs loaded, then the chains of its buses (#346): before its first note is planned. */
|
||||||
|
private async ready(needs: SequenceNeeds, id: number): Promise<void> {
|
||||||
|
await this.prepare(needs.layers, needs.files);
|
||||||
|
for (const name of this.groupBuses(id)) {
|
||||||
|
const bus = this.buses.get(name);
|
||||||
|
if (bus?.fx && !bus.chain) this.chain(bus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The names of the buses of a playing: its master and every track inside it. */
|
||||||
|
private groupBuses(id: number): string[] {
|
||||||
|
const master = groupBus(id, '');
|
||||||
|
return [...this.buses.keys()].filter((bus) => bus === master || bus.startsWith(`${master}:`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How late the latest way through the buses of a playing is (#346): how early its notes start. */
|
||||||
|
private groupAhead(id: number): number {
|
||||||
|
return Math.max(0, ...this.groupBuses(id).map((name) => this.busLatency(name)));
|
||||||
|
}
|
||||||
|
|
||||||
/** The master of a playing, from where it stands at `at` to `gain`, there `seconds` later. */
|
/** The master of a playing, from where it stands at `at` to `gain`, there `seconds` later. */
|
||||||
private level(id: number, gain: number, at: number, seconds: number): void {
|
private level(id: number, gain: number, at: number, seconds: number): void {
|
||||||
const master = this.buses.get(groupBus(id, ''));
|
const master = this.buses.get(groupBus(id, ''));
|
||||||
@@ -484,12 +746,12 @@ export class SoundLibrary implements AudioSink {
|
|||||||
if (this.pending.some((waiting) => !waiting.stopped && on(waiting.bus))) continue;
|
if (this.pending.some((waiting) => !waiting.stopped && on(waiting.bus))) continue;
|
||||||
this.groups.delete(id);
|
this.groups.delete(id);
|
||||||
const names = [...this.buses.keys()].filter(on);
|
const names = [...this.buses.keys()].filter(on);
|
||||||
this.after(this.audio().currentTime + FADE, () => {
|
// Its echoes, and what its shifters still hold, are heard after its last voice (#346).
|
||||||
|
const ring = Math.max(0, ...names.map((name) => this.ringOf(name)));
|
||||||
|
this.after(this.audio().currentTime + FADE + ring, () => {
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const bus = this.buses.get(name);
|
const bus = this.buses.get(name);
|
||||||
bus?.gain.disconnect();
|
if (bus) this.dropBus(name, bus);
|
||||||
bus?.pan.disconnect();
|
|
||||||
this.buses.delete(name);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -598,7 +860,7 @@ export class SoundLibrary implements AudioSink {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const voice = this.voices.find((other) => other.id === id);
|
const voice = this.voices.find((other) => other.id === id);
|
||||||
if (!voice) return false;
|
if (!voice) return this.sequences.has(id) && this.tuneMaster(id, params, time, ease);
|
||||||
voice.fx = { ...voice.fx, params: { ...voice.fx.params, ...params } };
|
voice.fx = { ...voice.fx, params: { ...voice.fx.params, ...params } };
|
||||||
if (this.glide(voice, time, ease)) this.tunedAt = this.audio().currentTime;
|
if (this.glide(voice, time, ease)) this.tunedAt = this.audio().currentTime;
|
||||||
// A longer feedback makes for a longer tail: it is counted again from where the change ends.
|
// A longer feedback makes for a longer tail: it is counted again from where the change ends.
|
||||||
@@ -607,6 +869,23 @@ export class SoundLibrary implements AudioSink {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sound FX of a pattern or song playing (#346), on its master; one still loading starts with
|
||||||
|
* the values. Says it was reached, whether or not it has a sound FX.
|
||||||
|
*/
|
||||||
|
private tuneMaster(id: number, params: FxParams, time: number, ease: EaseShare): boolean {
|
||||||
|
const master = this.buses.get(groupBus(id, ''));
|
||||||
|
const fx = master?.fx;
|
||||||
|
if (!master || !fx) return true;
|
||||||
|
master.fx = { ...fx, params: { ...fx.params, ...params } };
|
||||||
|
// The chain stays: `setFx` would see the same plan, and the knobs move it.
|
||||||
|
const levels = levelsOf(fxChainOf(fx.plan, master.fx.params, fx.tempo));
|
||||||
|
if (master.chain && glideKnobs(master.chain.knobs, levels, this.audio().currentTime, time, ease))
|
||||||
|
this.tunedAt = this.audio().currentTime;
|
||||||
|
this.tuned += 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seconds a voice rings on after its sound has ended, at the values it has now (#324); 0 for a
|
* Seconds a voice rings on after its sound has ended, at the values it has now (#324); 0 for a
|
||||||
* voice without echoes and for one that is not there.
|
* voice without echoes and for one that is not there.
|
||||||
@@ -966,11 +1245,27 @@ export class SoundLibrary implements AudioSink {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private start({ id, file, files, volume, loop, owner, fx, duration, at, clip, bus = '' }: Start): void {
|
private start({
|
||||||
|
id,
|
||||||
|
file,
|
||||||
|
files,
|
||||||
|
volume,
|
||||||
|
loop,
|
||||||
|
owner,
|
||||||
|
fx,
|
||||||
|
duration,
|
||||||
|
at: heard,
|
||||||
|
clip: asked,
|
||||||
|
bus = '',
|
||||||
|
}: Start): void {
|
||||||
const context = this.audio();
|
const context = this.audio();
|
||||||
// A context starts suspended where the page has not been interacted with; without this the
|
// A context starts suspended where the page has not been interacted with; without this the
|
||||||
// voice would run silently and end as if it had played.
|
// voice would run silently and end as if it had played.
|
||||||
if (context.state === 'suspended' && !this.held) void context.resume();
|
if (context.state === 'suspended' && !this.held) void context.resume();
|
||||||
|
// As much earlier as its bus is late, so it is heard when it was planned (#346).
|
||||||
|
const early = this.busLatency(bus);
|
||||||
|
const at = heard > 0 ? heard - early : heard;
|
||||||
|
const clip = asked && { ...asked, end: asked.end - early };
|
||||||
const when = Math.max(at, context.currentTime);
|
const when = Math.max(at, context.currentTime);
|
||||||
// A clip whose bytes came too late is over already, or starts as far in as it would be by now.
|
// A clip whose bytes came too late is over already, or starts as far in as it would be by now.
|
||||||
if (clip && clip.end <= when) return;
|
if (clip && clip.end <= when) return;
|
||||||
@@ -1277,16 +1572,7 @@ export class SoundLibrary implements AudioSink {
|
|||||||
private glide(voice: Voice, time: number, ease: EaseShare): boolean {
|
private glide(voice: Voice, time: number, ease: EaseShare): boolean {
|
||||||
const levels: number[] = [];
|
const levels: number[] = [];
|
||||||
for (const plan of voice.fx.layers) levelsOf(fxChainOf(plan, voice.fx.params, voice.fx.tempo), levels);
|
for (const plan of voice.fx.layers) levelsOf(fxChainOf(plan, voice.fx.params, voice.fx.tempo), levels);
|
||||||
const now = this.audio().currentTime;
|
return glideKnobs(voice.knobs, levels, this.audio().currentTime, time, ease);
|
||||||
let moved = false;
|
|
||||||
voice.knobs.forEach((knob, i) => {
|
|
||||||
const level = levels[i];
|
|
||||||
if (level === undefined || level === knob.target) return;
|
|
||||||
knob.target = level;
|
|
||||||
moved = true;
|
|
||||||
if (knob.param) move(knob.param, level, now, time, knob.even === 'ratio', ease);
|
|
||||||
});
|
|
||||||
return moved;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private audio(): AudioContext {
|
private audio(): AudioContext {
|
||||||
@@ -1376,11 +1662,31 @@ function groupBus(id: number, track: string): string {
|
|||||||
return track === '' ? `play:${id}` : `play:${id}:${track}`;
|
return track === '' ? `play:${id}` : `play:${id}:${track}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A track of a song (#334): its level, then its pan, then out; `set` is what they glide to. */
|
/**
|
||||||
|
* A track of a song (#334): what goes in passes its sound FX (#346), then its level, then its pan,
|
||||||
|
* then the bus it goes into or straight out; `set` is what level and pan glide to.
|
||||||
|
*/
|
||||||
interface Bus {
|
interface Bus {
|
||||||
|
input: GainNode;
|
||||||
gain: GainNode;
|
gain: GainNode;
|
||||||
pan: StereoPannerNode;
|
pan: StereoPannerNode;
|
||||||
set: { gain: number; pan: number };
|
set: { gain: number; pan: number };
|
||||||
|
into: string | null;
|
||||||
|
/** The sound FX it is set to, and the chain made of it once its worklet is there. */
|
||||||
|
fx: FxVoice | null;
|
||||||
|
chain: BusChain | null;
|
||||||
|
/** Set while it is to be let go a moment later, unless it is used again first. */
|
||||||
|
leaving: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sound FX of a bus as nodes (#346): one chain everything on the bus goes through. */
|
||||||
|
interface BusChain {
|
||||||
|
/** In the order `levelsOf` lists the numbers of its chain. */
|
||||||
|
knobs: Knob[];
|
||||||
|
/** Seconds it is late: its pitch shifters, the other way of a Mix held back to match them. */
|
||||||
|
latency: number;
|
||||||
|
stops: (() => void)[];
|
||||||
|
nodes: AudioNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What `stats` says about the last voice: enough for a check to tell which effects it went through. */
|
/** What `stats` says about the last voice: enough for a check to tell which effects it went through. */
|
||||||
@@ -1793,6 +2099,25 @@ function move(
|
|||||||
else param.setValueCurveAtTime(easedCurve(from, to, time, logarithmic, ease), now, time);
|
else param.setValueCurveAtTime(easedCurve(from, to, time, logarithmic, ease), now, time);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sends each knob to its level, as `glide` says; whether any of them had somewhere to go. */
|
||||||
|
function glideKnobs(
|
||||||
|
knobs: readonly Knob[],
|
||||||
|
levels: readonly number[],
|
||||||
|
now: number,
|
||||||
|
time: number,
|
||||||
|
ease: EaseShare,
|
||||||
|
): boolean {
|
||||||
|
let moved = false;
|
||||||
|
knobs.forEach((knob, i) => {
|
||||||
|
const level = levels[i];
|
||||||
|
if (level === undefined || level === knob.target) return;
|
||||||
|
knob.target = level;
|
||||||
|
moved = true;
|
||||||
|
if (knob.param) move(knob.param, level, now, time, knob.even === 'ratio', ease);
|
||||||
|
});
|
||||||
|
return moved;
|
||||||
|
}
|
||||||
|
|
||||||
/** Samples per second of an eased curve: a bounce keeps its corners, a long sweep stays small. */
|
/** Samples per second of an eased curve: a bounce keeps its corners, a long sweep stays small. */
|
||||||
const CURVE_RATE = 200;
|
const CURVE_RATE = 200;
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
* The audio thread's side of the effects that Web Audio has no node for. A pitched voice (#317)
|
* The audio thread's side of the effects that Web Audio has no node for. A pitched voice (#317)
|
||||||
* plays one sound through a `PitchPlayer`, its pitch the AudioParam `pitch` in semitones, so it
|
* plays one sound through a `PitchPlayer`, its pitch the AudioParam `pitch` in semitones, so it
|
||||||
* glides, ramps and eases like any other knob. A Bitcrusher (#322) runs a `Crusher`, a Chip
|
* glides, ramps and eases like any other knob. A Bitcrusher (#322) runs a `Crusher`, a Chip
|
||||||
* (#329) a `ChipPlayer`, a Phaser and a Flanger (#341) theirs. Loaded into the context once by the sound library; nothing on the page
|
* (#329) a `ChipPlayer`, a Phaser and a Flanger (#341) theirs, the pitch of a bus (#346) a
|
||||||
|
* `PitchShifter`. Loaded into the context once by the sound library; nothing on the page
|
||||||
* imports it.
|
* imports it.
|
||||||
*/
|
*/
|
||||||
import { type ChipShape, ChipPlayer } from '../core/soundfx/chip';
|
import { type ChipShape, ChipPlayer } from '../core/soundfx/chip';
|
||||||
import { Crusher } from '../core/soundfx/crusher';
|
import { Crusher } from '../core/soundfx/crusher';
|
||||||
import { Flanger, type FlangerNumbers, Phaser, type PhaserNumbers } from '../core/soundfx/phaser';
|
import { Flanger, type FlangerNumbers, Phaser, type PhaserNumbers } from '../core/soundfx/phaser';
|
||||||
import { PitchPlayer } from '../core/soundfx/pitchPlayer';
|
import { PitchPlayer, PitchShifter } from '../core/soundfx/pitchPlayer';
|
||||||
|
|
||||||
/** What the audio thread has and the page's types do not. */
|
/** What the audio thread has and the page's types do not. */
|
||||||
declare const sampleRate: number;
|
declare const sampleRate: number;
|
||||||
@@ -86,6 +87,38 @@ class PitchProcessor extends AudioWorkletProcessor {
|
|||||||
|
|
||||||
registerProcessor('pitch-player', PitchProcessor);
|
registerProcessor('pitch-player', PitchProcessor);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pitch of a bus (#346): what comes in goes out moved by `pitch`, a frame late. It runs until
|
||||||
|
* the bus tells it to stop.
|
||||||
|
*/
|
||||||
|
class ShiftProcessor extends AudioWorkletProcessor {
|
||||||
|
private readonly shifter = new PitchShifter(2, sampleRate);
|
||||||
|
private stopped = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.port.onmessage = () => {
|
||||||
|
this.stopped = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
process(
|
||||||
|
inputs: Float32Array[][],
|
||||||
|
outputs: Float32Array[][],
|
||||||
|
parameters: Record<string, Float32Array>,
|
||||||
|
): boolean {
|
||||||
|
if (this.stopped) return false;
|
||||||
|
this.shifter.render(inputs[0] ?? [], outputs[0]!, parameters.pitch!);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get parameterDescriptors() {
|
||||||
|
return [{ name: 'pitch', defaultValue: 0 }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor('pitch-shifter', ShiftProcessor);
|
||||||
|
|
||||||
/** A Bitcrusher (#322) between two stages of a voice; it runs until the voice tells it to stop. */
|
/** A Bitcrusher (#322) between two stages of a voice; it runs until the voice tells it to stop. */
|
||||||
class CrushProcessor extends AudioWorkletProcessor {
|
class CrushProcessor extends AudioWorkletProcessor {
|
||||||
private readonly crusher = new Crusher(sampleRate);
|
private readonly crusher = new Crusher(sampleRate);
|
||||||
|
|||||||
+18
-4
@@ -34,6 +34,9 @@ export class Music {
|
|||||||
private sequence: Sequence = { notes: [] };
|
private sequence: Sequence = { notes: [] };
|
||||||
/** Counts starts and stops: a start still loading its files gives up once another came after it. */
|
/** Counts starts and stops: a start still loading its files gives up once another came after it. */
|
||||||
private run = 0;
|
private run = 0;
|
||||||
|
/** The tracks last set, and when the chains of their sound FX are there (#346). */
|
||||||
|
private buses: readonly BusLevel[] = [];
|
||||||
|
private busesReady: Promise<void> = Promise.resolve();
|
||||||
/** The last notes and clips planned, newest last, for a check to hold against the clock. */
|
/** The last notes and clips planned, newest last, for a check to hold against the clock. */
|
||||||
readonly planned: PlannedNote[] = [];
|
readonly planned: PlannedNote[] = [];
|
||||||
readonly clips: PlannedClip[] = [];
|
readonly clips: PlannedClip[] = [];
|
||||||
@@ -42,17 +45,18 @@ export class Music {
|
|||||||
|
|
||||||
/** Loads what the notes and clips need, then plays from `from` ticks. */
|
/** Loads what the notes and clips need, then plays from `from` ticks. */
|
||||||
async play(sequence: Sequence, options: SchedulerOptions & { from?: number } = {}): Promise<void> {
|
async play(sequence: Sequence, options: SchedulerOptions & { from?: number } = {}): Promise<void> {
|
||||||
this.stop();
|
this.halt();
|
||||||
const run = this.run;
|
const run = this.run;
|
||||||
this.sequence = sequence;
|
this.sequence = sequence;
|
||||||
this.planned.length = 0;
|
this.planned.length = 0;
|
||||||
this.clips.length = 0;
|
this.clips.length = 0;
|
||||||
await this.prepare(sequence);
|
await Promise.all([this.prepare(sequence), this.busesReady]);
|
||||||
if (run !== this.run) return;
|
if (run !== this.run) return;
|
||||||
this.scheduler = new Scheduler(
|
this.scheduler = new Scheduler(
|
||||||
{
|
{
|
||||||
now: () => soundLibrary.time(),
|
now: () => soundLibrary.time(),
|
||||||
latency: () => soundLibrary.latency(),
|
latency: () => soundLibrary.latency(),
|
||||||
|
ahead: () => Math.max(0, ...this.buses.map((bus) => soundLibrary.busLatency(bus.name))),
|
||||||
play: (note) => this.playOne(note),
|
play: (note) => this.playOne(note),
|
||||||
playClip: (clip) => this.playClip(clip),
|
playClip: (clip) => this.playClip(clip),
|
||||||
playing: (voice) => soundLibrary.playing(voice),
|
playing: (voice) => soundLibrary.playing(voice),
|
||||||
@@ -73,12 +77,22 @@ export class Music {
|
|||||||
void this.prepare(sequence);
|
void this.prepare(sequence);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The tracks of a song set to their level and pan, heard at once (#334). */
|
/**
|
||||||
|
* The tracks of a song set to their level and pan, heard at once (#334), and to their sound FX
|
||||||
|
* (#346). A start waits for the chains.
|
||||||
|
*/
|
||||||
setBuses(levels: readonly BusLevel[]): void {
|
setBuses(levels: readonly BusLevel[]): void {
|
||||||
for (const { name, gain, pan } of levels) soundLibrary.setBus(name, gain, pan);
|
this.buses = levels;
|
||||||
|
this.busesReady = soundLibrary.setBuses(levels);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Its buses go once what is on them has faded, unless the next start uses them again. */
|
||||||
stop(): void {
|
stop(): void {
|
||||||
|
this.halt();
|
||||||
|
soundLibrary.releaseBuses(this.buses.map((bus) => bus.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private halt(): void {
|
||||||
this.run += 1;
|
this.run += 1;
|
||||||
this.scheduler?.stop();
|
this.scheduler?.stop();
|
||||||
this.scheduler = null;
|
this.scheduler = null;
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* A sound FX on what plays many notes (#346): a track, a pattern item, a whole Play pattern or Play
|
||||||
|
* song goes through one chain on a bus of its own; a Play note through one of its own, its parameters
|
||||||
|
* apart from the instrument's.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
CreateAssetCommand,
|
||||||
|
CreateSoundCommand,
|
||||||
|
SetAssetPropertyCommand,
|
||||||
|
} from '../../src/core/commands/assetCommands';
|
||||||
|
import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands';
|
||||||
|
import { SetSongCommand } from '../../src/core/commands/songCommands';
|
||||||
|
import { soundFxBuildFor } from '../../src/core/soundfx/compile';
|
||||||
|
import { VELOCITY_PARAM } from '../../src/core/instrument/nodes';
|
||||||
|
import type { BehaviorAsset, InstrumentAsset, SongAsset, SoundFxAsset } from '../../src/core/model/Asset';
|
||||||
|
import type { SequencePlay } from '../../src/core/music/playbacks';
|
||||||
|
import { itemBus, newItem, newTrack, songSequence, trackBus } from '../../src/core/music/song';
|
||||||
|
import { songBuses } from '../../src/core/music/songBuses';
|
||||||
|
import { fxChainOf, type FxParams, type FxPlan } from '../../src/core/soundfx/nodes';
|
||||||
|
import { type AudioSink, SILENT_AUDIO } from '../../src/runtime/systems/Audio';
|
||||||
|
import { graphEditor, makeBehavior, makeSpriteScene, place, startWorld } from '../helpers';
|
||||||
|
|
||||||
|
const FILE = `${'c4'.repeat(32)}.wav`;
|
||||||
|
const Q = 480;
|
||||||
|
const BAR = 4 * Q;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sound FX "Wobble" – a Gain fed by its parameter `depth` (-3 by default), then a Pitch of 12 – an
|
||||||
|
* instrument, a pattern of one note, and a song: on its first track Wobble, on its second the pattern
|
||||||
|
* through Wobble, the pattern through an FX that is gone, and a sound through Wobble by itself.
|
||||||
|
*/
|
||||||
|
function scene() {
|
||||||
|
const f = makeSpriteScene('Box');
|
||||||
|
const made = new CreateAssetCommand('soundfx', 'Wobble');
|
||||||
|
f.stack.execute(made);
|
||||||
|
const fx = made.asset as SoundFxAsset;
|
||||||
|
const fxEdit = graphEditor(f, fx as unknown as BehaviorAsset);
|
||||||
|
const input = fxEdit.addNode('SoundFxInput');
|
||||||
|
input.ports = [{ key: 'depth', kind: 'number', default: -3 }];
|
||||||
|
const gain = fxEdit.addNode('SoundFxGain');
|
||||||
|
const pitch = fxEdit.addNode('SoundFxPitch', { semitones: 12 });
|
||||||
|
const output = fxEdit.addNode('SoundFxOutput');
|
||||||
|
fxEdit.wire([
|
||||||
|
[input, 'out', gain, 'in'],
|
||||||
|
[input, 'depth', gain, 'gain'],
|
||||||
|
[gain, 'out', pitch, 'in'],
|
||||||
|
[pitch, 'out', output, 'in'],
|
||||||
|
]);
|
||||||
|
const sound = new CreateSoundCommand('Blip', FILE);
|
||||||
|
f.stack.execute(sound);
|
||||||
|
const ins = new CreateAssetCommand('instrument', 'Lead');
|
||||||
|
f.stack.execute(ins);
|
||||||
|
const lead = ins.asset as InstrumentAsset;
|
||||||
|
lead.graph.nodes.find((n) => n.type === 'InstrumentSample')!.params.sound = sound.asset.id;
|
||||||
|
const pattern = new CreateAssetCommand('pattern', 'Riff');
|
||||||
|
f.stack.execute(pattern);
|
||||||
|
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'instrument', lead.id));
|
||||||
|
f.stack.execute(new SetAssetPropertyCommand(pattern.asset.id, 'bars', 1));
|
||||||
|
f.stack.execute(
|
||||||
|
new SetPatternNotesCommand(pattern.asset.id, [{ id: 'n', tick: 0, length: Q, note: 60, velocity: 1 }]),
|
||||||
|
);
|
||||||
|
const created = new CreateAssetCommand('song', 'Theme');
|
||||||
|
f.stack.execute(created);
|
||||||
|
const songId = created.asset.id;
|
||||||
|
const song = () => f.project.assets.find((a) => a.id === songId) as SongAsset;
|
||||||
|
const [one, two] = [{ ...song().tracks[0]!, fx: fx.id }, newTrack(2)];
|
||||||
|
const through = newItem({ track: two.id, kind: 'pattern', asset: pattern.asset.id, tick: 0, length: BAR });
|
||||||
|
const gone = newItem({ track: two.id, kind: 'pattern', asset: pattern.asset.id, tick: BAR, length: BAR });
|
||||||
|
const clip = newItem({ track: two.id, kind: 'sound', asset: sound.asset.id, tick: 0, length: BAR });
|
||||||
|
f.stack.execute(
|
||||||
|
new SetSongCommand(songId, {
|
||||||
|
tracks: [one, two],
|
||||||
|
items: [
|
||||||
|
{ ...through, fx: fx.id },
|
||||||
|
{ ...gone, fx: 'gone' },
|
||||||
|
{ ...clip, fx: fx.id },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const behavior = makeBehavior(f, 'Logic');
|
||||||
|
place(f, behavior.id, { x: 0, y: 0 }, place(f, f.spriteId, { x: 0, y: 0 }));
|
||||||
|
const edit = graphEditor(f, behavior);
|
||||||
|
const params = edit.addNode('SoundFxParameters', { soundFxAsset: fx.id, depth: -9 });
|
||||||
|
params.ports = [{ key: 'depth', kind: 'number' }];
|
||||||
|
return { ...f, fx, lead, song, songId, tracks: [one.id, two.id], through, gone, pattern, edit, params };
|
||||||
|
}
|
||||||
|
|
||||||
|
function recording() {
|
||||||
|
const sequences: { name: string; play: SequencePlay }[] = [];
|
||||||
|
const notes: { layers: readonly FxPlan[]; params: FxParams }[] = [];
|
||||||
|
const tuned: { voice: number; params: FxParams }[] = [];
|
||||||
|
const sink: AudioSink = {
|
||||||
|
...SILENT_AUDIO,
|
||||||
|
playSequence: (name, play) => (sequences.push({ name, play }), 500),
|
||||||
|
playNote: (_name, layers, params) => (notes.push({ layers, params }), 7),
|
||||||
|
tune: (voice, params) => void tuned.push({ voice, params }),
|
||||||
|
};
|
||||||
|
return Object.assign(sink, { sequences, notes, tuned });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('a sound FX on the buses of a song (#346)', () => {
|
||||||
|
it('gives a track its sound FX, and a pattern item with one a bus of its own into its track', () => {
|
||||||
|
const f = scene();
|
||||||
|
const plan = soundFxBuildFor(f.fx).compiled!;
|
||||||
|
const [one, two] = f.tracks as [string, string];
|
||||||
|
expect(songBuses(f.project, f.song())).toEqual([
|
||||||
|
{ name: trackBus(f.songId, one), gain: 1, pan: 0, fx: { plan, params: {}, tempo: 120 } },
|
||||||
|
{ name: trackBus(f.songId, two), gain: 1, pan: 0 },
|
||||||
|
{
|
||||||
|
name: itemBus(f.songId, f.through),
|
||||||
|
gain: 1,
|
||||||
|
pan: 0,
|
||||||
|
into: trackBus(f.songId, two),
|
||||||
|
fx: { plan, params: {}, tempo: 120 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const { notes, clips } = songSequence(f.project, f.song());
|
||||||
|
// The item through Wobble on its own bus, the one whose FX is gone on its track's, like the sound.
|
||||||
|
expect(notes.map((n) => [n.tick, n.bus])).toEqual([
|
||||||
|
[0, itemBus(f.songId, f.through)],
|
||||||
|
[BAR, trackBus(f.songId, two)],
|
||||||
|
]);
|
||||||
|
expect(clips?.map((c) => [c.bus, c.fx])).toEqual([[trackBus(f.songId, two), f.fx.id]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a pattern item whose sound FX does not compile on a bus of its own that passes it on', () => {
|
||||||
|
const f = scene();
|
||||||
|
f.fx.graph.edges = f.fx.graph.edges.filter(
|
||||||
|
(e) =>
|
||||||
|
e.to.port !== 'in' ||
|
||||||
|
e.from.port !== 'out' ||
|
||||||
|
e.to.node !== f.fx.graph.nodes.find((n) => n.type === 'SoundFxOutput')!.id,
|
||||||
|
);
|
||||||
|
expect(soundFxBuildFor(f.fx).compiled).toBeNull();
|
||||||
|
const item = songBuses(f.project, f.song()).find((bus) => bus.name === itemBus(f.songId, f.through));
|
||||||
|
expect(item).toEqual({
|
||||||
|
name: itemBus(f.songId, f.through),
|
||||||
|
gain: 1,
|
||||||
|
pan: 0,
|
||||||
|
into: trackBus(f.songId, f.tracks[1]!),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands Play song its buses and its own sound FX with the values given, at its tempo', () => {
|
||||||
|
const f = scene();
|
||||||
|
const start = f.edit.addNode('OnStart');
|
||||||
|
const play = f.edit.addNode('PlaySong', { song: f.songId, fx: f.fx.id });
|
||||||
|
f.edit.wire([
|
||||||
|
[start, 'exec', play, 'exec'],
|
||||||
|
[f.params, 'params', play, 'fxParams'],
|
||||||
|
]);
|
||||||
|
const audio = recording();
|
||||||
|
startWorld(f, { behaviors: true, audio }).frame();
|
||||||
|
const [asked] = audio.sequences;
|
||||||
|
const plan = soundFxBuildFor(f.fx).compiled!;
|
||||||
|
expect(asked?.play.fx).toEqual({ plan, params: { depth: -9 }, tempo: 120 });
|
||||||
|
expect(asked?.play.buses?.map((bus) => [bus.name, bus.into ?? '', 'fx' in bus])).toEqual([
|
||||||
|
[trackBus(f.songId, f.tracks[0]!), '', true],
|
||||||
|
[trackBus(f.songId, f.tracks[1]!), '', false],
|
||||||
|
[itemBus(f.songId, f.through), trackBus(f.songId, f.tracks[1]!), true],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands Play pattern its sound FX the same way, and none where it names none', () => {
|
||||||
|
const f = scene();
|
||||||
|
const start = f.edit.addNode('OnStart');
|
||||||
|
const withFx = f.edit.addNode('PlayPattern', { pattern: f.pattern.asset.id, fx: f.fx.id });
|
||||||
|
const without = f.edit.addNode('PlayPattern', { pattern: f.pattern.asset.id });
|
||||||
|
f.edit.wire([
|
||||||
|
[start, 'exec', withFx, 'exec'],
|
||||||
|
[withFx, 'exec', without, 'exec'],
|
||||||
|
[f.params, 'params', withFx, 'fxParams'],
|
||||||
|
]);
|
||||||
|
const audio = recording();
|
||||||
|
startWorld(f, { behaviors: true, audio }).frame();
|
||||||
|
expect(audio.sequences.map((s) => s.play.fx?.params ?? null)).toEqual([{ depth: -9 }, null]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a sound FX of a Play note (#346)', () => {
|
||||||
|
it('sends the note through it, its Pitch on the sound itself, its parameters apart from the instrument’s', () => {
|
||||||
|
const f = scene();
|
||||||
|
const start = f.edit.addNode('OnStart');
|
||||||
|
const play = f.edit.addNode('PlayNote', { instrument: f.lead.id, note: 60, soundFx: f.fx.id });
|
||||||
|
f.edit.wire([
|
||||||
|
[start, 'exec', play, 'exec'],
|
||||||
|
[f.params, 'params', play, 'soundFxParams'],
|
||||||
|
]);
|
||||||
|
const audio = recording();
|
||||||
|
startWorld(f, { behaviors: true, audio }).frame();
|
||||||
|
const [note] = audio.notes;
|
||||||
|
const [layer] = note!.layers;
|
||||||
|
expect(layer).toMatchObject({
|
||||||
|
pitch: [{ value: 12 }],
|
||||||
|
stages: [{ kind: 'gain', db: { value: -3, param: 'fx:depth' } }],
|
||||||
|
source: { file: FILE },
|
||||||
|
});
|
||||||
|
expect(note!.params).toEqual({ [VELOCITY_PARAM]: 1, 'fx:depth': -9 });
|
||||||
|
const chain = fxChainOf(layer!, note!.params);
|
||||||
|
expect(chain).toMatchObject({ pitch: 12 });
|
||||||
|
expect(chain.stages[0]).toMatchObject({ kind: 'gain' });
|
||||||
|
expect('gain' in chain.stages[0]! && chain.stages[0].gain).toBeCloseTo(10 ** (-9 / 20), 9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is reached by Set sound FX parameters, which also reaches a playing of a song by its number', () => {
|
||||||
|
const f = scene();
|
||||||
|
const start = f.edit.addNode('OnStart');
|
||||||
|
const note = f.edit.addNode('PlayNote', { instrument: f.lead.id, soundFx: f.fx.id });
|
||||||
|
const song = f.edit.addNode('PlaySong', { song: f.songId, fx: f.fx.id });
|
||||||
|
const setNote = f.edit.addNode('SetSoundFxParameters');
|
||||||
|
const setSong = f.edit.addNode('SetSoundFxParameters');
|
||||||
|
f.edit.wire([
|
||||||
|
[start, 'exec', note, 'exec'],
|
||||||
|
[note, 'exec', song, 'exec'],
|
||||||
|
[song, 'exec', setNote, 'exec'],
|
||||||
|
[setNote, 'exec', setSong, 'exec'],
|
||||||
|
[note, 'voice', setNote, 'voice'],
|
||||||
|
[song, 'playback', setSong, 'voice'],
|
||||||
|
[f.params, 'params', setNote, 'fxParams'],
|
||||||
|
[f.params, 'params', setSong, 'fxParams'],
|
||||||
|
]);
|
||||||
|
const audio = recording();
|
||||||
|
startWorld(f, { behaviors: true, audio }).frame();
|
||||||
|
// A note's FX goes by its scope; a song's master and a sound have no scoped names to reach.
|
||||||
|
expect(audio.tuned).toEqual([
|
||||||
|
{ voice: 7, params: { depth: -9, 'fx:depth': -9 } },
|
||||||
|
{ voice: 500, params: { depth: -9, 'fx:depth': -9 } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -210,6 +210,33 @@ describe('the scheduler on the audio clock (#332)', () => {
|
|||||||
host.played.forEach((n, i) => expect(n.at - 10 - LEAD).toBeCloseTo(i * 0.4, 9));
|
host.played.forEach((n, i) => expect(n.at - 10 - LEAD).toBeCloseTo(i * 0.4, 9));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('plans as much further ahead as a note may be started early, its playhead on what is heard (#346)', () => {
|
||||||
|
const { host, sink, run } = fakeHost();
|
||||||
|
const early = 3 * (2048 / 48000);
|
||||||
|
const scheduler = new Scheduler({ ...sink, ahead: () => early }, () => quarters, { tempo: 150 });
|
||||||
|
let planned = 0;
|
||||||
|
const room: number[] = [];
|
||||||
|
const watch = () => {
|
||||||
|
// What the player has left once it starts the note that much earlier.
|
||||||
|
for (const n of host.played.slice(planned)) room.push(n.at - early - host.time);
|
||||||
|
planned = host.played.length;
|
||||||
|
};
|
||||||
|
scheduler.start();
|
||||||
|
watch();
|
||||||
|
expect(host.played.map((n) => n.at)).toEqual([10 + LEAD + early]);
|
||||||
|
let k = 0;
|
||||||
|
const jitter = () => [0.06, 0, 0.03, 0.045][k++ % 4]!;
|
||||||
|
for (let i = 0; i < 160; i++) {
|
||||||
|
run(INTERVAL, jitter);
|
||||||
|
watch();
|
||||||
|
}
|
||||||
|
expect(host.played).toHaveLength(8);
|
||||||
|
expect(Math.min(...room)).toBeGreaterThan(0);
|
||||||
|
host.played.forEach((n, i) => expect(n.at - 10 - LEAD - early).toBeCloseTo(i * 0.4, 9));
|
||||||
|
host.time = 10 + LEAD + early + 0.4;
|
||||||
|
expect(scheduler.position().ticks).toBeCloseTo(ticksOfSeconds(0.4, 150), 6);
|
||||||
|
});
|
||||||
|
|
||||||
it('runs a loop round and round without a gap at the seam', () => {
|
it('runs a loop round and round without a gap at the seam', () => {
|
||||||
const { host, sink, run } = fakeHost();
|
const { host, sink, run } = fakeHost();
|
||||||
const twoNotes: Sequence = { notes: [note(0), note(Q)] };
|
const twoNotes: Sequence = { notes: [note(0), note(Q)] };
|
||||||
|
|||||||
@@ -2065,9 +2065,22 @@ const CASES: Record<string, Case[]> = {
|
|||||||
detached: false,
|
detached: false,
|
||||||
instrumentParams: { cut: 2 },
|
instrumentParams: { cut: 2 },
|
||||||
tempo: 120,
|
tempo: 120,
|
||||||
|
fx: '',
|
||||||
|
fxParams: {},
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'hands on a sound FX of its own with its values, apart from the instrument parameters (#346)',
|
||||||
|
inputs: { instrument: 'ins-1', fxParams: { cut: 2 }, soundFx: 'fx-1', soundFxParams: { depth: -6 } },
|
||||||
|
next: 'exec',
|
||||||
|
after: (ctx) =>
|
||||||
|
expect(ctx.notes[0]).toMatchObject({
|
||||||
|
instrumentParams: { cut: 2 },
|
||||||
|
fx: 'fx-1',
|
||||||
|
fxParams: { depth: -6 },
|
||||||
|
}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'hands on its tempo for a Delay synced to beats (#332)',
|
name: 'hands on its tempo for a Delay synced to beats (#332)',
|
||||||
inputs: { instrument: 'ins-1', note: 60, tempo: 90 },
|
inputs: { instrument: 'ins-1', note: 60, tempo: 90 },
|
||||||
@@ -2098,7 +2111,15 @@ const CASES: Record<string, Case[]> = {
|
|||||||
next: 'exec',
|
next: 'exec',
|
||||||
outputs: { playback: 1 },
|
outputs: { playback: 1 },
|
||||||
after: (ctx) =>
|
after: (ctx) =>
|
||||||
expect(ctx.patterns).toEqual([{ patternId: 'pat-1', volume: 0.5, loop: true, detached: false }]),
|
expect(ctx.patterns).toEqual([
|
||||||
|
{ patternId: 'pat-1', volume: 0.5, loop: true, detached: false, fx: '', fxParams: {} },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'hands on a sound FX for all of it with its values (#346)',
|
||||||
|
inputs: { pattern: 'pat-1', fx: 'fx-1', fxParams: { depth: -6 } },
|
||||||
|
next: 'exec',
|
||||||
|
after: (ctx) => expect(ctx.patterns[0]).toMatchObject({ fx: 'fx-1', fxParams: { depth: -6 } }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'plays once and detached when asked to',
|
name: 'plays once and detached when asked to',
|
||||||
@@ -2148,9 +2169,15 @@ const CASES: Record<string, Case[]> = {
|
|||||||
outputs: { playback: 1 },
|
outputs: { playback: 1 },
|
||||||
after: (ctx) =>
|
after: (ctx) =>
|
||||||
expect(ctx.songs).toEqual([
|
expect(ctx.songs).toEqual([
|
||||||
{ songId: 'song-1', volume: 0.5, fadeIn: 2, loop: true, detached: false },
|
{ songId: 'song-1', volume: 0.5, fadeIn: 2, loop: true, detached: false, fx: '', fxParams: {} },
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'hands on a sound FX for the whole of it with its values (#346)',
|
||||||
|
inputs: { song: 'song-1', fx: 'fx-1', fxParams: { depth: -6 } },
|
||||||
|
next: 'exec',
|
||||||
|
after: (ctx) => expect(ctx.songs[0]).toMatchObject({ fx: 'fx-1', fxParams: { depth: -6 } }),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'plays once and detached when asked to',
|
name: 'plays once and detached when asked to',
|
||||||
inputs: { song: 'song-1', volume: 1, fadeIn: 0, loop: false, detached: true },
|
inputs: { song: 'song-1', volume: 1, fadeIn: 0, loop: false, detached: true },
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* The pitch of a bus (#346): a sound fed in while it plays comes out moved by the pitch, exactly one
|
||||||
|
* frame late at every pitch – the latency a song makes up by starting its notes that much earlier.
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { PitchPlayer, PitchShifter, shifterLatency } from '../../src/core/soundfx/pitchPlayer';
|
||||||
|
|
||||||
|
const RATE = 48000;
|
||||||
|
const BLOCK = 128;
|
||||||
|
/** A frame at 48 kHz. */
|
||||||
|
const FRAME = 2048;
|
||||||
|
|
||||||
|
function sine(frequency: number, samples: number, from = 0): Float32Array {
|
||||||
|
const out = new Float32Array(samples);
|
||||||
|
for (let i = from; i < samples; i++) out[i] = Math.sin((2 * Math.PI * frequency * (i - from)) / RATE);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feeds the channels in block by block, as a worklet does, the pitch from `semitones(sample)`. */
|
||||||
|
function shift(
|
||||||
|
input: readonly Float32Array[],
|
||||||
|
semitones: number | ((at: number) => number),
|
||||||
|
outputs = input.length,
|
||||||
|
): Float32Array[] {
|
||||||
|
const length = input[0]!.length;
|
||||||
|
const shifter = new PitchShifter(outputs, RATE);
|
||||||
|
const out = Array.from({ length: outputs }, () => new Float32Array(length));
|
||||||
|
const pitch = new Float32Array(BLOCK);
|
||||||
|
for (let at = 0; at < length; at += BLOCK) {
|
||||||
|
const end = Math.min(length, at + BLOCK);
|
||||||
|
for (let i = 0; i < BLOCK; i++) pitch[i] = typeof semitones === 'number' ? semitones : semitones(at + i);
|
||||||
|
shifter.render(
|
||||||
|
input.map((channel) => channel.subarray(at, end)),
|
||||||
|
out.map((channel) => channel.subarray(at, end)),
|
||||||
|
typeof semitones === 'number' ? [semitones] : pitch.subarray(0, end - at),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frequencyOf(samples: Float32Array, from: number, to: number): number {
|
||||||
|
let rises = 0;
|
||||||
|
for (let i = from + 1; i < to; i++) if (samples[i - 1]! < 0 && samples[i]! >= 0) rises++;
|
||||||
|
return rises / ((to - from) / RATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first sample at least half as loud as the loudest. */
|
||||||
|
function onset(samples: Float32Array): number {
|
||||||
|
const peak = samples.reduce((max, v) => Math.max(max, Math.abs(v)), 0);
|
||||||
|
return samples.findIndex((v) => Math.abs(v) >= peak / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the pitch shifter of a bus', () => {
|
||||||
|
it('is one frame late, in seconds as the sound library counts it', () => {
|
||||||
|
expect(new PitchShifter(2, RATE).latency).toBe(FRAME);
|
||||||
|
expect(shifterLatency(RATE)).toBe(FRAME / RATE);
|
||||||
|
expect(shifterLatency(44100)).toBe(FRAME / 44100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands out the sound itself at 0 semitones, exactly a frame later', () => {
|
||||||
|
const noise = Float32Array.from({ length: RATE / 2 }, (_, i) => Math.sin(i * 0.37) * Math.cos(i * 0.011));
|
||||||
|
const [out] = shift([noise], 0);
|
||||||
|
let off = 0;
|
||||||
|
for (let i = 0; i < FRAME; i++) off = Math.max(off, Math.abs(out![i]!));
|
||||||
|
for (let i = FRAME; i < noise.length; i++) off = Math.max(off, Math.abs(out![i]! - noise[i - FRAME]!));
|
||||||
|
expect(off).toBeLessThan(1e-5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[12, 880],
|
||||||
|
[-12, 220],
|
||||||
|
[7, 440 * 2 ** (7 / 12)],
|
||||||
|
[-5.5, 440 * 2 ** (-5.5 / 12)],
|
||||||
|
])('moves a 440 Hz tone fed in by %d semitones to about %d Hz', (semitones, expected) => {
|
||||||
|
const [out] = shift([sine(440, RATE)], semitones);
|
||||||
|
const heard = frequencyOf(out!, RATE / 4, (RATE * 3) / 4);
|
||||||
|
expect(heard).toBeGreaterThan(expected * 0.98);
|
||||||
|
expect(heard).toBeLessThan(expected * 1.02);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([12, -12, 7])(
|
||||||
|
'is late by the same frame at %d semitones as the file player is not',
|
||||||
|
(semitones) => {
|
||||||
|
const start = RATE / 2;
|
||||||
|
const tone = sine(440, RATE * 1.5, start);
|
||||||
|
const [late] = shift([tone], semitones);
|
||||||
|
const own = new Float32Array(tone.length);
|
||||||
|
new PitchPlayer([tone], 1, RATE, false).render([own], [semitones]);
|
||||||
|
expect(Math.abs(onset(late!) - onset(own) - FRAME)).toBeLessThanOrEqual(64);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('follows a pitch that changes while the sound goes on', () => {
|
||||||
|
const [out] = shift([sine(440, RATE * 2)], (at) => (at < RATE ? 0 : 12));
|
||||||
|
expect(frequencyOf(out!, RATE / 4, (RATE * 3) / 4)).toBeCloseTo(440, -1);
|
||||||
|
const up = frequencyOf(out!, RATE + RATE / 4, RATE + (RATE * 3) / 4);
|
||||||
|
expect(up).toBeGreaterThan(880 * 0.98);
|
||||||
|
expect(up).toBeLessThan(880 * 1.02);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plays a mono sound on both sides, and nothing coming in as silence', () => {
|
||||||
|
const [left, right] = shift([sine(440, RATE / 2)], 5, 2);
|
||||||
|
expect(Array.from(right!)).toEqual(Array.from(left!));
|
||||||
|
expect(left!.some((v) => Math.abs(v) > 0.5)).toBe(true);
|
||||||
|
const shifter = new PitchShifter(2, RATE);
|
||||||
|
const out = [new Float32Array(BLOCK), new Float32Array(BLOCK)];
|
||||||
|
for (let b = 0; b < 40; b++) shifter.render([], out, [3]);
|
||||||
|
expect(out.every((channel) => channel.every((v) => v === 0))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -120,6 +120,28 @@ describe('the songs a game plays', () => {
|
|||||||
expect(host.levels).toEqual([{ id: 5, gain: 0.5, at: 1 + LEAD, seconds: 3 }]);
|
expect(host.levels).toEqual([{ id: 5, gain: 0.5, at: 1 + LEAD, seconds: 3 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('starts its fade in with its first note heard, later by as much as its buses are late (#346)', async () => {
|
||||||
|
const levels: { at: number }[] = [];
|
||||||
|
let release = () => {};
|
||||||
|
const playbacks = new Playbacks({
|
||||||
|
now: () => 1,
|
||||||
|
latency: () => 0,
|
||||||
|
ahead: (id) => (id === 5 ? 0.125 : 0),
|
||||||
|
playing: () => false,
|
||||||
|
stop: () => {},
|
||||||
|
wake: () => () => {},
|
||||||
|
prepare: () => new Promise<void>((resolve) => (release = resolve)),
|
||||||
|
level: (_id, _gain, at) => void levels.push({ at }),
|
||||||
|
after: () => {},
|
||||||
|
});
|
||||||
|
const struck: (ScheduledNote | ScheduledClip)[] = [];
|
||||||
|
playbacks.start(5, 'song:a', song(struck, { volume: 0.5, fadeIn: 3 }), '');
|
||||||
|
release();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(levels).toEqual([{ at: 1 + LEAD + 0.125 }]);
|
||||||
|
expect(struck[0]).toMatchObject({ at: 1 + LEAD + 0.125 });
|
||||||
|
});
|
||||||
|
|
||||||
it('fades out over its fade, playing on meanwhile, then is gone; a hard stop then ends it at once', async () => {
|
it('fades out over its fade, playing on meanwhile, then is gone; a hard stop then ends it at once', async () => {
|
||||||
const { host, playbacks, run, loaded } = fakeHost();
|
const { host, playbacks, run, loaded } = fakeHost();
|
||||||
const struck: (ScheduledNote | ScheduledClip)[] = [];
|
const struck: (ScheduledNote | ScheduledClip)[] = [];
|
||||||
|
|||||||
@@ -744,7 +744,8 @@ describe('Set sound FX parameters (#315)', () => {
|
|||||||
input.press('x');
|
input.press('x');
|
||||||
frame();
|
frame();
|
||||||
expect(audio.played).toHaveLength(2);
|
expect(audio.played).toHaveLength(2);
|
||||||
expect(audio.tuned).toEqual([{ voice: 1, params: { depth: 3 } }]);
|
// Also by the scope a note's own sound FX goes by (#346), which a sound does not have.
|
||||||
|
expect(audio.tuned).toEqual([{ voice: 1, params: { depth: 3, 'fx:depth': 3 } }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reaches nothing where its Play sound played nothing', () => {
|
it('reaches nothing where its Play sound played nothing', () => {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
const BAR = 1920;
|
const BAR = 1920;
|
||||||
const TRACKS = ['t1', 't2', 't3'];
|
const TRACKS = ['t1', 't2', 't3'];
|
||||||
const rows = new Map(TRACKS.map((id, i) => [id, i]));
|
const rows = new Map(TRACKS.map((id, i) => [id, i]));
|
||||||
const tracks = TRACKS.map((id) => ({ id, name: id, volume: 0, pan: 0, mute: false, solo: false }));
|
const tracks = TRACKS.map((id) => ({ id, name: id, volume: 0, pan: 0, mute: false, solo: false, fx: '' }));
|
||||||
|
|
||||||
function item(
|
function item(
|
||||||
id: string,
|
id: string,
|
||||||
|
|||||||
+47
-8
@@ -225,11 +225,28 @@ export type FakeContext = NodeContext & {
|
|||||||
detached: boolean;
|
detached: boolean;
|
||||||
instrumentParams: Readonly<Record<string, number>>;
|
instrumentParams: Readonly<Record<string, number>>;
|
||||||
tempo: number;
|
tempo: number;
|
||||||
|
fx: string;
|
||||||
|
fxParams: Readonly<Record<string, number>>;
|
||||||
}[];
|
}[];
|
||||||
/** The patterns the context was asked to play; a playing's number is how many have been played. */
|
/** The patterns the context was asked to play; a playing's number is how many have been played. */
|
||||||
patterns: { patternId: string; volume: number; loop: boolean; detached: boolean }[];
|
patterns: {
|
||||||
|
patternId: string;
|
||||||
|
volume: number;
|
||||||
|
loop: boolean;
|
||||||
|
detached: boolean;
|
||||||
|
fx: string;
|
||||||
|
fxParams: Readonly<Record<string, number>>;
|
||||||
|
}[];
|
||||||
/** The songs the context was asked to play, numbered the same way. */
|
/** The songs the context was asked to play, numbered the same way. */
|
||||||
songs: { songId: string; volume: number; fadeIn: number; loop: boolean; detached: boolean }[];
|
songs: {
|
||||||
|
songId: string;
|
||||||
|
volume: number;
|
||||||
|
fadeIn: number;
|
||||||
|
loop: boolean;
|
||||||
|
detached: boolean;
|
||||||
|
fx: string;
|
||||||
|
fxParams: Readonly<Record<string, number>>;
|
||||||
|
}[];
|
||||||
silenced: string[];
|
silenced: string[];
|
||||||
tuned: { voice: number; fxParams: Readonly<Record<string, number>>; time: number; ease: EaseShare }[];
|
tuned: { voice: number; fxParams: Readonly<Record<string, number>>; time: number; ease: EaseShare }[];
|
||||||
/** The scenes the context was asked to load, in order. */
|
/** The scenes the context was asked to load, in order. */
|
||||||
@@ -287,16 +304,38 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
|||||||
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo) =>
|
playSound: (assetId, volume, loop, detached, fx, fxParams, tempo) =>
|
||||||
ctx.played.push({ assetId, volume, loop, detached, fx, fxParams, tempo }),
|
ctx.played.push({ assetId, volume, loop, detached, fx, fxParams, tempo }),
|
||||||
notes: [],
|
notes: [],
|
||||||
playNote: (instrumentId, note, velocity, volume, duration, detached, instrumentParams, tempo) =>
|
playNote: (
|
||||||
ctx.notes.push({ instrumentId, note, velocity, volume, duration, detached, instrumentParams, tempo }),
|
instrumentId,
|
||||||
|
note,
|
||||||
|
velocity,
|
||||||
|
volume,
|
||||||
|
duration,
|
||||||
|
detached,
|
||||||
|
instrumentParams,
|
||||||
|
tempo,
|
||||||
|
fx,
|
||||||
|
fxParams,
|
||||||
|
) =>
|
||||||
|
ctx.notes.push({
|
||||||
|
instrumentId,
|
||||||
|
note,
|
||||||
|
velocity,
|
||||||
|
volume,
|
||||||
|
duration,
|
||||||
|
detached,
|
||||||
|
instrumentParams,
|
||||||
|
tempo,
|
||||||
|
fx,
|
||||||
|
fxParams,
|
||||||
|
}),
|
||||||
patterns: [],
|
patterns: [],
|
||||||
playPattern: (patternId, volume, loop, detached) =>
|
playPattern: (patternId, volume, loop, detached, fx, fxParams) =>
|
||||||
ctx.patterns.push({ patternId, volume, loop, detached }),
|
ctx.patterns.push({ patternId, volume, loop, detached, fx, fxParams }),
|
||||||
stopPattern: (patternId, letRing) =>
|
stopPattern: (patternId, letRing) =>
|
||||||
ctx.silenced.push(`pattern ${patternId}${letRing ? ' (let ring)' : ''}`),
|
ctx.silenced.push(`pattern ${patternId}${letRing ? ' (let ring)' : ''}`),
|
||||||
songs: [],
|
songs: [],
|
||||||
playSong: (songId, volume, fadeIn, loop, detached) =>
|
playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams) =>
|
||||||
ctx.songs.push({ songId, volume, fadeIn, loop, detached }),
|
ctx.songs.push({ songId, volume, fadeIn, loop, detached, fx, fxParams }),
|
||||||
stopSong: (songId, fadeOut) => ctx.silenced.push(`song ${songId} (fade ${fadeOut})`),
|
stopSong: (songId, fadeOut) => ctx.silenced.push(`song ${songId} (fade ${fadeOut})`),
|
||||||
stopPlayback: (playback, fadeOut) => ctx.silenced.push(`playback ${playback} (fade ${fadeOut})`),
|
stopPlayback: (playback, fadeOut) => ctx.silenced.push(`playback ${playback} (fade ${fadeOut})`),
|
||||||
// Every sound lasts a quarter of a second, as if it had been loaded.
|
// Every sound lasts a quarter of a second, as if it had been loaded.
|
||||||
|
|||||||
@@ -318,7 +318,9 @@ beforeEach(() => {
|
|||||||
? 'chip'
|
? 'chip'
|
||||||
: name === 'phaser' || name === 'flanger'
|
: name === 'phaser' || name === 'flanger'
|
||||||
? name
|
? name
|
||||||
: 'pitch';
|
: name === 'pitch-shifter'
|
||||||
|
? 'shifter'
|
||||||
|
: 'pitch';
|
||||||
const node: FakeWorklet = wired(kind, {
|
const node: FakeWorklet = wired(kind, {
|
||||||
options,
|
options,
|
||||||
pitch: parameters.get('pitch') ?? param(0),
|
pitch: parameters.get('pitch') ?? param(0),
|
||||||
@@ -1609,7 +1611,7 @@ describe('clips and tracks of a song (#334)', () => {
|
|||||||
expect(worklets[0]!.options.processorOptions).toMatchObject({ startAt: 1, loop: true, skip: 200 });
|
expect(worklets[0]!.options.processorOptions).toMatchObject({ startAt: 1, loop: true, skip: 200 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends a song's voices through the bus of their track: its level, then its pan, then out", async () => {
|
it("sends a song's voices through the bus of their track: its input, level, then its pan, then out", async () => {
|
||||||
const library = new SoundLibrary();
|
const library = new SoundLibrary();
|
||||||
library.setBus('song:s:t', 0.5, -0.25);
|
library.setBus('song:s:t', 0.5, -0.25);
|
||||||
library.playNote(
|
library.playNote(
|
||||||
@@ -1632,11 +1634,12 @@ describe('clips and tracks of a song (#334)', () => {
|
|||||||
expect(wires).toEqual(expect.arrayContaining(['gain > panner', 'panner > destination']));
|
expect(wires).toEqual(expect.arrayContaining(['gain > panner', 'panner > destination']));
|
||||||
expect(wires.filter((wire) => wire === 'gain > gain').length).toBeGreaterThan(0);
|
expect(wires.filter((wire) => wire === 'gain > gain').length).toBeGreaterThan(0);
|
||||||
expect(wires).not.toContain('gain > destination');
|
expect(wires).not.toContain('gain > destination');
|
||||||
expect(gains[0]!.glides).toEqual([0.5]);
|
// The first gain is where the bus is fed, the second its level.
|
||||||
|
expect(gains[1]!.glides).toEqual([0.5]);
|
||||||
expect(panners[0]!.glides).toEqual([-0.25]);
|
expect(panners[0]!.glides).toEqual([-0.25]);
|
||||||
library.setBus('song:s:t', 0, 1);
|
library.setBus('song:s:t', 0, 1);
|
||||||
expect(panners).toHaveLength(1);
|
expect(panners).toHaveLength(1);
|
||||||
expect([gains[0]!.glides, panners[0]!.glides]).toEqual([
|
expect([gains[1]!.glides, panners[0]!.glides]).toEqual([
|
||||||
[0.5, 0],
|
[0.5, 0],
|
||||||
[-0.25, 1],
|
[-0.25, 1],
|
||||||
]);
|
]);
|
||||||
@@ -1689,14 +1692,21 @@ describe('the patterns and songs a game plays (#336)', () => {
|
|||||||
'logic',
|
'logic',
|
||||||
);
|
);
|
||||||
expect(library.busLevels()).toEqual([
|
expect(library.busLevels()).toEqual([
|
||||||
{ name: `play:${id}`, gain: 0, pan: 0 },
|
{ name: `play:${id}`, gain: 0, pan: 0, into: null, fx: false, latency: 0 },
|
||||||
{ name: `play:${id}:song:s:t`, gain: 0.25, pan: -1 },
|
{ name: `play:${id}:song:s:t`, gain: 0.25, pan: -1, into: `play:${id}`, fx: false, latency: 0 },
|
||||||
|
]);
|
||||||
|
expect(wires).toEqual([
|
||||||
|
'gain > gain',
|
||||||
|
'gain > panner',
|
||||||
|
'panner > destination',
|
||||||
|
'gain > gain',
|
||||||
|
'gain > panner',
|
||||||
|
'panner > gain',
|
||||||
]);
|
]);
|
||||||
expect(wires).toEqual(['gain > panner', 'panner > destination', 'gain > panner', 'panner > gain']);
|
|
||||||
await settle();
|
await settle();
|
||||||
expect(struck.map((note) => note.bus)).toEqual([`play:${id}:song:s:t`, `play:${id}`]);
|
expect(struck.map((note) => note.bus)).toEqual([`play:${id}:song:s:t`, `play:${id}`]);
|
||||||
// It rises from silence with its first note, 50 ms ahead, to its volume over its fade in.
|
// It rises from silence with its first note, 50 ms ahead, to its volume over its fade in.
|
||||||
expect(gains[0]!.ramps).toEqual([
|
expect(gains[1]!.ramps).toEqual([
|
||||||
['set', 0, 0.05],
|
['set', 0, 0.05],
|
||||||
['linear', 0.5, 2.05],
|
['linear', 0.5, 2.05],
|
||||||
]);
|
]);
|
||||||
@@ -1704,7 +1714,14 @@ describe('the patterns and songs a game plays (#336)', () => {
|
|||||||
expect(library.playbacks()).toEqual([]);
|
expect(library.playbacks()).toEqual([]);
|
||||||
clockTo();
|
clockTo();
|
||||||
expect(library.busLevels()).toEqual([]);
|
expect(library.busLevels()).toEqual([]);
|
||||||
expect(disconnects.filter((kind) => kind !== 'clock')).toEqual(['gain', 'panner', 'gain', 'panner']);
|
expect(disconnects.filter((kind) => kind !== 'clock')).toEqual([
|
||||||
|
'gain',
|
||||||
|
'gain',
|
||||||
|
'panner',
|
||||||
|
'gain',
|
||||||
|
'gain',
|
||||||
|
'panner',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is gone once played to its end, its last voice gone too – asking after it does not ask itself', async () => {
|
it('is gone once played to its end, its last voice gone too – asking after it does not ask itself', async () => {
|
||||||
@@ -1745,3 +1762,184 @@ describe('the patterns and songs a game plays (#336)', () => {
|
|||||||
expect(resumes).toBe(2);
|
expect(resumes).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('a sound FX on a bus (#346)', () => {
|
||||||
|
/** One frame of the shifter at the fake context's 48 kHz. */
|
||||||
|
const LATE = 2048 / 48000;
|
||||||
|
const layer: FxPlan = {
|
||||||
|
pitch: [],
|
||||||
|
stages: [],
|
||||||
|
source: { file: FILE, volume: 1, loop: false, loopStart: 0, loopEnd: 0, gate: false },
|
||||||
|
};
|
||||||
|
const voice = (plan: FxPlan): FxVoice => ({ plan, params: {}, tempo: 120 });
|
||||||
|
const up: FxPlan = { pitch: [{ value: 12 }], stages: [] };
|
||||||
|
const crush: FxPlan = planOf({ pitch: 0, stages: [{ kind: 'crush', bits: 4, rate: 8000, mix: 1 }] });
|
||||||
|
|
||||||
|
/** Two notes, one on track `t` and one on `u`, struck as the scheduler plans them. */
|
||||||
|
function playing(library: SoundLibrary, fields: Partial<SequencePlay>) {
|
||||||
|
const struck: ScheduledNote[] = [];
|
||||||
|
const note = { tick: 0, length: 480, velocity: 1, instrument: 'i' };
|
||||||
|
const id = library.playSequence(
|
||||||
|
'song:s',
|
||||||
|
{
|
||||||
|
sequence: {
|
||||||
|
notes: [
|
||||||
|
{ ...note, note: 60, bus: 't' },
|
||||||
|
{ ...note, note: 62, bus: 'u' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
tempo: 120,
|
||||||
|
length: 1920,
|
||||||
|
loop: null,
|
||||||
|
needs: { layers: [], files: [] },
|
||||||
|
buses: [
|
||||||
|
{ name: 't', gain: 1, pan: 0 },
|
||||||
|
{ name: 'u', gain: 1, pan: 0 },
|
||||||
|
],
|
||||||
|
strike: (scheduled) => {
|
||||||
|
struck.push(scheduled);
|
||||||
|
return library.playNote('instrument:i', [layer], {}, 1, '', scheduled.duration, {
|
||||||
|
at: scheduled.at,
|
||||||
|
...(scheduled.bus && { bus: scheduled.bus }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
...fields,
|
||||||
|
},
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
return { id, struck };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sends all of a playing through one chain on its master, however many notes it plays', async () => {
|
||||||
|
const library = new SoundLibrary();
|
||||||
|
const { id } = playing(library, { fx: voice(crush) });
|
||||||
|
await settle();
|
||||||
|
await arrive();
|
||||||
|
expect(worklets.filter((w) => w.kind === 'crush')).toHaveLength(1);
|
||||||
|
expect(library.busLevels().find((bus) => bus.name === `play:${id}`)).toMatchObject({
|
||||||
|
fx: true,
|
||||||
|
latency: 0,
|
||||||
|
});
|
||||||
|
// The chain sits between the master's input and its level, the tracks go into that input.
|
||||||
|
expect(wires).toEqual(expect.arrayContaining(['gain > crush', 'crush > gain']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shifts the pitch of a bus a frame late, and starts its notes as much earlier', async () => {
|
||||||
|
const library = new SoundLibrary();
|
||||||
|
const { id, struck } = playing(library, { fx: voice(up) });
|
||||||
|
await settle();
|
||||||
|
await arrive();
|
||||||
|
const shifter = worklets.find((w) => w.kind === 'shifter')!;
|
||||||
|
expect(shifter.options).toMatchObject({
|
||||||
|
channelCount: 2,
|
||||||
|
channelCountMode: 'explicit',
|
||||||
|
parameterData: { pitch: 12 },
|
||||||
|
});
|
||||||
|
expect(library.busLatency(`play:${id}`)).toBeCloseTo(LATE, 12);
|
||||||
|
// Planned to be heard a lead and a frame from now, started a frame before that.
|
||||||
|
expect(struck[0]!.at).toBeCloseTo(0.05 + LATE, 9);
|
||||||
|
expect(sources.map((source) => source.startedAt)).toEqual([
|
||||||
|
expect.closeTo(0.05, 9),
|
||||||
|
expect.closeTo(0.05, 9),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds the other way of a Mix back as far as its Pitch makes one late, so the two stay together', async () => {
|
||||||
|
const library = new SoundLibrary();
|
||||||
|
const doubled: FxPlan = { mix: { value: 0.5 }, a: up, b: { pitch: [], stages: [] }, stages: [] };
|
||||||
|
const { id } = playing(library, { fx: voice(doubled) });
|
||||||
|
await settle();
|
||||||
|
expect(delays.map((delay) => delay.delayTime.value)).toEqual([expect.closeTo(LATE, 9)]);
|
||||||
|
expect(library.busLatency(`play:${id}`)).toBeCloseTo(LATE, 12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts each note as much earlier as the buses it is heard through are late, and no more', async () => {
|
||||||
|
const library = new SoundLibrary();
|
||||||
|
const { id, struck } = playing(library, {
|
||||||
|
fx: voice(up),
|
||||||
|
buses: [
|
||||||
|
{ name: 't', gain: 1, pan: 0, fx: voice(up) },
|
||||||
|
{ name: 'i', gain: 1, pan: 0, into: 't', fx: voice(up) },
|
||||||
|
{ name: 'u', gain: 1, pan: 0 },
|
||||||
|
],
|
||||||
|
sequence: {
|
||||||
|
notes: [
|
||||||
|
{ tick: 0, length: 480, velocity: 1, instrument: 'i', note: 60, bus: 'i' },
|
||||||
|
{ tick: 0, length: 480, velocity: 1, instrument: 'i', note: 62, bus: 'u' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
await arrive();
|
||||||
|
expect(library.busLatency(`play:${id}:i`)).toBeCloseTo(3 * LATE, 12);
|
||||||
|
expect(library.busLatency(`play:${id}:u`)).toBeCloseTo(LATE, 12);
|
||||||
|
// Both are heard where they were planned, the latest way three frames after it is struck.
|
||||||
|
const heard = 0.05 + 3 * LATE;
|
||||||
|
expect(struck.map((note) => note.at)).toEqual([expect.closeTo(heard, 9), expect.closeTo(heard, 9)]);
|
||||||
|
expect(sources.map((source) => source.startedAt)).toEqual([
|
||||||
|
expect.closeTo(heard - 3 * LATE, 9),
|
||||||
|
expect.closeTo(heard - LATE, 9),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands values to the sound FX of a playing by its number, and nothing else by it', async () => {
|
||||||
|
const library = new SoundLibrary();
|
||||||
|
const loud: FxPlan = {
|
||||||
|
pitch: [],
|
||||||
|
stages: [{ kind: 'gain', db: { value: 0, param: 'loud', min: -120, max: 24 } }],
|
||||||
|
};
|
||||||
|
const { id } = playing(library, { fx: voice(loud) });
|
||||||
|
await settle();
|
||||||
|
const before = gains.length;
|
||||||
|
expect(library.tune(id, { loud: -6 })).toBe(true);
|
||||||
|
expect(gains.length).toBe(before);
|
||||||
|
expect(gains.flatMap((gain) => gain.glides)).toContainEqual(expect.closeTo(10 ** (-6 / 20), 9));
|
||||||
|
expect(library.stats().tuned).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the chains of a playing go after its last voice, once what they hold has been heard', async () => {
|
||||||
|
const library = new SoundLibrary();
|
||||||
|
playing(library, { fx: voice(up) });
|
||||||
|
await settle();
|
||||||
|
await arrive();
|
||||||
|
library.stopSequence('song:', false, 0);
|
||||||
|
const shifter = worklets.find((w) => w.kind === 'shifter')!;
|
||||||
|
expect(shifter.posted).toEqual([]);
|
||||||
|
clockTo();
|
||||||
|
expect(shifter.posted).toEqual(['stop']);
|
||||||
|
expect(shifter.disconnected).toBe(true);
|
||||||
|
expect(library.busLevels()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes the chain of a track in the Song tab once, and again only for another sound FX', async () => {
|
||||||
|
const library = new SoundLibrary();
|
||||||
|
const track = { name: 'song:s:t', gain: 1, pan: 0, fx: voice(crush) };
|
||||||
|
await library.setBuses([track]);
|
||||||
|
// As the Song tab hands them: a new voice of the same FX every time.
|
||||||
|
await library.setBuses([{ ...track, gain: 0.5, fx: voice(crush) }]);
|
||||||
|
expect(worklets.filter((w) => w.kind === 'crush')).toHaveLength(1);
|
||||||
|
await library.setBuses([{ ...track, fx: voice(up) }]);
|
||||||
|
expect(worklets.map((w) => [w.kind, w.posted])).toEqual([
|
||||||
|
['crush', ['stop']],
|
||||||
|
['shifter', []],
|
||||||
|
]);
|
||||||
|
expect(library.busLatency('song:s:t')).toBeCloseTo(LATE, 12);
|
||||||
|
await library.setBuses([{ ...track, fx: undefined }]);
|
||||||
|
expect(library.busLevels()).toEqual([
|
||||||
|
{ name: 'song:s:t', gain: 1, pan: 0, into: null, fx: false, latency: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the buses of the Song tab go after a stop, unless the next play takes them up first', async () => {
|
||||||
|
const library = new SoundLibrary();
|
||||||
|
await library.setBuses([{ name: 'song:s:t', gain: 1, pan: 0, fx: voice(up) }]);
|
||||||
|
library.releaseBuses(['song:s:t']);
|
||||||
|
await library.setBuses([{ name: 'song:s:t', gain: 1, pan: 0, fx: voice(up) }]);
|
||||||
|
clockTo();
|
||||||
|
expect(library.busLevels().map((bus) => bus.name)).toEqual(['song:s:t']);
|
||||||
|
library.releaseBuses(['song:s:t']);
|
||||||
|
clockTo();
|
||||||
|
expect(library.busLevels()).toEqual([]);
|
||||||
|
expect(worklets[0]!.posted).toEqual(['stop']);
|
||||||
|
});
|
||||||
|
});
|
||||||
+111
-1
@@ -694,10 +694,120 @@ export async function song(t) {
|
|||||||
JSON.stringify(thrown),
|
JSON.stringify(thrown),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 65m. A sound FX on a track (#346). Offline, the shifter a Pitch on a bus becomes moves a tone an
|
||||||
|
// octave and is late by exactly a frame. In the tab, the FX box of a head picks the FX: all of
|
||||||
|
// the track goes through one chain on its bus, the pattern's notes start a frame earlier and
|
||||||
|
// are heard where they were planned, as the clip of the other track is. Stop lets it go.
|
||||||
|
const shifted = await evaluate(`
|
||||||
|
(async () => {
|
||||||
|
const RATE = 48000;
|
||||||
|
const render = async (pitch, input) => {
|
||||||
|
const context = new OfflineAudioContext(2, RATE, RATE);
|
||||||
|
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
|
||||||
|
const buffer = context.createBuffer(1, RATE, RATE);
|
||||||
|
buffer.copyToChannel(input, 0);
|
||||||
|
const source = context.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
const shifter = new AudioWorkletNode(context, 'pitch-shifter', {
|
||||||
|
numberOfInputs: 1,
|
||||||
|
outputChannelCount: [2],
|
||||||
|
channelCount: 2,
|
||||||
|
channelCountMode: 'explicit',
|
||||||
|
parameterData: { pitch },
|
||||||
|
});
|
||||||
|
source.connect(shifter).connect(context.destination);
|
||||||
|
source.start();
|
||||||
|
return (await context.startRendering()).getChannelData(1);
|
||||||
|
};
|
||||||
|
const tone = new Float32Array(RATE).map((_, i) => Math.sin((2 * Math.PI * 1000 * i) / RATE));
|
||||||
|
const up = await render(12, tone);
|
||||||
|
let rises = 0;
|
||||||
|
for (let i = RATE / 4 + 1; i < (RATE * 3) / 4; i++) if (up[i - 1] < 0 && up[i] >= 0) rises++;
|
||||||
|
const click = new Float32Array(RATE);
|
||||||
|
click[4800] = 1;
|
||||||
|
const still = await render(0, click);
|
||||||
|
let peak = 0;
|
||||||
|
for (let i = 1; i < RATE; i++) if (Math.abs(still[i]) > Math.abs(still[peak])) peak = i;
|
||||||
|
return { hz: rises * 2, late: peak - 4800, height: still[peak] };
|
||||||
|
})()`);
|
||||||
|
check(
|
||||||
|
'the shifter of a bus moves a 1 kHz tone up to 2 kHz, and at 0 semitones is the sound exactly 2048 samples later',
|
||||||
|
Math.abs(shifted.hz - 2000) <= 20 && shifted.late === 2048 && Math.abs(shifted.height - 1) < 1e-4,
|
||||||
|
JSON.stringify(shifted),
|
||||||
|
);
|
||||||
|
await mutate(`
|
||||||
|
__gm.state.project.assets.push({
|
||||||
|
id: 'sg65-fx',
|
||||||
|
type: 'soundfx',
|
||||||
|
name: 'Octave 65',
|
||||||
|
props: {},
|
||||||
|
graph: {
|
||||||
|
nodes: ${JSON.stringify([
|
||||||
|
node('sg65-fin', 'SoundFxInput', 0, 0),
|
||||||
|
node('sg65-fpitch', 'SoundFxPitch', 200, 0, { semitones: 12 }),
|
||||||
|
node('sg65-fout', 'SoundFxOutput', 400, 0),
|
||||||
|
])},
|
||||||
|
edges: ${JSON.stringify([
|
||||||
|
edge('sg65-fe1', 'sg65-fin', 'out', 'sg65-fpitch', 'in'),
|
||||||
|
edge('sg65-fe2', 'sg65-fpitch', 'out', 'sg65-fout', 'in'),
|
||||||
|
])},
|
||||||
|
},
|
||||||
|
});`);
|
||||||
|
await evaluate(`__gm.state.openAsset(${JSON.stringify(id)})`);
|
||||||
|
await sleep(200);
|
||||||
|
p = await json("__gm.song.headAt(1, 'fx')");
|
||||||
|
await click(p.x, p.y);
|
||||||
|
await sleep(150);
|
||||||
|
await clickText('Octave 65');
|
||||||
|
await sleep(150);
|
||||||
|
const fxTracks = (await model()).tracks;
|
||||||
|
await shot('04-track-fx');
|
||||||
|
check(
|
||||||
|
"the FX box of a track's head picks a sound FX for that track",
|
||||||
|
fxTracks[1]?.fx === 'sg65-fx' && fxTracks.every((track, i) => i === 1 || track.fx === ''),
|
||||||
|
JSON.stringify(fxTracks.map((track) => track.fx)),
|
||||||
|
);
|
||||||
|
p = await json('__gm.song.rulerAt(0)');
|
||||||
|
await click(p.x, p.y);
|
||||||
|
await press('Space');
|
||||||
|
const throughFx = await waitFor(
|
||||||
|
() => json('{ planned: __gm.song.planned(), clips: __gm.song.clips() }'),
|
||||||
|
(s) => s.planned.length >= 1 && s.clips.length >= 1,
|
||||||
|
60,
|
||||||
|
);
|
||||||
|
const rate = await json('__gm.sound.sampleRate()');
|
||||||
|
const frame = 2048 / rate;
|
||||||
|
const fxBus = (await json('__gm.song.buses()')).find((b) => b.name === busOf(tracks[1]));
|
||||||
|
const [fxNote] = throughFx.planned;
|
||||||
|
const [plainClip] = throughFx.clips;
|
||||||
|
const noteStart = await json(`__gm.sound.startedAt(${fxNote?.voice ?? 0})`);
|
||||||
|
const clipStart = await json(`__gm.sound.startedAt(${plainClip?.voice ?? 0})`);
|
||||||
|
check(
|
||||||
|
'the track plays through one shifter on its bus: its notes start a frame early, the clip of the other on time',
|
||||||
|
fxBus?.fx === true &&
|
||||||
|
Math.abs(fxBus.latency - frame) < 1e-9 &&
|
||||||
|
fxNote?.bus === busOf(tracks[1]) &&
|
||||||
|
plainClip?.bus === busOf(tracks[0]) &&
|
||||||
|
Math.abs(fxNote.at - plainClip.at - 2) < 1e-6 &&
|
||||||
|
Math.abs(noteStart - (fxNote.at - frame)) < 1e-6 &&
|
||||||
|
Math.abs(clipStart - plainClip.at) < 1e-6,
|
||||||
|
JSON.stringify({ rate, fxBus, fxNote, plainClip, noteStart, clipStart }),
|
||||||
|
);
|
||||||
|
await press('Space');
|
||||||
|
const released = await waitFor(
|
||||||
|
() => json('__gm.song.buses()'),
|
||||||
|
(list) => !list.some((b) => b.name === busOf(tracks[1])),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'stopping lets the buses of the tab go, the chain on them too',
|
||||||
|
!(await json('__gm.song.playing()')) && !released.some((b) => b.name.startsWith(`song:${id}:`)),
|
||||||
|
JSON.stringify(released),
|
||||||
|
);
|
||||||
|
|
||||||
// 65j. The step takes back everything it made.
|
// 65j. The step takes back everything it made.
|
||||||
await evaluate('__gm.sound.stopAll()');
|
await evaluate('__gm.sound.stopAll()');
|
||||||
await mutate(`
|
await mutate(`
|
||||||
const gone = new Set([${JSON.stringify(SOUND)}, 'sg65-ins', ${JSON.stringify(PATTERN)}, ${JSON.stringify(id)}]);
|
const gone = new Set([${JSON.stringify(SOUND)}, 'sg65-ins', ${JSON.stringify(PATTERN)}, ${JSON.stringify(id)}, 'sg65-fx']);
|
||||||
const project = __gm.state.project;
|
const project = __gm.state.project;
|
||||||
project.assets = project.assets.filter((a) => !gone.has(a.id));
|
project.assets = project.assets.filter((a) => !gone.has(a.id));
|
||||||
__gm.state.select(null);`);
|
__gm.state.select(null);`);
|
||||||
|
|||||||
Reference in new issue
Block a user