diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index 68539b4e..29070536 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -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. */ diff --git a/src/core/graph/nodes/action/PlayNote.ts b/src/core/graph/nodes/action/PlayNote.ts index 8aa0ddb2..8446f5e1 100644 --- a/src/core/graph/nodes/action/PlayNote.ts +++ b/src/core/graph/nodes/action/PlayNote.ts @@ -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 } }; diff --git a/src/core/graph/nodes/action/PlayPattern.ts b/src/core/graph/nodes/action/PlayPattern.ts index 7989c8cb..c2aac66e 100644 --- a/src/core/graph/nodes/action/PlayPattern.ts +++ b/src/core/graph/nodes/action/PlayPattern.ts @@ -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 } }; }, diff --git a/src/core/graph/nodes/action/PlaySong.ts b/src/core/graph/nodes/action/PlaySong.ts index 518b327b..668f0231 100644 --- a/src/core/graph/nodes/action/PlaySong.ts +++ b/src/core/graph/nodes/action/PlaySong.ts @@ -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 } }; diff --git a/src/core/graph/nodes/action/SetSoundFxParameters.ts b/src/core/graph/nodes/action/SetSoundFxParameters.ts index 7593ac2c..c4b15c6a 100644 --- a/src/core/graph/nodes/action/SetSoundFxParameters.ts +++ b/src/core/graph/nodes/action/SetSoundFxParameters.ts @@ -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 ' + diff --git a/src/core/graph/nodes/data/SoundFxParameters.ts b/src/core/graph/nodes/data/SoundFxParameters.ts index 61b2a1cc..7d721214 100644 --- a/src/core/graph/nodes/data/SoundFxParameters.ts +++ b/src/core/graph/nodes/data/SoundFxParameters.ts @@ -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], diff --git a/src/core/instrument/compile.ts b/src/core/instrument/compile.ts index afe69c27..d383619c 100644 --- a/src/core/instrument/compile.ts +++ b/src/core/instrument/compile.ts @@ -325,7 +325,7 @@ function notesOutputs(node: GraphNode, note: number): Record { * a value handed to the FX itself would be. One left open keeps its default. */ function throughFx(fx: FxPlan, sound: FxPlan, given: Readonly>): 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 + 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); diff --git a/src/core/music/Scheduler.ts b/src/core/music/Scheduler.ts index af605523..95d393fb 100644 --- a/src/core/music/Scheduler.ts +++ b/src/core/music/Scheduler.ts @@ -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; diff --git a/src/core/music/needs.ts b/src/core/music/needs.ts index 5d786f06..51059e3f 100644 --- a/src/core/music/needs.ts +++ b/src/core/music/needs.ts @@ -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. */ diff --git a/src/core/music/playbacks.ts b/src/core/music/playbacks.ts index a9bdd603..300400b2 100644 --- a/src/core/music/playbacks.ts +++ b/src/core/music/playbacks.ts @@ -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 { - prepare(needs: SequenceNeeds): Promise; +export interface PlaybackHost extends Omit { + /** Loads what that playing needs and makes its buses ready, their sound FX among them (#346). */ + prepare(needs: SequenceNeeds, id: number): Promise; + /** 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)); }); } diff --git a/src/core/music/song.ts b/src/core/music/song.ts index a1220600..3ab350da 100644 --- a/src/core/music/song.ts +++ b/src/core/music/song.ts @@ -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): 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)]; diff --git a/src/core/music/songBuses.ts b/src/core/music/songBuses.ts new file mode 100644 index 00000000..f681eef4 --- /dev/null +++ b/src/core/music/songBuses.ts @@ -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]; +} diff --git a/src/core/soundfx/pitchPlayer.ts b/src/core/soundfx/pitchPlayer.ts index b4b23c2e..92489eb5 100644 --- a/src/core/soundfx/pitchPlayer.ts +++ b/src/core/soundfx/pitchPlayer.ts @@ -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): 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): 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): 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)); } diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index f33eccac..82d184e9 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -73,6 +73,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E playbacks: () => soundLibrary.playbacks(), /** Whether the audio clock is held by the pause of the toolbar or runs. */ 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. * `at` is when its voice starts, which a Flanger's sweep counts from (#341). diff --git a/src/editor/inspector/songSection.ts b/src/editor/inspector/songSection.ts index 6fb98ada..fa916129 100644 --- a/src/editor/inspector/songSection.ts +++ b/src/editor/inspector/songSection.ts @@ -16,6 +16,14 @@ import { createFieldRow } from './fieldFactory'; import { addNameField, addNote, addTitle, type InspectorHost } from './host'; import { previewingHandlers } from './previewHandlers'; +const FX_ROW: PropertyDef = { + key: 'fx', + label: 'Sound FX', + kind: 'asset', + default: '', + assetTypes: ['soundfx'], +}; + const TRACK_ROWS: readonly PropertyDef[] = [ { 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: 'mute', label: 'Mute', 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. */ @@ -65,7 +74,7 @@ function itemRows(item: SongItem): PropertyDef[] { }, { 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; } @@ -134,6 +143,7 @@ function buildTrack(host: InspectorHost, asset: SongAsset, track: SongTrack): vo }); 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, 'Its Sound FX takes everything on it together, before its volume and pan.', 'small'); for (const def of TRACK_ROWS) { const handlers = previewingHandlers(host, { 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.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 { diff --git a/src/editor/song/SongEditor.ts b/src/editor/song/SongEditor.ts index e5a832ce..4b3af343 100644 --- a/src/editor/song/SongEditor.ts +++ b/src/editor/song/SongEditor.ts @@ -5,12 +5,12 @@ import { newItem, SONG_GRID_KEY, SONG_GRID_OPTIONS, - songBuses, songLoop, songMeter, songSequence, songTempo, } from '../../core/music/song'; +import { songBuses } from '../../core/music/songBuses'; import { sampleCount, takeName, takeStart, takeTrack, takeTrackIndex, wavBytes } from '../../core/music/take'; import { ticksOfSeconds } from '../../core/music/time'; import type { Asset, SongAsset } from '../../core/model/Asset'; @@ -104,7 +104,7 @@ export class SongEditor extends AssetTabEditor { }; timeline.onTracksPreview = (tracks) => { 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.onDiscardTake = () => this.stop({ discard: true }); @@ -148,7 +148,7 @@ export class SongEditor extends AssetTabEditor { claimTransport(this.halt); this.playingId = asset.id; 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), { tempo: songTempo(asset.props), meter: songMeter(asset.props), @@ -218,7 +218,7 @@ export class SongEditor extends AssetTabEditor { this.showTake(take, false); this.syncPlayButton(); 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), { tempo, meter: songMeter(asset.props), @@ -413,7 +413,7 @@ export class SongEditor extends AssetTabEditor { return; } 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); if (settings === this.told) return; this.told = settings; diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts index 74a06c14..ec7c1026 100644 --- a/src/editor/song/SongTimeline.ts +++ b/src/editor/song/SongTimeline.ts @@ -113,9 +113,10 @@ const TAKE_COLOR = 0xb33a3a; /** The id the track of a take being recorded is drawn under, until it is made. */ const TAKE_TRACK = '__take'; const SOLO_COLOR = 0xc9c23a; +const FX_COLOR = 0x8fb8de; /** Height of an item's name strip; what it plays is drawn below. */ 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(); DARK_LABEL.fill = 0x30343c; @@ -673,7 +674,8 @@ export class SongTimeline extends Widget { } if (e.button !== 0) return; 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 = part === 'mute' ? track.mute @@ -1014,6 +1016,24 @@ export class SongTimeline extends Widget { 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 { const track = asset.tracks[row]!; const swap = (other: number) => { @@ -1111,6 +1131,7 @@ export class SongTimeline extends Widget { pan: 0, mute: false, solo: false, + fx: '', }); 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); 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); - 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 }); + 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(solo, y, track.solo, SOLO_COLOR); labels.push( diff --git a/src/editor/song/songView.ts b/src/editor/song/songView.ts index e5e33f4e..2621cb7e 100644 --- a/src/editor/song/songView.ts +++ b/src/editor/song/songView.ts @@ -157,11 +157,12 @@ export function indexOf(tracks: readonly SongTrack[]): Map { 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. */ -export type HeadPart = 'name' | 'mute' | 'solo' | 'volume' | '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' | 'fx' | 'mute' | 'solo' | 'volume' | 'pan'; export const HEAD_BOXES: Readonly> = { - 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 }, solo: { x: 150, y: 5, w: 18, h: 16 }, volume: { x: 8, y: 30, w: 100, h: 12 }, @@ -170,7 +171,7 @@ export const HEAD_BOXES: Readonly> = { export function headPartAt(x: number, yInTrack: number, trackHeight = TRACK_HEIGHT): HeadPart { 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; 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) diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index 2d671ac3..8da4ec8e 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -16,22 +16,22 @@ import { hasRenderLayer } from '../../core/properties/PropertySchema'; import { resolveAssetProps } from '../../core/properties/resolve'; import { loopsOf, volumeOf } from '../../core/properties/sounds'; 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 { 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 type { ScheduledClip, ScheduledNote } from '../../core/music/Scheduler'; import { dbToGain, - songBuses, songLength, songLoop, songSequence, songTempo, SOUND_ITEM_MAX_DB, } 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 { PhysicsSystem } from '../physics/PhysicsSystem'; import type { World } from '../World'; @@ -181,15 +181,21 @@ export function behaviorContext( }, playSound: (assetId, volume, loop, detached, fx, fxParams, tempo) => playSound(run, assetId, volume, loop, detached ? '' : entity.id, fx, fxParams, tempo), - playNote: (instrumentId, note, velocity, volume, duration, detached, params, tempo) => - playNote(run, instrumentId, { note, velocity, volume, duration }, detached ? '' : entity.id, params, { - tempo, - }), - playPattern: (patternId, volume, loop, detached) => - playPattern(run, patternId, volume, loop, detached ? '' : entity.id), + playNote: (instrumentId, note, velocity, volume, duration, detached, params, tempo, fx, fxParams) => + playNote( + run, + instrumentId, + { note, velocity, volume, duration }, + 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), - playSong: (songId, volume, fadeIn, loop, detached) => - playSong(run, songId, volume, fadeIn, loop, detached ? '' : entity.id), + playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams) => + playSong(run, songId, volume, fadeIn, loop, detached ? '' : entity.id, fx, fxParams), stopSong: (songId, fadeOut) => run.audio.stopSequence(songVoiceName(songId), false, heldFade(fadeOut)), stopPlayback: (playback, fadeOut) => run.audio.stopPlayback(playback, heldFade(fadeOut)), soundLength: (assetId) => { @@ -204,7 +210,7 @@ export function behaviorContext( if (assetId === '' || file) run.audio.stop(file, 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), addJoint: (assetId, owner, connected, at) => { 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 * 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. - * `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( { project, audio }: Pick, @@ -291,18 +298,35 @@ export function playNote( owner: string, params: FxParams, timing: NoteTiming = {}, + fx: FxVoice | null = null, ): number { const asset = findAssetOf(project, instrumentId, 'instrument'); if (!asset) return 0; - const { layers } = instrumentBuildFor(asset, heldNote(struck.note), instrumentSourcesOf(project)); - if (layers.length === 0) return 0; + const built = instrumentBuildFor(asset, heldNote(struck.note), instrumentSourcesOf(project)).layers; + 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 level = Math.max(0, Math.min(1, struck.volume * velocity)); 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); } +/** 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. */ export function instrumentVoiceName(instrumentId: string): string { return `instrument:${instrumentId}`; @@ -319,6 +343,8 @@ function playPattern( volume: number, loop: boolean, owner: string, + fxId: string, + fxParams: FxParams, ): number { const asset = findAssetOf(project, patternId, 'pattern'); if (!asset) return 0; @@ -327,14 +353,17 @@ function playPattern( if (!instrument || sequence.notes.length === 0) return 0; const level = heldLevel(volume); const length = patternLength(asset); + const tempo = patternTempo(asset.props); + const fx = fxVoiceOf(project, fxId, tempo, fxParams); return audio.playSequence( patternVoiceName(patternId), { sequence, - tempo: patternTempo(asset.props), + tempo, length, loop: loop ? { start: 0, end: length } : null, needs: sequenceNeeds(project, sequence), + ...(fx && { fx }), strike: (note) => strikeNote({ project, audio }, { ...note, volume: level }, owner), }, owner, @@ -354,21 +383,26 @@ function playSong( fadeIn: number, loop: boolean, owner: string, + fxId: string, + fxParams: FxParams, ): number { const song = findAssetOf(project, songId, 'song'); if (!song) return 0; const sequence = songSequence(project, song); + const tempo = songTempo(song.props); + const fx = fxVoiceOf(project, fxId, tempo, fxParams); return audio.playSequence( songVoiceName(songId), { sequence, - tempo: songTempo(song.props), + tempo, length: songLength(song), loop: loop ? songLoop(song) : null, needs: sequenceNeeds(project, sequence), volume: heldLevel(volume), fadeIn: heldFade(fadeIn), - buses: songBuses(song), + buses: songBuses(project, song), + ...(fx && { fx }), strike: (note) => strikeNote({ project, audio }, note, owner), strikeClip: (clip) => playClip({ project, audio }, clip), }, diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index 4cf6656f..e853b127 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -22,11 +22,12 @@ import { } from '../core/soundfx/nodes'; import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument/envelope'; 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 { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song'; import { DEFAULT_TEMPO } from '../core/music/time'; 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 { ChipOptions, FlangerOptions, PhaserOptions, PitchOptions } from './fxWorklet'; 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); }, 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), after: (at, then) => void this.after(at, then), closed: () => this.sweep(), @@ -295,6 +297,8 @@ export class SoundLibrary implements AudioSink { private readonly groups = new Set(); /** Held by the pause of the toolbar: nothing that reads the clock may wake it then. */ 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. */ 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); } - /** What each track's bus was last set to (test hook). */ - busLevels(): { name: string; gain: number; pan: number }[] { - return [...this.buses].map(([name, bus]) => ({ name, ...bus.set })); + /** + * The tracks of a song in the Song tab (#334), each set as `setBus` sets one, with the bus it goes + * 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 { + const waits: Promise[] = []; + 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(); + 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 `''`. */ 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); - if (known) return known; + if (known) { + known.leaving = 0; + return known; + } const context = this.audio(); - const made = { gain: context.createGain(), pan: context.createStereoPanner(), set: { gain: 1, pan: 0 } }; - made.gain.connect(made.pan).connect(out ?? context.destination); + const made: Bus = { + 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); 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 { + 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 * 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; const id = this.nextId++; 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 into = (bus: string | undefined) => groupBus(id, bus !== undefined && tracks.has(bus) ? bus : ''); const { strike, strikeClip } = play; + const chains = [...buses.flatMap((bus) => bus.fx ?? []), ...(play.fx ? [play.fx] : [])]; const routed: SequencePlay = { ...play, + needs: { ...play.needs, layers: [...play.needs.layers, ...chains.map((fx) => fx.plan)] }, strike: (note) => strike({ ...note, bus: into(note.bus) }), ...(strikeClip && { 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.set = { gain: 0, pan: 0 }; - for (const { name, gain, pan } of tracks) { - const track = this.bus(groupBus(id, name), master.gain); + master.fx = fx; + 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.pan.pan.value = Math.max(-1, Math.min(1, pan)); track.set = { gain: track.gain.gain.value, pan: track.pan.pan.value }; + track.fx = trackFx ?? null; } 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 { + 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. */ private level(id: number, gain: number, at: number, seconds: number): void { 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; this.groups.delete(id); 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) { const bus = this.buses.get(name); - bus?.gain.disconnect(); - bus?.pan.disconnect(); - this.buses.delete(name); + if (bus) this.dropBus(name, bus); } }); } @@ -598,7 +860,7 @@ export class SoundLibrary implements AudioSink { return true; } 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 } }; 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. @@ -607,6 +869,23 @@ export class SoundLibrary implements AudioSink { 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 * 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(); // 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. 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); // 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; @@ -1277,16 +1572,7 @@ export class SoundLibrary implements AudioSink { private glide(voice: Voice, time: number, ease: EaseShare): boolean { const levels: number[] = []; for (const plan of voice.fx.layers) levelsOf(fxChainOf(plan, voice.fx.params, voice.fx.tempo), levels); - const now = this.audio().currentTime; - 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; + return glideKnobs(voice.knobs, levels, this.audio().currentTime, time, ease); } private audio(): AudioContext { @@ -1376,11 +1662,31 @@ function groupBus(id: number, track: string): string { 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 { + input: GainNode; gain: GainNode; pan: StereoPannerNode; 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. */ @@ -1793,6 +2099,25 @@ function move( 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. */ const CURVE_RATE = 200; diff --git a/src/viewport/fxWorklet.ts b/src/viewport/fxWorklet.ts index 0f6dd7b8..e1a8a227 100644 --- a/src/viewport/fxWorklet.ts +++ b/src/viewport/fxWorklet.ts @@ -2,13 +2,14 @@ * 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 * 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. */ import { type ChipShape, ChipPlayer } from '../core/soundfx/chip'; import { Crusher } from '../core/soundfx/crusher'; 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. */ declare const sampleRate: number; @@ -86,6 +87,38 @@ class PitchProcessor extends AudioWorkletProcessor { 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, + ): 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. */ class CrushProcessor extends AudioWorkletProcessor { private readonly crusher = new Crusher(sampleRate); diff --git a/src/viewport/music.ts b/src/viewport/music.ts index 53b73729..298779a8 100644 --- a/src/viewport/music.ts +++ b/src/viewport/music.ts @@ -34,6 +34,9 @@ export class Music { private sequence: Sequence = { notes: [] }; /** Counts starts and stops: a start still loading its files gives up once another came after it. */ 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 = Promise.resolve(); /** The last notes and clips planned, newest last, for a check to hold against the clock. */ readonly planned: PlannedNote[] = []; readonly clips: PlannedClip[] = []; @@ -42,17 +45,18 @@ export class Music { /** Loads what the notes and clips need, then plays from `from` ticks. */ async play(sequence: Sequence, options: SchedulerOptions & { from?: number } = {}): Promise { - this.stop(); + this.halt(); const run = this.run; this.sequence = sequence; this.planned.length = 0; this.clips.length = 0; - await this.prepare(sequence); + await Promise.all([this.prepare(sequence), this.busesReady]); if (run !== this.run) return; this.scheduler = new Scheduler( { now: () => soundLibrary.time(), latency: () => soundLibrary.latency(), + ahead: () => Math.max(0, ...this.buses.map((bus) => soundLibrary.busLatency(bus.name))), play: (note) => this.playOne(note), playClip: (clip) => this.playClip(clip), playing: (voice) => soundLibrary.playing(voice), @@ -73,12 +77,22 @@ export class Music { 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 { - 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 { + this.halt(); + soundLibrary.releaseBuses(this.buses.map((bus) => bus.name)); + } + + private halt(): void { this.run += 1; this.scheduler?.stop(); this.scheduler = null; diff --git a/tests/core/bus-fx.test.ts b/tests/core/bus-fx.test.ts new file mode 100644 index 00000000..c679fdd5 --- /dev/null +++ b/tests/core/bus-fx.test.ts @@ -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 } }, + ]); + }); +}); diff --git a/tests/core/music.test.ts b/tests/core/music.test.ts index 13fdaf96..fdd41bca 100644 --- a/tests/core/music.test.ts +++ b/tests/core/music.test.ts @@ -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)); }); + 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', () => { const { host, sink, run } = fakeHost(); const twoNotes: Sequence = { notes: [note(0), note(Q)] }; diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 67cd018b..8c2e81d4 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -2065,9 +2065,22 @@ const CASES: Record = { detached: false, instrumentParams: { cut: 2 }, 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)', inputs: { instrument: 'ins-1', note: 60, tempo: 90 }, @@ -2098,7 +2111,15 @@ const CASES: Record = { next: 'exec', outputs: { playback: 1 }, 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', @@ -2148,9 +2169,15 @@ const CASES: Record = { outputs: { playback: 1 }, after: (ctx) => 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', inputs: { song: 'song-1', volume: 1, fadeIn: 0, loop: false, detached: true }, diff --git a/tests/core/pitch-shifter.test.ts b/tests/core/pitch-shifter.test.ts new file mode 100644 index 00000000..3bf3938f --- /dev/null +++ b/tests/core/pitch-shifter.test.ts @@ -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); + }); +}); diff --git a/tests/core/song-play.test.ts b/tests/core/song-play.test.ts index d6918ed4..101bb0ef 100644 --- a/tests/core/song-play.test.ts +++ b/tests/core/song-play.test.ts @@ -120,6 +120,28 @@ describe('the songs a game plays', () => { 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((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 () => { const { host, playbacks, run, loaded } = fakeHost(); const struck: (ScheduledNote | ScheduledClip)[] = []; diff --git a/tests/core/soundfx.test.ts b/tests/core/soundfx.test.ts index e8cf988b..97891c1e 100644 --- a/tests/core/soundfx.test.ts +++ b/tests/core/soundfx.test.ts @@ -744,7 +744,8 @@ describe('Set sound FX parameters (#315)', () => { input.press('x'); frame(); 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', () => { diff --git a/tests/editor/song-edit.test.ts b/tests/editor/song-edit.test.ts index ef824d77..85a1982d 100644 --- a/tests/editor/song-edit.test.ts +++ b/tests/editor/song-edit.test.ts @@ -37,7 +37,7 @@ import { const BAR = 1920; const TRACKS = ['t1', 't2', 't3']; 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( id: string, diff --git a/tests/helpers.ts b/tests/helpers.ts index b378bfa0..17244e46 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -225,11 +225,28 @@ export type FakeContext = NodeContext & { detached: boolean; instrumentParams: Readonly>; tempo: number; + fx: string; + fxParams: Readonly>; }[]; /** 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>; + }[]; /** 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>; + }[]; silenced: string[]; tuned: { voice: number; fxParams: Readonly>; time: number; ease: EaseShare }[]; /** The scenes the context was asked to load, in order. */ @@ -287,16 +304,38 @@ export function fakeContext(overrides: Partial = {}): FakeContext { playSound: (assetId, volume, loop, detached, fx, fxParams, tempo) => ctx.played.push({ assetId, volume, loop, detached, fx, fxParams, tempo }), notes: [], - playNote: (instrumentId, note, velocity, volume, duration, detached, instrumentParams, tempo) => - ctx.notes.push({ instrumentId, note, velocity, volume, duration, detached, instrumentParams, tempo }), + playNote: ( + instrumentId, + note, + velocity, + volume, + duration, + detached, + instrumentParams, + tempo, + fx, + fxParams, + ) => + ctx.notes.push({ + instrumentId, + note, + velocity, + volume, + duration, + detached, + instrumentParams, + tempo, + fx, + fxParams, + }), patterns: [], - playPattern: (patternId, volume, loop, detached) => - ctx.patterns.push({ patternId, volume, loop, detached }), + playPattern: (patternId, volume, loop, detached, fx, fxParams) => + ctx.patterns.push({ patternId, volume, loop, detached, fx, fxParams }), stopPattern: (patternId, letRing) => ctx.silenced.push(`pattern ${patternId}${letRing ? ' (let ring)' : ''}`), songs: [], - playSong: (songId, volume, fadeIn, loop, detached) => - ctx.songs.push({ songId, volume, fadeIn, loop, detached }), + playSong: (songId, volume, fadeIn, loop, detached, fx, fxParams) => + ctx.songs.push({ songId, volume, fadeIn, loop, detached, fx, fxParams }), stopSong: (songId, fadeOut) => ctx.silenced.push(`song ${songId} (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. diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index e3eed80a..f8e079d7 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -318,7 +318,9 @@ beforeEach(() => { ? 'chip' : name === 'phaser' || name === 'flanger' ? name - : 'pitch'; + : name === 'pitch-shifter' + ? 'shifter' + : 'pitch'; const node: FakeWorklet = wired(kind, { options, 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 }); }); - 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(); library.setBus('song:s:t', 0.5, -0.25); 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.filter((wire) => wire === 'gain > gain').length).toBeGreaterThan(0); 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]); library.setBus('song:s:t', 0, 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.25, 1], ]); @@ -1689,14 +1692,21 @@ describe('the patterns and songs a game plays (#336)', () => { 'logic', ); expect(library.busLevels()).toEqual([ - { name: `play:${id}`, gain: 0, pan: 0 }, - { name: `play:${id}:song:s:t`, gain: 0.25, pan: -1 }, + { name: `play:${id}`, gain: 0, pan: 0, into: null, fx: false, latency: 0 }, + { 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(); 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. - expect(gains[0]!.ramps).toEqual([ + expect(gains[1]!.ramps).toEqual([ ['set', 0, 0.05], ['linear', 0.5, 2.05], ]); @@ -1704,7 +1714,14 @@ describe('the patterns and songs a game plays (#336)', () => { expect(library.playbacks()).toEqual([]); clockTo(); 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 () => { @@ -1745,3 +1762,184 @@ describe('the patterns and songs a game plays (#336)', () => { 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) { + 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']); + }); +}); diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index b550f8a6..0c2a58d8 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -694,10 +694,120 @@ export async function song(t) { 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. await evaluate('__gm.sound.stopAll()'); 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; project.assets = project.assets.filter((a) => !gone.has(a.id)); __gm.state.select(null);`);