diff --git a/src/core/instrument/compile.ts b/src/core/instrument/compile.ts index a229af3f..07af1897 100644 --- a/src/core/instrument/compile.ts +++ b/src/core/instrument/compile.ts @@ -255,6 +255,7 @@ function throughFx(fx: FxPlan, sound: FxPlan, given: Readonly bindStage(stage, bind)); if ('mix' in fx) return { + ...fx, mix: bind(fx.mix), a: throughFx(fx.a, sound, given), b: throughFx(fx.b, sound, given), diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index f8a75240..303d6d96 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -48,11 +48,15 @@ export interface FxSource { * passes, in order. `gain` is a factor, a filter's `gain` is in dB as Web Audio takes it. `shifted` * says the way has a Pitch at all – at 0 too, when that comes from a parameter – and so is played * by the pitch worklet (#317). Behind a Mix (#316) it is two sounds, `b` making up the share `mix` - * of what goes on through `stages`. + * of what goes on through `stages` – or with the curve `both` each one full up to the middle and + * only fading on the other half. */ export type FxChain = | { pitch: number; shifted: boolean; stages: readonly FxStage[]; source?: FxSource } - | { mix: number; a: FxChain; b: FxChain; stages: readonly FxStage[] }; + | { mix: number; curve?: MixCurve; a: FxChain; b: FxChain; stages: readonly FxStage[] }; + +/** 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: [] }; @@ -85,7 +89,7 @@ export type FxStagePlan = /** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */ export type FxPlan = | { pitch: readonly FxAmount[]; stages: readonly FxStagePlan[]; source?: FxSource } - | { mix: FxAmount; a: FxPlan; b: FxPlan; stages: readonly FxStagePlan[] }; + | { mix: FxAmount; curve?: MixCurve; a: FxPlan; b: FxPlan; stages: readonly FxStagePlan[] }; export const EMPTY_PLAN: FxPlan = { pitch: [], stages: [] }; @@ -261,23 +265,35 @@ export const SoundFxEq: SoundFxNodeDef = { }; const MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 0.5 }; +const MIX_CURVE = { + key: 'curve', + label: 'Curve', + kind: 'enum' as const, + default: 'crossfade', + options: [ + { value: 'crossfade', label: 'Crossfade' }, + { value: 'both', label: 'Both at center' }, + ], +}; export const SoundFxMix: SoundFxNodeDef = { type: 'SoundFxMix', label: 'Mix', category: 'sound', description: - 'Blends two sounds: at 0 only a is heard, at 1 only b, at 0.5 half of each. Both ways can start at the same Input, each with its own effects, its own Pitch included.', + 'Blends two sounds: at 0 only a is heard, at 1 only b. With the curve "Crossfade" 0.5 is half of each. With "Both at center" each stays full up to the middle and only fades on the other half: 0.25 is a full and b half, 0.5 both full, added as two sounds on top of each other. Both ways can start at the same Input, each with its own effects, its own Pitch included.', inputs: [{ key: 'a', label: 'a', type: 'audio' }, { key: 'b', label: 'b', type: 'audio' }, numberIn(MIX)], outputs: [audioOut], - params: [], - emit: (sounds, amounts) => { + params: [MIX_CURVE], + emit: (sounds, amounts, settings) => { const mix = held(amounts.mix, MIX); const a = sounds.a ?? EMPTY_PLAN; const b = sounds.b ?? EMPTY_PLAN; if (mix.param === undefined && mix.value <= 0) return a; if (mix.param === undefined && mix.value >= 1) return b; - return { mix, a, b, stages: [] }; + return settings[MIX_CURVE.key] === 'both' + ? { mix, curve: 'both', a, b, stages: [] } + : { mix, a, b, stages: [] }; }, }; @@ -397,6 +413,7 @@ export function fxChainOf(plan: FxPlan, params: FxParams = {}, tempo = DEFAULT_T } return { mix: amountOf(plan.mix, params), + ...(plan.curve === 'both' && { curve: plan.curve }), a: fxChainOf(plan.a, params, tempo), b: fxChainOf(plan.b, params, tempo), stages, diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 00f1add2..f9017da1 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -4,7 +4,7 @@ import type { SchedulerOptions } from '../core/music/Scheduler'; import type { Sequence } from '../core/music/sequence'; import type { FxStage } from '../core/soundfx/nodes'; import { Music } from '../viewport/music'; -import { soundLibrary, stageNodes } from '../viewport/SoundLibrary'; +import { bothAtCenter, soundLibrary, stageNodes } from '../viewport/SoundLibrary'; import { curveHooks } from './debug/curveHooks'; import { documentHooks } from './debug/documentHooks'; import { graphHooks } from './debug/graphHooks'; @@ -58,6 +58,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E stop: (file: string, letRing: boolean) => soundLibrary.stop(file, letRing), /** The nodes a stage of a voice becomes, in any context – an offline one renders what it does. */ stageNodes: (context: BaseAudioContext, stage: FxStage) => stageNodes(context, stage), + /** What drives the two shares of a Mix "Both at center", in any context (unstarted). */ + bothAtCenter: (context: BaseAudioContext, mix: number, a: GainNode, b: GainNode) => + bothAtCenter(context, mix, a, b), }, /** * The scheduler (#332), which nothing in the editor plays yet: a check hands it a sequence of diff --git a/src/viewport/SoundLibrary.ts b/src/viewport/SoundLibrary.ts index e1178a0f..df3584b7 100644 --- a/src/viewport/SoundLibrary.ts +++ b/src/viewport/SoundLibrary.ts @@ -70,6 +70,8 @@ interface Voice { /** The behavior this voice belongs to; `''` for one nothing owns (see `AudioSink.play`). */ owner: string; sources: Source[]; + /** What carries a number of it on the audio thread (a Mix "Both at center"): stopped when it goes. */ + drivers: ConstantSourceNode[]; /** Calls the end of one source in, once however it comes: by itself or by a release. */ finish: ((source: Source) => void) | null; gain: GainNode; @@ -108,6 +110,7 @@ interface Wiring { loop: boolean; rings: boolean; sources: Source[]; + drivers: ConstantSourceNode[]; pitches: number[]; knobs: Knob[]; stages: number; @@ -180,6 +183,8 @@ export class SoundLibrary implements AudioSink { private started = 0; private stopped = 0; private tuned = 0; + /** Second of the audio clock the last change moved a playing voice: a timed one runs from there. */ + private tunedAt: number | null = null; /** The last voice actually started, with the values it was started at – what a check reads back. */ private last: LastVoice | null = null; /** @@ -311,7 +316,7 @@ export class SoundLibrary implements AudioSink { const voice = this.voices.find((other) => other.id === id); if (!voice) return false; voice.fx = { ...voice.fx, params: { ...voice.fx.params, ...params } }; - this.glide(voice, time, ease); + if (this.glide(voice, time, ease)) this.tunedAt = this.audio().currentTime; // A longer feedback makes for a longer tail: it is counted again from where the change ends. if (voice.ringing) this.ringFor(voice, Math.max(0, time) + this.tailOf(voice.fx)); this.tuned += 1; @@ -379,6 +384,7 @@ export class SoundLibrary implements AudioSink { started: number; stopped: number; tuned: number; + tunedAt: number | null; decoded: number; failed: number; /** Whether the pitch worklet is there; null while nothing has asked for it or it is loading. */ @@ -390,6 +396,7 @@ export class SoundLibrary implements AudioSink { started: this.started, stopped: this.stopped, tuned: this.tuned, + tunedAt: this.tunedAt, decoded: this.buffers.size, failed: this.failed.size, worklet: this.canPitch, @@ -514,6 +521,7 @@ export class SoundLibrary implements AudioSink { const context = this.audio(); const done = () => { for (const source of voice.sources) source.stop(); + for (const driver of voice.drivers) driver.stop(); voice.gain.disconnect(); }; // A suspended clock would hold the fade – and the sound – until the next run resumes it. @@ -554,6 +562,7 @@ export class SoundLibrary implements AudioSink { /** A voice whose sound and echoes have run out: out of the count, and its nodes let go. */ private done(voice: Voice): void { this.forget(voice); + for (const driver of voice.drivers) driver.stop(); // An echo is a loop of nodes; left connected it would keep the audio thread busy for nothing. if (voice.rings) voice.gain.disconnect(); } @@ -612,6 +621,7 @@ export class SoundLibrary implements AudioSink { loop, rings, sources: [], + drivers: [], pitches: [], knobs: [], stages: 0, @@ -634,6 +644,7 @@ export class SoundLibrary implements AudioSink { file, owner, sources, + drivers: wiring.drivers, finish: null, gain, fx, @@ -649,6 +660,7 @@ export class SoundLibrary implements AudioSink { for (const source of sources) source.ended(() => voice.finish?.(source)); this.voices.push(voice); this.started += 1; + for (const driver of wiring.drivers) driver.start(); for (const source of sources) source.start(when); if (duration > 0) this.release(id, when + duration); } @@ -663,14 +675,20 @@ export class SoundLibrary implements AudioSink { let tail: AudioNode; if ('mix' in chain) { tail = context.createGain(); + const both = chain.curve === 'both'; const wayA = this.wire(wiring, chain.a); - const shareA = gainNode(context, 1 - chain.mix); + const shareA = gainNode(context, both ? 0 : 1 - chain.mix); wayA.connect(shareA).connect(tail); - knobs.push({ param: shareA.gain, target: 1 - chain.mix, even: 'value' }); + if (!both) knobs.push({ param: shareA.gain, target: 1 - chain.mix, even: 'value' }); const wayB = this.wire(wiring, chain.b); - const shareB = gainNode(context, chain.mix); + const shareB = gainNode(context, both ? 0 : chain.mix); wayB.connect(shareB).connect(tail); - knobs.push({ param: shareB.gain, target: chain.mix, even: 'value' }); + if (!both) knobs.push({ param: shareB.gain, target: chain.mix, even: 'value' }); + else { + const driver = bothAtCenter(context, chain.mix, shareA, shareB); + knobs.push({ param: driver.offset, target: chain.mix, even: 'value' }); + wiring.drivers.push(driver); + } } else tail = this.source(wiring, chain); for (const stage of chain.stages) { const made = stageNodes(context, stage); @@ -772,18 +790,21 @@ export class SoundLibrary implements AudioSink { /** * Sends every knob of a voice towards what its values say now, in `time` seconds of the audio - * clock along `ease`, or else in a quick glide. + * clock along `ease`, or else in a quick glide. Says whether any of them had somewhere to go. */ - private glide(voice: Voice, time: number, ease: EaseShare): void { + private glide(voice: Voice, time: number, ease: EaseShare): boolean { const levels: number[] = []; for (const plan of voice.fx.layers) levelsOf(fxChainOf(plan, voice.fx.params, voice.fx.tempo), levels); const now = this.audio().currentTime; + let moved = false; voice.knobs.forEach((knob, i) => { const level = levels[i]; if (level === undefined || level === knob.target) return; knob.target = level; + moved = true; if (knob.param) move(knob.param, level, now, time, knob.even === 'ratio', ease); }); + return moved; } private audio(): AudioContext { @@ -940,6 +961,36 @@ function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes { }; } +/** 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; +} + /** Sends a level to silence in `FADE` seconds from wherever it is now. */ function fadeOut(param: AudioParam, now: number): void { param.cancelScheduledValues(now); @@ -999,7 +1050,11 @@ function easedCurve( /** 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) { + 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); diff --git a/tests/core/instrument.test.ts b/tests/core/instrument.test.ts index 7826034b..4905fc7c 100644 --- a/tests/core/instrument.test.ts +++ b/tests/core/instrument.test.ts @@ -403,6 +403,34 @@ describe('compiling a note', () => { expect(callProblem(through, lookup, f.instrument.id)).toBe('No sound FX picked'); }); + it('keeps the curve of a Mix inside a sound FX it sends a sound through', () => { + const f = makeInstrument(); + nodeOf(f, 'InstrumentSample').params.sound = f.sound('Piano', KICK).id; + // The FX: its Input on both sides of a Mix "Both at center", then the Output. + const made = new CreateAssetCommand('soundfx', 'Wide'); + f.stack.execute(made); + const fx = made.asset as SoundFxAsset; + const fxEdit = graphEditor(f, fx as unknown as BehaviorAsset); + const input = fxEdit.addNode('SoundFxInput'); + const mix = fxEdit.addNode('SoundFxMix', { curve: 'both' }); + const fxOut = fxEdit.addNode('SoundFxOutput'); + fxEdit.wire([ + [input, 'out', mix, 'a'], + [input, 'out', mix, 'b'], + [mix, 'out', fxOut, 'in'], + ]); + const sample = nodeOf(f, 'InstrumentSample'); + const output = nodeOf(f, 'InstrumentOutput'); + f.instrument.graph.edges = f.instrument.graph.edges.filter((e) => e.to.node !== output.id); + const through = f.edit.addNode('InstrumentSoundFx', { fx: fx.id }); + f.edit.wire([ + [sample, 'out', through, 'in'], + [through, 'out', output, 'in'], + ]); + const [layer] = compile(f, 60).layers; + expect(layer).toMatchObject({ mix: { value: 0.5 }, curve: 'both', a: { source: { file: KICK } } }); + }); + it('says what keeps it from compiling: a Sample nothing triggers, a sound that is gone', () => { const f = makeInstrument(); const sample = nodeOf(f, 'InstrumentSample'); diff --git a/tests/core/soundfx.test.ts b/tests/core/soundfx.test.ts index 3e3d60bb..496ca4b3 100644 --- a/tests/core/soundfx.test.ts +++ b/tests/core/soundfx.test.ts @@ -230,6 +230,15 @@ describe('a Mix in a sound FX graph (#316)', () => { expect(b).toMatchObject({ pitch: 0, stages: [{ kind: 'gain' }, { kind: 'filter' }] }); }); + it('says its curve only where it is "Both at center", and is still one sound at 0 or 1', () => { + expect(fxChainOf(compileSoundFx(forked().fx.graph).compiled!)).not.toHaveProperty('curve'); + const f = forked(); + f.mix.params.curve = 'both'; + expect(fxChainOf(compileSoundFx(f.fx.graph).compiled!)).toMatchObject({ mix: 0.5, curve: 'both' }); + f.mix.params.mix = 0; + expect(fxChainOf(compileSoundFx(f.fx.graph).compiled!)).toMatchObject({ pitch: 12 }); + }); + it('takes its share from a parameter, held between 0 and 1, and keeps both ways for it (#315)', () => { const f = forked(); f.input.ports = [{ key: 'blend', kind: 'number', default: 0.25 }]; diff --git a/tests/viewport/sound-library.test.ts b/tests/viewport/sound-library.test.ts index 4ae41d58..21f99d44 100644 --- a/tests/viewport/sound-library.test.ts +++ b/tests/viewport/sound-library.test.ts @@ -104,7 +104,16 @@ const disconnects: string[] = []; * The silent sources the library times things on the audio clock with (#324): when each was told to * stop, and whether it has. `clockTo` lets the clock run. */ -const clocks: { stopAt: number; fired: boolean; onended: (() => void) | null }[] = []; +const clocks: { + offset: FakeParam; + stopAt: number; + fired: boolean; + started: boolean; + stopped: boolean; + onended: (() => void) | null; +}[] = []; +/** The curve of every WaveShaper made, in order. */ +const shapers: { curve: Float32Array | null }[] = []; let contextState = 'running'; /** Runs the audio clock up to `until`, ending each timer on the way in order, also ones set meanwhile. */ @@ -210,18 +219,28 @@ function fakeContext() { createChannelSplitter: () => wired('splitter', {}), createConstantSource: () => { const clock = wired('clock', { - offset: { value: 1 }, + offset: param(1), stopAt: Infinity, fired: false, + started: false, + stopped: false, onended: null as (() => void) | null, - start: () => {}, - stop: (at: number) => { - clock.stopAt = at; + start: () => { + clock.started = true; + }, + stop: (at?: number) => { + clock.stopAt = at ?? Infinity; + clock.stopped = true; }, }); clocks.push(clock); return clock; }, + createWaveShaper: () => { + const shaper = wired('shaper', { curve: null as Float32Array | null }); + shapers.push(shaper); + return shaper; + }, }; } @@ -239,6 +258,7 @@ beforeEach(() => { delays.length = 0; disconnects.length = 0; clocks.length = 0; + shapers.length = 0; contextState = 'running'; decodes = []; vi.stubGlobal( @@ -533,6 +553,60 @@ describe('the sound library', () => { expect(wires.filter((w) => w.startsWith('source'))).toEqual(['source > gain', 'source > gain']); }); + describe('a Mix with the curve "Both at center"', () => { + /** Two plain ways, the share open as `blend`. */ + const both = (): FxVoice => ({ + plan: { + mix: { value: 0.25, param: 'blend', min: 0, max: 1 }, + curve: 'both', + a: { pitch: [], stages: [] }, + b: { pitch: [], stages: [] }, + stages: [], + }, + params: {}, + }); + + it('shapes both shares from one source carrying the mix, so a glide follows the curve', async () => { + const library = new SoundLibrary(); + const voice = await play(library, FILE, {}, both()); + // The voice, the Mix, share of a, share of b: the shares hold nothing of their own. + const [, , shareA, shareB] = gains; + expect([shareA!.value, shareB!.value]).toEqual([0, 0]); + expect(shapers.map((s) => [...s.curve!])).toEqual([ + [1, 1, 1, 1, 0], + [0, 0, 0, 1, 1], + ]); + expect(wires.filter((w) => w.startsWith('clock') || w.startsWith('shaper'))).toEqual([ + 'clock > shaper', + 'shaper > ?', + 'clock > shaper', + 'shaper > ?', + ]); + const [driver] = clocks; + expect(driver).toMatchObject({ started: true, stopped: false, offset: { value: 0.25 } }); + expect(library.levels(FILE)).toEqual([[0.25]]); + + expect(library.tune(voice, { blend: 0.75 })).toBe(true); + expect(driver!.offset.glides).toEqual([0.75]); + expect([shareA!.glides, shareB!.glides]).toEqual([[], []]); + }); + + it('stops that source with the voice, cut or ended by itself', async () => { + const library = new SoundLibrary(); + await play(library, FILE, {}, both()); + library.stop(FILE); + expect(clocks[0]!.stopped).toBe(false); + clockTo(); + expect(clocks[0]!.stopped).toBe(true); + + await play(library, FILE, {}, both()); + const driver = clocks.find((c) => !c.stopped && c.started && c.offset.value === 0.25)!; + for (const source of sources.slice(-2)) source.onended?.(); + expect(library.stats().playing).toBe(0); + expect(driver.stopped).toBe(true); + }); + }); + describe('a parameter changed while the voice plays (#315)', () => { it('glides every knob it moves, a pitch in semitones, and leaves the rest alone', async () => { const library = new SoundLibrary(); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 58c60d72..084f61fa 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -578,8 +578,9 @@ export async function sound(t) { await play(); const pitchNow = () => json(`({ pitch: __gm.sound.levels(${JSON.stringify(blip.file)})?.[0]?.[0], clock: __gm.sound.clock() })`); - const sinceStart = async (seen) => { - const from = (await stats()).last.at; + const sinceStart = async (seen, since = 'start') => { + const now = await stats(); + const from = since === 'tune' ? now.tunedAt : now.last.at; return seen .filter((s) => typeof s.pitch === 'number') .map((s) => ({ t: +(s.clock - from).toFixed(3), pitch: +s.pitch.toFixed(3) })); @@ -596,12 +597,16 @@ export async function sound(t) { ); await stop(); // Step holds the pitch for the whole time and then jumps there: every reading before 2 s on the - // audio clock is still 7, the first one past it is the end. + // audio clock is still 7, the first one past it is the end. Timed from the change, not from the + // start of the voice: task 529 saw it arrive 80 ms later and the 7 held, rightly, to 2.08 s. await mutate(` const tune = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph.nodes.find((n) => n.id === 'sn-tune'); Object.assign(tune.params, { ease: 'step', time: 2 });`); await play(); - const steps = await sinceStart(await trace(pitchNow, (s) => typeof s.pitch === 'number' && s.pitch < 6)); + const steps = await sinceStart( + await trace(pitchNow, (s) => typeof s.pitch === 'number' && s.pitch < 6), + 'tune', + ); const held = steps.filter(({ pitch }) => pitch === 7); const jumped = steps.find(({ pitch }) => pitch < 6); check( @@ -750,6 +755,50 @@ export async function sound(t) { [echo.late, echo.latePlain].every((late) => late.every((n, k) => n === 0 || n === k * 128)), JSON.stringify(echo), ); + // A Mix "Both at center", rendered offline: a steady 1 on a and 0.5 on b in two channels, so each + // share reads off as it is. Then mix glides 0 → 1 over a second: at its middle both stay full. + const atCenter = await evaluate(` + (async () => { + const RATE = 48000; + const render = async (mix, glide) => { + const context = new OfflineAudioContext(2, RATE, RATE); + const merger = context.createChannelMerger(2); + merger.connect(context.destination); + const shareA = context.createGain(); + const shareB = context.createGain(); + shareA.gain.value = 0; + shareB.gain.value = 0; + const driver = __gm.sound.bothAtCenter(context, mix, shareA, shareB); + for (const [share, level, side] of [[shareA, 1, 0], [shareB, 0.5, 1]]) { + const steady = context.createConstantSource(); + steady.offset.value = level; + steady.connect(share).connect(merger, 0, side); + steady.start(); + } + if (glide) driver.offset.setValueAtTime(0, 0).linearRampToValueAtTime(1, 1); + driver.start(); + const out = await context.startRendering(); + const at = (s) => [out.getChannelData(0)[Math.round(s * RATE)], out.getChannelData(1)[Math.round(s * RATE)] * 2]; + return glide ? [0.25, 0.5, 0.75].map(at) : at(0.5); + }; + const round = (pair) => pair.map((v) => Math.round(v * 1000) / 1000); + const fixed = {}; + for (const mix of [0, 0.25, 0.5, 0.75, 1]) fixed[mix] = round(await render(mix, false)); + return { fixed, glide: (await render(0, true)).map(round) }; + })()`); + const pairNear = (pair, a, b) => near(pair[0], a) && near(pair[1], b); + check( + 'a Mix "Both at center" keeps a full up to the middle and b full from it, also while mix glides', + pairNear(atCenter.fixed[0], 1, 0) && + pairNear(atCenter.fixed[0.25], 1, 0.5) && + pairNear(atCenter.fixed[0.5], 1, 1) && + pairNear(atCenter.fixed[0.75], 0.5, 1) && + pairNear(atCenter.fixed[1], 0, 1) && + pairNear(atCenter.glide[0], 1, 0.5) && + pairNear(atCenter.glide[1], 1, 1) && + pairNear(atCenter.glide[2], 0.5, 1), + JSON.stringify(atCenter), + ); await mutate(` const logic = __gm.state.project.assets.find((a) => a.id === 'sn-logic'); logic.variables = [];