diff --git a/README.md b/README.md index 7e7445ca..ed1e00c9 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ way in. - An **asset** is a named, typed thing (scene, camera, sprite, text, behavior, variables, struct, collider, rigidbody, joint, container, clip area, texture, vector art, pixel art, palette, - animation, curve, tile, tilemap, state machine, shader, shader function, font, sound, particles) - with properties and no position. + animation, curve, tile, tilemap, state machine, shader, shader function, font, sound, sound FX, + instrument, pattern, song, bus, particles) with properties and no position. - A **scene object** places an asset: position, sparse **overrides** of its properties, an optional parent. Only what differs from the asset is stored; the inspector paints it gold and offers ↺ / ↑. - A **container** is a reusable group edited once and placed many times; its content is resolved @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1744 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1919 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs @@ -75,7 +75,10 @@ CI. Details, the GPU requirement and the detached runner are in | [Vector art](https://git.nockal.com/meleki/game-maker/wiki/Vector-Art) | Pictures drawn from shapes: the pen, curves, gradients and texture fills, combining shapes | | [Pixel art](https://git.nockal.com/meleki/game-maker/wiki/Pixel-Art) | Pictures painted pixel by pixel: the palette, the tools, layers and frames | | [Fonts](https://git.nockal.com/meleki/game-maker/wiki/Fonts) | Font assets, the picker, what a text falls back to | -| [Sounds](https://git.nockal.com/meleki/game-maker/wiki/Sounds) | Sound assets and `Play sound` | +| [Sounds](https://git.nockal.com/meleki/game-maker/wiki/Sounds) | Sound assets and clips, `Play sound`, buses and the Master routing, sound FX | +| [Instruments](https://git.nockal.com/meleki/game-maker/wiki/Instruments) | Instruments as node graphs, Chips and presets, the keys of the Instrument tab, `Play note` | +| [Patterns](https://git.nockal.com/meleki/game-maker/wiki/Patterns) | Patterns and the piano roll, playing one in a loop or from a game | +| [Songs](https://git.nockal.com/meleki/game-maker/wiki/Songs) | Songs, their tracks and items, routing, recording, playing one from a game | | [Animations](https://git.nockal.com/meleki/game-maker/wiki/Animations) | Animation assets and the Animation tab | | [Curves](https://git.nockal.com/meleki/game-maker/wiki/Curves) | Easings drawn by hand, the Curve tab, using one on a key or a `Lerp` | | [Shaders](https://git.nockal.com/meleki/game-maker/wiki/Shaders) | Shader graphs that sprites, texts, particle fields and cameras wear, their node set, passes, shader functions | diff --git a/src/core/commands/patternCommands.ts b/src/core/commands/patternCommands.ts index 38545a1a..178c6f05 100644 --- a/src/core/commands/patternCommands.ts +++ b/src/core/commands/patternCommands.ts @@ -1,14 +1,14 @@ /** * Editing the notes of a pattern (#333). As with a curve, one gesture is one command: the whole list * is written once the pointer lets go, so an undo takes the drag back. What is written is held to the - * rules of a note first (`heldPatternNotes`). + * rules of a note first (`heldPatternNotes`). The loop of a song's ruler is set the same way (#334). */ -import type { PatternAsset } from '../model/Asset'; +import type { PatternAsset, SongAsset } from '../model/Asset'; import { heldPatternNotes, type PatternNote } from '../music/pattern'; import { heldLoop, type LoopRange } from '../music/sequence'; import type { Project } from '../model/Project'; import type { Command } from './Command'; -import { requirePattern } from './require'; +import { requirePattern, requireSong } from './require'; export class SetPatternNotesCommand implements Command { private previous: PatternNote[] = []; @@ -35,20 +35,30 @@ export class SetPatternNotesCommand implements Command { } } -/** Sets the loop of the ruler, or takes it away (`null`): then the whole pattern is the loop. */ -export class SetPatternLoopCommand implements Command { +/** + * Sets the loop of the ruler of a pattern or a song, or takes it away (`null`): then the whole of it + * is the loop. + */ +export class SetLoopCommand implements Command { readonly label: string; private previous: LoopRange | undefined; constructor( + private readonly kind: 'pattern' | 'song', private readonly assetId: string, private readonly loop: LoopRange | null, ) { - this.label = loop ? 'Set loop' : 'Loop the whole pattern'; + this.label = loop ? 'Set loop' : `Loop the whole ${kind}`; + } + + private asset(project: Project): PatternAsset | SongAsset { + return this.kind === 'pattern' + ? requirePattern(project, this.assetId) + : requireSong(project, this.assetId); } execute(project: Project): void { - const asset = requirePattern(project, this.assetId); + const asset = this.asset(project); this.previous = asset.loop; const held = heldLoop(this.loop); if (held) asset.loop = { start: Math.round(held.start), end: Math.round(held.end) }; @@ -56,7 +66,7 @@ export class SetPatternLoopCommand implements Command { } undo(project: Project): void { - const asset = requirePattern(project, this.assetId); + const asset = this.asset(project); if (this.previous) asset.loop = this.previous; else delete asset.loop; } diff --git a/src/core/commands/songCommands.ts b/src/core/commands/songCommands.ts index 584eb320..67beddb1 100644 --- a/src/core/commands/songCommands.ts +++ b/src/core/commands/songCommands.ts @@ -11,7 +11,6 @@ import { settleEmbedded } from '../music/embedded'; import { routedKind, sendsTapped } from '../music/projectRouting'; import { heldSend, heldSongGraph } from '../music/routing'; import { heldSongItems, heldSongTracks, type SongItem, type SongTrack } from '../music/song'; -import { heldLoop, type LoopRange } from '../music/sequence'; import { type Removed, removeWhere, restoreAt } from '../util/list'; import { type Command, CommandError } from './Command'; import { requireEdge, requireSong } from './require'; @@ -109,30 +108,3 @@ export class SetSendCommand implements Command { else delete edge.send; } } - -/** Sets the loop of the ruler, or takes it away (`null`): then the whole song is the loop. */ -export class SetSongLoopCommand implements Command { - readonly label: string; - private previous: LoopRange | undefined; - - constructor( - private readonly assetId: string, - private readonly loop: LoopRange | null, - ) { - this.label = loop ? 'Set loop' : 'Loop the whole song'; - } - - execute(project: Project): void { - const asset = requireSong(project, this.assetId); - this.previous = asset.loop; - const held = heldLoop(this.loop); - if (held) asset.loop = { start: Math.round(held.start), end: Math.round(held.end) }; - else delete asset.loop; - } - - undo(project: Project): void { - const asset = requireSong(project, this.assetId); - if (this.previous) asset.loop = this.previous; - else delete asset.loop; - } -} diff --git a/src/core/graph/dropTarget.ts b/src/core/graph/dropTarget.ts index 672d054f..7f0b23e7 100644 --- a/src/core/graph/dropTarget.ts +++ b/src/core/graph/dropTarget.ts @@ -22,10 +22,6 @@ export function portOf(graph: Graph, side: PortSide, lookup: GraphLookup): PortD return list?.find((p) => p.key === side.port); } -export function portTypeOf(graph: Graph, side: PortSide, lookup: GraphLookup): PortType | undefined { - return portOf(graph, side, lookup)?.type; -} - /** Whether a drag from `from` may land on `to`, i.e. whether ConnectCommand would accept it. */ export function canConnect(graph: Graph, from: PortSide, to: PortSide, lookup: GraphLookup): boolean { if (from.isOutput === to.isOutput || from.nodeId === to.nodeId) return false; @@ -37,7 +33,10 @@ export function canConnect(graph: Graph, from: PortSide, to: PortSide, lookup: G return portsMeet(out, inp) && !linked(graph, from, to) && !closesLoop(graph, outNode, inNode, lookup); } -/** Whether a link from `outNode` into `inNode` would lead round to where it started, between nodes that forbid that (#350). */ +/** + * Whether a link from `outNode` into `inNode` would lead round to where it started, between nodes + * that forbid that (#350). + */ export function closesLoop(graph: Graph, outNode: string, inNode: string, lookup: GraphLookup): boolean { const acyclic = (id: string) => { const node = findNode(graph, id); diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index 2f16cfe3..858244eb 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -48,7 +48,7 @@ const PLAY_NOTE = 'PlayNote'; export const BEHAVIOR_PARAM = 'behavior'; export const FUNCTION_PARAM = 'function'; export const VARIABLES_PARAM = 'variablesAsset'; -export const SOUND_FX_PARAM = 'soundFxAsset'; +const SOUND_FX_PARAM = 'soundFxAsset'; /** Whose parameters a `Sound FX parameters` node sets. */ export const SOUND_FX_SETTING: PropertyDef = { diff --git a/src/core/graph/nodes/action/PlayNote.ts b/src/core/graph/nodes/action/PlayNote.ts index 399f0516..e8e28c0d 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, playedBusPort, soundFxPort, withDefault } from '../shared'; +import { detachedPort, exec, fxParamsPort, num, playedBusPort, soundFxPort, withDefault } from '../shared'; import { fxParamsOf, tempoPort } from './PlaySound'; export const PlayNote: NodeDef = { @@ -35,17 +35,11 @@ export const PlayNote: NodeDef = { withDefault(num('velocity'), 1), withDefault(num('volume'), 1), withDefault(num('duration'), 0), - { - key: 'detached', - label: 'detached', - type: 'boolean', - default: false, - field: { key: 'detached', label: 'Detached (outlives this object)', kind: 'boolean', default: false }, - }, - { key: 'fxParams', label: 'params', type: 'fxparams' }, + detachedPort(), + fxParamsPort('fxParams', 'params'), tempoPort(), { ...soundFxPort(), key: 'soundFx' }, - { key: 'soundFxParams', label: 'fx params', type: 'fxparams' }, + fxParamsPort('soundFxParams'), playedBusPort(), ], outputs: [exec(), num('voice')], diff --git a/src/core/graph/nodes/action/PlayPattern.ts b/src/core/graph/nodes/action/PlayPattern.ts index 3db01d3d..ec2c5ee9 100644 --- a/src/core/graph/nodes/action/PlayPattern.ts +++ b/src/core/graph/nodes/action/PlayPattern.ts @@ -1,6 +1,15 @@ import type { NodeDef, PortDef } from '../../NodeRegistry'; import { asBoolean, asNumber, asString } from '../../values'; -import { exec, num, playedBusPort, soundFxPort, withDefault } from '../shared'; +import { + checkPort, + detachedPort, + exec, + fxParamsPort, + num, + playedBusPort, + soundFxPort, + withDefault, +} from '../shared'; import { fxParamsOf } from './PlaySound'; export const patternPort = (): PortDef => ({ @@ -30,22 +39,10 @@ export const PlayPattern: NodeDef = { exec(), patternPort(), withDefault(num('volume'), 1), - { - key: 'loop', - label: 'loop', - type: 'boolean', - default: true, - field: { key: 'loop', label: 'Loop', kind: 'boolean', default: true }, - }, - { - key: 'detached', - label: 'detached', - type: 'boolean', - default: false, - field: { key: 'detached', label: 'Detached (outlives this object)', kind: 'boolean', default: false }, - }, + checkPort('loop', 'loop', 'Loop', true), + detachedPort(), soundFxPort(), - { key: 'fxParams', label: 'fx params', type: 'fxparams' }, + fxParamsPort(), playedBusPort(), ], outputs: [exec(), num('playback')], diff --git a/src/core/graph/nodes/action/PlaySong.ts b/src/core/graph/nodes/action/PlaySong.ts index b9b577a3..7238e529 100644 --- a/src/core/graph/nodes/action/PlaySong.ts +++ b/src/core/graph/nodes/action/PlaySong.ts @@ -1,6 +1,15 @@ import type { NodeDef, PortDef } from '../../NodeRegistry'; import { asBoolean, asNumber, asString } from '../../values'; -import { exec, num, playedBusPort, soundFxPort, withDefault } from '../shared'; +import { + checkPort, + detachedPort, + exec, + fxParamsPort, + num, + playedBusPort, + soundFxPort, + withDefault, +} from '../shared'; import { fxParamsOf } from './PlaySound'; export const songPort = (): PortDef => ({ @@ -31,22 +40,10 @@ export const PlaySong: NodeDef = { songPort(), withDefault(num('volume'), 1), withDefault(num('fadeIn', 'fade in'), 0), - { - key: 'loop', - label: 'loop', - type: 'boolean', - default: true, - field: { key: 'loop', label: 'Loop', kind: 'boolean', default: true }, - }, - { - key: 'detached', - label: 'detached', - type: 'boolean', - default: false, - field: { key: 'detached', label: 'Detached (outlives this object)', kind: 'boolean', default: false }, - }, + checkPort('loop', 'loop', 'Loop', true), + detachedPort(), soundFxPort(), - { key: 'fxParams', label: 'fx params', type: 'fxparams' }, + fxParamsPort(), playedBusPort(), ], outputs: [exec(), num('playback')], diff --git a/src/core/graph/nodes/action/PlaySound.ts b/src/core/graph/nodes/action/PlaySound.ts index 91c43302..bf00293f 100644 --- a/src/core/graph/nodes/action/PlaySound.ts +++ b/src/core/graph/nodes/action/PlaySound.ts @@ -3,7 +3,16 @@ import { DEFAULT_TEMPO, heldTempo, MAX_TEMPO, MIN_TEMPO } from '../../../music/t import type { FxParams } from '../../../soundfx/nodes'; import type { NodeDef, PortDef } from '../../NodeRegistry'; import { asBoolean, asNumber, asString, type Value } from '../../values'; -import { exec, num, playedBusPort, soundFxPort, soundPort, withDefault } from '../shared'; +import { + detachedPort, + exec, + fxParamsPort, + num, + playedBusPort, + soundFxPort, + soundPort, + withDefault, +} from '../shared'; /** * Three answers, not two: a checkbox cannot say "leave it to the asset", and without that a sound @@ -37,19 +46,6 @@ function loopChoice(value: Value | undefined): boolean | undefined { return undefined; } -/** - * The way out of the ownership rule, off by default. Needed for one shape that is common enough to - * deserve it: an object that plays its own death sound and destroys itself in the same flow – the - * voice would be cut off in the instant it started, because the behavior holding it is gone. - */ -const detachedPort = (): PortDef => ({ - key: 'detached', - label: 'detached', - type: 'boolean', - default: false, - field: { key: 'detached', label: 'Detached (outlives this object)', kind: 'boolean', default: false }, -}); - export const PlaySound: NodeDef = { type: 'PlaySound', label: 'Play sound', @@ -76,7 +72,7 @@ export const PlaySound: NodeDef = { loopPort(), detachedPort(), soundFxPort(), - { key: 'fxParams', label: 'fx params', type: 'fxparams' }, + fxParamsPort(), tempoPort(), playedBusPort(), ], diff --git a/src/core/graph/nodes/action/SetSoundFxParameters.ts b/src/core/graph/nodes/action/SetSoundFxParameters.ts index 421563de..3a4fb071 100644 --- a/src/core/graph/nodes/action/SetSoundFxParameters.ts +++ b/src/core/graph/nodes/action/SetSoundFxParameters.ts @@ -1,7 +1,7 @@ import type { NodeDef } from '../../NodeRegistry'; import { asNumber, asString } from '../../values'; import { curvesOf, easingOfParams, easingParams, idleEasing } from '../easing'; -import { busPort, exec, num } from '../shared'; +import { busPort, exec, fxParamsPort, num } from '../shared'; import { fxParamsOf } from './PlaySound'; import { easeShareOf } from '../../../model/animation'; @@ -25,13 +25,7 @@ export const SetSoundFxParameters: NodeDef = { 'time: seconds the values take to get there, along the chosen easing and on the clock of the ' + 'sound itself, so a sweep sounds the same every time; 0 is a quick glide. Without a voice it changes ' + 'the sound FX of "bus" instead, the master or a bus asset, for everything heard on it.', - inputs: [ - exec(), - { ...num('voice'), inline: false }, - { key: 'fxParams', label: 'fx params', type: 'fxparams' }, - num('time'), - busPort('None', true), - ], + inputs: [exec(), { ...num('voice'), inline: false }, fxParamsPort(), num('time'), busPort('None', true)], outputs: [exec()], params: easingParams(), idleParams: idleEasing, diff --git a/src/core/graph/nodes/action/StopPattern.ts b/src/core/graph/nodes/action/StopPattern.ts index 630cef2a..1ff95eb4 100644 --- a/src/core/graph/nodes/action/StopPattern.ts +++ b/src/core/graph/nodes/action/StopPattern.ts @@ -1,6 +1,6 @@ import type { NodeDef } from '../../NodeRegistry'; import { asBoolean, asNumber, asString } from '../../values'; -import { exec, num } from '../shared'; +import { exec, letRingPort, num } from '../shared'; import { patternPort } from './PlayPattern'; export const StopPattern: NodeDef = { @@ -18,13 +18,7 @@ export const StopPattern: NodeDef = { patternPort(), // Not typed in: unconnected it is null, and only then does the pattern decide. { ...num('playback'), inline: false }, - { - key: 'letRing', - label: 'let ring', - type: 'boolean', - default: false, - field: { key: 'letRing', label: 'Let ring', kind: 'boolean', default: false }, - }, + letRingPort(), ], outputs: [exec()], params: [], diff --git a/src/core/graph/nodes/action/StopSound.ts b/src/core/graph/nodes/action/StopSound.ts index 3fab4127..594bd200 100644 --- a/src/core/graph/nodes/action/StopSound.ts +++ b/src/core/graph/nodes/action/StopSound.ts @@ -1,6 +1,6 @@ import type { NodeDef } from '../../NodeRegistry'; import { asBoolean, asNumber, asString } from '../../values'; -import { exec, num, soundPort } from '../shared'; +import { exec, letRingPort, num, soundPort } from '../shared'; export const StopSound: NodeDef = { type: 'StopSound', @@ -18,13 +18,7 @@ export const StopSound: NodeDef = { soundPort(), // Not typed in: unconnected it is null, and only then does the sound decide. { ...num('voice'), inline: false }, - { - key: 'letRing', - label: 'let ring', - type: 'boolean', - default: false, - field: { key: 'letRing', label: 'Let ring', kind: 'boolean', default: false }, - }, + letRingPort(), ], outputs: [exec()], params: [], diff --git a/src/core/graph/nodes/data/InstrumentParameterValues.ts b/src/core/graph/nodes/data/InstrumentParameterValues.ts index c46a87d0..7e5b6ff4 100644 --- a/src/core/graph/nodes/data/InstrumentParameterValues.ts +++ b/src/core/graph/nodes/data/InstrumentParameterValues.ts @@ -1,37 +1,15 @@ -import type { StructValue } from '../../../model/Properties'; -import { INSTRUMENT_PARAMETER_VALUES, INSTRUMENT_SETTING, soundFxSourceOf } from '../../nested'; +import { INSTRUMENT_PARAMETER_VALUES, INSTRUMENT_SETTING } from '../../nested'; import type { NodeDef } from '../../NodeRegistry'; -import { asNumber } from '../../values'; +import { parameterValues } from './SoundFxParameters'; /** - * Values for the parameters of one instrument (#331), picked like those of a sound FX: each one picked - * is a number input starting at the instrument's default. The bundle goes into the `params` input of - * a `Play note`, or the `fx params` of a `Set sound FX parameters` that changes a playing note. + * Values for the parameters of one instrument (#331), picked like those of a sound FX. The bundle + * goes into the `params` input of a `Play note`, or the `fx params` of a `Set sound FX parameters` + * that changes a playing note. */ -export const InstrumentParameterValues: NodeDef = { - type: INSTRUMENT_PARAMETER_VALUES, - label: 'Instrument parameters', - category: 'data', - description: - 'Values for the parameters of an instrument, for the "params" input of a Play note or the "fx params" of a Set sound FX parameters. Pick the parameters to set; one not picked keeps the default its Parameters node gives it, or on a playing note the value it has.', - inputs: [], - outputs: [{ key: 'params', label: 'params', type: 'fxparams' }], - params: [INSTRUMENT_SETTING], - declaresPorts: 'inputs', - portKinds: ['number'], - declaredPort: (port, node, _graph, lookup) => { - const declared = lookup.soundFx(soundFxSourceOf(node.params))?.params.find((p) => p.key === port.key); - const value = declared?.default ?? 0; - return { - key: port.key, - label: port.key, - type: 'number', - default: value, - field: { key: port.key, label: port.key, kind: 'number', default: value }, - }; - }, - run: (_ctx, inputs, _params, def) => { - const values: StructValue = Object.fromEntries(def.inputs.map((p) => [p.key, asNumber(inputs[p.key])])); - return { outputs: { params: values } }; - }, -}; +export const InstrumentParameterValues: NodeDef = parameterValues( + INSTRUMENT_PARAMETER_VALUES, + 'Instrument parameters', + 'Values for the parameters of an instrument, for the "params" input of a Play note or the "fx params" of a Set sound FX parameters. Pick the parameters to set; one not picked keeps the default its Parameters node gives it, or on a playing note the value it has.', + INSTRUMENT_SETTING, +); diff --git a/src/core/graph/nodes/data/SoundFxParameters.ts b/src/core/graph/nodes/data/SoundFxParameters.ts index 7d721214..2ec6e729 100644 --- a/src/core/graph/nodes/data/SoundFxParameters.ts +++ b/src/core/graph/nodes/data/SoundFxParameters.ts @@ -1,37 +1,55 @@ import type { StructValue } from '../../../model/Properties'; +import type { PropertyDef } from '../../../properties/PropertySchema'; import { SOUND_FX_PARAMETERS, SOUND_FX_SETTING, soundFxSourceOf } from '../../nested'; import type { NodeDef } from '../../NodeRegistry'; import { asNumber } from '../../values'; /** - * Values for the parameters of one sound FX (#311), picked from those its Input declares the way a - * Variables node picks variables. Each picked one is a number input starting at the FX's default; - * the bundle goes into the `fx params` input of a `Play sound` or a `Set sound FX parameters`. + * A node that bundles values for the parameters of one sound FX or instrument, picked from those it + * declares the way a Variables node picks variables. Each picked one is a number input starting at + * the asset's default; the bundle goes into an `fx params` or `params` input. */ -export const SoundFxParameters: NodeDef = { - type: SOUND_FX_PARAMETERS, - label: 'Sound FX parameters', - category: 'data', - description: - '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], - declaresPorts: 'inputs', - portKinds: ['number'], - declaredPort: (port, node, _graph, lookup) => { - const declared = lookup.soundFx(soundFxSourceOf(node.params))?.params.find((p) => p.key === port.key); - const value = declared?.default ?? 0; - return { - key: port.key, - label: port.key, - type: 'number', - default: value, - field: { key: port.key, label: port.key, kind: 'number', default: value }, - }; - }, - run: (_ctx, inputs, _params, def) => { - const values: StructValue = Object.fromEntries(def.inputs.map((p) => [p.key, asNumber(inputs[p.key])])); - return { outputs: { params: values } }; - }, -}; +export function parameterValues( + type: string, + label: string, + description: string, + setting: PropertyDef, +): NodeDef { + return { + type, + label, + category: 'data', + description, + inputs: [], + outputs: [{ key: 'params', label: 'params', type: 'fxparams' }], + params: [setting], + declaresPorts: 'inputs', + portKinds: ['number'], + declaredPort: (port, node, _graph, lookup) => { + const declared = lookup.soundFx(soundFxSourceOf(node.params))?.params.find((p) => p.key === port.key); + const value = declared?.default ?? 0; + return { + key: port.key, + label: port.key, + type: 'number', + default: value, + field: { key: port.key, label: port.key, kind: 'number', default: value }, + }; + }, + run: (_ctx, inputs, _params, def) => { + const values: StructValue = Object.fromEntries(def.inputs.map((p) => [p.key, asNumber(inputs[p.key])])); + return { outputs: { params: values } }; + }, + }; +} + +/** + * Values for the parameters of one sound FX (#311), for the `fx params` input of a `Play sound` or a + * `Set sound FX parameters`. + */ +export const SoundFxParameters: NodeDef = parameterValues( + SOUND_FX_PARAMETERS, + 'Sound FX parameters', + '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.', + SOUND_FX_SETTING, +); diff --git a/src/core/graph/nodes/shared.ts b/src/core/graph/nodes/shared.ts index 48eb93d1..58c21e50 100644 --- a/src/core/graph/nodes/shared.ts +++ b/src/core/graph/nodes/shared.ts @@ -14,6 +14,15 @@ export const any = (key: string, label = key): PortDef => ({ key, label, type: ' export const withDefault = (port: PortDef, value: PropertyValue): PortDef => ({ ...port, default: value }); +/** A boolean input that shows a check box while it is not connected. */ +export const checkPort = (key: string, label: string, fieldLabel: string, value = false): PortDef => ({ + key, + label, + type: 'boolean', + default: value, + field: { key, label: fieldLabel, kind: 'boolean', default: value }, +}); + /** A whole-number input (an index, a count): its field shows no decimals, the node floors it anyway. */ export const int = (key: string, value = 0, label = key): PortDef => ({ key, @@ -58,13 +67,7 @@ export const PER_CELL_INPUT = 'perCell'; * Which pairs a collision event hears of a map's trigger tiles (#160): off, one per area of a tile – * a pond is entered once –, on, one per cell. */ -export const perCellPort = (): PortDef => ({ - key: PER_CELL_INPUT, - label: 'per cell', - type: 'boolean', - default: false, - field: { key: PER_CELL_INPUT, label: 'Per cell (trigger tiles)', kind: 'boolean', default: false }, -}); +export const perCellPort = (): PortDef => checkPort(PER_CELL_INPUT, 'per cell', 'Per cell (trigger tiles)'); /** * The trigger tile a collider lies on (#160) and its cell in the map, which is then `other`. No @@ -127,6 +130,24 @@ export const busPort = (none: string, master: boolean): PortDef => ({ /** The `bus` input of a Play node: empty is the bus its asset names. */ export const playedBusPort = (): PortDef => busPort('From the asset', true); +/** + * The way out of the ownership rule, off by default. Needed for one shape that is common enough to + * deserve it: an object that plays its own death sound and destroys itself in the same flow – the + * voice would be cut off in the instant it started, because the behavior holding it is gone. + */ +export const detachedPort = (): PortDef => + checkPort('detached', 'detached', 'Detached (outlives this object)'); + +/** Values for the parameters of a sound FX or an instrument, bundled by their node (#311, #331). */ +export const fxParamsPort = (key = 'fxParams', label = 'fx params'): PortDef => ({ + key, + label, + type: 'fxparams', +}); + +/** Whether a Stop node lets what it stops fall away in its release instead of cutting it. */ +export const letRingPort = (): PortDef => checkPort('letRing', 'let ring', 'Let ring'); + export function binaryMath(type: string, description: string, fn: (a: number, b: number) => number): NodeDef { return { type, diff --git a/src/core/instrument/chipPresets.ts b/src/core/instrument/chipPresets.ts index 35003475..4d4d5b53 100644 --- a/src/core/instrument/chipPresets.ts +++ b/src/core/instrument/chipPresets.ts @@ -434,8 +434,9 @@ const everyNote: PropertyBag = { from: String(LOWEST_NOTE), to: String(HIGHEST_N /** * An effect preset as a whole graph (#329): every note plays one Chip, which ends by itself through - * an Envelope, a Filter, sfxr's phaser where it has one, and a Bitcrusher. The Chip keeps to the preset's own frequency, as sfxr does, - * so every key plays the same sound until "follow note" is turned on. Each call draws it anew. + * an Envelope, a Filter, sfxr's phaser where it has one, and a Bitcrusher. The Chip keeps to the + * preset's own frequency, as sfxr does, so every key plays the same sound until "follow note" is + * turned on. Each call draws it anew. */ function effectGraph(preset: EffectPreset, random: Random): Graph { const p = presetSfxr(preset, random); @@ -795,7 +796,10 @@ export function randomizedParams(graph: Graph, random: Random = Math.random): Pa return changes; } -/** How far Mutate moves a number at most: a share of an octave where it is heard as a ratio, else a step and a tenth of it. */ +/** + * How far Mutate moves a number at most: a share of an octave where it is heard as a ratio, else a + * step and a tenth of it. + */ const MUTATE: Readonly> = { frequency: { octaves: 0.25 }, minFrequency: { octaves: 0.25 }, diff --git a/src/core/instrument/envelope.ts b/src/core/instrument/envelope.ts index 7e594803..964f5df2 100644 --- a/src/core/instrument/envelope.ts +++ b/src/core/instrument/envelope.ts @@ -7,7 +7,7 @@ import type { FxEnvelope, FxMod } from '../soundfx/nodes'; export const SETTLE = Math.log(1000); /** Seconds a release takes at least, so a note ending on a release of 0 does not click. */ -export const SHORTEST_RELEASE = 0.01; +const SHORTEST_RELEASE = 0.01; export function releaseTime(envelope: FxEnvelope): number { return Math.max(SHORTEST_RELEASE, envelope.release); diff --git a/src/core/instrument/nodes.ts b/src/core/instrument/nodes.ts index dd7c6125..c84eace3 100644 --- a/src/core/instrument/nodes.ts +++ b/src/core/instrument/nodes.ts @@ -79,7 +79,7 @@ export function singleNotes(params: PropertyBag): boolean { } /** What a note is called on its output and in a piano roll: its own name, else its note name. */ -export function noteLabel(params: PropertyBag, note: number): string { +function noteLabel(params: PropertyBag, note: number): string { return textOf(params[noteNameKey(note)] ?? '').trim() || noteName(note); } @@ -120,7 +120,7 @@ const ROOT = noteRow('root', 'Root', MIDDLE_C); const FOLLOW = { key: 'followNote', label: 'Follow note', kind: 'boolean' as const, default: true }; /** How a Sample plays while its note is held and once it is let go. */ export type SamplePlay = 'gate' | 'oneshot' | 'loop' | 'release'; -export const SAMPLE_PLAYS: { value: SamplePlay; label: string }[] = [ +const SAMPLE_PLAYS: { value: SamplePlay; label: string }[] = [ { value: 'gate', label: 'Gate' }, { value: 'oneshot', label: 'One-shot' }, { value: 'loop', label: 'Loop' }, diff --git a/src/core/model/sound.ts b/src/core/model/sound.ts index 6d573673..ccfc745c 100644 --- a/src/core/model/sound.ts +++ b/src/core/model/sound.ts @@ -10,8 +10,8 @@ export function isSoundFile(name: string): boolean { return FILE_PATTERN.test(name); } -export const CLIP_SEPARATOR = ' / '; -export const DEFAULT_CLIP_NAME = 'Clip'; +const CLIP_SEPARATOR = ' / '; +const DEFAULT_CLIP_NAME = 'Clip'; export function clipsOf(asset: SoundAsset): readonly SoundClip[] { return asset.clips ?? []; @@ -58,11 +58,6 @@ export function renewClipIds(copy: Asset, ids: Map): void { } } -/** Every id that means "this sound": the sound itself and each of its clips. */ -export function soundRefIds(asset: SoundAsset): string[] { - return [asset.id, ...clipsOf(asset).map((c) => c.id)]; -} - /** "Kick" for the sound, "Kick / Clip 1" for a clip of it. */ export function soundRefName(ref: SoundRef, soundName = ref.asset.name): string { return ref.clip ? `${soundName}${CLIP_SEPARATOR}${ref.clip.name}` : soundName; @@ -86,11 +81,6 @@ export function uniqueClipName(asset: SoundAsset, name: string, ignoreId = ''): ); } -/** The file a `sound` value points at, empty when the id is unset or no longer resolves. */ -export function soundFileOf(project: Project, id: string): string { - return findSound(project, id)?.asset.file ?? ''; -} - /** The shape of the ramp at a cut edge (#345), from silence to full; the end runs it backwards. */ export type FadeCurve = 'linear' | 'equalPower' | 'sCurve' | 'exponential' | 'logarithmic'; diff --git a/src/core/model/tilemap.ts b/src/core/model/tilemap.ts index 326fb6f3..a7edcd29 100644 --- a/src/core/model/tilemap.ts +++ b/src/core/model/tilemap.ts @@ -120,6 +120,11 @@ export function neighboursOf( ): Neighbours { const same = (dx: number, dy: number): boolean => inGrid(props, x + dx, y + dy) && cellAt(layer, x + dx, y + dy) === tileId; + return joinedBy(same); +} + +/** The eight neighbours, each joined where `same` finds the same tile that far off. */ +function joinedBy(same: (dx: number, dy: number) => boolean): Neighbours { return { n: same(0, -1), e: same(1, 0), @@ -175,16 +180,7 @@ export function neighboursIn(cells: LayerCells, x: number, y: number, tileId: st const ny = y + dy; return nx >= 0 && ny >= 0 && nx < columns && ny < rows && at.get(ny * columns + nx) === tileId; }; - return { - n: same(0, -1), - e: same(1, 0), - s: same(0, 1), - w: same(-1, 0), - ne: same(1, -1), - se: same(1, 1), - sw: same(-1, 1), - nw: same(-1, -1), - }; + return joinedBy(same); } /** One cell as it is drawn: where it sits, which tile it is and which quarters build it. */ diff --git a/src/core/music/embedded.ts b/src/core/music/embedded.ts index eb03330b..57621622 100644 --- a/src/core/music/embedded.ts +++ b/src/core/music/embedded.ts @@ -19,7 +19,7 @@ import { PATTERN_SIGN_KEY, PATTERN_TEMPO_KEY, patternInstrument } from './patter import { type SongItem, songMeter, songTempo, type SongTrack } from './song'; import { signOf } from './time'; -export const EMBEDDABLE_TYPES: readonly AssetType[] = ['sound', 'pattern', 'soundfx']; +const EMBEDDABLE_TYPES: readonly AssetType[] = ['sound', 'pattern', 'soundfx']; /** * The rows of an embedded asset that do nothing in a song: how it repeats is the song's. A pattern diff --git a/src/core/music/pattern.ts b/src/core/music/pattern.ts index 8f770b87..698cb172 100644 --- a/src/core/music/pattern.ts +++ b/src/core/music/pattern.ts @@ -72,7 +72,7 @@ export function patternTempo(props: PropertyBag): number { return heldTempo(typeof tempo === 'number' ? tempo : NaN); } -export function patternBars(props: PropertyBag): number { +function patternBars(props: PropertyBag): number { return wholeNumber(props[PATTERN_BARS_KEY], DEFAULT_BARS, 1, MAX_BARS); } @@ -116,7 +116,7 @@ export function patternSequence(asset: PatternAsset): Sequence { } /** Notes in the order they are played, the lower one first where two start together. */ -export function byStart(a: PatternNote, b: PatternNote): number { +function byStart(a: PatternNote, b: PatternNote): number { return a.tick - b.tick || a.note - b.note; } diff --git a/src/core/music/projectRouting.ts b/src/core/music/projectRouting.ts index e6c22aa1..c90151ce 100644 --- a/src/core/music/projectRouting.ts +++ b/src/core/music/projectRouting.ts @@ -4,7 +4,7 @@ * that comes gets a node of its own and the links most others of its type have, one that goes takes * its node along. */ -import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode, reaches } from '../graph/Graph'; +import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode } from '../graph/Graph'; import { newId } from '../ids'; import type { Asset } from '../model/Asset'; import type { AssetType } from '../model/AssetTypes'; @@ -13,10 +13,10 @@ import type { Project } from '../model/Project'; import { MASTER_BUS } from '../properties/buses'; import { type Removed, removeWhere, restoreAt } from '../util/list'; import { isEmbedded } from './embedded'; -import { heldSend } from './routing'; +import { addLink, heldSend } from './routing'; import { ROUTED_ASSET, ROUTED_KIND, ROUTED_MASTER, ROUTED_NAME } from './routingNodes'; -export const ROUTED_TYPES: readonly AssetType[] = ['sound', 'instrument', 'pattern', 'song', 'bus']; +const ROUTED_TYPES: readonly AssetType[] = ['sound', 'instrument', 'pattern', 'song', 'bus']; /** What has its own sound FX and level on a bus of its own, and so may send from before them. */ const TAPPED_TYPES: readonly AssetType[] = ['bus', 'song']; @@ -96,30 +96,18 @@ export function heldRouting(graph: Graph | undefined, assets: readonly Asset[], const kinds = new Map(nodes.map((node) => [node.id, routedKind(node)])); const takesIn = (id: string) => id === MASTER_BUS || kinds.get(id) === 'bus'; const edges: GraphEdge[] = []; - const edgeIds = new Set(); const lost = new Set(); for (const edge of given.edges) { const from = edge.from.node; - const to = edge.to.node; const kind = kinds.get(from); if (!kind || fresh.has(from) || edge.from.port !== 'out') continue; - if (!takesIn(to) || edge.to.port !== 'in') { + if (!takesIn(edge.to.node) || edge.to.port !== 'in') { lost.add(from); continue; } - if (from === to || edgeIds.has(edge.id)) continue; - if (edges.some((e) => e.from.node === from && e.to.node === to)) continue; - if (to !== MASTER_BUS && reaches({ edges }, to, from)) continue; - edgeIds.add(edge.id); const raw = heldSend(edge.send); const send = raw && !sendsTapped(kind) ? heldSend({ ...raw, tap: 'post' }) : raw; - edges.push({ - id: edge.id, - from: { node: from, port: 'out' }, - to: { node: to, port: 'in' }, - ...(edge.folded && { folded: true }), - ...(send && { send }), - }); + addLink(edges, edge, send, MASTER_BUS); } const linked = new Set(edges.map((e) => e.from.node)); const toMaster = (from: string): GraphEdge => ({ diff --git a/src/core/music/routing.ts b/src/core/music/routing.ts index cac04bc4..a8b23f9a 100644 --- a/src/core/music/routing.ts +++ b/src/core/music/routing.ts @@ -27,6 +27,26 @@ export const SEND_TAPS: readonly { value: SendTap; label: string }[] = [ { value: 'preFx', label: 'Pre volume (Pre FX)' }, ]; +/** + * Adds a link from out to in, as `edge` runs, to `edges` – unless it is one to itself, a second one + * under the same id or between the same two nodes, or one that closes a loop. A link into `sink`, + * which sends on to nothing, never does. + */ +export function addLink(edges: GraphEdge[], edge: GraphEdge, send: EdgeSend | undefined, sink: string): void { + const from = edge.from.node; + const to = edge.to.node; + if (from === to || edges.some((e) => e.id === edge.id || (e.from.node === from && e.to.node === to))) + return; + if (to !== sink && reaches({ edges }, to, from)) return; + edges.push({ + id: edge.id, + from: { node: from, port: 'out' }, + to: { node: to, port: 'in' }, + ...(edge.folded && { folded: true }), + ...(send && { send }), + }); +} + /** What a link of the routing sends: as its `send` says, else at 0 dB after volume and pan. */ export function sendOf(edge: Pick): EdgeSend { return edge.send ?? { volume: 0, tap: 'post' }; @@ -73,24 +93,10 @@ export function heldSongGraph(graph: Graph | undefined, tracks: readonly SongTra const ids = new Set(trackNodes.map((n) => n.id)); const edges: GraphEdge[] = []; - const edgeIds = new Set(); for (const edge of given.edges) { - const from = edge.from.node; - const to = edge.to.node; - const toOutput = to === SONG_OUTPUT_ID; - if (!ids.has(from) || edge.from.port !== 'out' || edge.to.port !== 'in') continue; - if ((!toOutput && !ids.has(to)) || from === to || edgeIds.has(edge.id)) continue; - if (edges.some((e) => e.from.node === from && e.to.node === to)) continue; - if (!toOutput && reaches({ edges }, to, from)) continue; - edgeIds.add(edge.id); - const send = heldSend(edge.send); - edges.push({ - id: edge.id, - from: { node: from, port: 'out' }, - to: { node: to, port: 'in' }, - ...(edge.folded && { folded: true }), - ...(send && { send }), - }); + if (!ids.has(edge.from.node) || edge.from.port !== 'out' || edge.to.port !== 'in') continue; + if (edge.to.node !== SONG_OUTPUT_ID && !ids.has(edge.to.node)) continue; + addLink(edges, edge, heldSend(edge.send), SONG_OUTPUT_ID); } for (const id of fresh) edges.push({ id: newId(), from: { node: id, port: 'out' }, to: { node: SONG_OUTPUT_ID, port: 'in' } }); diff --git a/src/core/music/routingNodes.ts b/src/core/music/routingNodes.ts index 405968ee..1f651f28 100644 --- a/src/core/music/routingNodes.ts +++ b/src/core/music/routingNodes.ts @@ -10,7 +10,7 @@ export const SONG_TRACK_NAME = 'name'; const audioIn: PortDef = { key: 'in', label: 'in', type: 'audio', many: true }; const audioOut: PortDef = { key: 'out', label: 'out', type: 'audio' }; -export const SongTrackNode = registerNode({ +registerNode({ type: SONG_TRACK, label: 'Track', category: 'sound', @@ -24,7 +24,7 @@ export const SongTrackNode = registerNode({ derivePorts: ({ node }) => ({ label: textOf(node.params[SONG_TRACK_NAME] ?? '') || 'Track' }), }); -export const SongOutputNode = registerNode({ +registerNode({ type: SONG_OUTPUT, label: 'Output', category: 'sound', @@ -57,7 +57,7 @@ const KIND_WORDS: Record = { bus: 'Bus', }; -export const RoutedAssetNode = registerNode({ +registerNode({ type: ROUTED_ASSET, label: 'Asset', category: 'sound', @@ -78,7 +78,7 @@ export const RoutedAssetNode = registerNode({ }, }); -export const RoutedMasterNode = registerNode({ +registerNode({ type: ROUTED_MASTER, label: 'Master', category: 'sound', diff --git a/src/core/music/song.ts b/src/core/music/song.ts index fb6a40f2..588c1e64 100644 --- a/src/core/music/song.ts +++ b/src/core/music/song.ts @@ -62,9 +62,9 @@ export const SONG_GRID_KEY = 'grid'; export const DEFAULT_SONG_GRID = '1/16'; export const SONG_GRID_OPTIONS = [{ value: 'bar', label: 'Bar' }, ...GRID_OPTIONS]; /** A new song has this many tracks. */ -export const DEFAULT_TRACKS = 1; +const DEFAULT_TRACKS = 1; /** A song is never shorter than this many bars, on the ruler or when it plays. */ -export const MIN_BARS = 1; +const MIN_BARS = 1; export const TRACK_MIN_DB = -60; export const TRACK_MAX_DB = 12; diff --git a/src/core/music/take.ts b/src/core/music/take.ts index 075a324a..6bf80b16 100644 --- a/src/core/music/take.ts +++ b/src/core/music/take.ts @@ -9,7 +9,7 @@ import { newTrack, type SongTrack } from './song'; /** What a sound may weigh on the server, less a little for the header. */ export const MAX_TAKE_BYTES = 256 * 1024 * 1024 - 1024; -export const WAV_HEADER_BYTES = 44; +const WAV_HEADER_BYTES = 44; /** A run of samples as the input gave them, from `frame` of the audio clock on. */ export interface TakeChunk { diff --git a/src/core/music/time.ts b/src/core/music/time.ts index a330ee58..4c00e839 100644 --- a/src/core/music/time.ts +++ b/src/core/music/time.ts @@ -29,7 +29,7 @@ export function heldMeter(meter: Meter): Meter { } /** The time signatures a song or a pattern is written in (#363, #364), one row for both numbers. */ -export const SIGNS: readonly string[] = [ +const SIGNS: readonly string[] = [ '1/2', '2/2', '3/2', diff --git a/src/core/properties/sounds.ts b/src/core/properties/sounds.ts index 9ba295d4..dc0968ec 100644 --- a/src/core/properties/sounds.ts +++ b/src/core/properties/sounds.ts @@ -13,7 +13,7 @@ export const END_KEY = 'end'; /** Milliseconds the start and the end ramp over when they lie inside the file, so they do not click (#345). */ export const FADE_IN_KEY = 'fadeIn'; export const FADE_OUT_KEY = 'fadeOut'; -export const DEFAULT_FADE_MS = 5; +const DEFAULT_FADE_MS = 5; /** The shape of each ramp. */ export const FADE_IN_CURVE_KEY = 'fadeInCurve'; export const FADE_OUT_CURVE_KEY = 'fadeOutCurve'; diff --git a/src/core/soundfx/dynamics.ts b/src/core/soundfx/dynamics.ts index ac0fcb83..97923e9b 100644 --- a/src/core/soundfx/dynamics.ts +++ b/src/core/soundfx/dynamics.ts @@ -59,7 +59,7 @@ export function compressorMakeup( } /** How far a Limiter looks ahead: a peak is met by a gain that has come down over this long. */ -export const LIMITER_LOOKAHEAD = 0.005; +const LIMITER_LOOKAHEAD = 0.005; /** Samples a Limiter holds the sound back at this rate. */ export function limiterDelay(sampleRate: number): number { diff --git a/src/core/soundfx/lfo.ts b/src/core/soundfx/lfo.ts index 5c9bec9b..f716969c 100644 --- a/src/core/soundfx/lfo.ts +++ b/src/core/soundfx/lfo.ts @@ -15,9 +15,6 @@ export const LFO_SHAPES: { value: LfoShape; label: string }[] = [ { value: 'saw', label: 'Saw' }, ]; -/** The four shapes by a number, as the worklet takes them. */ -export const LFO_SHAPE_INDEX: Record = { sine: 0, triangle: 1, square: 2, saw: 3 }; - /** An LFO at `phase` (0 to 1, one period): -1 to 1, each shape starting at 0 or rising from it. */ export function lfoAt(shape: LfoShape, phase: number): number { const p = phase - Math.floor(phase); @@ -74,7 +71,7 @@ export function lfoSwing(shape: LfoShape): number { const speedUp = (depth: number) => 2 ** (Math.max(0, depth) / 12) - 1; /** The longest a Vibrato's delay may get, and so what its line holds. */ -export const VIBRATO_MAX_DELAY = 0.5; +const VIBRATO_MAX_DELAY = 0.5; /** * Seconds a Vibrato holds the sound back on average: as far as its delay swings out at the rate and diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index 47ba119a..e6785138 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -269,8 +269,6 @@ export type FxChain = /** How a Mix shares its two sounds out: `crossfade` (left out of a plan) or `both` at the middle. */ export type MixCurve = 'crossfade' | 'both'; -export const DRY: FxChain = { pitch: 0, shifted: false, stages: [] }; - /** * A number of the graph as the compiler knows it: typed in, or a parameter of the Input that a * `Play sound` may hand a value for (#311). `value` is what it is otherwise, and a parameter is held @@ -423,7 +421,7 @@ const BYPASS = { key: 'bypass', label: 'Bypass', kind: 'boolean' as const, defau const WET = { ...numberRow('wet', 'Wet', 0, 1, 0.01), default: 1 }; /** Whether a node is bypassed (#354): the graph is compiled as if it were not there. */ -export const isBypassed = (settings: PropertyBag): boolean => settings[BYPASS.key] === true; +const isBypassed = (settings: PropertyBag): boolean => settings[BYPASS.key] === true; /** * An effect with a Bypass and a Wet (#354). Bypassed, it hands on what arrives. Its Wet (`blend`) @@ -557,7 +555,7 @@ export const SoundFxPitch: SoundFxNodeDef = effect( ); /** The five bands of the EQ: a shelf at either end and three peaks between them. */ -export const EQ_BANDS: readonly { key: string; label: string; filter: FxFilter; frequency: number }[] = [ +const EQ_BANDS: readonly { key: string; label: string; filter: FxFilter; frequency: number }[] = [ { key: 'low', label: '80 Hz', filter: 'lowshelf', frequency: 80 }, { key: 'lowMid', label: '250 Hz', filter: 'peaking', frequency: 250 }, { key: 'mid', label: '1 kHz', filter: 'peaking', frequency: 1000 }, @@ -848,7 +846,7 @@ const PHASER_STAGE_ROW = { options: PHASER_STAGES.map((n) => ({ value: String(n), label: String(n) })), }; -export function phaserStagesOf(settings: PropertyBag): number { +function phaserStagesOf(settings: PropertyBag): number { const stages = Number(settings[PHASER_STAGE_ROW.key] ?? PHASER_STAGE_ROW.default); return PHASER_STAGES.find((n) => n === stages) ?? 4; } @@ -971,7 +969,7 @@ const COMPRESS_ATTACK = { ...numberRow('attack', 'Attack (s)', 0, 1, 0.001), def const COMPRESS_RELEASE = { ...numberRow('release', 'Release (s)', 0, 1, 0.001), default: 0.25 }; const MAKEUP = numberRow('makeup', 'Makeup (dB)', -24, 24, 0.5); const AUTO_GAIN = numberRow('autoGain', 'Auto-gain', 0, 1, 0.01); -export const COMPRESS_ROWS = { +const COMPRESS_ROWS = { threshold: COMPRESS_THRESHOLD, ratio: COMPRESS_RATIO, knee: COMPRESS_KNEE, @@ -1013,7 +1011,7 @@ const LIMIT_THRESHOLD = { ...numberRow('threshold', 'Threshold (dB)', -60, 0, 0. const CEILING = { ...numberRow('ceiling', 'Ceiling (dB)', -30, 0, 0.1), default: -1 }; const LIMIT_RELEASE = { ...numberRow('release', 'Release (s)', 0.001, 2, 0.001), default: 0.1 }; const LIMIT_AUTO_GAIN = { ...AUTO_GAIN, default: 1 }; -export const LIMIT_ROWS = { +const LIMIT_ROWS = { threshold: LIMIT_THRESHOLD, ceiling: CEILING, release: LIMIT_RELEASE, @@ -1050,7 +1048,7 @@ const TONE = { ...numberRow('tone', 'Tone', 0, 1, 0.01), default: 1 }; const OUTPUT = numberRow('output', 'Output (dB)', -24, 24, 0.5); const SATURATION_AUTO_GAIN = { ...AUTO_GAIN, default: 1 }; const SATURATION_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 1 }; -export const SATURATION_ROWS = { +const SATURATION_ROWS = { drive: DRIVE, tone: TONE, output: OUTPUT, @@ -1065,7 +1063,7 @@ const SATURATION_TYPE = { options: SATURATION_TYPES, }; -export function saturationTypeOf(settings: PropertyBag): SaturationType { +function saturationTypeOf(settings: PropertyBag): SaturationType { return SATURATION_TYPES.find((option) => option.value === settings[SATURATION_TYPE.key])?.value ?? 'soft'; } @@ -1107,7 +1105,7 @@ const LFO_SHAPE = { options: LFO_SHAPES, }; -export function lfoShapeOf(settings: PropertyBag): LfoShape { +function lfoShapeOf(settings: PropertyBag): LfoShape { return LFO_SHAPES.find((option) => option.value === settings[LFO_SHAPE.key])?.value ?? 'sine'; } @@ -1115,8 +1113,6 @@ const TREMOLO_RATE = { ...numberRow('rate', 'Rate (Hz)', 0, 20, 0.01), default: const TREMOLO_DEPTH = { ...numberRow('depth', 'Depth (%)', 0, 100, 1), default: 50 }; const VIBRATO_RATE = { ...numberRow('rate', 'Rate (Hz)', 0.5, 20, 0.01), default: 5 }; const VIBRATO_DEPTH = { ...numberRow('depth', 'Depth (st)', 0, 4, 0.01), default: 0.5 }; -export const TREMOLO_ROWS = { rate: TREMOLO_RATE, depth: TREMOLO_DEPTH }; -export const VIBRATO_ROWS = { rate: VIBRATO_RATE, depth: VIBRATO_DEPTH }; export const SoundFxTremolo: SoundFxNodeDef = effect( { @@ -1174,14 +1170,8 @@ const REVERB_DECAY = { ...numberRow('decay', 'Decay (s)', 0.1, REVERB_MAX_DECAY, const REVERB_DAMPING = { ...numberRow('damping', 'Damping', 0, 1, 0.01), default: 0.4 }; const PREDELAY = { ...numberRow('predelay', 'Pre-delay (s)', 0, 0.5, 0.001), default: 0.01 }; const REVERB_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 0.3 }; -export const REVERB_ROWS = { - decay: REVERB_DECAY, - damping: REVERB_DAMPING, - predelay: PREDELAY, - mix: REVERB_MIX, -}; -export function reverbRoomOf(settings: PropertyBag): ReverbRoom { +function reverbRoomOf(settings: PropertyBag): ReverbRoom { return REVERB_ROOMS.find((option) => option.value === settings[REVERB_ROOM.key])?.value ?? 'room'; } @@ -1269,7 +1259,7 @@ function delaySeconds(time: number, beats: boolean, tempo: number): number { return Math.min(DELAY_MAX, beats ? secondsOfQuarters(time, heldTempo(tempo)) : time); } -export function envelopeOf(plan: FxEnvelopePlan, params: FxParams): FxEnvelope { +function envelopeOf(plan: FxEnvelopePlan, params: FxParams): FxEnvelope { return { id: plan.id, attack: amountOf(plan.attack, params), diff --git a/src/core/soundfx/parametricEq.ts b/src/core/soundfx/parametricEq.ts index 4035784e..4875874a 100644 --- a/src/core/soundfx/parametricEq.ts +++ b/src/core/soundfx/parametricEq.ts @@ -98,7 +98,7 @@ export const isEqBandHeard = (band: EqBand): boolean => !hasGain(band.type) || b export const eqStagesOf = (band: EqBand): number => (hasSlope(band.type) ? band.slope / 12 : 1); /** The nominal rate the curve is drawn at; the context's own differs only near the very top. */ -export const EQ_SAMPLE_RATE = 48000; +const EQ_SAMPLE_RATE = 48000; interface Biquad { b0: number; @@ -207,9 +207,20 @@ export function eqBandDb(band: EqBand, frequency: number, rate = EQ_SAMPLE_RATE) return eqBandResponse(band, rate)(frequency); } -/** What all bands do together at a frequency, in dB. */ -export function eqCurveDb(bands: readonly EqBand[], frequency: number, rate = EQ_SAMPLE_RATE): number { - let db = 0; - for (const band of bands) if (isEqBandHeard(band)) db += eqBandDb(band, frequency, rate); - return Math.max(FLOOR_DB, db); +/** + * What each band does at every one of `frequencies`, in dB, and what the bands that are heard do + * together: the lines the EQ tab draws. Each band's coefficients are worked out once. + */ +export function eqCurves( + bands: readonly EqBand[], + frequencies: readonly number[], + rate = EQ_SAMPLE_RATE, +): { bands: number[][]; sum: number[] } { + const sum = frequencies.map(() => 0); + const each = bands.map((band) => { + const dbs = frequencies.map(eqBandResponse(band, rate)); + if (isEqBandHeard(band)) dbs.forEach((db, i) => (sum[i]! += db)); + return dbs; + }); + return { bands: each, sum }; } diff --git a/src/core/soundfx/phaser.ts b/src/core/soundfx/phaser.ts index cf628ad3..cf332345 100644 --- a/src/core/soundfx/phaser.ts +++ b/src/core/soundfx/phaser.ts @@ -72,7 +72,7 @@ export class Phaser { } /** The longest a Flanger delays its copy, in ms; a delay past it stays there, as sfxr's does at its own end. */ -export const FLANGER_MAX_MS = 20; +const FLANGER_MAX_MS = 20; /** * The flanger, which sfxr calls its phaser: the sound and a copy `offset` ms late. `sweep` moves diff --git a/src/core/soundfx/pitchPlayer.ts b/src/core/soundfx/pitchPlayer.ts index 92489eb5..07c978af 100644 --- a/src/core/soundfx/pitchPlayer.ts +++ b/src/core/soundfx/pitchPlayer.ts @@ -11,7 +11,7 @@ const PULL = 0.1; 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 { +function pitchFrameSize(rate: number): number { return 2 ** Math.round(Math.log2(rate * 0.043)); } diff --git a/src/editor/FilePicker.ts b/src/editor/FilePicker.ts index 24681ef7..8b083aca 100644 --- a/src/editor/FilePicker.ts +++ b/src/editor/FilePicker.ts @@ -48,7 +48,7 @@ export function withAssetNames( } /** Whose a file is (#344), as the line under the list says it. */ -export function ownerText(owner: string | null): string { +function ownerText(owner: string | null): string { return owner === null ? 'no owner' : owner === '' ? 'owner deleted' : `owner ${owner}`; } diff --git a/src/editor/FloatWindow.ts b/src/editor/FloatWindow.ts index 413b7d56..d086cd08 100644 --- a/src/editor/FloatWindow.ts +++ b/src/editor/FloatWindow.ts @@ -1,5 +1,5 @@ import { type Container, type FederatedPointerEvent, Graphics, Rectangle } from 'pixi.js'; -import { Button, crisp, drawControlBox, Icon, Label, Spacer, Theme, ui, Widget } from '../ui'; +import { Button, crisp, drawFloatingBox, Icon, Label, Spacer, Theme, ui, Widget } from '../ui'; import { onScreen, sized, type WindowBox } from './floating'; import { TabHeader } from './TabHeader'; @@ -170,9 +170,7 @@ export class FloatWindow extends Widget { override redraw(): void { const c = Theme.colors; - this.bg.clear(); - this.bg.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 }); // drop shadow - drawControlBox(this.bg, this.w, this.h, { fill: c.panel, border: c.border }); + drawFloatingBox(this.bg, this.w, this.h, c.panel); // Three short diagonals in the corner, the usual sign of a handle that sizes a window. this.grip.clear(); for (const at of [4, 8, 12]) this.grip.moveTo(GRIP - at, GRIP).lineTo(GRIP, GRIP - at); diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 0a94ba71..d0dbc935 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -4,14 +4,8 @@ import type { SchedulerOptions } from '../core/music/Scheduler'; import type { Sequence } from '../core/music/sequence'; import type { FxEnvelope, FxStage } from '../core/soundfx/nodes'; import { Music } from '../viewport/music'; -import { - bothAtCenter, - envelopeDriver, - followCurve, - releaseEnvelope, - soundLibrary, - stageNodes, -} from '../viewport/SoundLibrary'; +import { bothAtCenter, envelopeDriver, followCurve, releaseEnvelope, stageNodes } from '../viewport/fxNodes'; +import { soundLibrary } from '../viewport/SoundLibrary'; import { saturationAutoGain } from '../core/soundfx/saturation'; import { curveHooks } from './debug/curveHooks'; import { soundHooks } from './debug/soundHooks'; diff --git a/src/editor/floating.ts b/src/editor/floating.ts index 921360ec..5c21205e 100644 --- a/src/editor/floating.ts +++ b/src/editor/floating.ts @@ -13,7 +13,7 @@ export interface WindowBox { export const MIN_WINDOW_WIDTH = 320; export const MIN_WINDOW_HEIGHT = 200; /** How far each further window opens down and to the right, so it does not hide the one before. */ -export const WINDOW_STEP = 24; +const WINDOW_STEP = 24; /** Half the screen's width and 60 % of its height, in the middle; the `count`-th one a step further. */ export function openingBox(screenW: number, screenH: number, count: number): WindowBox { diff --git a/src/editor/graph/paletteEntries.ts b/src/editor/graph/paletteEntries.ts index 46409fcf..3bd8438b 100644 --- a/src/editor/graph/paletteEntries.ts +++ b/src/editor/graph/paletteEntries.ts @@ -128,9 +128,10 @@ function shaderEntries(project: Project, asset: ShaderAsset | ShaderFunctionAsse /** * The node types offerable in `asset`'s graph. A state machine holds states and nothing else, each * new one named by its number; a shader holds shader nodes (`shaderEntries`), a sound FX its effects - * with one Input and one Output, an instrument its own nodes and the same effects. In a behavior, every other behavior appears under its own name instead of behind a - * generic "Run behavior" – and once more as the event "On " if it has an End to be heard - * through (#294) –, and the two terminals drop out once the graph has them. + * with one Input and one Output, an instrument its own nodes and the same effects. In a behavior, + * every other behavior appears under its own name instead of behind a generic "Run behavior" – and + * once more as the event "On " if it has an End to be heard through (#294) –, and the two + * terminals drop out once the graph has them. */ export function paletteEntries(project: Project, asset: GraphHolder): PaletteEntry[] { if (asset.type === 'statemachine') { diff --git a/src/editor/instrument/InstrumentEditor.ts b/src/editor/instrument/InstrumentEditor.ts index 67085c00..c1beeb7d 100644 --- a/src/editor/instrument/InstrumentEditor.ts +++ b/src/editor/instrument/InstrumentEditor.ts @@ -24,29 +24,18 @@ import { fxVoiceOf } from '../../core/music/needs'; import { MASTER_MIX } from '../../core/music/mixer'; import { DEFAULT_TEMPO } from '../../core/music/time'; import { instrumentFx, playNote } from '../../runtime/systems/behaviorContext'; -import { - Button, - Dropdown, - HStack, - Label, - Menu, - NumberField, - Stack, - Theme, - VStack, - type Widget, -} from '../../ui'; +import { Button, Dropdown, Label, Menu, VStack, type Widget } from '../../ui'; import { soundLibrary } from '../../viewport/SoundLibrary'; import { AssetTabEditor } from '../AssetTabEditor'; import { GraphCanvas } from '../graph/GraphCanvas'; import { SnapToggle } from '../graph/SnapToggle'; import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons'; +import { PreviewParams } from '../soundfx/PreviewParams'; import type { EditorState } from '../state'; import { PianoKeyboard } from './PianoKeyboard'; /** Room kept for "N notes", or for the reason the graph does not compile: "No sound picked" fits, a longer one is cut. */ const INFO_WIDTH = 140; -const PARAM_WIDTH = 48; /** Whose voices the tab's Stop silences: its own, never a running game's. */ const PREVIEW_OWNER = 'instrument-preview'; /** How long a note is heard after Randomize, Mutate or a change with Audition on: a held one is let go then. */ @@ -74,14 +63,8 @@ export class InstrumentEditor extends AssetTabEditor(); - private values: Record = {}; - private defaults: Record = {}; - private paramsKey = ''; - private valuesFor = ''; - /** The voices the tab started that may still be playing – what a moved field reaches – and whose. */ - private voices: number[] = []; + private readonly params = new PreviewParams(); + /** The asset whose voices the tab may still be playing. */ private playedFor = ''; /** The graph as last heard or shown, by `soundPrint`, and whose it was. */ private heard = ''; @@ -122,11 +105,11 @@ export class InstrumentEditor extends AssetTabEditor { - this.valuesFor = ''; + this.params.forget(); this.heardFor = ''; }); } @@ -148,13 +131,11 @@ export class InstrumentEditor extends AssetTabEditor { - return { ...this.values }; + return this.params.current; } setPreviewValue(key: string, value: number): void { - if (!(key in this.values)) return; - this.paramFields.get(key)?.setNumber(value); - this.setValue(key, value); + this.params.set(key, value); } /** Starts one note as the keys do, held until it is released or for `duration` seconds; gives back its voice. */ @@ -168,12 +149,12 @@ export class InstrumentEditor extends AssetTabEditor a - b); } - private setValue(key: string, value: number): void { - this.values[key] = value; - this.voices = this.voices.filter((voice) => soundLibrary.tune(voice, { [key]: value })); - } - protected override infoText(asset: InstrumentAsset): string { const build = instrumentCheck(asset, instrumentSourcesOf(this.state.project)); if (build.reason) return build.reason; @@ -264,7 +240,7 @@ export class InstrumentEditor extends AssetTabEditor = {}; - const defaults: Record = {}; - for (const port of params) { - const fallback = port.default ?? 0; - const kept = same && this.defaults[port.key] === fallback ? this.values[port.key] : undefined; - values[port.key] = kept ?? fallback; - defaults[port.key] = fallback; - } - this.values = values; - this.defaults = defaults; - - const key = params.map((p) => p.key).join('\u0000'); - if (key !== this.paramsKey) { - this.paramsKey = key; - for (const child of this.paramBar.removeChildren()) child.destroy({ children: true }); - this.paramFields.clear(); - for (const port of params) { - this.paramBar.add(new Label(port.key, { style: 'small' })); - const field = new NumberField(values[port.key], { - width: PARAM_WIDTH, - step: 0.1, - onInput: (value) => this.setValue(port.key, value), - onChange: (value) => this.setValue(port.key, value), - }); - this.paramFields.set(port.key, field); - this.paramBar.add(field); - } - if (this.paramBar.parent instanceof Stack) this.paramBar.parent.relayout(); - this.paramBar.relayout(); - } - for (const [name, field] of this.paramFields) field.setNumber(this.values[name] ?? 0); - } } /** How a note of the instrument sounds hangs on its nodes' settings and edges, not on where they lie. */ diff --git a/src/editor/music/timeView.ts b/src/editor/music/timeView.ts index 2c8b0535..4b77b1f6 100644 --- a/src/editor/music/timeView.ts +++ b/src/editor/music/timeView.ts @@ -38,7 +38,7 @@ export function zoomTime(view: TimeView, factor: number, x: number, most = MAX_P } /** How far right of the area's left edge tick 0 can be panned, as a share of the area's width. */ -export const TIME_LEAD = 0.5; +const TIME_LEAD = 0.5; /** * Time starts at 0, but it need not stand at the left edge: a pan can take it up to half the area in, @@ -51,7 +51,7 @@ export function clampTimePan(view: V, width: number, earlies } /** How far past the first or the last row (a note, a track) a pan can go, as a share of the area's height. */ -export const ROW_LEAD = 0.5; +const ROW_LEAD = 0.5; /** * Down the area as across it: the first row can be panned up to half the height down from the top diff --git a/src/editor/pattern/PianoRoll.ts b/src/editor/pattern/PianoRoll.ts index 07ab93ec..086107f2 100644 --- a/src/editor/pattern/PianoRoll.ts +++ b/src/editor/pattern/PianoRoll.ts @@ -5,7 +5,7 @@ import { Graphics, type Text, } from 'pixi.js'; -import { SetPatternLoopCommand, SetPatternNotesCommand } from '../../core/commands/patternCommands'; +import { SetLoopCommand, SetPatternNotesCommand } from '../../core/commands/patternCommands'; import type { GhostNote, Ghosts } from '../../core/music/ghosts'; import { HIGHEST_NOTE, isSharp, LOWEST_NOTE, MIDDLE_C, noteName } from '../../core/instrument/notes'; import { @@ -81,8 +81,8 @@ import { zoomRows, } from './rollView'; -export const KEYS_WIDTH = 84; -export const VELOCITY_HEIGHT = 64; +const KEYS_WIDTH = 84; +const VELOCITY_HEIGHT = 64; const DRAG_THRESHOLD = 3; /** How close to an end of the loop a press on the ruler takes that end. */ const LOOP_EDGE_REACH = 4; @@ -151,8 +151,9 @@ function mix(a: number, b: number, t: number): number { * The Pattern tab's canvas (#333): the keys down the left, a ruler along the top, the notes in a grid * between them and their velocities in a lane below. A click into the grid draws a note, a drag spans * a band that picks notes; a note is dragged by its body, sized by either end, which lights up under - * the pointer, and goes with a right-click or Delete. A press or a drag on the ruler moves where playing starts, one on the strip - * along its top sets the loop. The wheel zooms: the time over the grid, the rows over the keys. + * the pointer, and goes with a right-click or Delete. A press or a drag on the ruler moves where + * playing starts, one on the strip along its top sets the loop. The wheel zooms: the time over the + * grid, the rows over the keys. */ export class PianoRoll extends Widget { private readonly bg = new Graphics(); @@ -718,7 +719,7 @@ export class PianoRoll extends Widget { case 'loop': { const same = JSON.stringify(gesture.loop) === JSON.stringify(patternLoop(asset)); if (gesture.active && gesture.loop && !same) - this.state.execute(new SetPatternLoopCommand(asset.id, gesture.loop)); + this.state.execute(new SetLoopCommand('pattern', asset.id, gesture.loop)); break; } case 'cursor': @@ -884,12 +885,12 @@ export class PianoRoll extends Widget { { label: `Loop bar ${first / bar + 1}`, onSelect: () => - this.state.execute(new SetPatternLoopCommand(asset.id, { start: first, end: first + bar })), + this.state.execute(new SetLoopCommand('pattern', asset.id, { start: first, end: first + bar })), }, { label: 'Loop the whole pattern', disabled: !asset.loop, - onSelect: () => this.state.execute(new SetPatternLoopCommand(asset.id, null)), + onSelect: () => this.state.execute(new SetLoopCommand('pattern', asset.id, null)), }, ]).open(global.x, global.y); } diff --git a/src/editor/pattern/rollView.ts b/src/editor/pattern/rollView.ts index d3a2ebc7..19689dcd 100644 --- a/src/editor/pattern/rollView.ts +++ b/src/editor/pattern/rollView.ts @@ -16,8 +16,8 @@ export interface RollView extends TimeView { } export const ROWS = HIGHEST_NOTE - LOWEST_NOTE + 1; -export const MIN_ROW_HEIGHT = 6; -export const MAX_ROW_HEIGHT = 64; +const MIN_ROW_HEIGHT = 6; +const MAX_ROW_HEIGHT = 64; export const DEFAULT_ROW_HEIGHT = 14; /** How far in from an end of a note a press takes that end rather than the note. */ export const END_GRIP = 6; diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts index 9fe7cab9..310ae97d 100644 --- a/src/editor/song/SongTimeline.ts +++ b/src/editor/song/SongTimeline.ts @@ -7,7 +7,8 @@ import { } from 'pixi.js'; import { CreateAssetCommand, MakeProjectAssetCommand } from '../../core/commands/assetCommands'; import { CompoundCommand } from '../../core/commands/CompoundCommand'; -import { SetSongCommand, SetSongLoopCommand, type SongParts } from '../../core/commands/songCommands'; +import { SetLoopCommand } from '../../core/commands/patternCommands'; +import { SetSongCommand, type SongParts } from '../../core/commands/songCommands'; import { embeddedCopy, embeddedName, @@ -218,9 +219,10 @@ interface Drop { * The Song tab's canvas (#334): the heads of the tracks down the left, a ruler along the top and the * items on the lanes between. An asset dragged in from the panel lands as an item; an item is moved * by its body and sized by either end – pulled out past what it plays, it repeats that, and an end - * snaps to where a round starts as well as to the grid –, and goes with Delete or its menu. A head sets its track's mute, solo, volume and pan. What is picked lives in the editor's - * selection, so the inspector shows it; a click on nothing picks the song. The wheel zooms: the time - * over the lanes, the tracks over their heads. + * snaps to where a round starts as well as to the grid –, and goes with Delete or its menu. A head + * sets its track's mute, solo, volume and pan. What is picked lives in the editor's selection, so + * the inspector shows it; a click on nothing picks the song. The wheel zooms: the time over the + * lanes, the tracks over their heads. */ export class SongTimeline extends Widget { private readonly bg = new Graphics(); @@ -975,7 +977,7 @@ export class SongTimeline extends Widget { case 'loop': { const same = JSON.stringify(gesture.loop) === JSON.stringify(songLoop(asset)); if (gesture.active && gesture.loop && !same) - this.state.execute(new SetSongLoopCommand(asset.id, gesture.loop)); + this.state.execute(new SetLoopCommand('song', asset.id, gesture.loop)); break; } case 'cursor': @@ -1118,12 +1120,12 @@ export class SongTimeline extends Widget { { label: `Loop bar ${first / bar + 1}`, onSelect: () => - this.state.execute(new SetSongLoopCommand(asset.id, { start: first, end: first + bar })), + this.state.execute(new SetLoopCommand('song', asset.id, { start: first, end: first + bar })), }, { label: 'Loop the whole song', disabled: !asset.loop, - onSelect: () => this.state.execute(new SetSongLoopCommand(asset.id, null)), + onSelect: () => this.state.execute(new SetLoopCommand('song', asset.id, null)), }, ]).open(global.x, global.y); } diff --git a/src/editor/song/meterView.ts b/src/editor/song/meterView.ts index bf0f80e9..8939c084 100644 --- a/src/editor/song/meterView.ts +++ b/src/editor/song/meterView.ts @@ -1,8 +1,8 @@ import { clamp } from '../../ui'; /** The scale of a level meter (#359) in dBFS: what is quieter shows nothing, past 0 it clips. */ -export const METER_MIN_DB = -48; -export const METER_MAX_DB = 6; +const METER_MIN_DB = -48; +const METER_MAX_DB = 6; /** From here on a meter is yellow; past 0 dBFS it is red. */ export const METER_WARM_DB = -6; /** Seconds the loudest peak stays put before it falls. */ diff --git a/src/editor/sound/waveView.ts b/src/editor/sound/waveView.ts index 302bfc8d..a477aa22 100644 --- a/src/editor/sound/waveView.ts +++ b/src/editor/sound/waveView.ts @@ -11,12 +11,12 @@ export interface WaveView { } /** From a few minutes across a screen to a sample every few pixels at 48 kHz. */ -export const MIN_PX_PER_SECOND = 0.5; -export const MAX_PX_PER_SECOND = 200_000; +const MIN_PX_PER_SECOND = 0.5; +const MAX_PX_PER_SECOND = 200_000; /** How far either end of the file can be panned in from its edge of the area, as a share of its width. */ const LEAD = 0.5; /** Pixels either side of a handle that grab it. */ -export const EDGE_REACH = 6; +const EDGE_REACH = 6; export function secondsToX(view: WaveView, seconds: number): number { return view.panX + seconds * view.pxPerSecond; @@ -210,7 +210,7 @@ function roundTo(seconds: number, rate: number): number { } /** Samples each block of `WavePeaks` covers. */ -export const PEAK_BLOCK = 256; +const PEAK_BLOCK = 256; /** The lowest and highest sample of each block of a sound, over all its channels. */ export interface WavePeaks { diff --git a/src/editor/soundfx/EqEditor.ts b/src/editor/soundfx/EqEditor.ts index 5dba4eba..30b3e614 100644 --- a/src/editor/soundfx/EqEditor.ts +++ b/src/editor/soundfx/EqEditor.ts @@ -17,13 +17,12 @@ import { EQ_GAIN, EQ_Q, EQ_SLOPES, - eqBandResponse, eqBandsOf, eqBandsValue, + eqCurves, hasGain, hasQ, hasSlope, - isEqBandHeard, MAX_EQ_BANDS, newEqBand, withBandSet, @@ -230,21 +229,19 @@ class EqCurve extends Widget { const zero = Math.round(dbToY(0, area)) + 0.5; this.grid.moveTo(0, zero).lineTo(this.w, zero).stroke({ color: c.textMuted, width: 1, alpha: 0.5 }); - // Each band once at every step; the sum is added up from them rather than worked out again. const bands = this.host.bands(); this.lines.clear(); const xs = Array.from({ length: CURVE_STEPS + 1 }, (_, i) => (i / CURVE_STEPS) * this.w); - const frequencies = xs.map((x) => xToFrequency(x, area)); const yOf = (db: number) => Math.max(-2, Math.min(this.h + 2, dbToY(db, area))); - const sum = new Array(xs.length).fill(0); - bands.forEach((band, i) => { - const response = eqBandResponse(band); - const dbs = frequencies.map(response); - if (isEqBandHeard(band)) dbs.forEach((db, j) => (sum[j]! += db)); + const curves = eqCurves( + bands, + xs.map((x) => xToFrequency(x, area)), + ); + curves.bands.forEach((dbs, i) => { this.trace(xs, dbs.map(yOf)); this.lines.stroke({ color: BAND_COLORS[i]!, width: 1, alpha: i === this.host.picked ? 0.9 : 0.4 }); }); - const ys = sum.map(yOf); + const ys = curves.sum.map(yOf); for (const shape of fillShapes(xs, ys, zero)) this.lines.poly(shape).fill({ color: c.accent, alpha: 0.12 }); this.trace(xs, ys); diff --git a/src/editor/soundfx/PreviewParams.ts b/src/editor/soundfx/PreviewParams.ts new file mode 100644 index 00000000..664fbfe6 --- /dev/null +++ b/src/editor/soundfx/PreviewParams.ts @@ -0,0 +1,89 @@ +import type { DeclaredPort } from '../../core/graph/Graph'; +import { HStack, Label, NumberField, Stack, Theme } from '../../ui'; +import { soundLibrary } from '../../viewport/SoundLibrary'; + +const FIELD_WIDTH = 48; + +/** + * The header's fields for the parameters of the graph a tab plays (#311, #331): a name and a number + * each, the tab's own values, never written into the asset. A moved field is heard at once on the + * voices the tab started that still play (#315). Another asset starts from its defaults; an edit of + * the graph keeps what a field was moved to, unless that parameter's default changed with it. + */ +export class PreviewParams { + readonly bar = new HStack({ gap: Theme.gap, align: 'center' }); + private readonly fields = new Map(); + private values: Record = {}; + private defaults: Record = {}; + private keys = ''; + private valuesFor = ''; + private voices: number[] = []; + + /** What the fields are set to. */ + get current(): Record { + return { ...this.values }; + } + + /** Sets one parameter, as typing into its field does. */ + set(key: string, value: number): void { + if (!(key in this.values)) return; + this.fields.get(key)?.setNumber(value); + this.setValue(key, value); + } + + /** A voice the tab started, for a moved field to reach while it plays. */ + heard(voice: number): void { + if (voice) this.voices.push(voice); + } + + /** The tab stopped everything it played. */ + silenced(): void { + this.voices = []; + } + + /** Another project: the next asset starts from its defaults. */ + forget(): void { + this.valuesFor = ''; + } + + sync(assetId: string, params: readonly DeclaredPort[]): void { + const same = assetId === this.valuesFor; + this.valuesFor = assetId; + const values: Record = {}; + const defaults: Record = {}; + for (const port of params) { + const fallback = port.default ?? 0; + const kept = same && this.defaults[port.key] === fallback ? this.values[port.key] : undefined; + values[port.key] = kept ?? fallback; + defaults[port.key] = fallback; + } + this.values = values; + this.defaults = defaults; + + const keys = params.map((p) => p.key).join('\u0000'); + if (keys !== this.keys) { + this.keys = keys; + for (const child of this.bar.removeChildren()) child.destroy({ children: true }); + this.fields.clear(); + for (const port of params) { + this.bar.add(new Label(port.key, { style: 'small' })); + const field = new NumberField(values[port.key], { + width: FIELD_WIDTH, + step: 0.1, + onInput: (value) => this.setValue(port.key, value), + onChange: (value) => this.setValue(port.key, value), + }); + this.fields.set(port.key, field); + this.bar.add(field); + } + if (this.bar.parent instanceof Stack) this.bar.parent.relayout(); + this.bar.relayout(); + } + for (const [name, field] of this.fields) field.setNumber(this.values[name] ?? 0); + } + + private setValue(key: string, value: number): void { + this.values[key] = value; + this.voices = this.voices.filter((voice) => soundLibrary.tune(voice, { [key]: value })); + } +} diff --git a/src/editor/soundfx/SoundFxEditor.ts b/src/editor/soundfx/SoundFxEditor.ts index ccf5c2fb..ade26462 100644 --- a/src/editor/soundfx/SoundFxEditor.ts +++ b/src/editor/soundfx/SoundFxEditor.ts @@ -5,7 +5,7 @@ import { playedSoundOf } from '../../core/properties/playedSound'; import { resolveAssetProps } from '../../core/properties/resolve'; import { volumeOf } from '../../core/properties/sounds'; import { soundFxBuildFor, soundFxParams } from '../../core/soundfx/compile'; -import { Button, Dropdown, HStack, Label, Menu, NumberField, Stack, Theme } from '../../ui'; +import { Button, Dropdown, Menu } from '../../ui'; import { soundLibrary } from '../../viewport/SoundLibrary'; import { MASTER_MIX } from '../../core/music/mixer'; import { AssetTabEditor } from '../AssetTabEditor'; @@ -13,11 +13,11 @@ import { GraphCanvas } from '../graph/GraphCanvas'; import { SnapToggle } from '../graph/SnapToggle'; import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons'; import type { EditorState } from '../state'; +import { PreviewParams } from './PreviewParams'; /** Room kept for "N effects", or for the reason the graph does not compile. */ const INFO_WIDTH = 220; const SOUND_WIDTH = 140; -const PARAM_WIDTH = 48; /** Whose voices the tab's Stop silences: its own, never a running game's. */ const PREVIEW_OWNER = 'soundfx-preview'; @@ -34,16 +34,8 @@ export class SoundFxEditor extends AssetTabEditor { readonly stopButton: Button; private soundId = ''; /** One name and number per parameter of the Input: what the header's Play hands in. */ - private readonly paramBar = new HStack({ gap: Theme.gap, align: 'center' }); - private readonly paramFields = new Map(); - /** The values the tab plays with – its own, never written into the asset. */ - private values: Record = {}; - /** The defaults the values were taken from, so an edited default comes through and a moved value stays. */ - private defaults: Record = {}; - private paramsKey = ''; - private valuesFor = ''; - /** The voices the tab started that may still be playing – what a moved field reaches – and whose. */ - private voices: number[] = []; + private readonly params = new PreviewParams(); + /** The asset whose voices the tab may still be playing. */ private playedFor = ''; constructor(state: EditorState) { @@ -59,11 +51,11 @@ export class SoundFxEditor extends AssetTabEditor { this.soundPicker, this.playButton, this.stopButton, - this.paramBar, + this.params.bar, ]); state.on('load', () => { this.soundId = ''; - this.valuesFor = ''; + this.params.forget(); }); } @@ -88,14 +80,12 @@ export class SoundFxEditor extends AssetTabEditor { /** What the header's parameter fields are set to – the check scripts read this. */ get previewValues(): Record { - return { ...this.values }; + return this.params.current; } /** Sets one parameter the header plays with, as typing into its field does. */ setPreviewValue(key: string, value: number): void { - if (!(key in this.values)) return; - this.paramFields.get(key)?.setNumber(value); - this.setValue(key, value); + this.params.set(key, value); } /** Plays the picked sound through the graph; a graph that does not compile is not played at all. */ @@ -105,23 +95,17 @@ export class SoundFxEditor extends AssetTabEditor { const plan = asset && soundFxBuildFor(asset, this.state.project).compiled; if (!sound?.asset.file || !plan) return; const props = resolveAssetProps(this.state.project, sound.asset); - const fx = { plan, params: { ...this.values } }; + const fx = { plan, params: this.params.current }; // Straight into the master: a preview leaves the routing of the sound out (#368). const file = playedSoundOf(sound); const voice = soundLibrary.play(file, volumeOf(props), false, PREVIEW_OWNER, fx, MASTER_MIX); - if (voice) this.voices.push(voice); + this.params.heard(voice); this.playedFor = asset.id; } - /** A moved field is heard at once on what the tab is playing (#315); an ended voice is let go. */ - private setValue(key: string, value: number): void { - this.values[key] = value; - this.voices = this.voices.filter((voice) => soundLibrary.tune(voice, { [key]: value })); - } - stop(): void { soundLibrary.stopOwner(PREVIEW_OWNER); - this.voices = []; + this.params.silenced(); } protected override closed(assetId: string): void { @@ -144,48 +128,7 @@ export class SoundFxEditor extends AssetTabEditor { Boolean(asset && sound?.asset.file && soundFxBuildFor(asset, this.state.project).compiled), ); this.stopButton.setEnabled(Boolean(asset)); - this.syncParams(asset); - } - - /** - * Another asset starts from its defaults; an edit of the graph keeps what a field was moved to, - * unless that parameter's default changed with it. - */ - private syncParams(asset: SoundFxAsset | null): void { - const params = asset ? soundFxParams(asset.graph) : []; - const same = (asset?.id ?? '') === this.valuesFor; - this.valuesFor = asset?.id ?? ''; - const values: Record = {}; - const defaults: Record = {}; - for (const port of params) { - const fallback = port.default ?? 0; - const kept = same && this.defaults[port.key] === fallback ? this.values[port.key] : undefined; - values[port.key] = kept ?? fallback; - defaults[port.key] = fallback; - } - this.values = values; - this.defaults = defaults; - - const key = params.map((p) => p.key).join('\u0000'); - if (key !== this.paramsKey) { - this.paramsKey = key; - for (const child of this.paramBar.removeChildren()) child.destroy({ children: true }); - this.paramFields.clear(); - for (const port of params) { - this.paramBar.add(new Label(port.key, { style: 'small' })); - const field = new NumberField(values[port.key], { - width: PARAM_WIDTH, - step: 0.1, - onInput: (value) => this.setValue(port.key, value), - onChange: (value) => this.setValue(port.key, value), - }); - this.paramFields.set(port.key, field); - this.paramBar.add(field); - } - if (this.paramBar.parent instanceof Stack) this.paramBar.parent.relayout(); - this.paramBar.relayout(); - } - for (const [name, field] of this.paramFields) field.setNumber(this.values[name] ?? 0); + this.params.sync(asset?.id ?? '', asset ? soundFxParams(asset.graph) : []); } private openSoundPicker(): void { diff --git a/src/editor/soundfx/eqView.ts b/src/editor/soundfx/eqView.ts index b4cbc1f1..10d80a1e 100644 --- a/src/editor/soundfx/eqView.ts +++ b/src/editor/soundfx/eqView.ts @@ -10,7 +10,7 @@ export interface EqArea { } /** The dB the picture shows above and below 0. */ -export const EQ_VIEW_DB = EQ_GAIN.max; +const EQ_VIEW_DB = EQ_GAIN.max; const SPAN = Math.log(EQ_FREQUENCY.max / EQ_FREQUENCY.min); diff --git a/src/runtime/systems/behaviorContext.ts b/src/runtime/systems/behaviorContext.ts index 6a9d0419..fb9d7605 100644 --- a/src/runtime/systems/behaviorContext.ts +++ b/src/runtime/systems/behaviorContext.ts @@ -306,7 +306,7 @@ export function instrumentFx(project: Project, instrumentId: string, picked: str } /** The bus of the mix a note of that instrument is heard on, by the pick of a node (#325). */ -export function instrumentBus(project: Project, instrumentId: string, picked: string): string { +function instrumentBus(project: Project, instrumentId: string, picked: string): string { return playedBus(project, picked, instrumentId); } @@ -339,7 +339,7 @@ export function playNote( } /** 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:'; +const FX_SCOPE = 'fx:'; function scoped(params: FxParams): FxParams { return Object.fromEntries(Object.entries(params).map(([key, value]) => [`${FX_SCOPE}${key}`, value])); @@ -354,7 +354,7 @@ function withScoped(params: FxParams): FxParams { } /** What a note's voice is named by, where a sound's is named by its file. */ -export function instrumentVoiceName(instrumentId: string): string { +function instrumentVoiceName(instrumentId: string): string { return `instrument:${instrumentId}`; } @@ -492,12 +492,12 @@ function heldFade(seconds: number): number { } /** `pattern:` alone stands for every pattern (see `goesBy`). */ -export function patternVoiceName(patternId: string): string { +function patternVoiceName(patternId: string): string { return `pattern:${patternId}`; } /** `song:` alone stands for every song. */ -export function songVoiceName(songId: string): string { +function songVoiceName(songId: string): string { return `song:${songId}`; } diff --git a/src/ui/draw.ts b/src/ui/draw.ts index 8e4a80b8..8fe085e0 100644 --- a/src/ui/draw.ts +++ b/src/ui/draw.ts @@ -22,6 +22,13 @@ export function drawControlBox(g: Graphics, w: number, h: number, options: Contr g.roundRect(x + line / 2, y + line / 2, w - line, h - line, radius).stroke({ width: line, color: border }); } +/** A box that floats above the rest – a popup, a window: a shadow down and to the right, then the box. */ +export function drawFloatingBox(g: Graphics, w: number, h: number, fill: number): void { + g.clear(); + g.roundRect(2, 3, w, h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 }); + drawControlBox(g, w, h, { fill, border: Theme.colors.border }); +} + /** A bar across the top of something – the toolbar, a tab's header, the breadcrumb: header colour, a line below. */ export function drawHeaderBar(g: Graphics, w: number, h: number): void { const line = hairline(); diff --git a/src/ui/index.ts b/src/ui/index.ts index 8267177e..3b65bb11 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -37,6 +37,7 @@ export { export { drawBand, drawControlBox, + drawFloatingBox, drawGrid, drawHeaderBar, type ControlBoxOptions, diff --git a/src/ui/widgets/Popup.ts b/src/ui/widgets/Popup.ts index a1bad818..0550d201 100644 --- a/src/ui/widgets/Popup.ts +++ b/src/ui/widgets/Popup.ts @@ -1,5 +1,5 @@ import { type FederatedPointerEvent, Graphics, Rectangle } from 'pixi.js'; -import { drawControlBox } from '../draw'; +import { drawFloatingBox } from '../draw'; import { VStack } from '../layout/Stack'; import { clamp } from '../math'; import { Theme } from '../Theme'; @@ -181,9 +181,6 @@ export class Popup extends Widget { } override redraw(): void { - const c = Theme.colors; - this.bg.clear(); - this.bg.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 }); // drop shadow - drawControlBox(this.bg, this.w, this.h, { fill: c.panelHeader, border: c.border }); + drawFloatingBox(this.bg, this.w, this.h, Theme.colors.panelHeader); } } diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index a42f982e..30b4b235 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -1,36 +1,20 @@ import { - DELAY_MAX, DRY_VOICE, - FLAT_Q, - passQ, fxChainOf, fxLatencyOf, type FxChain, type FxChip, - type FxCompress, - type FxCrush, - type FxDelay, type FxEnvelope, - type FxFlanger, - type FxLfo, - type FxMod, - type FxPass, - type FxPhaser, - type FxReverb, - type FxSaturate, fxRings, fxTailOf, impulsesOf, type FxParams, type FxPlan, - type FxStage, type FxStagePlan, type FxVoice, - type FxWet, - delayShares, stageLatency, } from '../core/soundfx/nodes'; -import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument/envelope'; +import { releaseTime } from '../core/instrument/envelope'; import type { EaseShare } from '../core/model/animation'; import { type BusLevel, Playbacks, type SequenceNeeds, type SequencePlay } from '../core/music/playbacks'; import { arrivalOf, sendDelays, type TimedBus } from '../core/music/routing'; @@ -42,29 +26,41 @@ 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 { LFO_SHAPES } from '../core/soundfx/lfo'; -import { REVERB_MAX_DECAY, reverbImpulse } from '../core/soundfx/reverb'; -import { driveGain, saturationCurve, SHAPER_LATENCY_FRAMES } from '../core/soundfx/saturation'; +import { REVERB_MAX_DECAY } from '../core/soundfx/reverb'; import type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio'; -import type { ChipOptions, FlangerOptions, LfoOptions, PhaserOptions, PitchOptions } from './fxWorklet'; +import type { ChipOptions, PitchOptions } from './fxWorklet'; import fxWorkletUrl from './fxWorklet.ts?worker&url'; +import { + bothAtCenter, + endedProcessor, + envelopeDriver, + FADE, + type Follow, + followCurve, + fadeOut, + gainNode, + GLIDE, + glideKnobs, + heldBack, + type Knob, + levelsOf, + move, + stageNodes, + releaseEnvelope, + stopProcessor, +} from './fxNodes'; import { wake } from './wake'; /** A voice is no louder than a sound item of a song turned all the way up. */ const MAX_VOLUME = dbToGain(SOUND_ITEM_MAX_DB); const DEFAULT_BASE = '/game-maker/data/sounds'; -/** Seconds a changed parameter takes to get most of the way there: quick, but no click (#315). */ -const GLIDE = 0.03; /** The rate a sound loaded ahead of its first play is decoded at; a source resamples it on the way out. */ const DECODE_RATE = 48000; /** The longest a send of a track is held back to meet the latest way of it (#350). */ const MAX_SEND_DELAY = 1; -/** Seconds a stopped voice takes to fall silent, so it does not click (#324). */ -const FADE = 0.01; - /** Samples a level meter looks back over (#359): a frame of 40 ms at 48 kHz misses no peak. */ const METER_SPAN = 2048; /** The loudest the limiter at the output lets a sample be (#359), and how fast it lets go again. */ @@ -81,19 +77,6 @@ const PEAK_BUCKETS = 1024; /** Samples the cuts made so far may hold together before the oldest are let go; one is always kept. */ const CUT_BUDGET = 64 * 1024 * 1024; -/** - * One number of a voice that can still change while it plays (#315): a level, a share of a Mix, the - * gain of a band, the pitch of a way in semitones, a number of an echo. `even` says what a timed - * change runs evenly in: a gain stage in decibels (its node holds the factor) and the damping of an - * echo in octaves (its node holds the cutoff), everything else in what it holds. A way whose pitch - * has no worklet to play it has no `param`: it keeps its own pitch. - */ -interface Knob { - param: AudioParam | null; - target: number; - even: 'value' | 'ratio'; -} - /** * One way's sound as a voice holds it: a buffer source, or the pitch worklet where the way has a * Pitch (#317). A voice that can ring on gives each of its sources a `fade` of its own, so they can @@ -591,8 +574,8 @@ export class SoundLibrary implements AudioSink { /** * A bus by its name, made on its first use into `into`, or into the master; only the master is - * heard straight out (#325). It is not to go any more. The context comes first: the one made now - * sets up the mix, whose bus this may be, and that one is to be kept (#369). + * heard straight out (#325). One that was being let go stays. The context comes first: the one + * made now sets up the mix, whose bus this may be, and that one is to be kept (#369). */ private bus(name: string, into: string | null = null): Bus { const context = this.audio(); @@ -2191,468 +2174,6 @@ function loopIn(buffer: AudioBuffer, start: number, end: number): { loopStart: n return from < to ? { loopStart: from, loopEnd: to } : { loopStart: 0, loopEnd: last }; } -function gainNode(context: BaseAudioContext, level: number): GainNode { - const gain = context.createGain(); - gain.gain.value = level; - return gain; -} - -/** What a stage becomes: where its sound goes in, where it comes out, and the numbers a parameter can move. */ -interface StageNodes { - input: AudioNode; - output: AudioNode; - knobs: Knob[]; - /** Ends a stage that runs on the audio thread until it is told to. */ - stop?: () => void; -} - -/** - * Moves `param` along the curve of an Envelope (#335), from `from` to `to` in equal steps or equal - * ratios, `map` turning that into what the node holds. The param's own value is added, so one that - * follows alone is set to 0 first. - */ -export type Follow = (mod: FxMod, param: AudioParam, ratio: boolean, map?: (value: number) => number) => void; - -/** - * Shapes the curve an Envelope's `driver` carries into `param` through a WaveShaper. Exported for a - * check that renders it offline in a real browser (test hook). - */ -export function followCurve( - context: BaseAudioContext, - driver: AudioNode, - mod: FxMod, - param: AudioParam, - ratio: boolean, - map?: (value: number) => number, -): void { - const shape = context.createWaveShaper(); - shape.curve = modCurve(mod, ratio, map); - driver.connect(shape).connect(param); -} - -/** - * Exported for a check that renders a stage offline in a real browser (test hook). Without `follow` - * a number an Envelope moves stays at 0, and an Envelope's own stage silent. `at` is when the voice - * starts. - */ -export function stageNodes( - context: BaseAudioContext, - stage: FxStage, - follow?: Follow, - at = context.currentTime, - impulse: AudioBuffer | null = null, -): StageNodes { - if (stage.kind === 'gain') { - const gain = gainNode(context, stage.mod ? 0 : stage.gain); - if (stage.mod) follow?.(stage.mod, gain.gain, true); - const knob: Knob = { param: stage.mod ? null : gain.gain, target: stage.gain, even: 'ratio' }; - return { input: gain, output: gain, knobs: [knob] }; - } - if (stage.kind === 'envelope') { - const gain = gainNode(context, 0); - follow?.({ envelope: stage.envelope, from: 0, to: 1 }, gain.gain, false); - return { input: gain, output: gain, knobs: [] }; - } - if (stage.kind === 'pass') return passNodes(context, stage, follow); - if (stage.kind === 'delay') return delayNodes(context, stage); - if (stage.kind === 'crush') return crushNodes(context, stage, follow); - if (stage.kind === 'phaser') return phaserNodes(context, stage, follow); - if (stage.kind === 'flanger') return flangerNodes(context, stage, at, follow); - if (stage.kind === 'compress') return compressNodes(context, stage); - if (stage.kind === 'saturate') return saturateNodes(context, stage); - if (stage.kind === 'reverb') return reverbNodes(context, stage, impulse); - if (stage.kind === 'tremolo' || stage.kind === 'vibrato') return lfoNodes(context, stage, at); - if (stage.kind === 'wet') return wetNodes(context, stage, follow, at); - if (stage.kind === 'limit') - return workletNodes(context, 'limiter', [ - { name: 'threshold', value: stage.threshold, even: 'value' }, - { name: 'ceiling', value: stage.ceiling, even: 'value' }, - { name: 'release', value: stage.release, even: 'ratio' }, - { name: 'autoGain', value: stage.autoGain, even: 'value' }, - ]); - if (stage.kind === 'pan') { - const panner = context.createStereoPanner(); - panner.pan.value = stage.mod ? 0 : stage.pan; - if (stage.mod) follow?.(stage.mod, panner.pan, false); - return { - input: panner, - output: panner, - knobs: [{ param: stage.mod ? null : panner.pan, target: stage.pan, even: 'value' }], - }; - } - const filter = context.createBiquadFilter(); - filter.type = stage.filter; - filter.frequency.value = stage.frequency; - filter.gain.value = stage.mod ? 0 : stage.gain; - filter.Q.value = stage.q; - if (stage.mod) follow?.(stage.mod, filter.gain, false); - return { - input: filter, - output: filter, - knobs: [{ param: stage.mod ? null : filter.gain, target: stage.gain, even: 'value' }], - }; -} - -/** - * A Filter (#335, #318): a cutoff that moves in octaves, a Q – as a factor in ratios too, in dB for a - * low- or high-pass – and a gain in dB. Several in a row share one cutoff, carried by a source of its - * own into each of them; only the last has the Q, the others none. - */ -function passNodes(context: BaseAudioContext, stage: FxPass, follow?: Follow): StageNodes { - const qEven = stage.filter === 'lowpass' || stage.filter === 'highpass' ? 'value' : 'ratio'; - const filters = Array.from({ length: Math.max(1, stage.stages) }, () => { - const filter = context.createBiquadFilter(); - filter.type = stage.filter; - filter.Q.value = passQ(stage.filter, FLAT_Q); - return filter; - }); - filters.slice(1).forEach((filter, i) => filters[i]!.connect(filter)); - const last = filters.at(-1)!; - last.Q.value = stage.qMod ? 0 : stage.q; - last.gain.value = stage.gainMod ? 0 : stage.gain; - if (stage.qMod) follow?.(stage.qMod, last.Q, qEven === 'ratio'); - if (stage.gainMod) follow?.(stage.gainMod, last.gain, false); - let cutoff = filters[0]!.frequency; - let stop: (() => void) | undefined; - if (filters.length > 1) { - const driver = context.createConstantSource(); - for (const filter of filters) { - filter.frequency.value = 0; - driver.connect(filter.frequency); - } - driver.start(); - cutoff = driver.offset; - stop = () => driver.stop(); - } - cutoff.value = stage.cutoffMod ? 0 : stage.cutoff; - if (stage.cutoffMod) follow?.(stage.cutoffMod, cutoff, true); - return { - input: filters[0]!, - output: last, - knobs: [ - { param: stage.cutoffMod ? null : cutoff, target: stage.cutoff, even: 'ratio' }, - { param: stage.qMod ? null : last.Q, target: stage.q, even: qEven }, - { param: stage.gainMod ? null : last.gain, target: stage.gain, even: 'value' }, - ], - ...(stop && { stop }), - }; -} - -/** - * What carries the curve of an Envelope (#335) from `at`: a straight line up to 1, then easing down - * towards the sustain. Its caller starts it and stops it with the voice. Exported for a check that - * renders it offline in a real browser (test hook). - */ -export function envelopeDriver( - context: BaseAudioContext, - envelope: FxEnvelope, - at: number, -): ConstantSourceNode { - const driver = context.createConstantSource(); - const level = driver.offset; - level.value = 0; - const top = at + envelope.attack; - if (envelope.attack > 0) { - level.setValueAtTime(0, at); - level.linearRampToValueAtTime(1, top); - } else level.setValueAtTime(1, at); - if (envelope.sustain < 1) { - if (envelope.decay > 0) level.setTargetAtTime(envelope.sustain, top, envelope.decay / SETTLE); - else level.setValueAtTime(envelope.sustain, top); - } - return driver; -} - -/** - * Lets an Envelope that started at `startedAt` fall away from `at`, from the level it has by then – - * mid-attack too – to silence in its release. Exported for the same check (test hook). - */ -export function releaseEnvelope( - level: AudioParam, - envelope: FxEnvelope, - startedAt: number, - at: number, -): void { - const held = level as Omit & - Partial>; - if (held.cancelAndHoldAtTime) held.cancelAndHoldAtTime(at); - else { - // Firefox: the level by then is known, the curve up to it is what was planned. - const reached = envelopeLevel(envelope, at - startedAt); - level.cancelScheduledValues(at); - if (at - startedAt < envelope.attack) level.linearRampToValueAtTime(reached, at); - else level.setValueAtTime(reached, at); - } - level.setTargetAtTime(0, at, releaseTime(envelope) / SETTLE); -} - -/** - * What one node adds, blended with what arrives at it (#354): its stages one after another at `wet`, - * and the sound as it came in at the rest, held back as far as the stages are late. - */ -function wetNodes( - context: BaseAudioContext, - stage: FxWet, - follow: Follow | undefined, - at: number, -): StageNodes { - const input = context.createGain(); - const output = context.createGain(); - const knobs: Knob[] = []; - const stops: (() => void)[] = []; - let tail: AudioNode = input; - for (const inner of stage.stages) { - const made = stageNodes(context, inner, follow, at); - tail.connect(made.input); - tail = made.output; - knobs.push(...made.knobs); - if (made.stop) stops.push(made.stop); - } - const moved = stage.wetMod; - const wet = gainNode(context, moved ? 0 : stage.wet); - const dry = gainNode(context, moved ? 0 : 1 - stage.wet); - if (moved) { - follow?.(moved, wet.gain, false); - follow?.(moved, dry.gain, false, (share) => 1 - share); - } - tail.connect(wet).connect(output); - heldBack(context, input, stageLatency(stage, context.sampleRate)).connect(dry).connect(output); - knobs.push( - { param: moved ? null : wet.gain, target: stage.wet, even: 'value' }, - { param: moved ? null : dry.gain, target: 1 - stage.wet, even: 'value' }, - ); - return { input, output, knobs, ...(stops.length > 0 && { stop: () => stops.forEach((stop) => stop()) }) }; -} - -/** - * A Compressor (#326): Web Audio's node, and a gain after it for makeup and auto-gain. Its times - * move in ratios, everything in dB as it is. - */ -function compressNodes(context: BaseAudioContext, stage: FxCompress): StageNodes { - const node = context.createDynamicsCompressor(); - node.threshold.value = stage.threshold; - node.knee.value = stage.knee; - node.ratio.value = stage.ratio; - node.attack.value = stage.attack; - node.release.value = stage.release; - const gain = gainNode(context, stage.gain); - node.connect(gain); - return { - input: node, - output: gain, - knobs: [ - { param: node.threshold, target: stage.threshold, even: 'value' }, - { param: node.knee, target: stage.knee, even: 'value' }, - { param: node.ratio, target: stage.ratio, even: 'ratio' }, - { param: node.attack, target: stage.attack, even: 'ratio' }, - { param: node.release, target: stage.release, even: 'ratio' }, - { param: gain.gain, target: stage.gain, even: 'ratio' }, - ], - }; -} - -/** - * A Tremolo or a Vibrato (#320) on the worklet, its LFO at phase 0 when its voice starts at `at`. - * A Vibrato's delay swings around the center its start values give, which is how late it is. - */ -function lfoNodes(context: BaseAudioContext, stage: FxLfo, at: number): StageNodes { - const options: LfoOptions & { vibrato: boolean } = { - shape: LFO_SHAPES.findIndex((option) => option.value === stage.shape), - startAt: at, - center: stage.kind === 'vibrato' ? stageLatency(stage, context.sampleRate) : 0, - vibrato: stage.kind === 'vibrato', - }; - return workletNodes( - context, - 'lfo', - [ - { name: 'rate', value: stage.rate, even: 'ratio' }, - { name: 'depth', value: stage.depth, even: 'value' }, - ], - undefined, - options, - ); -} - -/** Impulse responses made for a Reverb, by rate, decay and damping: the last few are kept. */ -const impulses = new Map(); -const KEPT_IMPULSES = 16; - -/** The impulse response a Reverb makes for itself (#328), made once for these numbers. */ -function madeImpulse(context: BaseAudioContext, decay: number, damping: number): AudioBuffer { - const key = `${context.sampleRate}|${decay}|${damping}`; - let buffer = impulses.get(key); - if (!buffer) { - const channels = reverbImpulse(context.sampleRate, decay, damping); - buffer = context.createBuffer(channels.length, channels[0]!.length, context.sampleRate); - channels.forEach((data, c) => buffer!.copyToChannel(data as Float32Array, c)); - impulses.set(key, buffer); - if (impulses.size > KEPT_IMPULSES) impulses.delete(impulses.keys().next().value!); - } - return buffer; -} - -/** - * A Reverb (#328): the sound itself, and beside it through the pre-delay into a ConvolverNode with - * the impulse response made for it – or the one of a sound, `impulse`, where it takes a sound; a - * sound not loaded leaves the reverb silent. Only the mix moves while it plays. - */ -function reverbNodes(context: BaseAudioContext, stage: FxReverb, impulse: AudioBuffer | null): StageNodes { - const input = context.createGain(); - const output = context.createGain(); - const convolver = context.createConvolver(); - const buffer = stage.impulse ? impulse : madeImpulse(context, stage.decay, stage.damping); - if (buffer) convolver.buffer = buffer; - const wet = gainNode(context, stage.mix); - const dry = gainNode(context, 1 - stage.mix); - heldBack(context, input, stage.predelay).connect(convolver).connect(wet).connect(output); - input.connect(dry).connect(output); - return { - input, - output, - knobs: [ - { param: null, target: stage.decay, even: 'value' }, - { param: null, target: stage.damping, even: 'value' }, - { param: null, target: stage.predelay, even: 'value' }, - { param: wet.gain, target: stage.mix, even: 'value' }, - { param: dry.gain, target: 1 - stage.mix, even: 'value' }, - ], - }; -} - -/** Below what a Saturation takes out the DC its bent sound gained (#323). */ -const DC_BLOCK_HZ = 10; - -/** - * A Saturation (#323): the sound driven into the curve of its type, oversampled 4x, its DC taken - * out, through the low-pass of its tone and set to its level – and the sound itself beside it, - * held back as far as the oversampling makes the bent one late, so the two meet without a comb. - */ -function saturateNodes(context: BaseAudioContext, stage: FxSaturate): StageNodes { - const input = context.createGain(); - const output = context.createGain(); - const drive = gainNode(context, driveGain(stage.drive)); - const shaper = context.createWaveShaper(); - shaper.curve = saturationCurve(stage.curve) as Float32Array; - shaper.oversample = '4x'; - const dc = context.createBiquadFilter(); - dc.type = 'highpass'; - dc.frequency.value = DC_BLOCK_HZ; - dc.Q.value = passQ('highpass', FLAT_Q); - const tone = context.createBiquadFilter(); - tone.type = 'lowpass'; - tone.frequency.value = stage.tone; - tone.Q.value = passQ('lowpass', FLAT_Q); - const level = gainNode(context, stage.level); - const wet = gainNode(context, stage.mix); - const dry = gainNode(context, 1 - stage.mix); - input.connect(drive).connect(shaper).connect(dc).connect(tone).connect(level).connect(wet).connect(output); - heldBack(context, input, SHAPER_LATENCY_FRAMES / context.sampleRate) - .connect(dry) - .connect(output); - return { - input, - output, - knobs: [ - { param: drive.gain, target: driveGain(stage.drive), even: 'ratio' }, - { param: tone.frequency, target: stage.tone, even: 'ratio' }, - { param: level.gain, target: stage.level, even: 'ratio' }, - { param: wet.gain, target: stage.mix, even: 'value' }, - { param: dry.gain, target: 1 - stage.mix, even: 'value' }, - ], - }; -} - -/** - * An echo (#327): the sound goes on, and through a delay line whose output is damped, fed back into - * it and added – each at its share of the wet `mix` (#354). Ping-pong sends the sound into the left - * of the line only and swaps the sides on every round. - */ -function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes { - const input = context.createGain(); - const output = context.createGain(); - const shares = delayShares(stage.mix); - const dry = gainNode(context, shares.dry); - input.connect(dry).connect(output); - const line = context.createDelay(DELAY_MAX); - line.delayTime.value = stage.time; - const damp = context.createBiquadFilter(); - damp.type = 'lowpass'; - damp.frequency.value = stage.damping; - damp.Q.value = passQ('lowpass', FLAT_Q); - const feedback = gainNode(context, stage.feedback); - const wet = gainNode(context, shares.echo); - line.connect(damp); - damp.connect(wet).connect(output); - if (stage.pingPong) { - const mono = context.createGain(); - mono.channelCount = 1; - mono.channelCountMode = 'explicit'; - const left = context.createChannelMerger(2); - input.connect(mono).connect(left, 0, 0); - left.connect(line); - const sides = context.createChannelSplitter(2); - const crossed = context.createChannelMerger(2); - damp.connect(sides); - sides.connect(crossed, 0, 1); - sides.connect(crossed, 1, 0); - crossed.connect(feedback).connect(line); - } else { - input.connect(line); - damp.connect(feedback).connect(line); - } - return { - input, - output, - knobs: [ - { param: line.delayTime, target: stage.time, even: 'value' }, - { param: feedback.gain, target: stage.feedback, even: 'value' }, - { param: damp.frequency, target: stage.damping, even: 'ratio' }, - { param: wet.gain, target: shares.echo, even: 'value' }, - { param: dry.gain, target: shares.dry, even: 'value' }, - ], - }; -} - -/** A number of a stage on the worklet: what it holds, the Envelope moving it instead, how it moves. */ -interface WorkletNumber { - name: string; - value: number; - mod?: FxMod | undefined; - even: Knob['even']; -} - -/** - * A stage on the worklet (#322, #341), its numbers AudioParams of the processor. One an Envelope - * moves starts at 0 and leaves its knob to it. Where the worklet is not there the sound goes - * through as it is. - */ -function workletNodes( - context: BaseAudioContext, - processor: string, - numbers: readonly WorkletNumber[], - follow?: Follow, - processorOptions?: object, -): StageNodes { - let node: AudioWorkletNode; - try { - node = new AudioWorkletNode(context, processor, { - parameterData: Object.fromEntries(numbers.map((n) => [n.name, n.mod ? 0 : n.value])), - ...(processorOptions && { processorOptions }), - }); - } catch { - const through = context.createGain(); - const knobs = numbers.map((n): Knob => ({ param: null, target: n.value, even: n.even })); - return { input: through, output: through, knobs }; - } - const knobs = numbers.map(({ name, value, mod, even }): Knob => { - const param = node.parameters.get(name)!; - if (mod) follow?.(mod, param, even === 'ratio'); - return { param: mod ? null : param, target: value, even }; - }); - return { input: node, output: node, knobs, stop: () => stopProcessor(node) }; -} - /** The loudest sample of each side over the stretch a meter looks back over, 1 at full scale. */ function readMeter(meter: BusMeter): [number, number] { const [left, right] = meter.sides.map((side) => { @@ -2664,234 +2185,4 @@ function readMeter(meter: BusMeter): [number, number] { return [left!, right!]; } -/** - * Stops a processor and lets go of its port. A port that is listened to is never collected, and - * with it everything its listener reaches: the voice and every node of it, still on its bus. - */ -function stopProcessor(node: AudioWorkletNode): void { - node.port.postMessage('stop'); - node.port.onmessage = null; - node.port.close(); - node.disconnect(); -} - -/** Calls `then` once a source on the worklet has played to its end, and lets go of its port. */ -function endedProcessor(node: AudioWorkletNode, then: () => void): void { - node.port.onmessage = (event: MessageEvent) => { - if (event.data !== 'ended') return; - node.port.onmessage = null; - node.port.close(); - then(); - }; -} - -/** A Bitcrusher (#322): bits and mix move in what they hold, the rate in octaves. */ -function crushNodes(context: BaseAudioContext, stage: FxCrush, follow?: Follow): StageNodes { - return workletNodes( - context, - 'bit-crusher', - [ - { name: 'bits', value: stage.bits, mod: stage.bitsMod, even: 'value' }, - { name: 'rate', value: stage.rate, mod: stage.rateMod, even: 'ratio' }, - { name: 'mix', value: stage.mix, mod: stage.mixMod, even: 'value' }, - ], - follow, - ); -} - -/** A Phaser (#341): its frequency moves in octaves, the rest in what they hold. */ -function phaserNodes(context: BaseAudioContext, stage: FxPhaser, follow?: Follow): StageNodes { - const options: PhaserOptions = { stages: stage.stages }; - return workletNodes( - context, - 'phaser', - [ - { name: 'frequency', value: stage.frequency, mod: stage.frequencyMod, even: 'ratio' }, - { name: 'rate', value: stage.rate, even: 'value' }, - { name: 'depth', value: stage.depth, even: 'value' }, - { name: 'feedback', value: stage.feedback, even: 'value' }, - { name: 'mix', value: stage.mix, mod: stage.mixMod, even: 'value' }, - ], - follow, - options, - ); -} - -/** A Flanger (#341), whose sweep counts from `at`, when the voice starts. */ -function flangerNodes(context: BaseAudioContext, stage: FxFlanger, at: number, follow?: Follow): StageNodes { - const options: FlangerOptions = { startAt: at }; - return workletNodes( - context, - 'flanger', - [ - { name: 'offset', value: stage.offset, mod: stage.offsetMod, even: 'value' }, - { name: 'sweep', value: stage.sweep, even: 'value' }, - { name: 'rate', value: stage.rate, even: 'value' }, - { name: 'depth', value: stage.depth, even: 'value' }, - { name: 'feedback', value: stage.feedback, even: 'value' }, - { name: 'mix', value: stage.mix, mod: stage.mixMod, even: 'value' }, - ], - follow, - options, - ); -} - -/** The shares of a and b over `mix` at 0, 0.5 and 1: a WaveShaper reads -1…1, so its curve starts below 0. */ -const SHARE_A = new Float32Array([1, 1, 1, 1, 0]); -const SHARE_B = new Float32Array([0, 0, 0, 1, 1]); - -/** - * The shares of a Mix with the curve "Both at center": a and b each stay full up to the middle and - * fade on the other half. A share is not a straight line of `mix` there, so two ramps of their own - * would dip at the middle when `mix` glides across it. The source carries `mix` itself, and each - * share is shaped from it on the audio thread. Its caller starts it and stops it with the voice. - * Exported for a check that renders it offline in a real browser (test hook). - */ -export function bothAtCenter( - context: BaseAudioContext, - mix: number, - a: GainNode, - b: GainNode, -): ConstantSourceNode { - const driver = context.createConstantSource(); - driver.offset.value = mix; - for (const [curve, share] of [ - [SHARE_A, a], - [SHARE_B, b], - ] as const) { - const shape = context.createWaveShaper(); - shape.curve = curve; - driver.connect(shape).connect(share.gain); - } - return driver; -} - -/** A node's sound held back `seconds` by a delay line after it, or the node itself for none. */ -function heldBack(context: BaseAudioContext, node: AudioNode, seconds: number): AudioNode { - if (!(seconds > 1e-9)) return node; - const line = context.createDelay(seconds); - line.delayTime.value = seconds; - return node.connect(line); -} - -/** Sends a level to silence in `FADE` seconds from wherever it is now. */ -function fadeOut(param: AudioParam, now: number): void { - param.cancelScheduledValues(now); - param.setValueAtTime(param.value, now); - param.linearRampToValueAtTime(0, now + FADE); -} - -/** - * Sends one knob from where it is right now to `to`. Whatever was still planned for it is dropped - * first, so a change never queues behind an older one. A timed change runs along `ease` – a `ratio` - * one in its logarithm, which is what decibels are. Linear and step are the clock's - * own ramps, every other easing is handed over as a curve of samples. - */ -function move( - param: AudioParam, - to: number, - now: number, - time: number, - ratio: boolean, - ease: EaseShare = 'linear', -): void { - const from = param.value; - // Firefox has no cancelAndHoldAtTime: there the value it reads out now is held by hand. - const held = param as Omit & - Partial>; - if (held.cancelAndHoldAtTime) held.cancelAndHoldAtTime(now); - else { - param.cancelScheduledValues(now); - param.setValueAtTime(from, now); - } - const logarithmic = ratio && from > 0 && to > 0; - if (time <= 0) param.setTargetAtTime(to, now, GLIDE); - else if (ease === 'step') param.setValueAtTime(to, now + time); - else if (ease === 'linear' && logarithmic) param.exponentialRampToValueAtTime(to, now + time); - else if (ease === 'linear') param.linearRampToValueAtTime(to, now + time); - else param.setValueCurveAtTime(easedCurve(from, to, time, logarithmic, ease), now, time); -} - -/** Sends each knob to its level, as `glide` says; whether any of them had somewhere to go. */ -function glideKnobs( - knobs: readonly Knob[], - levels: readonly number[], - now: number, - time: number, - ease: EaseShare, -): boolean { - let moved = false; - knobs.forEach((knob, i) => { - const level = levels[i]; - if (level === undefined || level === knob.target) return; - knob.target = level; - moved = true; - if (knob.param) move(knob.param, level, now, time, knob.even === 'ratio', ease); - }); - return moved; -} - -/** Samples per second of an eased curve: a bounce keeps its corners, a long sweep stays small. */ -const CURVE_RATE = 200; - -function easedCurve( - from: number, - to: number, - time: number, - logarithmic: boolean, - share: (t: number) => number, -): Float32Array { - const count = Math.min(Math.max(Math.ceil(time * CURVE_RATE) + 1, 16), 4096); - const curve = new Float32Array(count); - for (let i = 0; i < count; i++) { - const s = share(i / (count - 1)); - curve[i] = logarithmic ? from * (to / from) ** s : from + (to - from) * s; - } - return curve; -} - -/** The numbers of a chain in the order `wire` makes their knobs: a stage's `gain` is what its node holds. */ -function levelsOf(chain: FxChain, into: number[] = []): number[] { - if ('mix' in chain && chain.curve === 'both') { - levelsOf(chain.a, into); - levelsOf(chain.b, into); - into.push(chain.mix); - } else if ('mix' in chain) { - levelsOf(chain.a, into); - into.push(1 - chain.mix); - levelsOf(chain.b, into); - into.push(chain.mix); - } else { - if (chain.shifted) into.push(chain.pitch); - if (chain.chip) into.push(chain.chip.frequency, chain.chip.duty); - } - for (const stage of chain.stages) stageLevels(stage, into); - return into; -} - -function stageLevels(stage: FxStage, into: number[]): void { - if (stage.kind === 'delay') { - const shares = delayShares(stage.mix); - into.push(stage.time, stage.feedback, stage.damping, shares.echo, shares.dry); - } else if (stage.kind === 'wet') { - for (const inner of stage.stages) stageLevels(inner, into); - into.push(stage.wet, 1 - stage.wet); - } else if (stage.kind === 'pass') into.push(stage.cutoff, stage.q, stage.gain); - else if (stage.kind === 'crush') into.push(stage.bits, stage.rate, stage.mix); - else if (stage.kind === 'phaser') - into.push(stage.frequency, stage.rate, stage.depth, stage.feedback, stage.mix); - else if (stage.kind === 'flanger') - into.push(stage.offset, stage.sweep, stage.rate, stage.depth, stage.feedback, stage.mix); - else if (stage.kind === 'pan') into.push(stage.pan); - else if (stage.kind === 'compress') - into.push(stage.threshold, stage.knee, stage.ratio, stage.attack, stage.release, stage.gain); - else if (stage.kind === 'limit') into.push(stage.threshold, stage.ceiling, stage.release, stage.autoGain); - else if (stage.kind === 'tremolo' || stage.kind === 'vibrato') into.push(stage.rate, stage.depth); - else if (stage.kind === 'reverb') - into.push(stage.decay, stage.damping, stage.predelay, stage.mix, 1 - stage.mix); - else if (stage.kind === 'saturate') - into.push(driveGain(stage.drive), stage.tone, stage.level, stage.mix, 1 - stage.mix); - else if (stage.kind !== 'envelope') into.push(stage.gain); -} - export const soundLibrary = new SoundLibrary(); diff --git a/src/viewport/fxNodes.ts b/src/viewport/fxNodes.ts new file mode 100644 index 00000000..8ba3e5a5 --- /dev/null +++ b/src/viewport/fxNodes.ts @@ -0,0 +1,743 @@ +/** + * The Web Audio side of a sound FX plan: what each stage of a chain becomes as nodes (#311), the + * knobs a playing voice moves (#315), and the drivers an Envelope or a Mix carries its numbers on. + * The sound library wires voices and buses from these; nothing here keeps a voice or a bus. + */ +import { envelopeLevel, modCurve, releaseTime, SETTLE } from '../core/instrument/envelope'; +import type { EaseShare } from '../core/model/animation'; +import { LFO_SHAPES } from '../core/soundfx/lfo'; +import { + DELAY_MAX, + delayShares, + FLAT_Q, + type FxChain, + type FxCompress, + type FxCrush, + type FxDelay, + type FxEnvelope, + type FxFlanger, + type FxLfo, + type FxMod, + type FxPass, + type FxPhaser, + type FxReverb, + type FxSaturate, + type FxStage, + type FxWet, + passQ, + stageLatency, +} from '../core/soundfx/nodes'; +import { reverbImpulse } from '../core/soundfx/reverb'; +import { driveGain, saturationCurve, SHAPER_LATENCY_FRAMES } from '../core/soundfx/saturation'; +import type { FlangerOptions, LfoOptions, PhaserOptions } from './fxWorklet'; + +/** Seconds a changed parameter takes to get most of the way there: quick, but no click (#315). */ +export const GLIDE = 0.03; + +/** Seconds a stopped voice takes to fall silent, so it does not click (#324). */ +export const FADE = 0.01; + +/** + * One number of a voice that can still change while it plays (#315): a level, a share of a Mix, the + * gain of a band, the pitch of a way in semitones, a number of an echo. `even` says what a timed + * change runs evenly in: a gain stage in decibels (its node holds the factor) and the damping of an + * echo in octaves (its node holds the cutoff), everything else in what it holds. A way whose pitch + * has no worklet to play it has no `param`: it keeps its own pitch. + */ +export interface Knob { + param: AudioParam | null; + target: number; + even: 'value' | 'ratio'; +} + +export function gainNode(context: BaseAudioContext, level: number): GainNode { + const gain = context.createGain(); + gain.gain.value = level; + return gain; +} + +/** What a stage becomes: where its sound goes in, where it comes out, and the numbers a parameter can move. */ +export interface StageNodes { + input: AudioNode; + output: AudioNode; + knobs: Knob[]; + /** Ends a stage that runs on the audio thread until it is told to. */ + stop?: () => void; +} + +/** + * Moves `param` along the curve of an Envelope (#335), from `from` to `to` in equal steps or equal + * ratios, `map` turning that into what the node holds. The param's own value is added, so one that + * follows alone is set to 0 first. + */ +export type Follow = (mod: FxMod, param: AudioParam, ratio: boolean, map?: (value: number) => number) => void; + +/** + * Shapes the curve an Envelope's `driver` carries into `param` through a WaveShaper. Exported for a + * check that renders it offline in a real browser (test hook). + */ +export function followCurve( + context: BaseAudioContext, + driver: AudioNode, + mod: FxMod, + param: AudioParam, + ratio: boolean, + map?: (value: number) => number, +): void { + const shape = context.createWaveShaper(); + shape.curve = modCurve(mod, ratio, map); + driver.connect(shape).connect(param); +} + +/** + * Exported for a check that renders a stage offline in a real browser (test hook). Without `follow` + * a number an Envelope moves stays at 0, and an Envelope's own stage silent. `at` is when the voice + * starts. + */ +export function stageNodes( + context: BaseAudioContext, + stage: FxStage, + follow?: Follow, + at = context.currentTime, + impulse: AudioBuffer | null = null, +): StageNodes { + if (stage.kind === 'gain') { + const gain = gainNode(context, stage.mod ? 0 : stage.gain); + if (stage.mod) follow?.(stage.mod, gain.gain, true); + const knob: Knob = { param: stage.mod ? null : gain.gain, target: stage.gain, even: 'ratio' }; + return { input: gain, output: gain, knobs: [knob] }; + } + if (stage.kind === 'envelope') { + const gain = gainNode(context, 0); + follow?.({ envelope: stage.envelope, from: 0, to: 1 }, gain.gain, false); + return { input: gain, output: gain, knobs: [] }; + } + if (stage.kind === 'pass') return passNodes(context, stage, follow); + if (stage.kind === 'delay') return delayNodes(context, stage); + if (stage.kind === 'crush') return crushNodes(context, stage, follow); + if (stage.kind === 'phaser') return phaserNodes(context, stage, follow); + if (stage.kind === 'flanger') return flangerNodes(context, stage, at, follow); + if (stage.kind === 'compress') return compressNodes(context, stage); + if (stage.kind === 'saturate') return saturateNodes(context, stage); + if (stage.kind === 'reverb') return reverbNodes(context, stage, impulse); + if (stage.kind === 'tremolo' || stage.kind === 'vibrato') return lfoNodes(context, stage, at); + if (stage.kind === 'wet') return wetNodes(context, stage, follow, at); + if (stage.kind === 'limit') + return workletNodes(context, 'limiter', [ + { name: 'threshold', value: stage.threshold, even: 'value' }, + { name: 'ceiling', value: stage.ceiling, even: 'value' }, + { name: 'release', value: stage.release, even: 'ratio' }, + { name: 'autoGain', value: stage.autoGain, even: 'value' }, + ]); + if (stage.kind === 'pan') { + const panner = context.createStereoPanner(); + panner.pan.value = stage.mod ? 0 : stage.pan; + if (stage.mod) follow?.(stage.mod, panner.pan, false); + return { + input: panner, + output: panner, + knobs: [{ param: stage.mod ? null : panner.pan, target: stage.pan, even: 'value' }], + }; + } + const filter = context.createBiquadFilter(); + filter.type = stage.filter; + filter.frequency.value = stage.frequency; + filter.gain.value = stage.mod ? 0 : stage.gain; + filter.Q.value = stage.q; + if (stage.mod) follow?.(stage.mod, filter.gain, false); + return { + input: filter, + output: filter, + knobs: [{ param: stage.mod ? null : filter.gain, target: stage.gain, even: 'value' }], + }; +} + +/** + * A Filter (#335, #318): a cutoff that moves in octaves, a Q – as a factor in ratios too, in dB for a + * low- or high-pass – and a gain in dB. Several in a row share one cutoff, carried by a source of its + * own into each of them; only the last has the Q, the others none. + */ +function passNodes(context: BaseAudioContext, stage: FxPass, follow?: Follow): StageNodes { + const qEven = stage.filter === 'lowpass' || stage.filter === 'highpass' ? 'value' : 'ratio'; + const filters = Array.from({ length: Math.max(1, stage.stages) }, () => { + const filter = context.createBiquadFilter(); + filter.type = stage.filter; + filter.Q.value = passQ(stage.filter, FLAT_Q); + return filter; + }); + filters.slice(1).forEach((filter, i) => filters[i]!.connect(filter)); + const last = filters.at(-1)!; + last.Q.value = stage.qMod ? 0 : stage.q; + last.gain.value = stage.gainMod ? 0 : stage.gain; + if (stage.qMod) follow?.(stage.qMod, last.Q, qEven === 'ratio'); + if (stage.gainMod) follow?.(stage.gainMod, last.gain, false); + let cutoff = filters[0]!.frequency; + let stop: (() => void) | undefined; + if (filters.length > 1) { + const driver = context.createConstantSource(); + for (const filter of filters) { + filter.frequency.value = 0; + driver.connect(filter.frequency); + } + driver.start(); + cutoff = driver.offset; + stop = () => driver.stop(); + } + cutoff.value = stage.cutoffMod ? 0 : stage.cutoff; + if (stage.cutoffMod) follow?.(stage.cutoffMod, cutoff, true); + return { + input: filters[0]!, + output: last, + knobs: [ + { param: stage.cutoffMod ? null : cutoff, target: stage.cutoff, even: 'ratio' }, + { param: stage.qMod ? null : last.Q, target: stage.q, even: qEven }, + { param: stage.gainMod ? null : last.gain, target: stage.gain, even: 'value' }, + ], + ...(stop && { stop }), + }; +} + +/** + * What carries the curve of an Envelope (#335) from `at`: a straight line up to 1, then easing down + * towards the sustain. Its caller starts it and stops it with the voice. Exported for a check that + * renders it offline in a real browser (test hook). + */ +export function envelopeDriver( + context: BaseAudioContext, + envelope: FxEnvelope, + at: number, +): ConstantSourceNode { + const driver = context.createConstantSource(); + const level = driver.offset; + level.value = 0; + const top = at + envelope.attack; + if (envelope.attack > 0) { + level.setValueAtTime(0, at); + level.linearRampToValueAtTime(1, top); + } else level.setValueAtTime(1, at); + if (envelope.sustain < 1) { + if (envelope.decay > 0) level.setTargetAtTime(envelope.sustain, top, envelope.decay / SETTLE); + else level.setValueAtTime(envelope.sustain, top); + } + return driver; +} + +/** + * Lets an Envelope that started at `startedAt` fall away from `at`, from the level it has by then – + * mid-attack too – to silence in its release. Exported for the same check (test hook). + */ +export function releaseEnvelope( + level: AudioParam, + envelope: FxEnvelope, + startedAt: number, + at: number, +): void { + const held = level as Omit & + Partial>; + if (held.cancelAndHoldAtTime) held.cancelAndHoldAtTime(at); + else { + // Firefox: the level by then is known, the curve up to it is what was planned. + const reached = envelopeLevel(envelope, at - startedAt); + level.cancelScheduledValues(at); + if (at - startedAt < envelope.attack) level.linearRampToValueAtTime(reached, at); + else level.setValueAtTime(reached, at); + } + level.setTargetAtTime(0, at, releaseTime(envelope) / SETTLE); +} + +/** + * What one node adds, blended with what arrives at it (#354): its stages one after another at `wet`, + * and the sound as it came in at the rest, held back as far as the stages are late. + */ +function wetNodes( + context: BaseAudioContext, + stage: FxWet, + follow: Follow | undefined, + at: number, +): StageNodes { + const input = context.createGain(); + const output = context.createGain(); + const knobs: Knob[] = []; + const stops: (() => void)[] = []; + let tail: AudioNode = input; + for (const inner of stage.stages) { + const made = stageNodes(context, inner, follow, at); + tail.connect(made.input); + tail = made.output; + knobs.push(...made.knobs); + if (made.stop) stops.push(made.stop); + } + const moved = stage.wetMod; + const wet = gainNode(context, moved ? 0 : stage.wet); + const dry = gainNode(context, moved ? 0 : 1 - stage.wet); + if (moved) { + follow?.(moved, wet.gain, false); + follow?.(moved, dry.gain, false, (share) => 1 - share); + } + tail.connect(wet).connect(output); + heldBack(context, input, stageLatency(stage, context.sampleRate)).connect(dry).connect(output); + knobs.push( + { param: moved ? null : wet.gain, target: stage.wet, even: 'value' }, + { param: moved ? null : dry.gain, target: 1 - stage.wet, even: 'value' }, + ); + return { input, output, knobs, ...(stops.length > 0 && { stop: () => stops.forEach((stop) => stop()) }) }; +} + +/** + * A Compressor (#326): Web Audio's node, and a gain after it for makeup and auto-gain. Its times + * move in ratios, everything in dB as it is. + */ +function compressNodes(context: BaseAudioContext, stage: FxCompress): StageNodes { + const node = context.createDynamicsCompressor(); + node.threshold.value = stage.threshold; + node.knee.value = stage.knee; + node.ratio.value = stage.ratio; + node.attack.value = stage.attack; + node.release.value = stage.release; + const gain = gainNode(context, stage.gain); + node.connect(gain); + return { + input: node, + output: gain, + knobs: [ + { param: node.threshold, target: stage.threshold, even: 'value' }, + { param: node.knee, target: stage.knee, even: 'value' }, + { param: node.ratio, target: stage.ratio, even: 'ratio' }, + { param: node.attack, target: stage.attack, even: 'ratio' }, + { param: node.release, target: stage.release, even: 'ratio' }, + { param: gain.gain, target: stage.gain, even: 'ratio' }, + ], + }; +} + +/** + * A Tremolo or a Vibrato (#320) on the worklet, its LFO at phase 0 when its voice starts at `at`. + * A Vibrato's delay swings around the center its start values give, which is how late it is. + */ +function lfoNodes(context: BaseAudioContext, stage: FxLfo, at: number): StageNodes { + const options: LfoOptions & { vibrato: boolean } = { + shape: LFO_SHAPES.findIndex((option) => option.value === stage.shape), + startAt: at, + center: stage.kind === 'vibrato' ? stageLatency(stage, context.sampleRate) : 0, + vibrato: stage.kind === 'vibrato', + }; + return workletNodes( + context, + 'lfo', + [ + { name: 'rate', value: stage.rate, even: 'ratio' }, + { name: 'depth', value: stage.depth, even: 'value' }, + ], + undefined, + options, + ); +} + +/** Impulse responses made for a Reverb, by rate, decay and damping: the last few are kept. */ +const impulses = new Map(); +const KEPT_IMPULSES = 16; + +/** The impulse response a Reverb makes for itself (#328), made once for these numbers. */ +function madeImpulse(context: BaseAudioContext, decay: number, damping: number): AudioBuffer { + const key = `${context.sampleRate}|${decay}|${damping}`; + let buffer = impulses.get(key); + if (!buffer) { + const channels = reverbImpulse(context.sampleRate, decay, damping); + buffer = context.createBuffer(channels.length, channels[0]!.length, context.sampleRate); + channels.forEach((data, c) => buffer!.copyToChannel(data as Float32Array, c)); + impulses.set(key, buffer); + if (impulses.size > KEPT_IMPULSES) impulses.delete(impulses.keys().next().value!); + } + return buffer; +} + +/** + * A Reverb (#328): the sound itself, and beside it through the pre-delay into a ConvolverNode with + * the impulse response made for it – or the one of a sound, `impulse`, where it takes a sound; a + * sound not loaded leaves the reverb silent. Only the mix moves while it plays. + */ +function reverbNodes(context: BaseAudioContext, stage: FxReverb, impulse: AudioBuffer | null): StageNodes { + const input = context.createGain(); + const output = context.createGain(); + const convolver = context.createConvolver(); + const buffer = stage.impulse ? impulse : madeImpulse(context, stage.decay, stage.damping); + if (buffer) convolver.buffer = buffer; + const wet = gainNode(context, stage.mix); + const dry = gainNode(context, 1 - stage.mix); + heldBack(context, input, stage.predelay).connect(convolver).connect(wet).connect(output); + input.connect(dry).connect(output); + return { + input, + output, + knobs: [ + { param: null, target: stage.decay, even: 'value' }, + { param: null, target: stage.damping, even: 'value' }, + { param: null, target: stage.predelay, even: 'value' }, + { param: wet.gain, target: stage.mix, even: 'value' }, + { param: dry.gain, target: 1 - stage.mix, even: 'value' }, + ], + }; +} + +/** Below what a Saturation takes out the DC its bent sound gained (#323). */ +const DC_BLOCK_HZ = 10; + +/** + * A Saturation (#323): the sound driven into the curve of its type, oversampled 4x, its DC taken + * out, through the low-pass of its tone and set to its level – and the sound itself beside it, + * held back as far as the oversampling makes the bent one late, so the two meet without a comb. + */ +function saturateNodes(context: BaseAudioContext, stage: FxSaturate): StageNodes { + const input = context.createGain(); + const output = context.createGain(); + const drive = gainNode(context, driveGain(stage.drive)); + const shaper = context.createWaveShaper(); + shaper.curve = saturationCurve(stage.curve) as Float32Array; + shaper.oversample = '4x'; + const dc = context.createBiquadFilter(); + dc.type = 'highpass'; + dc.frequency.value = DC_BLOCK_HZ; + dc.Q.value = passQ('highpass', FLAT_Q); + const tone = context.createBiquadFilter(); + tone.type = 'lowpass'; + tone.frequency.value = stage.tone; + tone.Q.value = passQ('lowpass', FLAT_Q); + const level = gainNode(context, stage.level); + const wet = gainNode(context, stage.mix); + const dry = gainNode(context, 1 - stage.mix); + input.connect(drive).connect(shaper).connect(dc).connect(tone).connect(level).connect(wet).connect(output); + heldBack(context, input, SHAPER_LATENCY_FRAMES / context.sampleRate) + .connect(dry) + .connect(output); + return { + input, + output, + knobs: [ + { param: drive.gain, target: driveGain(stage.drive), even: 'ratio' }, + { param: tone.frequency, target: stage.tone, even: 'ratio' }, + { param: level.gain, target: stage.level, even: 'ratio' }, + { param: wet.gain, target: stage.mix, even: 'value' }, + { param: dry.gain, target: 1 - stage.mix, even: 'value' }, + ], + }; +} + +/** + * An echo (#327): the sound goes on, and through a delay line whose output is damped, fed back into + * it and added – each at its share of the wet `mix` (#354). Ping-pong sends the sound into the left + * of the line only and swaps the sides on every round. + */ +function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes { + const input = context.createGain(); + const output = context.createGain(); + const shares = delayShares(stage.mix); + const dry = gainNode(context, shares.dry); + input.connect(dry).connect(output); + const line = context.createDelay(DELAY_MAX); + line.delayTime.value = stage.time; + const damp = context.createBiquadFilter(); + damp.type = 'lowpass'; + damp.frequency.value = stage.damping; + damp.Q.value = passQ('lowpass', FLAT_Q); + const feedback = gainNode(context, stage.feedback); + const wet = gainNode(context, shares.echo); + line.connect(damp); + damp.connect(wet).connect(output); + if (stage.pingPong) { + const mono = context.createGain(); + mono.channelCount = 1; + mono.channelCountMode = 'explicit'; + const left = context.createChannelMerger(2); + input.connect(mono).connect(left, 0, 0); + left.connect(line); + const sides = context.createChannelSplitter(2); + const crossed = context.createChannelMerger(2); + damp.connect(sides); + sides.connect(crossed, 0, 1); + sides.connect(crossed, 1, 0); + crossed.connect(feedback).connect(line); + } else { + input.connect(line); + damp.connect(feedback).connect(line); + } + return { + input, + output, + knobs: [ + { param: line.delayTime, target: stage.time, even: 'value' }, + { param: feedback.gain, target: stage.feedback, even: 'value' }, + { param: damp.frequency, target: stage.damping, even: 'ratio' }, + { param: wet.gain, target: shares.echo, even: 'value' }, + { param: dry.gain, target: shares.dry, even: 'value' }, + ], + }; +} + +/** A number of a stage on the worklet: what it holds, the Envelope moving it instead, how it moves. */ +interface WorkletNumber { + name: string; + value: number; + mod?: FxMod | undefined; + even: Knob['even']; +} + +/** + * A stage on the worklet (#322, #341), its numbers AudioParams of the processor. One an Envelope + * moves starts at 0 and leaves its knob to it. Where the worklet is not there the sound goes + * through as it is. + */ +function workletNodes( + context: BaseAudioContext, + processor: string, + numbers: readonly WorkletNumber[], + follow?: Follow, + processorOptions?: object, +): StageNodes { + let node: AudioWorkletNode; + try { + node = new AudioWorkletNode(context, processor, { + parameterData: Object.fromEntries(numbers.map((n) => [n.name, n.mod ? 0 : n.value])), + ...(processorOptions && { processorOptions }), + }); + } catch { + const through = context.createGain(); + const knobs = numbers.map((n): Knob => ({ param: null, target: n.value, even: n.even })); + return { input: through, output: through, knobs }; + } + const knobs = numbers.map(({ name, value, mod, even }): Knob => { + const param = node.parameters.get(name)!; + if (mod) follow?.(mod, param, even === 'ratio'); + return { param: mod ? null : param, target: value, even }; + }); + return { input: node, output: node, knobs, stop: () => stopProcessor(node) }; +} + +/** + * Stops a processor and lets go of its port. A port that is listened to is never collected, and + * with it everything its listener reaches: the voice and every node of it, still on its bus. + */ +export function stopProcessor(node: AudioWorkletNode): void { + node.port.postMessage('stop'); + node.port.onmessage = null; + node.port.close(); + node.disconnect(); +} + +/** Calls `then` once a source on the worklet has played to its end, and lets go of its port. */ +export function endedProcessor(node: AudioWorkletNode, then: () => void): void { + node.port.onmessage = (event: MessageEvent) => { + if (event.data !== 'ended') return; + node.port.onmessage = null; + node.port.close(); + then(); + }; +} + +/** A Bitcrusher (#322): bits and mix move in what they hold, the rate in octaves. */ +function crushNodes(context: BaseAudioContext, stage: FxCrush, follow?: Follow): StageNodes { + return workletNodes( + context, + 'bit-crusher', + [ + { name: 'bits', value: stage.bits, mod: stage.bitsMod, even: 'value' }, + { name: 'rate', value: stage.rate, mod: stage.rateMod, even: 'ratio' }, + { name: 'mix', value: stage.mix, mod: stage.mixMod, even: 'value' }, + ], + follow, + ); +} + +/** A Phaser (#341): its frequency moves in octaves, the rest in what they hold. */ +function phaserNodes(context: BaseAudioContext, stage: FxPhaser, follow?: Follow): StageNodes { + const options: PhaserOptions = { stages: stage.stages }; + return workletNodes( + context, + 'phaser', + [ + { name: 'frequency', value: stage.frequency, mod: stage.frequencyMod, even: 'ratio' }, + { name: 'rate', value: stage.rate, even: 'value' }, + { name: 'depth', value: stage.depth, even: 'value' }, + { name: 'feedback', value: stage.feedback, even: 'value' }, + { name: 'mix', value: stage.mix, mod: stage.mixMod, even: 'value' }, + ], + follow, + options, + ); +} + +/** A Flanger (#341), whose sweep counts from `at`, when the voice starts. */ +function flangerNodes(context: BaseAudioContext, stage: FxFlanger, at: number, follow?: Follow): StageNodes { + const options: FlangerOptions = { startAt: at }; + return workletNodes( + context, + 'flanger', + [ + { name: 'offset', value: stage.offset, mod: stage.offsetMod, even: 'value' }, + { name: 'sweep', value: stage.sweep, even: 'value' }, + { name: 'rate', value: stage.rate, even: 'value' }, + { name: 'depth', value: stage.depth, even: 'value' }, + { name: 'feedback', value: stage.feedback, even: 'value' }, + { name: 'mix', value: stage.mix, mod: stage.mixMod, even: 'value' }, + ], + follow, + options, + ); +} + +/** The shares of a and b over `mix` at 0, 0.5 and 1: a WaveShaper reads -1…1, so its curve starts below 0. */ +const SHARE_A = new Float32Array([1, 1, 1, 1, 0]); +const SHARE_B = new Float32Array([0, 0, 0, 1, 1]); + +/** + * The shares of a Mix with the curve "Both at center": a and b each stay full up to the middle and + * fade on the other half. A share is not a straight line of `mix` there, so two ramps of their own + * would dip at the middle when `mix` glides across it. The source carries `mix` itself, and each + * share is shaped from it on the audio thread. Its caller starts it and stops it with the voice. + * Exported for a check that renders it offline in a real browser (test hook). + */ +export function bothAtCenter( + context: BaseAudioContext, + mix: number, + a: GainNode, + b: GainNode, +): ConstantSourceNode { + const driver = context.createConstantSource(); + driver.offset.value = mix; + for (const [curve, share] of [ + [SHARE_A, a], + [SHARE_B, b], + ] as const) { + const shape = context.createWaveShaper(); + shape.curve = curve; + driver.connect(shape).connect(share.gain); + } + return driver; +} + +/** A node's sound held back `seconds` by a delay line after it, or the node itself for none. */ +export function heldBack(context: BaseAudioContext, node: AudioNode, seconds: number): AudioNode { + if (!(seconds > 1e-9)) return node; + const line = context.createDelay(seconds); + line.delayTime.value = seconds; + return node.connect(line); +} + +/** Sends a level to silence in `FADE` seconds from wherever it is now. */ +export function fadeOut(param: AudioParam, now: number): void { + param.cancelScheduledValues(now); + param.setValueAtTime(param.value, now); + param.linearRampToValueAtTime(0, now + FADE); +} + +/** + * Sends one knob from where it is right now to `to`. Whatever was still planned for it is dropped + * first, so a change never queues behind an older one. A timed change runs along `ease` – a `ratio` + * one in its logarithm, which is what decibels are. Linear and step are the clock's + * own ramps, every other easing is handed over as a curve of samples. + */ +export function move( + param: AudioParam, + to: number, + now: number, + time: number, + ratio: boolean, + ease: EaseShare = 'linear', +): void { + const from = param.value; + // Firefox has no cancelAndHoldAtTime: there the value it reads out now is held by hand. + const held = param as Omit & + Partial>; + if (held.cancelAndHoldAtTime) held.cancelAndHoldAtTime(now); + else { + param.cancelScheduledValues(now); + param.setValueAtTime(from, now); + } + const logarithmic = ratio && from > 0 && to > 0; + if (time <= 0) param.setTargetAtTime(to, now, GLIDE); + else if (ease === 'step') param.setValueAtTime(to, now + time); + else if (ease === 'linear' && logarithmic) param.exponentialRampToValueAtTime(to, now + time); + else if (ease === 'linear') param.linearRampToValueAtTime(to, now + time); + else param.setValueCurveAtTime(easedCurve(from, to, time, logarithmic, ease), now, time); +} + +/** Sends each knob to its level, as `glide` says; whether any of them had somewhere to go. */ +export 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; + +function easedCurve( + from: number, + to: number, + time: number, + logarithmic: boolean, + share: (t: number) => number, +): Float32Array { + const count = Math.min(Math.max(Math.ceil(time * CURVE_RATE) + 1, 16), 4096); + const curve = new Float32Array(count); + for (let i = 0; i < count; i++) { + const s = share(i / (count - 1)); + curve[i] = logarithmic ? from * (to / from) ** s : from + (to - from) * s; + } + return curve; +} + +/** The numbers of a chain in the order `wire` makes their knobs: a stage's `gain` is what its node holds. */ +export function levelsOf(chain: FxChain, into: number[] = []): number[] { + if ('mix' in chain && chain.curve === 'both') { + levelsOf(chain.a, into); + levelsOf(chain.b, into); + into.push(chain.mix); + } else if ('mix' in chain) { + levelsOf(chain.a, into); + into.push(1 - chain.mix); + levelsOf(chain.b, into); + into.push(chain.mix); + } else { + if (chain.shifted) into.push(chain.pitch); + if (chain.chip) into.push(chain.chip.frequency, chain.chip.duty); + } + for (const stage of chain.stages) stageLevels(stage, into); + return into; +} + +function stageLevels(stage: FxStage, into: number[]): void { + if (stage.kind === 'delay') { + const shares = delayShares(stage.mix); + into.push(stage.time, stage.feedback, stage.damping, shares.echo, shares.dry); + } else if (stage.kind === 'wet') { + for (const inner of stage.stages) stageLevels(inner, into); + into.push(stage.wet, 1 - stage.wet); + } else if (stage.kind === 'pass') into.push(stage.cutoff, stage.q, stage.gain); + else if (stage.kind === 'crush') into.push(stage.bits, stage.rate, stage.mix); + else if (stage.kind === 'phaser') + into.push(stage.frequency, stage.rate, stage.depth, stage.feedback, stage.mix); + else if (stage.kind === 'flanger') + into.push(stage.offset, stage.sweep, stage.rate, stage.depth, stage.feedback, stage.mix); + else if (stage.kind === 'pan') into.push(stage.pan); + else if (stage.kind === 'compress') + into.push(stage.threshold, stage.knee, stage.ratio, stage.attack, stage.release, stage.gain); + else if (stage.kind === 'limit') into.push(stage.threshold, stage.ceiling, stage.release, stage.autoGain); + else if (stage.kind === 'tremolo' || stage.kind === 'vibrato') into.push(stage.rate, stage.depth); + else if (stage.kind === 'reverb') + into.push(stage.decay, stage.damping, stage.predelay, stage.mix, 1 - stage.mix); + else if (stage.kind === 'saturate') + into.push(driveGain(stage.drive), stage.tone, stage.level, stage.mix, 1 - stage.mix); + else if (stage.kind !== 'envelope') into.push(stage.gain); +} diff --git a/src/viewport/fxWorklet.ts b/src/viewport/fxWorklet.ts index 38256d57..d9b5986e 100644 --- a/src/viewport/fxWorklet.ts +++ b/src/viewport/fxWorklet.ts @@ -3,8 +3,8 @@ * 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, the pitch of a bus (#346) a - * `PitchShifter`, a Limiter (#326) a `Limiter`, a Tremolo and a Vibrato (#320) theirs. Loaded into the context once by the sound library; nothing on the page - * imports it. + * `PitchShifter`, a Limiter (#326) a `Limiter`, a Tremolo and a Vibrato (#320) theirs. 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'; @@ -24,6 +24,8 @@ declare function registerProcessor( processor: new (options: { processorOptions: T }) => object, ): void; +type ParamValues = Record; + /** A processor that runs until it is told to stop, then lets go of its port so it can be collected. */ abstract class Stoppable extends AudioWorkletProcessor { protected stopped = false; @@ -34,6 +36,77 @@ abstract class Stoppable extends AudioWorkletProcessor { } } +/** An effect between two stages of a voice or a bus; it runs until it is told to stop. */ +abstract class Effect extends Stoppable { + constructor() { + super(); + this.port.onmessage = () => this.stop(); + } + + process(inputs: Float32Array[][], outputs: Float32Array[][], parameters: ParamValues): boolean { + if (this.stopped) return false; + this.render(inputs[0] ?? [], outputs[0]!, parameters); + return true; + } + + protected abstract render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void; +} + +/** A block of a voice, or the part of it from the voice's first sample on. */ +interface Block { + out: Float32Array[]; + input: Float32Array[]; + parameters: ParamValues; +} + +/** The second of the audio clock a voice is first heard at (#332); before it, silence. */ +class Start { + constructor(private at: number) {} + + /** + * The block from the voice's first sample on, the output, the input and every parameter cut + * there, or null while all of it lies before. Once the voice runs, the block as it is. + */ + cut(block: Block): Block | null { + if (this.at <= currentTime) return block; + const wait = Math.round((this.at - currentTime) * sampleRate); + for (const channel of block.out) channel.fill(0, 0, wait); + if (wait >= (block.out[0]?.length ?? 0)) return null; + this.at = 0; + const from = (values: Float32Array) => (values.length > 1 ? values.subarray(wait) : values); + return { + out: block.out.map((channel) => channel.subarray(wait)), + input: block.input.map(from), + parameters: Object.fromEntries(Object.entries(block.parameters).map(([name, v]) => [name, from(v)])), + }; + } +} + +/** + * A sound of its own, heard from a second of the audio clock on; it tells the page once it has + * ended, and runs until then or until it is told to stop. + */ +abstract class Source extends Stoppable { + private readonly start: Start; + + constructor(startAt: number) { + super(); + this.start = new Start(startAt); + } + + process(_inputs: Float32Array[][], outputs: Float32Array[][], parameters: ParamValues): boolean { + if (this.stopped) return false; + const block = this.start.cut({ out: outputs[0]!, input: [], parameters }); + if (!block) return true; + if (this.render(block.out, block.parameters)) return true; + this.port.postMessage('ended'); + return false; + } + + /** Fills `out`; false once the sound has ended. */ + protected abstract render(out: Float32Array[], parameters: ParamValues): boolean; +} + export interface PitchOptions { /** The file the samples are of: they are sent with the first voice of it only and kept here. */ file: string; @@ -53,14 +126,12 @@ export interface PitchOptions { /** Every sound a voice has been played from, for the next voice of it – processors share this scope. */ const sounds = new Map(); -class PitchProcessor extends Stoppable { +class PitchProcessor extends Source { private readonly player: PitchPlayer; - private startAt: number; constructor({ processorOptions }: { processorOptions: PitchOptions }) { - super(); - const { file, channels, step, loop, loopStart, loopEnd, startAt, skip } = processorOptions; - this.startAt = startAt ?? 0; + super(processorOptions.startAt ?? 0); + const { file, channels, step, loop, loopStart, loopEnd, skip } = processorOptions; if (channels) sounds.set(file, channels); const region = loop && loopEnd !== undefined ? { start: loopStart ?? 0, end: loopEnd } : loop; this.player = new PitchPlayer(sounds.get(file) ?? [], step, sampleRate, region, skip); @@ -70,25 +141,8 @@ class PitchProcessor extends Stoppable { }; } - process( - _inputs: Float32Array[][], - outputs: Float32Array[][], - parameters: Record, - ): boolean { - if (this.stopped) return false; - let out = outputs[0]!; - let pitch = parameters.pitch!; - // A block is silence until then, the one it falls in starts on that sample. - if (this.startAt > currentTime) { - const wait = Math.round((this.startAt - currentTime) * sampleRate); - if (wait >= (out[0]?.length ?? 0)) return true; - out = out.map((channel) => channel.subarray(wait)); - if (pitch.length > 1) pitch = pitch.subarray(wait); - this.startAt = 0; - } - if (this.player.render(out, pitch)) return true; - this.port.postMessage('ended'); - return false; + protected render(out: Float32Array[], parameters: ParamValues): boolean { + return this.player.render(out, parameters.pitch!); } static get parameterDescriptors() { @@ -98,26 +152,12 @@ class PitchProcessor extends Stoppable { 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 Stoppable { +/** The pitch of a bus (#346): what comes in goes out moved by `pitch`, a frame late. */ +class ShiftProcessor extends Effect { private readonly shifter = new PitchShifter(2, sampleRate); - constructor() { - super(); - this.port.onmessage = () => this.stop(); - } - - process( - inputs: Float32Array[][], - outputs: Float32Array[][], - parameters: Record, - ): boolean { - if (this.stopped) return false; - this.shifter.render(inputs[0] ?? [], outputs[0]!, parameters.pitch!); - return true; + protected render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void { + this.shifter.render(input, out, parameters.pitch!); } static get parameterDescriptors() { @@ -127,23 +167,12 @@ class ShiftProcessor extends Stoppable { 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 Stoppable { +/** A Bitcrusher (#322). */ +class CrushProcessor extends Effect { private readonly crusher = new Crusher(sampleRate); - constructor() { - super(); - this.port.onmessage = () => this.stop(); - } - - process( - inputs: Float32Array[][], - outputs: Float32Array[][], - parameters: Record, - ): boolean { - if (this.stopped) return false; - this.crusher.render(inputs[0] ?? [], outputs[0]!, parameters.bits!, parameters.rate!, parameters.mix!); - return true; + protected render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void { + this.crusher.render(input, out, parameters.bits!, parameters.rate!, parameters.mix!); } static get parameterDescriptors() { @@ -163,24 +192,17 @@ export interface PhaserOptions { const PHASER_PARAMS = ['frequency', 'rate', 'depth', 'feedback', 'mix'] as const; -/** A Phaser (#341) between two stages of a voice; it runs until the voice tells it to stop. */ -class PhaserProcessor extends Stoppable { +/** A Phaser (#341). */ +class PhaserProcessor extends Effect { private readonly phaser: Phaser; constructor({ processorOptions }: { processorOptions: PhaserOptions }) { super(); this.phaser = new Phaser(sampleRate, processorOptions.stages); - this.port.onmessage = () => this.stop(); } - process( - inputs: Float32Array[][], - outputs: Float32Array[][], - parameters: Record, - ): boolean { - if (this.stopped) return false; - this.phaser.render(inputs[0] ?? [], outputs[0]!, parameters as PhaserNumbers); - return true; + protected render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void { + this.phaser.render(input, out, parameters as PhaserNumbers); } static get parameterDescriptors() { @@ -197,41 +219,20 @@ export interface FlangerOptions { const FLANGER_PARAMS = ['offset', 'sweep', 'rate', 'depth', 'feedback', 'mix'] as const; -/** A Flanger (#341) between two stages of a voice; its sweep starts with the voice. */ -class FlangerProcessor extends Stoppable { +/** A Flanger (#341); its sweep starts with the voice. */ +class FlangerProcessor extends Effect { private readonly flanger = new Flanger(sampleRate); - private startAt: number; + private readonly start: Start; constructor({ processorOptions }: { processorOptions: FlangerOptions }) { super(); - this.startAt = processorOptions.startAt; - this.port.onmessage = () => this.stop(); + this.start = new Start(processorOptions.startAt); } - process( - inputs: Float32Array[][], - outputs: Float32Array[][], - parameters: Record, - ): boolean { - if (this.stopped) return false; - let input = inputs[0] ?? []; - let out = outputs[0]!; - let numbers = parameters as FlangerNumbers; + protected render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void { // Nothing is heard before the voice starts, and the sweep waits for its first sample. - if (this.startAt > currentTime) { - const wait = Math.round((this.startAt - currentTime) * sampleRate); - for (const channel of out) channel.fill(0, 0, wait); - if (wait >= (out[0]?.length ?? 0)) return true; - const from = (values: Float32Array) => (values.length > 1 ? values.subarray(wait) : values); - input = input.map(from); - out = out.map((channel) => channel.subarray(wait)); - numbers = Object.fromEntries( - FLANGER_PARAMS.map((name) => [name, from(numbers[name])]), - ) as FlangerNumbers; - this.startAt = 0; - } - this.flanger.render(input, out, numbers); - return true; + const block = this.start.cut({ out, input, parameters }); + if (block) this.flanger.render(block.input, block.out, block.parameters as FlangerNumbers); } static get parameterDescriptors() { @@ -243,23 +244,12 @@ registerProcessor('flanger', FlangerProcessor); const LIMITER_PARAMS = ['threshold', 'ceiling', 'release', 'autoGain'] as const; -/** A Limiter (#326) between two stages of a voice or a bus; it runs until it is told to stop. */ -class LimitProcessor extends Stoppable { +/** A Limiter (#326). */ +class LimitProcessor extends Effect { private readonly limiter = new Limiter(sampleRate); - constructor() { - super(); - this.port.onmessage = () => this.stop(); - } - - process( - inputs: Float32Array[][], - outputs: Float32Array[][], - parameters: Record, - ): boolean { - if (this.stopped) return false; - this.limiter.render(inputs[0] ?? [], outputs[0]!, parameters as LimiterNumbers); - return true; + protected render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void { + this.limiter.render(input, out, parameters as LimiterNumbers); } static get parameterDescriptors() { @@ -278,10 +268,10 @@ export interface LfoOptions { center: number; } -/** A Tremolo or a Vibrato (#320) between two stages; its LFO starts with its voice. */ -class LfoProcessor extends Stoppable { +/** A Tremolo or a Vibrato (#320); its LFO starts with its voice. */ +class LfoProcessor extends Effect { private readonly effect: Tremolo | Vibrato; - private startAt: number; + private readonly start: Start; constructor({ processorOptions }: { processorOptions: LfoOptions & { vibrato: boolean } }) { super(); @@ -289,32 +279,13 @@ class LfoProcessor extends Stoppable { this.effect = processorOptions.vibrato ? new Vibrato(shape, sampleRate, processorOptions.center) : new Tremolo(shape, sampleRate); - this.startAt = processorOptions.startAt; - this.port.onmessage = () => this.stop(); + this.start = new Start(processorOptions.startAt); } - process( - inputs: Float32Array[][], - outputs: Float32Array[][], - parameters: Record, - ): boolean { - if (this.stopped) return false; - let input = inputs[0] ?? []; - let out = outputs[0]!; - let numbers = parameters as LfoNumbers; + protected render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void { // Nothing is heard before the voice starts, and the LFO waits for its first sample. - if (this.startAt > currentTime) { - const wait = Math.round((this.startAt - currentTime) * sampleRate); - for (const channel of out) channel.fill(0, 0, wait); - if (wait >= (out[0]?.length ?? 0)) return true; - const from = (values: Float32Array) => (values.length > 1 ? values.subarray(wait) : values); - input = input.map(from); - out = out.map((channel) => channel.subarray(wait)); - numbers = { rate: from(numbers.rate), depth: from(numbers.depth) }; - this.startAt = 0; - } - this.effect.render(input, out, numbers); - return true; + const block = this.start.cut({ out, input, parameters }); + if (block) this.effect.render(block.input, block.out, block.parameters as LfoNumbers); } static get parameterDescriptors() { @@ -333,36 +304,17 @@ export interface ChipOptions extends ChipShape { } /** The Chip of a note (#329): a source of its own, its frequency, duty and pitch AudioParams. */ -class ChipProcessor extends Stoppable { +class ChipProcessor extends Source { private readonly player: ChipPlayer; - private startAt: number; constructor({ processorOptions }: { processorOptions: ChipOptions }) { - super(); + super(processorOptions.startAt); this.player = new ChipPlayer(processorOptions, sampleRate); - this.startAt = processorOptions.startAt; this.port.onmessage = () => this.stop(); } - process( - _inputs: Float32Array[][], - outputs: Float32Array[][], - parameters: Record, - ): boolean { - if (this.stopped) return false; - let out = outputs[0]!; - let { frequency, duty, pitch } = parameters as Record<'frequency' | 'duty' | 'pitch', Float32Array>; - if (this.startAt > currentTime) { - const wait = Math.round((this.startAt - currentTime) * sampleRate); - if (wait >= (out[0]?.length ?? 0)) return true; - const from = (values: Float32Array) => (values.length > 1 ? values.subarray(wait) : values); - out = out.map((channel) => channel.subarray(wait)); - [frequency, duty, pitch] = [from(frequency), from(duty), from(pitch)]; - this.startAt = 0; - } - if (this.player.render(out, frequency, duty, pitch)) return true; - this.port.postMessage('ended'); - return false; + protected render(out: Float32Array[], parameters: ParamValues): boolean { + return this.player.render(out, parameters.frequency!, parameters.duty!, parameters.pitch!); } static get parameterDescriptors() { diff --git a/src/viewport/pixelGrid.ts b/src/viewport/pixelGrid.ts index da509b10..3ae90fb2 100644 --- a/src/viewport/pixelGrid.ts +++ b/src/viewport/pixelGrid.ts @@ -9,7 +9,7 @@ export function gridSteps(devicePixelsPerUnit: number): number { return Math.max(1, Math.round(devicePixelsPerUnit)); } -export function onGrid(v: number, steps: number): number { +function onGrid(v: number, steps: number): number { return Math.round(v * steps) / steps; } diff --git a/tests/core/master-routing.test.ts b/tests/core/master-routing.test.ts index 90d646ec..61f20bf5 100644 --- a/tests/core/master-routing.test.ts +++ b/tests/core/master-routing.test.ts @@ -117,7 +117,7 @@ describe('the nodes of the master routing (#351)', () => { ); expect(f.linksOf(f.create('song', 'Outro'))).toEqual([reverb]); expect(f.linksOf(f.create('pattern', 'Riff')).sort()).toEqual([drums, reverb].sort()); - // A bus goes to the master as before. + // A new bus goes to the master only. expect(f.linksOf(f.create('bus', 'Delay'))).toEqual([MASTER_BUS]); // Undone, a new asset takes its links along. const links = f.project.routing.edges.length; diff --git a/tests/core/parametric-eq.test.ts b/tests/core/parametric-eq.test.ts index 1f12424f..b3d47ee0 100644 --- a/tests/core/parametric-eq.test.ts +++ b/tests/core/parametric-eq.test.ts @@ -11,7 +11,7 @@ import { type EqBand, eqBandDb, eqBandsOf, - eqCurveDb, + eqCurves, MAX_EQ_BANDS, withBandSet, } from '../../src/core/soundfx/parametricEq'; @@ -91,9 +91,13 @@ describe('the bands of a Parametric EQ', () => { expect(eqBandDb(shelf, 200)).toBeCloseTo(3, 1); expect(eqBandDb(band({ type: 'notch' }), 1000)).toBeLessThan(-60); - // Together they add up in dB. - const both = [peak, shelf]; - expect(eqCurveDb(both, 300)).toBeCloseTo(eqBandDb(peak, 300) + eqBandDb(shelf, 300), 6); + // Together they add up in dB; a band that does nothing is drawn but not counted. + const flat = band({ type: 'peaking', frequency: 300, gain: 0 }); + const curves = eqCurves([peak, flat, shelf], [300, 3000]); + expect(curves.bands[1]).toHaveLength(2); + expect(curves.bands[2]![0]).toBeCloseTo(eqBandDb(shelf, 300), 6); + expect(curves.sum[0]).toBeCloseTo(eqBandDb(peak, 300) + eqBandDb(shelf, 300), 6); + expect(curves.sum[1]).toBeCloseTo(eqBandDb(peak, 3000) + eqBandDb(shelf, 3000), 6); }); it('fills a curve that crosses 0 dB with one shape for each side, meeting on the line', () => { diff --git a/tests/core/pattern.test.ts b/tests/core/pattern.test.ts index c2540942..99a85cb8 100644 --- a/tests/core/pattern.test.ts +++ b/tests/core/pattern.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; -import { SetPatternLoopCommand, SetPatternNotesCommand } from '../../src/core/commands/patternCommands'; +import { SetLoopCommand, SetPatternNotesCommand } from '../../src/core/commands/patternCommands'; import { namedNotes } from '../../src/core/instrument/compile'; import { starterInstrumentGraph } from '../../src/core/instrument/nodes'; import type { PatternAsset } from '../../src/core/model/Asset'; @@ -137,12 +137,12 @@ describe('pattern asset (#333)', () => { it('loops the whole pattern until a loop is set, and a loop is cut at its end', () => { const f = makePattern(); expect(patternLoop(f.pattern())).toEqual({ start: 0, end: 7680 }); - f.stack.execute(new SetPatternLoopCommand(f.id, { start: 1920, end: 9600 })); + f.stack.execute(new SetLoopCommand('pattern', f.id, { start: 1920, end: 9600 })); expect(f.pattern().loop).toEqual({ start: 1920, end: 9600 }); expect(patternLoop(f.pattern())).toEqual({ start: 1920, end: 7680 }); f.set('bars', 1); expect(patternLoop(f.pattern())).toEqual({ start: 0, end: 1920 }); - f.stack.execute(new SetPatternLoopCommand(f.id, null)); + f.stack.execute(new SetLoopCommand('pattern', f.id, null)); expect(f.pattern().loop).toBeUndefined(); f.stack.undo(); expect(f.pattern().loop).toEqual({ start: 1920, end: 9600 }); @@ -153,7 +153,7 @@ describe('pattern asset (#333)', () => { f.stack.execute( new SetPatternNotesCommand(f.id, [note('a', 0, 120, 36, 1), note('b', 240, 60, 38, 0.25)]), ); - f.stack.execute(new SetPatternLoopCommand(f.id, { start: 0, end: 960 })); + f.stack.execute(new SetLoopCommand('pattern', f.id, { start: 0, end: 960 })); f.set('grid', '1/8T'); const back = roundTrip(f.project).assets.find((a) => a.id === f.id) as PatternAsset; expect(back.notes).toEqual(f.pattern().notes); diff --git a/tests/core/shared-port-type.test.ts b/tests/core/shared-port-type.test.ts index bd183c1e..898ee660 100644 --- a/tests/core/shared-port-type.test.ts +++ b/tests/core/shared-port-type.test.ts @@ -10,7 +10,7 @@ import { type GraphNode, type PortDef, portsCompatible, - portTypeOf, + portOf, resolveDef, } from '../../src/core/graph'; import { graphLookup } from '../../src/core/model/graphLookup'; @@ -112,8 +112,8 @@ describe('First not empty, whose a, b and value carry the same value', () => { expect(f.behavior.graph.edges.filter((e) => e.to.node === join.id)).toHaveLength(1); const lookup = graphLookup(f.project); - const out = portTypeOf(f.behavior.graph, { nodeId: node.id, port: 'value', isOutput: true }, lookup)!; - const inp = portTypeOf(f.behavior.graph, { nodeId: join.id, port: 'text', isOutput: false }, lookup)!; + const out = portOf(f.behavior.graph, { nodeId: node.id, port: 'value', isOutput: true }, lookup)!.type; + const inp = portOf(f.behavior.graph, { nodeId: join.id, port: 'text', isOutput: false }, lookup)!.type; expect(portsCompatible(out, inp)).toBe(false); }); }); @@ -142,12 +142,12 @@ describe('Select, whose false, true and value carry the same value', () => { ]); const play = f.edit.addNode('PlayAnimation'); const lookup = graphLookup(f.project); - const out = portTypeOf(f.behavior.graph, { nodeId: node.id, port: 'value', isOutput: true }, lookup)!; - const inp = portTypeOf( + const out = portOf(f.behavior.graph, { nodeId: node.id, port: 'value', isOutput: true }, lookup)!.type; + const inp = portOf( f.behavior.graph, { nodeId: play.id, port: 'animation', isOutput: false }, lookup, - )!; + )!.type; expect(portsCompatible(out, inp)).toBe(true); }); }); @@ -291,9 +291,9 @@ describe('a type handed on through several nodes with a shared group (#210)', () ]); const lookup = () => graphLookup(f.project); const out = () => - portTypeOf(f.behavior.graph, { nodeId: second.id, port: 'value', isOutput: true }, lookup())!; + portOf(f.behavior.graph, { nodeId: second.id, port: 'value', isOutput: true }, lookup())!.type; const inp = () => - portTypeOf(f.behavior.graph, { nodeId: sub.id, port: 'text', isOutput: false }, lookup())!; + portOf(f.behavior.graph, { nodeId: sub.id, port: 'text', isOutput: false }, lookup())!.type; expect(portsCompatible(out(), inp())).toBe(true); f.edit.wire([ diff --git a/tests/core/song-play.test.ts b/tests/core/song-play.test.ts index b5cd9e2f..aa61aee0 100644 --- a/tests/core/song-play.test.ts +++ b/tests/core/song-play.test.ts @@ -8,8 +8,8 @@ import { CreateSoundCommand, SetAssetPropertyCommand, } from '../../src/core/commands/assetCommands'; -import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands'; -import { SetSongCommand, SetSongLoopCommand } from '../../src/core/commands/songCommands'; +import { SetLoopCommand, SetPatternNotesCommand } from '../../src/core/commands/patternCommands'; +import { SetSongCommand } from '../../src/core/commands/songCommands'; import type { InstrumentAsset, SongAsset } from '../../src/core/model/Asset'; import { goesBy, Playbacks, type SequencePlay } from '../../src/core/music/playbacks'; import { INTERVAL, LEAD, type ScheduledClip, type ScheduledNote } from '../../src/core/music/Scheduler'; @@ -230,7 +230,7 @@ function playingSong(params: Record = {}) { ], }), ); - f.stack.execute(new SetSongLoopCommand(id, { start: BAR, end: 2 * BAR })); + f.stack.execute(new SetLoopCommand('song', id, { start: BAR, end: 2 * BAR })); const behavior = makeBehavior(f, 'Logic'); const box = place(f, f.spriteId, { x: 0, y: 0 }); place(f, behavior.id, { x: 0, y: 0 }, box); diff --git a/tests/core/song.test.ts b/tests/core/song.test.ts index d4715c84..d057a7d6 100644 --- a/tests/core/song.test.ts +++ b/tests/core/song.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; -import { SetPatternNotesCommand } from '../../src/core/commands/patternCommands'; -import { SetSongCommand, SetSongLoopCommand } from '../../src/core/commands/songCommands'; +import { SetLoopCommand, SetPatternNotesCommand } from '../../src/core/commands/patternCommands'; +import { SetSongCommand } from '../../src/core/commands/songCommands'; import type { PatternAsset, SongAsset } from '../../src/core/model/Asset'; import { heldSongItems, @@ -118,7 +118,7 @@ describe('the song asset (#334)', () => { f.lay(f.item({ tick: 4 * BAR, length: 100 })); expect(songLength(f.song())).toBe(5 * BAR); expect(songLoop(f.song())).toEqual({ start: 0, end: 5 * BAR }); - f.stack.execute(new SetSongLoopCommand(f.song().id, { start: BAR, end: 2 * BAR })); + f.stack.execute(new SetLoopCommand('song', f.song().id, { start: BAR, end: 2 * BAR })); expect(songLoop(f.song())).toEqual({ start: BAR, end: 2 * BAR }); f.stack.undo(); expect(f.song().loop).toBeUndefined(); @@ -160,7 +160,7 @@ describe('the song asset (#334)', () => { const f = makeSong(); f.lay(f.item({ tick: BAR, length: BAR, transpose: -2, volume: -6 })); - f.stack.execute(new SetSongLoopCommand(f.song().id, { start: 0, end: BAR })); + f.stack.execute(new SetLoopCommand('song', f.song().id, { start: 0, end: BAR })); const back = roundTrip(f.project).assets.find((a) => a.id === f.song().id) as SongAsset; expect([back.tracks, back.items, back.loop]).toEqual([f.song().tracks, f.song().items, f.song().loop]); }); diff --git a/tests/core/soundfx.test.ts b/tests/core/soundfx.test.ts index 4a506354..87be99a3 100644 --- a/tests/core/soundfx.test.ts +++ b/tests/core/soundfx.test.ts @@ -15,7 +15,6 @@ import { assetRefChoices } from '../../src/core/properties/assetRef'; import { compileSoundFx, soundFxBuildFor } from '../../src/core/soundfx/compile'; import { dampingCutoff, - DRY, EMPTY_PLAN, type FxChain, fxChainOf, @@ -50,6 +49,9 @@ import { const FILE = `${'a1'.repeat(32)}.wav`; const OTHER_FILE = `${'b2'.repeat(32)}.wav`; +/** What a sound with no FX goes through. */ +const DRY: FxChain = { pitch: 0, shifted: false, stages: [] }; + /** The level of a gain or filter stage. */ const gainOf = (stage: FxStage | undefined) => (stage && 'gain' in stage ? stage.gain : undefined); diff --git a/tests/core/sounds.test.ts b/tests/core/sounds.test.ts index 9aad5136..dae05696 100644 --- a/tests/core/sounds.test.ts +++ b/tests/core/sounds.test.ts @@ -18,9 +18,9 @@ import { cutRange, FADE_CURVES, fadeGain, + findSound, isSoundFile, parseCutName, - soundFileOf, } from '../../src/core/model/sound'; import { assetRefChoices } from '../../src/core/properties/assetRef'; import { schemaFor } from '../../src/core/properties/PropertySchema'; @@ -175,13 +175,13 @@ describe('the sound asset', () => { it('resolves to a file, and to none when the id is unset, dangling or of another type', () => { const s = makeSound(); - expect(soundFileOf(s.project, s.sound.id)).toBe(FILE); - expect(soundFileOf(s.project, '')).toBe(''); - expect(soundFileOf(s.project, 'no-such-asset')).toBe(''); + expect(findSound(s.project, s.sound.id)?.asset.file).toBe(FILE); + expect(findSound(s.project, '')).toBeNull(); + expect(findSound(s.project, 'no-such-asset')).toBeNull(); const text = new CreateAssetCommand('text', 'Title'); s.stack.execute(text); - expect(soundFileOf(s.project, text.asset.id)).toBe(''); + expect(findSound(s.project, text.asset.id)).toBeNull(); }); it('is what a sound picker offers, and nothing else is', () => { diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index 55aa121e..7ab59016 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -20,7 +20,8 @@ import type { SequencePlay } from '../../src/core/music/playbacks'; import type { ScheduledNote } from '../../src/core/music/Scheduler'; import type { LfoOptions, PitchOptions } from '../../src/viewport/fxWorklet'; import { saturationCurve } from '../../src/core/soundfx/saturation'; -import { SoundLibrary, stageNodes } from '../../src/viewport/SoundLibrary'; +import { stageNodes } from '../../src/viewport/fxNodes'; +import { SoundLibrary } from '../../src/viewport/SoundLibrary'; /** What a test wants to hear: a chain whose pitch, where it is not 0, is a Pitch typed in. */ type Chain = @@ -1410,7 +1411,7 @@ describe('notes of an instrument (#331)', () => { sample(OTHER, { loop: true, loopEnd: 1 }), ]); library.release(voice); - // The first leaves its loop and is not faded; the second, a plain Loop, fades out as before. + // The first leaves its loop and is not faded; the second, a plain Loop, fades out. expect(sources.map((source) => source.loop)).toEqual([false, true]); expect(gains[1]!.ramps).toEqual([]); expect(gains[2]!.ramps).toEqual([['linear', 0, 0.01]]); diff --git a/tools/e2e/01-hierarchy.mjs b/tools/e2e/01-hierarchy.mjs index dc5414e6..805c224f 100644 --- a/tools/e2e/01-hierarchy.mjs +++ b/tools/e2e/01-hierarchy.mjs @@ -54,7 +54,7 @@ export async function hierarchy(t) { same(pingSelection.objectIds, ['o3']), JSON.stringify({ afterFirst, ping, o3Asset, pingSelection }), ); - // A double-click opens the asset, selected or not – it no longer folds the row. + // A double-click opens the asset, selected or not, and leaves the row folded as it was. const o2Asset = await assetOf('o2'); const row = await json(`__gm.hierarchyRow('o2')`); await doubleClick(row.x, row.y); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 6855655a..864d5712 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -809,7 +809,7 @@ export async function sound(t) { JSON.stringify(offline), ); // #327: the player's own echo, rendered offline. A 10 ms burst into a ping-pong Delay (0.3 s, - // feedback 0.5, wet 0.25 – the mix 0.5 of before #354) comes back left at 0.3 s at half its level, right at 0.6 s at + // feedback 0.5, mix 0.25) comes back left at 0.3 s at half its level, right at 0.6 s at // half of that, left again at 0.9 s – in Chrome each round 128 samples later than that. Then // 64 stereo voices of it for 2 s, timed, for how many this machine plays at once (#324). const echo = await evaluate(` diff --git a/tools/e2e/64-pattern.mjs b/tools/e2e/64-pattern.mjs index 1b5709b4..c9ea1052 100644 --- a/tools/e2e/64-pattern.mjs +++ b/tools/e2e/64-pattern.mjs @@ -4,13 +4,14 @@ import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs'; /** * Patterns and their piano roll (#333). "+ → Pattern" opens the tab; with an instrument picked its * keys sound where the instrument plays. A click draws a note on the grid, either end sizes it and - * lights up under the pointer, its body moves it – with Alt off the grid –, a band picks notes, Ctrl+D puts a copy behind - * them, the arrows transpose, Delete and a right-click take notes away, Ctrl+C/V copies them to the - * start set on the ruler, the lane sets a velocity, a drag on the ruler moves the start and one on - * the strip above it sets the loop, the wheel zooms the time or the rows. Space plays the - * pattern round its loop through the scheduler, and a note drawn meanwhile is planned without a - * restart. A game plays it through Play pattern, looped or once. Nothing is heard in CI (muted browser): the witnesses are the model, the planned notes and - * the sound counters, as in e2e 34. + * lights up under the pointer, its body moves it – with Alt off the grid –, a band picks notes, + * Ctrl+D puts a copy behind them, the arrows transpose, Delete and a right-click take notes away, + * Ctrl+C/V copies them to the start set on the ruler, the lane sets a velocity, a drag on the ruler + * moves the start and one on the strip above it sets the loop, the wheel zooms the time or the + * rows. Space plays the pattern round its loop through the scheduler, and a note drawn meanwhile is + * planned without a restart. A game plays it through Play pattern, looped or once. Nothing is + * heard in CI (muted browser): the witnesses are the model, the planned notes and the sound + * counters, as in e2e 34. * * Everything it adds carries a `pt64` id or is the pattern it made, and goes again at the end. */ @@ -373,7 +374,7 @@ export async function pattern(t) { countAbove === countPanned, JSON.stringify({ panned, pannedDown, area, countPanned, countLeft, countAbove }), ); - // The notes span few keys: Fit zooms the rows past the old default of 14 px to fill the height. + // The notes span few keys: Fit zooms the rows past the default of 14 px to fill the height. check( 'Fit zooms the rows too, so the notes fill the height', fitted.rowHeight > 14, diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index 743cd2fa..f7539b0e 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -3,15 +3,15 @@ import { LANES, pickAssetType, scaffoldBehavior } from './lib/scaffold.mjs'; /** * Songs and their tracks (#334). "+ → Song" opens the tab on one empty track, a head's menu adds - * three more. A pattern and a - * sound dragged in from the Assets panel land as items where the preview showed them; an item's - * right end lengthens it, its body moves it across tracks, its left end cuts it back or pulls it - * out earlier, an end snaps to the end of a round, Ctrl+V lands under the pointer, Ctrl+D puts a - * copy behind it, Ctrl+E splits it where playing starts, Delete or its menu takes it away. A click - * on nothing picks the song, an end under the pointer lights up, a long item marks its rounds, the - * wheel zooms tracks or time, the strip above the ruler sets the loop. A head mutes, solos and sets - * the volume of its track, a double-click sets a slider back to 0, its menu adds one. Space plays the song through the scheduler: - * the pattern round and round at the song's tempo, the sound as a clip, each track on its own bus. + * three more. A pattern and a sound dragged in from the Assets panel land as items where the + * preview showed them; an item's right end lengthens it, its body moves it across tracks, its left + * end cuts it back or pulls it out earlier, an end snaps to the end of a round, Ctrl+V lands under + * the pointer, Ctrl+D puts a copy behind it, Ctrl+E splits it where playing starts, Delete or its + * menu takes it away. A click on nothing picks the song, an end under the pointer lights up, a long + * item marks its rounds, the wheel zooms tracks or time, the strip above the ruler sets the loop. A + * head mutes, solos and sets the volume of its track, a double-click sets a slider back to 0, its + * menu adds one. Space plays the song through the scheduler: the pattern round and round at the + * song's tempo, the sound as a clip, each track on its own bus. * A pattern started meanwhile takes over, and a double-click opens a pattern item in its tab. Record * takes chromium's test tone onto a track of its own (#342), Esc throws a take away. A right-click * on an empty spot of a track makes a pattern the song keeps to itself (#355). @@ -355,7 +355,7 @@ export async function song(t) { ); // 65f-ii. A click on nothing picks the song; a sound item turns up to +12 dB in the inspector, which - // has no delete button any more. An end of an item under the pointer shows it can be dragged, + // has no delete button. An end of an item under the pointer shows it can be dragged, // and an item longer than what it plays marks where it starts over. p = await json('__gm.song.laneAt(300, 3)'); await click(p.x, p.y);