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
|
||||
* `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
|
||||
* values for the instrument's parameters, `tempo` as for `playSound`. Returns the number of the
|
||||
* voice, 0 where nothing plays.
|
||||
* values for the instrument's parameters, `tempo` as for `playSound`. `fx` sends the note through a
|
||||
* 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(
|
||||
instrumentId: string,
|
||||
@@ -268,22 +269,41 @@ export interface NodeContext {
|
||||
detached: boolean,
|
||||
instrumentParams: FxParams,
|
||||
tempo: number,
|
||||
fx: string,
|
||||
fxParams: FxParams,
|
||||
): number;
|
||||
/**
|
||||
* 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
|
||||
* there is nothing to play.
|
||||
* note at `volume`. Owned like a `playSound` voice. `fx` is a sound FX all of it goes through as
|
||||
* 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. */
|
||||
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
|
||||
* 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.
|
||||
* 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. */
|
||||
stopSong(songId: string, fadeOut: number): void;
|
||||
/** 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 type { NodeDef } from '../../NodeRegistry';
|
||||
import { asBoolean, asNumber, asString } from '../../values';
|
||||
import { exec, num, withDefault } from '../shared';
|
||||
import { exec, num, soundFxPort, withDefault } from '../shared';
|
||||
import { fxParamsOf, tempoPort } from './PlaySound';
|
||||
|
||||
export const PlayNote: NodeDef = {
|
||||
@@ -10,7 +10,7 @@ export const PlayNote: NodeDef = {
|
||||
label: 'Play note',
|
||||
category: 'action',
|
||||
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: [
|
||||
exec(),
|
||||
{
|
||||
@@ -44,6 +44,8 @@ export const PlayNote: NodeDef = {
|
||||
},
|
||||
{ key: 'fxParams', label: 'params', type: 'fxparams' },
|
||||
tempoPort(),
|
||||
{ ...soundFxPort(), key: 'soundFx' },
|
||||
{ key: 'soundFxParams', label: 'fx params', type: 'fxparams' },
|
||||
],
|
||||
outputs: [exec(), num('voice')],
|
||||
params: [],
|
||||
@@ -59,6 +61,8 @@ export const PlayNote: NodeDef = {
|
||||
asBoolean(inputs.detached),
|
||||
fxParamsOf(inputs.fxParams),
|
||||
heldTempo(asNumber(inputs.tempo)),
|
||||
asString(inputs.soundFx),
|
||||
fxParamsOf(inputs.soundFxParams),
|
||||
)
|
||||
: 0;
|
||||
return { next: 'exec', outputs: { voice } };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
||||
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 => ({
|
||||
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 ' +
|
||||
'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 ' +
|
||||
'"detached" is on. "playback" is the number of this playing, for Stop pattern; 0 where the ' +
|
||||
'pattern has no instrument or no notes.',
|
||||
'"detached" is on. "fx" sends all of it through one sound FX, its notes together: an LFO runs on ' +
|
||||
'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: [
|
||||
exec(),
|
||||
patternPort(),
|
||||
@@ -39,13 +42,22 @@ export const PlayPattern: NodeDef = {
|
||||
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')],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
const pattern = asString(inputs.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;
|
||||
return { next: 'exec', outputs: { playback } };
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
||||
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 => ({
|
||||
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 ' +
|
||||
'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" ' +
|
||||
'is on. "playback" is the number of this playing, for Stop song; 0 where the song has nothing ' +
|
||||
'to play.',
|
||||
'is on. "fx" sends the whole song through one sound FX, after its tracks and before "volume"; ' +
|
||||
'"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: [
|
||||
exec(),
|
||||
songPort(),
|
||||
@@ -41,6 +44,8 @@ export const PlaySong: NodeDef = {
|
||||
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')],
|
||||
params: [],
|
||||
@@ -53,6 +58,8 @@ export const PlaySong: NodeDef = {
|
||||
asNumber(inputs.fadeIn),
|
||||
asBoolean(inputs.loop),
|
||||
asBoolean(inputs.detached),
|
||||
asString(inputs.fx),
|
||||
fxParamsOf(inputs.fxParams),
|
||||
)
|
||||
: 0;
|
||||
return { next: 'exec', outputs: { playback } };
|
||||
|
||||
@@ -16,8 +16,10 @@ export const SetSoundFxParameters: NodeDef = {
|
||||
label: 'Set sound FX parameters',
|
||||
category: 'action',
|
||||
description:
|
||||
'Changes the sound FX parameters of a sound while it plays: the voice a Play sound handed out ' +
|
||||
'glides to the values from a Sound FX parameters node. Keep the voice in a variable to reach it ' +
|
||||
'Changes the sound FX parameters of a sound while it plays: the voice a Play sound or Play note ' +
|
||||
'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 ' +
|
||||
'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 ' +
|
||||
|
||||
@@ -13,7 +13,7 @@ export const SoundFxParameters: NodeDef = {
|
||||
label: 'Sound FX parameters',
|
||||
category: 'data',
|
||||
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: [],
|
||||
outputs: [{ key: 'params', label: 'params', type: 'fxparams' }],
|
||||
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.
|
||||
*/
|
||||
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;
|
||||
const fed = given[amount.param];
|
||||
if (!fed) return amount;
|
||||
@@ -333,14 +333,27 @@ function throughFx(fx: FxPlan, sound: FxPlan, given: Readonly<Record<string, FxA
|
||||
return fed.param === undefined
|
||||
? { value }
|
||||
: { 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));
|
||||
if ('mix' in fx)
|
||||
return {
|
||||
...fx,
|
||||
mix: bind(fx.mix),
|
||||
a: throughFx(fx.a, sound, given),
|
||||
b: throughFx(fx.b, sound, given),
|
||||
a: throughFxWith(fx.a, sound, bind),
|
||||
b: throughFxWith(fx.b, sound, bind),
|
||||
stages,
|
||||
};
|
||||
const pitched = fx.pitch.reduce((plan, semitones) => withPitch(plan, bind(semitones)), sound);
|
||||
|
||||
@@ -50,6 +50,12 @@ export interface SchedulerHost {
|
||||
now(): number;
|
||||
/** Seconds between the audio clock and what is heard. */
|
||||
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. */
|
||||
play(note: ScheduledNote): number;
|
||||
/** 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 {
|
||||
this.stop();
|
||||
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.before = null;
|
||||
this.ended = false;
|
||||
@@ -177,7 +183,9 @@ export class Scheduler {
|
||||
/** Plans the notes of the window ahead that are not planned yet. */
|
||||
pump(): void {
|
||||
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;
|
||||
this.voices = this.voices.filter((voice) => this.host.playing(voice));
|
||||
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. */
|
||||
private headAt(time: number): number {
|
||||
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 { playedSoundById } from '../properties/playedSound';
|
||||
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 { 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 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. */
|
||||
|
||||
@@ -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 type { LoopRange, Sequence } from './sequence';
|
||||
|
||||
@@ -13,6 +13,10 @@ export interface BusLevel {
|
||||
name: string;
|
||||
gain: 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. */
|
||||
@@ -29,13 +33,18 @@ export interface SequencePlay {
|
||||
fadeIn?: number;
|
||||
/** The tracks of a song (#336): the buses its notes and clips name, each set to its level and pan. */
|
||||
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. */
|
||||
strike(note: ScheduledNote): number;
|
||||
strikeClip?(clip: ScheduledClip): number;
|
||||
}
|
||||
|
||||
export interface PlaybackHost extends Omit<SchedulerHost, 'play' | 'playClip'> {
|
||||
prepare(needs: SequenceNeeds): Promise<void>;
|
||||
export interface PlaybackHost extends Omit<SchedulerHost, 'play' | 'playClip' | 'ahead'> {
|
||||
/** 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. */
|
||||
level(id: number, gain: number, at: number, seconds: number): void;
|
||||
/** Calls `then` once the audio clock reaches `at`. */
|
||||
@@ -85,6 +94,7 @@ export class Playbacks {
|
||||
const scheduler = new Scheduler(
|
||||
{
|
||||
...this.host,
|
||||
ahead: () => this.host.ahead?.(id) ?? 0,
|
||||
play: (note) => (keep(planned, note), play.strike(note)),
|
||||
...(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 };
|
||||
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;
|
||||
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 { findAssetOf, type Project } from '../model/Project';
|
||||
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 { heldMeter, heldTempo, type Meter, secondsOfTicks, ticksPerBar } from './time';
|
||||
|
||||
@@ -23,6 +22,8 @@ export interface SongTrack {
|
||||
pan: number;
|
||||
mute: 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';
|
||||
@@ -44,7 +45,10 @@ export interface SongItem {
|
||||
transpose: number;
|
||||
/** The instrument a pattern is played with instead of its own, `''` for its own. */
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -107,7 +111,7 @@ export function songLoop(song: SongAsset): LoopRange {
|
||||
}
|
||||
|
||||
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[] {
|
||||
@@ -123,6 +127,11 @@ export function trackBus(songId: string, trackId: string): string {
|
||||
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 {
|
||||
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 };
|
||||
}
|
||||
|
||||
/** The level and pan each track's bus is set to. */
|
||||
export function songBuses(song: { id: string; tracks: readonly SongTrack[] }): BusLevel[] {
|
||||
return song.tracks.map((track) => ({
|
||||
name: trackBus(song.id, track.id),
|
||||
...trackLevel(track, song.tracks),
|
||||
}));
|
||||
/** Whether a pattern item is heard on a bus of its own (#346): it names a sound FX that is there. */
|
||||
export function hasItemBus(project: Project, item: SongItem): boolean {
|
||||
return item.kind === 'pattern' && item.fx !== '' && findAssetOf(project, item.fx, 'soundfx') !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,8 +166,8 @@ export function songSequence(project: Project, song: SongAsset): Sequence {
|
||||
const tracks = new Set(song.tracks.map((t) => t.id));
|
||||
for (const item of song.items) {
|
||||
if (!tracks.has(item.track)) continue;
|
||||
const bus = trackBus(song.id, item.track);
|
||||
const volume = dbToGain(item.volume);
|
||||
const bus = hasItemBus(project, item) ? itemBus(song.id, item) : trackBus(song.id, item.track);
|
||||
if (item.kind === 'sound') {
|
||||
if (!findAssetOf(project, item.asset, 'sound')?.file) continue;
|
||||
clips.push({
|
||||
@@ -218,6 +224,7 @@ export function heldSongTracks(raw: unknown): SongTrack[] {
|
||||
pan: clampTo(finite(t.pan, 0), -1, 1),
|
||||
mute: t.mute === true,
|
||||
solo: t.solo === true,
|
||||
fx: text(t.fx),
|
||||
});
|
||||
}
|
||||
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. */
|
||||
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
|
||||
* (#317). A phase vocoder: the sound is cut into overlapping frames, and each frame's spectrum is
|
||||
* Seconds a `PitchShifter` is late at an output rate (#346): one frame, at every pitch, 0 too.
|
||||
*/
|
||||
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
|
||||
* 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
|
||||
* rather than beating against itself. 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. A change of
|
||||
* pitch is heard about half a frame later, though: a frame takes the pitch at the sample it starts on.
|
||||
* rather than beating against itself. A frame takes the pitch at the sample it starts on, so a change
|
||||
* is heard about half a frame later. Where a frame's samples come from is up to `readFrame`.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export class PitchPlayer {
|
||||
private readonly size: number;
|
||||
private readonly hop: number;
|
||||
abstract class Vocoder {
|
||||
protected readonly size: number;
|
||||
protected readonly hop: number;
|
||||
private readonly bins: number;
|
||||
private readonly window: Float32Array;
|
||||
/** The window squared and weighed so four overlapping frames add up to the sound. */
|
||||
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. */
|
||||
private readonly real: Float64Array;
|
||||
private readonly fft: RealFft;
|
||||
@@ -47,39 +57,22 @@ export class PitchPlayer {
|
||||
private readonly lastPhase: Float64Array[];
|
||||
private readonly runPhase: Float64Array[];
|
||||
/** 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. */
|
||||
private given: number;
|
||||
protected given: number;
|
||||
/** 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. */
|
||||
private phased = 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;
|
||||
|
||||
/**
|
||||
* `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).
|
||||
*/
|
||||
/** `rate` is the output's, which sets the frame; `start` is the output sample the first frame starts on. */
|
||||
constructor(
|
||||
private readonly channels: readonly Float32Array[],
|
||||
private readonly step: number,
|
||||
private readonly count: number,
|
||||
rate: number,
|
||||
loop: boolean | { start: number; end: number },
|
||||
skip = 0,
|
||||
start: number,
|
||||
) {
|
||||
// About 43 ms: a frame holds a few periods of a low note.
|
||||
this.size = 2 ** Math.round(Math.log2(rate * 0.043));
|
||||
this.size = pitchFrameSize(rate);
|
||||
this.hop = this.size / OVERLAP;
|
||||
this.bins = this.size / 2 + 1;
|
||||
this.window = new Float32Array(this.size);
|
||||
@@ -94,74 +87,35 @@ export class PitchPlayer {
|
||||
this.frequency = new Float64Array(this.bins);
|
||||
this.peaks = new Int32Array(this.bins);
|
||||
this.nextRun = new Float64Array(this.bins);
|
||||
this.lastPhase = channels.map(() => new Float64Array(this.bins));
|
||||
this.runPhase = channels.map(() => new Float64Array(this.bins));
|
||||
this.sum = channels.map(() => new Float32Array(this.size));
|
||||
this.lastPhase = Array.from({ length: count }, () => new Float64Array(this.bins));
|
||||
this.runPhase = Array.from({ length: count }, () => new Float64Array(this.bins));
|
||||
this.sum = Array.from({ length: count }, () => new Float32Array(this.size));
|
||||
this.given = this.hop;
|
||||
this.frameAt = Math.max(0, Math.round(skip / step)) - 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 };
|
||||
this.frameAt = start - this.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
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 samples of channel `c` from output sample `from` on, a frame long, in `reading`. */
|
||||
protected abstract readFrame(c: number, from: number): Float64Array;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
/** The frames before the first sample: what it is made of, so the start is not faded in. */
|
||||
protected prime(semitones: number): void {
|
||||
if (this.primed) return;
|
||||
for (let k = 1; k < OVERLAP; k++) this.frame(semitones);
|
||||
this.given = this.hop;
|
||||
this.primed = true;
|
||||
}
|
||||
|
||||
/** 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.given = 0;
|
||||
const ratio = 2 ** (semitones / 12);
|
||||
if (ratio !== 1 && !this.phased) {
|
||||
// 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;
|
||||
for (let c = 0; c < this.channels.length; c++) {
|
||||
for (let c = 0; c < this.count; c++) {
|
||||
const sum = this.sum[c]!;
|
||||
sum.copyWithin(0, this.hop);
|
||||
sum.fill(0, this.size - this.hop);
|
||||
@@ -278,12 +232,90 @@ export class PitchPlayer {
|
||||
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
|
||||
* 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 samples = this.channels[c]!;
|
||||
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 {
|
||||
return phase - 2 * Math.PI * Math.round(phase / (2 * Math.PI));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user