Refactoring round 10: split SoundLibrary, fold sound duplicates (#85)
Refactoring round 10 covers everything since round 9 (808e1ee): 110 commits with about 47 000 new lines, nearly all of them sound, sound FX, instruments, patterns and songs. Behaviour is unchanged. Duplicate code (jscpd over src, 50 tokens, TypeScript only, measured on808e1eeand here alike: 47 clones / 426 lines before, 33 / 245 after; round 9 itself ended at 22 / 160 by this count): - PreviewParams (new, editor/soundfx) is the header's parameter bar of the Sound FX and the Instrument tab: values, defaults, fields and the voices a moved field reaches. Both tabs had it line for line. - fxWorklet: an Effect base runs the bit crusher, phaser, flanger, limiter, LFO and bus shifter, a Source base the pitch player and the Chip. Start is the one wait for a voice's first sample, which four processors wrote out each. - nodes/shared has checkPort, detachedPort, fxParamsPort and letRingPort. The Play and Stop nodes of sounds, notes, patterns and songs spelled each port out, up to five times. - parameterValues builds both Sound FX parameters and Instrument parameters, which differed in name and text only. - SetLoopCommand sets the ruler loop of a pattern or a song. It replaces SetPatternLoopCommand and SetSongLoopCommand. - addLink in music/routing is the one rule for a link that both the song routing and the Master routing keep. - eqCurves works out each band and their sum for the EQ tab, which did that in its drawing code. It replaces eqCurveDb, which only a test called. - Smaller ones: the eight neighbour directions of a tile cell, and drawFloatingBox for the shadowed box of Popup and FloatWindow. - Left on purpose: Play pattern and Play song stay two parallel node definitions, and the smaller clones under ten lines stay. Architecture: - SoundLibrary.ts goes from 2897 to 2188 lines. What a stage of a sound FX becomes as Web Audio nodes, and how the knobs of a playing voice move, is now viewport/fxNodes.ts. None of it keeps state of the library. Dead code (TS language service, references per export and member): - soundRefIds, LFO_SHAPE_INDEX, TREMOLO_ROWS, VIBRATO_ROWS and REVERB_ROWS had no reader. The four routing node defs are registered without an unused exported name. - soundFileOf and portTypeOf were called by tests only. The tests now ask findSound and portOf, which the editor uses. DRY moves from src into the one test that uses it. - 51 values used only in their own file lose their `export`. Types and the node catalogues stay exported. - Kept on purpose: neighboursOf is the cell by cell reading the indexed neighboursIn is checked against, and PitchShifter.latency is the test's one view of how late the shifter is. Comments and docs: - Seven comments told history ("no longer", "any more", "as before", "of before #354"). Nine comment blocks ran far past the line width. Proof that this changed no code: the bundle was md5 identical, and the touched tests and e2e modules were identical after esbuild. - README: sound FX, instrument, pattern, song and bus in the asset list, the pages Instruments, Patterns and Songs, 1919 checks. - Wiki: see its own commit. Tests: Gate (melektaus-atx) 274 files, 4236 tests, CHECK EXIT 0 e2e 34, 64, 65 ALL PASSED, 180 checks Unit, touched areas 16 files, 804 tests No check was added, the e2e sum stays at 1919. Counter-checks, each put back by copy and compared with cmp: - addLink without the loop rule: 2 tests fail, one per routing. - eqCurves with the sum of the last band only: 1 test fails. - Start in the worklet never waiting: e2e 34 fails in exactly the four checks that hang on a voice's first sample (Flanger, Tremolo, Chip, a pitched note planned ahead). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
76f5097a38
commit
25084f8576
72 files changed
+1347
-1436
No files matched your search
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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')],
|
||||
|
||||
@@ -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')],
|
||||
|
||||
@@ -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')],
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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:
|
||||
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.',
|
||||
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 } };
|
||||
},
|
||||
};
|
||||
INSTRUMENT_SETTING,
|
||||
);
|
||||
@@ -1,22 +1,28 @@
|
||||
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',
|
||||
export function parameterValues(
|
||||
type: string,
|
||||
label: string,
|
||||
description: string,
|
||||
setting: PropertyDef,
|
||||
): NodeDef {
|
||||
return {
|
||||
type,
|
||||
label,
|
||||
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.',
|
||||
description,
|
||||
inputs: [],
|
||||
outputs: [{ key: 'params', label: 'params', type: 'fxparams' }],
|
||||
params: [SOUND_FX_SETTING],
|
||||
params: [setting],
|
||||
declaresPorts: 'inputs',
|
||||
portKinds: ['number'],
|
||||
declaredPort: (port, node, _graph, lookup) => {
|
||||
@@ -35,3 +41,15 @@ export const SoundFxParameters: NodeDef = {
|
||||
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,
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Record<string, { octaves: number } | { by: number }>> = {
|
||||
frequency: { octaves: 0.25 },
|
||||
minFrequency: { octaves: 0.25 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' },
|
||||
|
||||
+2
-12
@@ -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<string, string>): 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';
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
const lost = new Set<string>();
|
||||
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 => ({
|
||||
|
||||
+23
-17
@@ -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<GraphEdge, 'send'>): 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<string>();
|
||||
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' } });
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<LfoShape, number> = { 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
|
||||
|
||||
+10
-20
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ export function withAssetNames<S extends StoredFile>(
|
||||
}
|
||||
|
||||
/** 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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <name>" 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 <name>" 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') {
|
||||
|
||||
@@ -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<InstrumentAsset, GraphCanva
|
||||
/** The note Randomize, Mutate or Audition plays: the one last played. */
|
||||
lastNote = MIDDLE_C;
|
||||
private readonly octaveLabel = new Label('', { style: 'small' });
|
||||
private readonly paramBar = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
private readonly paramFields = new Map<string, NumberField>();
|
||||
private values: Record<string, number> = {};
|
||||
private defaults: Record<string, number> = {};
|
||||
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<InstrumentAsset, GraphCanva
|
||||
this.mutateButton,
|
||||
this.auditionButton,
|
||||
this.octaveLabel,
|
||||
this.paramBar,
|
||||
this.params.bar,
|
||||
]);
|
||||
this.syncOctave();
|
||||
state.on('load', () => {
|
||||
this.valuesFor = '';
|
||||
this.params.forget();
|
||||
this.heardFor = '';
|
||||
});
|
||||
}
|
||||
@@ -148,13 +131,11 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
|
||||
|
||||
/** What the header's parameter fields are set to – the check scripts read this. */
|
||||
get previewValues(): Record<string, number> {
|
||||
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<InstrumentAsset, GraphCanva
|
||||
asset.id,
|
||||
struck,
|
||||
PREVIEW_OWNER,
|
||||
{ ...this.values },
|
||||
this.params.current,
|
||||
// Straight into the master: a preview leaves the routing of the instrument out (#368).
|
||||
{ bus: MASTER_MIX },
|
||||
fxVoiceOf(this.state.project, instrumentFx(this.state.project, asset.id, ''), DEFAULT_TEMPO),
|
||||
);
|
||||
if (voice) this.voices.push(voice);
|
||||
this.params.heard(voice);
|
||||
this.playedFor = asset.id;
|
||||
return voice;
|
||||
}
|
||||
@@ -224,7 +205,7 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
|
||||
stop(): void {
|
||||
this.keyboard.releaseAll();
|
||||
soundLibrary.stopOwner(PREVIEW_OWNER);
|
||||
this.voices = [];
|
||||
this.params.silenced();
|
||||
this.keyboard.syncRinging();
|
||||
}
|
||||
|
||||
@@ -242,11 +223,6 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
|
||||
.sort((a, b) => 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<InstrumentAsset, GraphCanva
|
||||
this.keyboard.setEnabled(Boolean(asset));
|
||||
if (!asset) this.keyboard.releaseAll();
|
||||
this.keyboard.setSounding(new Set(this.soundingNotes()));
|
||||
this.syncParams(asset);
|
||||
this.params.sync(asset?.id ?? '', asset ? instrumentParams(asset.graph) : []);
|
||||
this.followEdits(asset);
|
||||
}
|
||||
|
||||
@@ -283,44 +259,6 @@ export class InstrumentEditor extends AssetTabEditor<InstrumentAsset, GraphCanva
|
||||
const octave = this.keyboard.bottomOctave;
|
||||
this.octaveLabel.setText(`Keys ${noteName((octave + 1) * 12)} to ${noteName((octave + 3) * 12 + 4)}`);
|
||||
}
|
||||
|
||||
/** As in the Sound FX tab: another asset starts from its defaults, an edit keeps a moved value. */
|
||||
private syncParams(asset: InstrumentAsset | null): void {
|
||||
const params = asset ? instrumentParams(asset.graph) : [];
|
||||
const same = (asset?.id ?? '') === this.valuesFor;
|
||||
this.valuesFor = asset?.id ?? '';
|
||||
const values: Record<string, number> = {};
|
||||
const defaults: Record<string, number> = {};
|
||||
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. */
|
||||
|
||||
@@ -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<V extends TimeView>(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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<number>(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);
|
||||
|
||||
@@ -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<string, NumberField>();
|
||||
private values: Record<string, number> = {};
|
||||
private defaults: Record<string, number> = {};
|
||||
private keys = '';
|
||||
private valuesFor = '';
|
||||
private voices: number[] = [];
|
||||
|
||||
/** What the fields are set to. */
|
||||
get current(): Record<string, number> {
|
||||
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<string, number> = {};
|
||||
const defaults: Record<string, number> = {};
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
@@ -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<SoundFxAsset, GraphCanvas> {
|
||||
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<string, NumberField>();
|
||||
/** The values the tab plays with – its own, never written into the asset. */
|
||||
private values: Record<string, number> = {};
|
||||
/** The defaults the values were taken from, so an edited default comes through and a moved value stays. */
|
||||
private defaults: Record<string, number> = {};
|
||||
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<SoundFxAsset, GraphCanvas> {
|
||||
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<SoundFxAsset, GraphCanvas> {
|
||||
|
||||
/** What the header's parameter fields are set to – the check scripts read this. */
|
||||
get previewValues(): Record<string, number> {
|
||||
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<SoundFxAsset, GraphCanvas> {
|
||||
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<SoundFxAsset, GraphCanvas> {
|
||||
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<string, number> = {};
|
||||
const defaults: Record<string, number> = {};
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -37,6 +37,7 @@ export {
|
||||
export {
|
||||
drawBand,
|
||||
drawControlBox,
|
||||
drawFloatingBox,
|
||||
drawGrid,
|
||||
drawHeaderBar,
|
||||
type ControlBoxOptions,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
-733
@@ -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<AudioParam, 'cancelAndHoldAtTime'> &
|
||||
Partial<Pick<AudioParam, 'cancelAndHoldAtTime'>>;
|
||||
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<string, AudioBuffer>();
|
||||
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<ArrayBuffer>, 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<ArrayBuffer>;
|
||||
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<AudioParam, 'cancelAndHoldAtTime'> &
|
||||
Partial<Pick<AudioParam, 'cancelAndHoldAtTime'>>;
|
||||
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();
|
||||
@@ -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<AudioParam, 'cancelAndHoldAtTime'> &
|
||||
Partial<Pick<AudioParam, 'cancelAndHoldAtTime'>>;
|
||||
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<string, AudioBuffer>();
|
||||
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<ArrayBuffer>, 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<ArrayBuffer>;
|
||||
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<AudioParam, 'cancelAndHoldAtTime'> &
|
||||
Partial<Pick<AudioParam, 'cancelAndHoldAtTime'>>;
|
||||
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);
|
||||
}
|
||||
+114
-162
@@ -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<T>(
|
||||
processor: new (options: { processorOptions: T }) => object,
|
||||
): void;
|
||||
|
||||
type ParamValues = Record<string, Float32Array>;
|
||||
|
||||
/** 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<string, Float32Array[]>();
|
||||
|
||||
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<string, Float32Array>,
|
||||
): 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<string, Float32Array>,
|
||||
): 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<string, Float32Array>,
|
||||
): 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<string, Float32Array>,
|
||||
): 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<string, Float32Array>,
|
||||
): 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<string, Float32Array>,
|
||||
): 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<string, Float32Array>,
|
||||
): 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<string, Float32Array>,
|
||||
): 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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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<string, string | number | boolean> = {}) {
|
||||
],
|
||||
}),
|
||||
);
|
||||
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);
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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]]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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,
|
||||
|
||||
+10
-10
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user