From d49ef1fef03e14053abbb4b0951173bf5ee86a45 Mon Sep 17 00:00:00 2001 From: pitpit Date: Fri, 5 Jun 2026 00:43:10 +0200 Subject: [PATCH 1/6] feat: add SequentialSwitch module for step-based control --- .github/instructions/lint.instructions.md | 16 +++ README.md | 1 + src/control/Gate.ts | 2 +- src/control/SequentialSwitch.ts | 63 ++++++++++++ src/control/SwitchOn.ts | 2 +- src/core/Mod.ts | 106 +++++++++++--------- src/core/Plug.ts | 116 +++++++++++++--------- src/core/Plugs.ts | 115 +++++++++++++++++++-- src/core/Rack.ts | 69 +++++++++++++ src/core/RackSerializer.ts | 2 + src/effect/Chorus.ts | 2 +- src/effect/Flanger.ts | 2 +- src/effect/Panner.ts | 2 +- src/effect/Phaser.ts | 2 +- src/effect/Tremolo.ts | 2 +- src/effect/Vibrato.ts | 2 +- src/filter/HighPassFilter.ts | 2 +- src/oscillator/Oscillator.ts | 2 +- src/output/Speaker.ts | 2 +- src/ui/Library.ts | 40 +++++--- synt.schema.json | 1 + tests/unit/core/mod.test.ts | 22 ++-- 22 files changed, 432 insertions(+), 141 deletions(-) create mode 100644 src/control/SequentialSwitch.ts diff --git a/.github/instructions/lint.instructions.md b/.github/instructions/lint.instructions.md index 7617d2e..2fa8783 100644 --- a/.github/instructions/lint.instructions.md +++ b/.github/instructions/lint.instructions.md @@ -46,6 +46,22 @@ const obj = { name, getValue() { return 1; } }; const obj = { name: name, getValue: function() { return 1; } }; ``` +## Unnecessary Conditions + +Do not write conditions that TypeScript can prove are always true or always false. This triggers `@typescript-eslint/no-unnecessary-condition`. + +```ts +// ✅ — only guard when the type is genuinely nullable +const slot: SlotPlugTypes | undefined = arr[row]?.[col]; +if (!slot) return; + +// ❌ — SlotPlugTypes is [symbol,symbol,symbol,symbol], never falsy +const slot: SlotPlugTypes = arr[row][col]; +if (!slot) continue; // always false — remove it +``` + +Also remove optional chaining (`?.`) when the left-hand side is already a non-nullable type. + ## Template Literals Use template literals instead of string concatenation. diff --git a/README.md b/README.md index 8111ad2..9aed566 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,7 @@ Step E: Making SoundPatch the Quantizer CV Out into the 1V/Oct Input of your Wav ### UX +- [ ] when double clicking on stage, it open the modal with to integer input to set the stage size - [ ] multi selection & multi drag'n drop for mass deletion? - [ ] on Rack, set a "pan" cursor when the cursor is hover the stage and can be pan - [ ] Add several layers to put more Mods? diff --git a/src/control/Gate.ts b/src/control/Gate.ts index 968309e..bd888de 100644 --- a/src/control/Gate.ts +++ b/src/control/Gate.ts @@ -6,7 +6,7 @@ import PlugType from '../core/PlugType'; export default class Gate extends EffectMod { constructor() { super(); - this.configure([PlugType.IN, PlugType.NULL, PlugType.OUT]); + this.configure([PlugType.IN, PlugType.NULL, PlugType.OUT, PlugType.NULL]); } protected createEffectNode(): ToneAudioNode { diff --git a/src/control/SequentialSwitch.ts b/src/control/SequentialSwitch.ts new file mode 100644 index 0000000..9fd1acc --- /dev/null +++ b/src/control/SequentialSwitch.ts @@ -0,0 +1,63 @@ +import Konva from 'konva'; +import Mod from '../core/Mod'; +import PlugType from '../core/PlugType'; +import ControlSignal from '../core/ControlSignal'; +import type Signals from '../core/Signals'; + +const NUM_STEPS = 8; + +export default class SequentialSwitch extends Mod { + currentStep: number = 0; + + private lastClockSignal: ControlSignal | null = null; + + constructor() { + super(); + // Flat clockwise perimeter for a 1×8 module (2*(1+8) = 18 elements): + // NORTH (1): clock input + // EAST (8): step CV inputs 0-7, top→bottom + // SOUTH (1): unused + // WEST (8): output at row 7 (bottom), then NULLs bottom→top + this.configure( + [ + // NORTH (1 slot): + PlugType.CTRLIN, + // EAST (8 slots, rows 0-7): + PlugType.CTRLIN, PlugType.CTRLIN, PlugType.CTRLIN, PlugType.CTRLIN, + PlugType.CTRLIN, PlugType.CTRLIN, PlugType.CTRLIN, PlugType.CTRLIN, + // SOUTH (1 slot): + PlugType.NULL, + // WEST (8 slots, rows 7-0, bottom→top): + PlugType.CTRLOUT, PlugType.NULL, PlugType.NULL, PlugType.NULL, + PlugType.NULL, PlugType.NULL, PlugType.NULL, PlugType.NULL, + ], + 'seq', + 1, + NUM_STEPS, + ); + } + + override onSignalChanged(inputSignals: Signals): Signals { + // Index 0 = NORTH CTRLIN (clock) + const clockSignal = inputSignals[0]; + if (clockSignal instanceof ControlSignal) { + if (this.lastClockSignal === null || !clockSignal.eq(this.lastClockSignal)) { + this.currentStep = (this.currentStep + 1) % NUM_STEPS; + this.lastClockSignal = clockSignal; + } + } + + // Indices 4-11 = extended EAST CTRLIN for steps 0-7 + const stepSignal = inputSignals[4 + this.currentStep]; + const output: Signals = Array(this.plugs.items.length).fill(null) as Signals; + if (stepSignal instanceof ControlSignal) { + // Index 3 = WEST CTRLOUT + output[3] = stepSignal; + } + return output; + } + + override draw(_group: Konva.Group): void { + } + +} diff --git a/src/control/SwitchOn.ts b/src/control/SwitchOn.ts index 28d1b95..04218f3 100644 --- a/src/control/SwitchOn.ts +++ b/src/control/SwitchOn.ts @@ -13,7 +13,7 @@ export default class SwitchOn extends EffectMod { constructor() { super(); - this.configure([PlugType.IN, PlugType.NULL, PlugType.OUT]); + this.configure([PlugType.IN, PlugType.NULL, PlugType.OUT, PlugType.NULL]); } protected createEffectNode(): ToneAudioNode { diff --git a/src/core/Mod.ts b/src/core/Mod.ts index 250fa1b..ca388bb 100644 --- a/src/core/Mod.ts +++ b/src/core/Mod.ts @@ -38,7 +38,8 @@ export default abstract class Mod { private outputSignals: Signals = [null, null, null, null]; - private inputSignals: Signals = [null, null, null, null]; + // Indexed by plug position; resized in configure() to match plugs.items.length + inputSignals: Signals = [null, null, null, null]; /** * This method is called when drawing. @@ -56,24 +57,32 @@ export default abstract class Mod { */ // eslint-disable-next-line @typescript-eslint/no-unused-vars onSignalChanged(inputSignals: Signals): Signals { - return [null, null, null, null]; + return Array(this.plugs.items.length).fill(null) as Signals; } /** - * Configure the Mod + * Configure the Mod. + * plugTypes is a flat clockwise array of plug types around the module perimeter: + * NORTH left→right, EAST top→bottom, SOUTH right→left, WEST bottom→top. + * For a 1×1 mod this is [N, E, S, W] (4 elements). + * For an M×N mod this is 2*(width+height) elements. * @helper */ configure( - plugTypes:Array = [PlugType.NULL, PlugType.NULL, PlugType.NULL, PlugType.NULL], + plugTypes: symbol[] = [PlugType.NULL, PlugType.NULL, PlugType.NULL, PlugType.NULL], label: string = '', - width:number = 1, - height:number = 1, + width: number = 1, + height: number = 1, ): void { this.label = label; this.width = width; this.height = height; - this.plugs.setTypes(plugTypes); + this.plugs.setTypes(plugTypes, width, height); + + const n = this.plugs.items.length; + this.inputSignals = Array(n).fill(null) as Signals; + this.outputSignals = Array(n).fill(null) as Signals; } /** @@ -360,16 +369,20 @@ export default abstract class Mod { /** * Plug current Mod to every passed targets Mods (north, east, south, west). + * Only considers canonical plugs (indices 0-3). */ plug(targets: Array): void { // this.plugs.resetUntriggeredLinkedInput(); targets.forEach((target, plugPosition) => { - const oppositePlugPosition = PlugPosition.opposite(plugPosition); if (target) { const fromPlug = this.plugs.getPlug(plugPosition); - const toPlug = target.plugs.getPlug(oppositePlugPosition); - if (fromPlug.isLinkable(toPlug)) { - this.link(plugPosition, target); + const oppSide = PlugPosition.opposite(plugPosition); + const toIdx = target.plugs.findFirstBySide(oppSide); + if (toIdx !== -1) { + const toPlug = target.plugs.getPlug(toIdx); + if (fromPlug.isLinkable(toPlug)) { + this.link(plugPosition, target); + } } } }); @@ -385,52 +398,51 @@ export default abstract class Mod { /** * TODO move it to Plugs or Plug */ - link(plugPosition: number, target: Mod): void { - // TODO validate link (is the mod linked to another plug of this mod?) - const oppositePlugPosition = PlugPosition.opposite(plugPosition); - + link(plugPosition: number, target: Mod, targetPlugIndex?: number): void { const plug = this.plugs.getPlug(plugPosition); + const effSide = plug.side !== -1 ? plug.side : plugPosition; + + // Resolve which index on the target receives this connection. + // Fall back to PlugPosition.opposite() for unconfigured mods (side=-1). + const oppSide = PlugPosition.opposite(effSide); + const foundIdx = targetPlugIndex !== undefined ? targetPlugIndex : target.plugs.findFirstBySide(oppSide); + const resolvedTargetIdx = foundIdx !== -1 ? foundIdx : oppSide; + if (plug.mod) { if (target === plug.mod) { // Already linked to Mod {target}, abort return; } - - // Unlink current linked Mod to free the plug - plug.mod.unlink(oppositePlugPosition); + // Unlink the currently connected mod to free the plug + const oldRemote = plug.remoteIndex !== -1 ? plug.remoteIndex : resolvedTargetIdx; + plug.mod.unlink(oldRemote); } plug.mod = target; + plug.remoteIndex = resolvedTargetIdx; // Notify e2e tests that a plug connection was established. window.dispatchEvent(new CustomEvent('test:mod:link')); // Clear the target's receiving slot (if it's an input) before onLinked fires, // so the first incoming signal always triggers onSignalChanged even when the // value equals the stale cached entry from before the disconnect. - // This is done here (not in the reverse target.link call) so the clearing - // happens before onLinked pushes a signal, and is not repeated afterwards. - const targetOppPlug = target.plugs.getPlug(oppositePlugPosition); - if (targetOppPlug.isInput()) { - target.inputSignals[oppositePlugPosition] = null; + const targetPlug = target.plugs.getPlug(resolvedTargetIdx); + if (targetPlug.isInput()) { + target.inputSignals[resolvedTargetIdx] = null; } this.onLinked(plugPosition, target); - // Reserse link - target.link(oppositePlugPosition, this); + // Reverse link (pass our index so the target knows which slot to map back) + target.link(resolvedTargetIdx, this, plugPosition); // Replay the cached output signal to the newly connected target. - // Only do this when there is an active upstream source (at least one input - // plug is currently linked). If the mod has no input plugs at all (e.g. - // Knob) it is always considered active. This prevents a pass-through mod - // like ControlMeter from replaying a stale signal when it is connected - // downstream while its own input is not live. if (plug.isOutput()) { const cachedOutput = this.outputSignals[plugPosition]; const hasInputPlugs = this.plugs.items.some((p: Plug) => p.isInput()); const hasLinkedInput = this.plugs.items.some((p: Plug) => p.isInput() && p.mod !== null); if (cachedOutput && (!hasInputPlugs || hasLinkedInput)) { - target.pushInput(oppositePlugPosition, cachedOutput); + target.pushInput(resolvedTargetIdx, cachedOutput); } } } @@ -477,13 +489,20 @@ export default abstract class Mod { const plug = this.plugs.getPlug(plugPosition); if (plug.mod) { const { mod } = plug; + const remoteIdx = plug.remoteIndex; this.onUnlinked(plugPosition, mod); plug.mod = null; + plug.remoteIndex = -1; // Notify e2e tests that a plug connection was removed. window.dispatchEvent(new CustomEvent('test:mod:unlink')); // Reverse unlink target Mod - mod.unlink(PlugPosition.opposite(plugPosition)); + if (remoteIdx !== -1) { + mod.unlink(remoteIdx); + } else { + const effSide = plug.side !== -1 ? plug.side : plugPosition; + mod.unlink(PlugPosition.opposite(effSide)); + } } } @@ -491,11 +510,7 @@ export default abstract class Mod { * Compute state changes on plugs and trigger Mod onChange */ private processInputs(inputSignals: Signals): Signals { - let outputSignals: Signals = [null, null, null, null]; - - outputSignals = this.onSignalChanged(inputSignals); - - return outputSignals; + return this.onSignalChanged(inputSignals); } /** @@ -504,7 +519,7 @@ export default abstract class Mod { * it propagate them to every output plugs. */ start() { - this.inputSignals = [null, null, null, null]; + this.inputSignals = Array(this.plugs.items.length).fill(null) as Signals; const outputSignals: Signals = this.processInputs(this.inputSignals); this.pushOutputs(outputSignals); @@ -553,13 +568,7 @@ export default abstract class Mod { // Do not recompute output but propagate it directly outputSignals = this.outputSignals; } else { - const snapshot: Signals = [ - this.inputSignals[0], - this.inputSignals[1], - this.inputSignals[2], - this.inputSignals[3], - ]; - outputSignals = this.processInputs(snapshot); + outputSignals = this.processInputs([...this.inputSignals] as Signals); } this.pushOutputs(outputSignals); @@ -583,13 +592,14 @@ export default abstract class Mod { * @helper */ pushOutput(plugPosition: number, outputSignal: Signal|null): void { - // TODO store outputSignal for later diff this.outputSignals[plugPosition] = outputSignal; const plug = this.plugs.getPlug(plugPosition); if (plug.mod && plug.isOutput()) { - const oppositePlugPosition = PlugPosition.opposite(plugPosition); - plug.mod.pushInput(oppositePlugPosition, outputSignal); + const targetIdx = plug.remoteIndex !== -1 + ? plug.remoteIndex + : PlugPosition.opposite(plug.side !== -1 ? plug.side : plugPosition); + plug.mod.pushInput(targetIdx, outputSignal); } } diff --git a/src/core/Plug.ts b/src/core/Plug.ts index f2c4062..8172d4f 100644 --- a/src/core/Plug.ts +++ b/src/core/Plug.ts @@ -6,7 +6,16 @@ import Mod from './Mod'; export default class Plug { type: symbol = PlugType.NULL; - mod: Mod| null = null; + mod: Mod | null = null; + + /** Which face this plug is on (0=N,1=E,2=S,3=W). -1 means unset (falls back to plugPosition). */ + side: number = -1; + + /** Which slot row (for E/W faces) or slot col (for N/S faces) this plug is drawn at. */ + slotOffset: number = 0; + + /** Index in the remote mod's plugs array; set during link(), -1 when unlinked. */ + remoteIndex: number = -1; isLinkable(toPlug: Plug) { if ( @@ -48,6 +57,7 @@ export default class Plug { strokeWidth: number, ) { const plugLineStrokeWidth = 5; + const effSide = this.side !== -1 ? this.side : plugPosition; if ( PlugType.IN === this.type @@ -55,7 +65,7 @@ export default class Plug { ) { this.drawIoPlug( group, - plugPosition, + effSide, width, height, slotWidth, @@ -69,7 +79,7 @@ export default class Plug { ) { this.drawCtrlPlug( group, - plugPosition, + effSide, width, height, slotWidth, @@ -104,54 +114,58 @@ export default class Plug { if (PlugPosition.NORTH === plugPosition) { const y = strokeWidth + plugLineStrokeWidth / 2; - const plugLine1 = new Konva.Line({ - points: [strokeWidth, y, slotWidth / 2, y], + const xStart = this.slotOffset * slotWidth + strokeWidth; + const xMid = this.slotOffset * slotWidth + slotWidth / 2; + const xEnd = (this.slotOffset + 1) * slotWidth - strokeWidth; + group.add(new Konva.Line({ + points: [xStart, y, xMid, y], stroke: 'green', strokeWidth: plugLineStrokeWidth, lineCap: 'butt', - }); - const plugLine2 = new Konva.Line({ - points: [slotWidth / 2, y, slotWidth - strokeWidth, y], + })); + group.add(new Konva.Line({ + points: [xMid, y, xEnd, y], stroke: 'red', strokeWidth: plugLineStrokeWidth, lineCap: 'butt', - }); - group.add(plugLine1); - group.add(plugLine2); + })); } else if (PlugPosition.EAST === plugPosition) { const x = width * slotWidth - (strokeWidth + plugLineStrokeWidth / 2); - const plugLine = new Konva.Line({ - points: [x, strokeWidth, x, height * slotHeight - strokeWidth], + const yStart = this.slotOffset * slotHeight + strokeWidth; + const yEnd = (this.slotOffset + 1) * slotHeight - strokeWidth; + group.add(new Konva.Line({ + points: [x, yStart, x, yEnd], stroke: color, strokeWidth: plugLineStrokeWidth, lineCap: 'butt', - }); - group.add(plugLine); + })); } else if (PlugPosition.SOUTH === plugPosition) { const y = height * slotHeight - (strokeWidth + plugLineStrokeWidth / 2); - const plugLine1 = new Konva.Line({ - points: [strokeWidth, y, slotWidth / 2, y], + const xStart = this.slotOffset * slotWidth + strokeWidth; + const xMid = this.slotOffset * slotWidth + slotWidth / 2; + const xEnd = (this.slotOffset + 1) * slotWidth - strokeWidth; + group.add(new Konva.Line({ + points: [xStart, y, xMid, y], stroke: 'red', strokeWidth: plugLineStrokeWidth, lineCap: 'butt', - }); - const plugLine2 = new Konva.Line({ - points: [slotWidth / 2, y, slotWidth - strokeWidth, y], + })); + group.add(new Konva.Line({ + points: [xMid, y, xEnd, y], stroke: 'green', strokeWidth: plugLineStrokeWidth, lineCap: 'butt', - }); - group.add(plugLine1); - group.add(plugLine2); + })); } else if (PlugPosition.WEST === plugPosition) { const x = strokeWidth + plugLineStrokeWidth / 2; - const plugLine = new Konva.Line({ - points: [x, strokeWidth, x, height * slotHeight - strokeWidth], + const yStart = this.slotOffset * slotHeight + strokeWidth; + const yEnd = (this.slotOffset + 1) * slotHeight - strokeWidth; + group.add(new Konva.Line({ + points: [x, yStart, x, yEnd], stroke: color, strokeWidth: plugLineStrokeWidth, lineCap: 'butt', - }); - group.add(plugLine); + })); } else { throw new Error('Invalid plugPosition value'); } @@ -179,40 +193,50 @@ export default class Plug { throw new Error('Invalid plug type'); } - let bottomPoints: Array = [0, 0, 0, 0]; - let topPoints: Array = [0, 0, 0, 0]; + let bottomPoints: Array; + let topPoints: Array; if (PlugPosition.NORTH === plugPosition) { const y = strokeWidth + plugLineStrokeWidth / 2; - bottomPoints = [strokeWidth, y, slotWidth / 2, y]; - topPoints = [slotWidth / 2, y, slotWidth - strokeWidth, y]; + const xStart = this.slotOffset * slotWidth + strokeWidth; + const xMid = this.slotOffset * slotWidth + slotWidth / 2; + const xEnd = (this.slotOffset + 1) * slotWidth - strokeWidth; + bottomPoints = [xStart, y, xMid, y]; + topPoints = [xMid, y, xEnd, y]; } else if (PlugPosition.EAST === plugPosition) { const x = width * slotWidth - (strokeWidth + plugLineStrokeWidth / 2); - bottomPoints = [x, strokeWidth, x, height * (slotHeight / 2)]; - topPoints = [x, height * (slotHeight / 2), x, height * slotHeight - strokeWidth]; - } else if (PlugPosition.SOUTH === plugPosition) { // South - const y = width * slotWidth - (strokeWidth + plugLineStrokeWidth / 2); - bottomPoints = [strokeWidth, y, slotWidth / 2, y]; - topPoints = [slotWidth / 2, y, slotWidth - strokeWidth, y]; - } else if (PlugPosition.WEST === plugPosition) { // West + const yStart = this.slotOffset * slotHeight + strokeWidth; + const yMid = this.slotOffset * slotHeight + slotHeight / 2; + const yEnd = (this.slotOffset + 1) * slotHeight - strokeWidth; + bottomPoints = [x, yStart, x, yMid]; + topPoints = [x, yMid, x, yEnd]; + } else if (PlugPosition.SOUTH === plugPosition) { + const y = height * slotHeight - (strokeWidth + plugLineStrokeWidth / 2); + const xStart = this.slotOffset * slotWidth + strokeWidth; + const xMid = this.slotOffset * slotWidth + slotWidth / 2; + const xEnd = (this.slotOffset + 1) * slotWidth - strokeWidth; + bottomPoints = [xStart, y, xMid, y]; + topPoints = [xMid, y, xEnd, y]; + } else if (PlugPosition.WEST === plugPosition) { const x = strokeWidth + plugLineStrokeWidth / 2; - bottomPoints = [x, strokeWidth, x, height * (slotHeight / 2)]; - topPoints = [x, height * (slotHeight / 2), x, height * slotHeight - strokeWidth]; + const yStart = this.slotOffset * slotHeight + strokeWidth; + const yMid = this.slotOffset * slotHeight + slotHeight / 2; + const yEnd = (this.slotOffset + 1) * slotHeight - strokeWidth; + bottomPoints = [x, yStart, x, yMid]; + topPoints = [x, yMid, x, yEnd]; } else { throw new Error('Invalid plugPosition value'); } - const plugLine1 = new Konva.Line({ + group.add(new Konva.Line({ points: bottomPoints, stroke: color1, strokeWidth: plugLineStrokeWidth, lineCap: 'butt', - }); - const plugLine2 = new Konva.Line({ + })); + group.add(new Konva.Line({ points: topPoints, stroke: color2, strokeWidth: plugLineStrokeWidth, lineCap: 'butt', - }); - group.add(plugLine1); - group.add(plugLine2); + })); } } diff --git a/src/core/Plugs.ts b/src/core/Plugs.ts index b723085..b9cdd75 100644 --- a/src/core/Plugs.ts +++ b/src/core/Plugs.ts @@ -1,7 +1,8 @@ import Plug from './Plug'; +import PlugType from './PlugType'; export default class Plugs { - items:Array = [new Plug(), new Plug(), new Plug(), new Plug()]; + items: Array = [new Plug(), new Plug(), new Plug(), new Plug()]; untriggeredInput: Array = [true, true, true, true]; @@ -13,15 +14,112 @@ export default class Plugs { } /** - * Set type of every plugs. + * Configure plugs from a flat clockwise perimeter array of plug types: + * NORTH left→right, EAST top→bottom, SOUTH right→left, WEST bottom→top. + * For a 1×1 mod this is [N, E, S, W] (4 elements). + * For an M×N mod this is 2*(width+height) elements. + * The first non-NULL type on each face becomes the canonical plug (indices 0-3) + * with its slotOffset set. Further non-NULL types on the same face are added + * via addExtendedPlug(). */ - setTypes(plugTypes: Array): this { - this.items.forEach((plug, plugPosition) => { - plug.type = plugTypes[plugPosition]; - }); + setTypes( + plugTypes: symbol[], + width: number = 1, + height: number = 1, + ): this { + // Reset to 4 default NULL plugs + this.items = [new Plug(), new Plug(), new Plug(), new Plug()]; + this.untriggeredInput = [true, true, true, true]; + + // Set side for canonical plugs + for (let s = 0; s < 4; s += 1) { + this.items[s].side = s; + this.items[s].type = PlugType.NULL; + } + + let i = 0; + + // NORTH: left → right (row=0) + for (let col = 0; col < width; col += 1) { + const t = plugTypes[i] ?? PlugType.NULL; + i += 1; + if (t !== PlugType.NULL) { + if (this.items[0].type === PlugType.NULL) { + this.items[0].type = t; + this.items[0].slotOffset = col; + } else { + this.addExtendedPlug(t, 0, col); + } + } + } + + // EAST: top → bottom (col=width-1) + for (let row = 0; row < height; row += 1) { + const t = plugTypes[i] ?? PlugType.NULL; + i += 1; + if (t !== PlugType.NULL) { + if (this.items[1].type === PlugType.NULL) { + this.items[1].type = t; + this.items[1].slotOffset = row; + } else { + this.addExtendedPlug(t, 1, row); + } + } + } + + // SOUTH: right → left (row=height-1) + for (let col = width - 1; col >= 0; col -= 1) { + const t = plugTypes[i] ?? PlugType.NULL; + i += 1; + if (t !== PlugType.NULL) { + if (this.items[2].type === PlugType.NULL) { + this.items[2].type = t; + this.items[2].slotOffset = col; + } else { + this.addExtendedPlug(t, 2, col); + } + } + } + + // WEST: bottom → top (col=0) + for (let row = height - 1; row >= 0; row -= 1) { + const t = plugTypes[i] ?? PlugType.NULL; + i += 1; + if (t !== PlugType.NULL) { + if (this.items[3].type === PlugType.NULL) { + this.items[3].type = t; + this.items[3].slotOffset = row; + } else { + this.addExtendedPlug(t, 3, row); + } + } + } + return this; } + /** + * Append an extended plug (index 4+) with explicit side and slotOffset. + * Returns the index of the new plug. + */ + addExtendedPlug(type: symbol, side: number, slotOffset: number): number { + const plug = new Plug(); + plug.type = type; + plug.side = side; + plug.slotOffset = slotOffset; + this.items.push(plug); + this.untriggeredInput.push(true); + return this.items.length - 1; + } + + /** + * Returns the index of the first plug whose side matches the given value. + * Returns -1 if none found. + */ + findFirstBySide(side: number): number { + return this.items.findIndex((p) => p.side === side); + } + /** * Iterate over plugs. */ @@ -32,7 +130,7 @@ export default class Plugs { } /** - * If a plus has several inputs and one of them did not received + * If a plug has several inputs and one of them did not receive * an input signal from linked mods, it returns true. */ hasUntriggeredLinkedInput(): boolean { @@ -46,8 +144,7 @@ export default class Plugs { * @see hasUntriggeredLinkedInput() */ resetUntriggeredLinkedInput(): this { - this.untriggeredInput = [true, true, true, true]; - + this.untriggeredInput = Array(this.items.length).fill(true) as boolean[]; return this; } } diff --git a/src/core/Rack.ts b/src/core/Rack.ts index 52d0ce5..d7556c4 100644 --- a/src/core/Rack.ts +++ b/src/core/Rack.ts @@ -1,6 +1,8 @@ import Konva from 'konva'; import * as Tone from 'tone'; import Mod from './Mod'; +import PlugType from './PlugType'; +import PlugPosition from './PlugPosition'; import Annotation from '../annotation/Annotation'; import StickyNote from '../annotation/StickyNote'; import type Library from '../ui/Library'; @@ -400,9 +402,72 @@ export default class Rack { this.getFromGrid(mod.x, mod.y + mod.height), // South this.getFromGrid(mod.x - 1, mod.y), // West ]); + this.plugExtended(mod); }); } + /** + * Wire each extended plug (index 4+) of a mod to the adjacent neighbour + * at the grid cell determined by the plug's side and slotOffset. + */ + private plugExtended(mod: Mod): void { + mod.plugs.items.forEach((plug, i) => { + if (i < 4 || plug.type === PlugType.NULL || plug.side === -1) return; + let neighbor: Mod | null = null; + if (plug.side === PlugPosition.NORTH) { + neighbor = this.getFromGrid(mod.x + plug.slotOffset, mod.y - 1); + } else if (plug.side === PlugPosition.EAST) { + neighbor = this.getFromGrid(mod.x + mod.width, mod.y + plug.slotOffset); + } else if (plug.side === PlugPosition.SOUTH) { + neighbor = this.getFromGrid(mod.x + plug.slotOffset, mod.y + mod.height); + } else if (plug.side === PlugPosition.WEST) { + neighbor = this.getFromGrid(mod.x - 1, mod.y + plug.slotOffset); + } + if (!neighbor) return; + const oppSide = PlugPosition.opposite(plug.side); + const targetIdx = neighbor.plugs.findFirstBySide(oppSide); + if (targetIdx === -1) return; + const targetPlug = neighbor.plugs.getPlug(targetIdx); + if (plug.isLinkable(targetPlug)) { + mod.link(i, neighbor, targetIdx); + mod.findEntries().forEach((entry) => { entry.start(); }); + } + }); + } + + /** + * After placing a new mod, rewire extended plugs on any adjacent multi-slot mods + * that may now have a neighbour facing one of their extended plug positions. + */ + private rewireNeighborExtended(mod: Mod): void { + // Check all grid cells adjacent to this mod for multi-slot neighbours + const checked = new Set(); + for (let row = 0; row < mod.height; row += 1) { + const neighbors = [ + this.getFromGrid(mod.x - 1, mod.y + row), + this.getFromGrid(mod.x + mod.width, mod.y + row), + ]; + neighbors.forEach((neighbor) => { + if (neighbor && neighbor !== mod && !checked.has(neighbor) && neighbor.plugs.items.length > 4) { + checked.add(neighbor); + this.plugExtended(neighbor); + } + }); + } + for (let col = 0; col < mod.width; col += 1) { + const neighbors = [ + this.getFromGrid(mod.x + col, mod.y - 1), + this.getFromGrid(mod.x + col, mod.y + mod.height), + ]; + neighbors.forEach((neighbor) => { + if (neighbor && neighbor !== mod && !checked.has(neighbor) && neighbor.plugs.items.length > 4) { + checked.add(neighbor); + this.plugExtended(neighbor); + } + }); + } + } + /** * Add a mod to the rack dynamically (without a full redraw), * preserving existing mod connections. @@ -417,6 +482,8 @@ export default class Rack { this.getFromGrid(mod.x, mod.y + mod.height), // South this.getFromGrid(mod.x - 1, mod.y), // West ]); + this.plugExtended(mod); + this.rewireNeighborExtended(mod); this.layer.batchDraw(); mod.onAdded(); } @@ -440,6 +507,8 @@ export default class Rack { this.getFromGrid(mod.x, mod.y + mod.height), // South this.getFromGrid(mod.x - 1, mod.y), // West ]); + this.plugExtended(mod); + this.rewireNeighborExtended(mod); mod.onAdded(); }); diff --git a/src/core/RackSerializer.ts b/src/core/RackSerializer.ts index c43f37a..7f08621 100644 --- a/src/core/RackSerializer.ts +++ b/src/core/RackSerializer.ts @@ -24,6 +24,7 @@ import Panner from '../effect/Panner'; import Phaser from '../effect/Phaser'; import Reverb from '../effect/Reverb'; import HighPassFilter from '../filter/HighPassFilter'; +import SequentialSwitch from '../control/SequentialSwitch'; // --------------------------------------------------------------------------- // Mod registry @@ -46,6 +47,7 @@ const MOD_REGISTRY: Record = { Phaser, Reverb, SawtoothOscillator, + SequentialSwitch, SineOscillator, Speaker, SquareOscillator, diff --git a/src/effect/Chorus.ts b/src/effect/Chorus.ts index bb876dd..510ce68 100644 --- a/src/effect/Chorus.ts +++ b/src/effect/Chorus.ts @@ -7,7 +7,7 @@ import PlugPosition from '../core/PlugPosition'; export default class Chorus extends EffectMod { constructor() { super(); - this.configure([PlugType.IN, PlugType.CTRLIN, PlugType.OUT], 'chorus'); + this.configure([PlugType.IN, PlugType.CTRLIN, PlugType.OUT, PlugType.NULL], 'chorus'); } protected createEffectNode(): ToneAudioNode { diff --git a/src/effect/Flanger.ts b/src/effect/Flanger.ts index 311f2b2..c6125f9 100644 --- a/src/effect/Flanger.ts +++ b/src/effect/Flanger.ts @@ -7,7 +7,7 @@ import PlugPosition from '../core/PlugPosition'; export default class Flanger extends EffectMod { constructor() { super(); - this.configure([PlugType.IN, PlugType.CTRLIN, PlugType.OUT], 'flanger'); + this.configure([PlugType.IN, PlugType.CTRLIN, PlugType.OUT, PlugType.NULL], 'flanger'); } protected createEffectNode(): ToneAudioNode { diff --git a/src/effect/Panner.ts b/src/effect/Panner.ts index 5cf402c..a2f8e23 100644 --- a/src/effect/Panner.ts +++ b/src/effect/Panner.ts @@ -7,7 +7,7 @@ import PlugPosition from '../core/PlugPosition'; export default class Panner extends EffectMod { constructor() { super(); - this.configure([PlugType.IN, PlugType.CTRLIN, PlugType.OUT], 'pan'); + this.configure([PlugType.IN, PlugType.CTRLIN, PlugType.OUT, PlugType.NULL], 'pan'); } protected createEffectNode(): ToneAudioNode { diff --git a/src/effect/Phaser.ts b/src/effect/Phaser.ts index deffbc0..842c47b 100644 --- a/src/effect/Phaser.ts +++ b/src/effect/Phaser.ts @@ -7,7 +7,7 @@ import PlugPosition from '../core/PlugPosition'; export default class Phaser extends EffectMod { constructor() { super(); - this.configure([PlugType.IN, PlugType.CTRLIN, PlugType.OUT], 'phaser'); + this.configure([PlugType.IN, PlugType.CTRLIN, PlugType.OUT, PlugType.NULL], 'phaser'); } protected createEffectNode(): ToneAudioNode { diff --git a/src/effect/Tremolo.ts b/src/effect/Tremolo.ts index 2b0f0c0..7399328 100644 --- a/src/effect/Tremolo.ts +++ b/src/effect/Tremolo.ts @@ -7,7 +7,7 @@ import PlugPosition from '../core/PlugPosition'; export default class Tremolo extends EffectMod { constructor() { super(); - this.configure([PlugType.IN, PlugType.CTRLIN, PlugType.OUT], 'tremolo'); + this.configure([PlugType.IN, PlugType.CTRLIN, PlugType.OUT, PlugType.NULL], 'tremolo'); } protected createEffectNode(): ToneAudioNode { diff --git a/src/effect/Vibrato.ts b/src/effect/Vibrato.ts index 9ef1424..ffb247f 100644 --- a/src/effect/Vibrato.ts +++ b/src/effect/Vibrato.ts @@ -7,7 +7,7 @@ import PlugPosition from '../core/PlugPosition'; export default class Vibrato extends EffectMod { constructor() { super(); - this.configure([PlugType.IN, PlugType.CTRLIN, PlugType.OUT], 'vibrato'); + this.configure([PlugType.IN, PlugType.CTRLIN, PlugType.OUT, PlugType.NULL], 'vibrato'); } protected createEffectNode(): ToneAudioNode { diff --git a/src/filter/HighPassFilter.ts b/src/filter/HighPassFilter.ts index 3a90e94..b961df8 100644 --- a/src/filter/HighPassFilter.ts +++ b/src/filter/HighPassFilter.ts @@ -7,7 +7,7 @@ import PlugPosition from '../core/PlugPosition'; export default class HighPassFilter extends EffectMod { constructor() { super(); - this.configure([PlugType.IN, PlugType.CTRLIN, PlugType.OUT], 'high-pass'); + this.configure([PlugType.IN, PlugType.CTRLIN, PlugType.OUT, PlugType.NULL], 'high-pass'); } protected createEffectNode(): ToneAudioNode { diff --git a/src/oscillator/Oscillator.ts b/src/oscillator/Oscillator.ts index 4bdbb2f..8c047a0 100644 --- a/src/oscillator/Oscillator.ts +++ b/src/oscillator/Oscillator.ts @@ -7,7 +7,7 @@ import PlugPosition from '../core/PlugPosition'; export default abstract class Oscillator extends SourceMod { constructor() { super(); - this.configure([PlugType.NULL, PlugType.CTRLIN, PlugType.OUT], 'osc'); + this.configure([PlugType.NULL, PlugType.CTRLIN, PlugType.OUT, PlugType.NULL], 'osc'); } protected abstract createOutputNode(): ToneOscillator; diff --git a/src/output/Speaker.ts b/src/output/Speaker.ts index b0e1f90..f928233 100644 --- a/src/output/Speaker.ts +++ b/src/output/Speaker.ts @@ -6,7 +6,7 @@ import PlugPosition from '../core/PlugPosition'; export default class Speaker extends SinkMod { constructor() { super(); - this.configure([PlugType.IN, PlugType.CTRLIN]); + this.configure([PlugType.IN, PlugType.CTRLIN, PlugType.NULL, PlugType.NULL]); } draw(group: Konva.Group) { diff --git a/src/ui/Library.ts b/src/ui/Library.ts index f4f9be7..c2967e7 100644 --- a/src/ui/Library.ts +++ b/src/ui/Library.ts @@ -20,6 +20,7 @@ import SwitchOn from '../control/SwitchOn'; import ControlMeter from '../control/ControlMeter'; import MidiIn from '../control/MidiIn'; import Oscilloscope from '../control/Oscilloscope'; +import SequentialSwitch from '../control/SequentialSwitch'; import Speaker from '../output/Speaker'; type ModConstructor = new () => Mod; @@ -45,6 +46,7 @@ const PROTOS: ProtoEntry[] = [ { Ctor: Reverb, label: 'reverb', category: 'effect' }, { Ctor: HighPassFilter, label: 'hp-flt', category: 'filter' }, { Ctor: Gate, label: 'gate', category: 'control' }, + { Ctor: SequentialSwitch, label: 'seq-sw', category: 'control' }, { Ctor: Arpeggiator, label: 'arp', category: 'control' }, { Ctor: Knob, label: 'knob', category: 'control' }, { Ctor: SwitchOn, label: 'switch', category: 'control' }, @@ -366,21 +368,22 @@ export default class Library { cursor += HEADER_H; const rowStart = cursor; - entries.forEach((proto, i) => { - const col = i % cols; - const row = Math.floor(i / cols); + const colCursors = Array(cols).fill(0) as number[]; + entries.forEach((proto) => { + const col = colCursors.indexOf(Math.min(...colCursors)); const group = new Konva.Group({ x: PANEL_PAD + col * slotWidth, - y: rowStart + row * slotHeight, + y: rowStart + colCursors[col], }); scrollGroup.add(group); const tempMod = new proto.Ctor(); + colCursors[col] += tempMod.height * slotHeight; tempMod.drawVisual(group, slotWidth, slotHeight); this.attachGhostDrag(layer, group, proto); }); - cursor += Math.ceil(entries.length / cols) * slotHeight + PANEL_PAD; + cursor += Math.max(...colCursors) + PANEL_PAD; }); this.totalContentHeight = cursor; @@ -513,6 +516,11 @@ export default class Library { // Close the panel after a short delay so the user sees the drag start setTimeout(() => { this.close(); }, 150); + // Instantiate ghost mod early to read its dimensions + const ghostMod = new proto.Ctor(); + const modW = ghostMod.width; + const modH = ghostMod.height; + // Get pointer in layer (world) coords const screenPos = rack.stage.getPointerPosition(); if (!screenPos) return; @@ -524,8 +532,8 @@ export default class Library { let snapHighlight: Konva.Rect | null = new Konva.Rect({ x: 0, y: 0, - width: slotWidth, - height: slotHeight, + width: modW * slotWidth, + height: modH * slotHeight, fill: '#cccccc', opacity: 0.6, stroke: '#dddddd', @@ -560,8 +568,8 @@ export default class Library { const gridY = Math.round((gy - padding) / slotHeight); if ( gridX >= 0 && gridY >= 0 - && gridX < rack.stageWidth && gridY < rack.stageHeight - && this.isSlotFree(gridX, gridY) + && gridX + modW <= rack.stageWidth && gridY + modH <= rack.stageHeight + && this.isSlotFree(gridX, gridY, modW, modH) ) { snapHighlight.position({ x: padding + gridX * slotWidth, @@ -607,9 +615,9 @@ export default class Library { if ( gridX >= 0 && gridY >= 0 - && gridX < rack.stageWidth - && gridY < rack.stageHeight - && this.isSlotFree(gridX, gridY) + && gridX + modW <= rack.stageWidth + && gridY + modH <= rack.stageHeight + && this.isSlotFree(gridX, gridY, modW, modH) ) { rack.addMod(new proto.Ctor(), gridX, gridY); } @@ -618,14 +626,14 @@ export default class Library { } /** - * Check whether a 1×1 slot is free in the main rack without + * Check whether a slot is free in the main rack without * instantiating a Mod (avoids audio side-effects for abandoned drops). */ - private isSlotFree(x: number, y: number): boolean { + private isSlotFree(x: number, y: number, width = 1, height = 1): boolean { return !this.rack.mods.some( (mod) => ( - x < mod.x + mod.width && x + 1 > mod.x - && y < mod.y + mod.height && y + 1 > mod.y + x < mod.x + mod.width && x + width > mod.x + && y < mod.y + mod.height && y + height > mod.y ), ); } diff --git a/synt.schema.json b/synt.schema.json index 248e538..47e63eb 100644 --- a/synt.schema.json +++ b/synt.schema.json @@ -54,6 +54,7 @@ "Phaser", "Reverb", "SawtoothOscillator", + "SequentialSwitch", "SineOscillator", "Speaker", "SquareOscillator", diff --git a/tests/unit/core/mod.test.ts b/tests/unit/core/mod.test.ts index 69d9f25..e762f52 100644 --- a/tests/unit/core/mod.test.ts +++ b/tests/unit/core/mod.test.ts @@ -10,24 +10,24 @@ test('findEntries: mod with no plugs is not an entry', () => { test('findEntries: mod is an entry', () => { const mod = new TestMod(); - mod.configure([PlugType.OUT]); + mod.configure([PlugType.OUT, PlugType.NULL, PlugType.NULL, PlugType.NULL]); expect(mod.findEntries()).toStrictEqual([mod]); }); test('findEntries: mod is not an entry because is has an input plug', () => { const mod = new TestMod(); - mod.configure([PlugType.OUT, PlugType.IN]); + mod.configure([PlugType.OUT, PlugType.IN, PlugType.NULL, PlugType.NULL]); expect(mod.findEntries()).toStrictEqual([]); }); test('findEntries: linked mod is an entry', () => { const mod1 = new TestMod(); - mod1.configure([PlugType.OUT, PlugType.NULL, PlugType.IN]); + mod1.configure([PlugType.OUT, PlugType.NULL, PlugType.IN, PlugType.NULL]); const mod2 = new TestMod(); - mod2.configure([PlugType.OUT]); + mod2.configure([PlugType.OUT, PlugType.NULL, PlugType.NULL, PlugType.NULL]); mod2.link(PlugPosition.NORTH, mod1); expect(mod1.findEntries()).toStrictEqual([mod2]); @@ -35,10 +35,10 @@ test('findEntries: linked mod is an entry', () => { test('findEntries: linked mods are entries', () => { const mod1 = new TestMod(); - mod1.configure([PlugType.OUT, PlugType.IN, PlugType.IN]); + mod1.configure([PlugType.OUT, PlugType.IN, PlugType.IN, PlugType.NULL]); const mod2 = new TestMod(); - mod2.configure([PlugType.OUT]); + mod2.configure([PlugType.OUT, PlugType.NULL, PlugType.NULL, PlugType.NULL]); mod2.link(PlugPosition.NORTH, mod1); const mod3 = new TestMod(); @@ -50,14 +50,14 @@ test('findEntries: linked mods are entries', () => { test('findEntries: linked mod to linked mod is an entry', () => { const mod1 = new TestMod(); - mod1.configure([PlugType.OUT, PlugType.IN, PlugType.IN]); + mod1.configure([PlugType.OUT, PlugType.IN, PlugType.IN, PlugType.NULL]); const mod2 = new TestMod(); - mod2.configure([PlugType.OUT, PlugType.NULL, PlugType.IN]); + mod2.configure([PlugType.OUT, PlugType.NULL, PlugType.IN, PlugType.NULL]); mod2.link(PlugPosition.NORTH, mod1); const mod3 = new TestMod(); - mod3.configure([PlugType.OUT]); + mod3.configure([PlugType.OUT, PlugType.NULL, PlugType.NULL, PlugType.NULL]); mod3.link(PlugPosition.NORTH, mod2); expect(mod1.findEntries()).toStrictEqual([mod3]); @@ -71,14 +71,14 @@ test('isEntry: mod with no plugs is not an entry', () => { test('isEntry: mod with outputs and no input is an entry', () => { const mod = new TestMod(); - mod.configure([PlugType.OUT, PlugType.OUT]); + mod.configure([PlugType.OUT, PlugType.OUT, PlugType.NULL, PlugType.NULL]); expect(mod.isEntry()).toBeTruthy(); }); test('isEntry: mod with one input is not an entry', () => { const mod = new TestMod(); - mod.configure([PlugType.OUT, PlugType.IN, PlugType.OUT]); + mod.configure([PlugType.OUT, PlugType.IN, PlugType.OUT, PlugType.NULL]); expect(mod.isEntry()).toBeFalsy(); }); From 9e2406caea209b3d60cc8b0c9afeaf0764b8e384 Mon Sep 17 00:00:00 2001 From: pitpit Date: Fri, 5 Jun 2026 00:43:22 +0200 Subject: [PATCH 2/6] feat: add Clock module and integrate with existing control components --- src/control/Clock.ts | 80 +++++++++++++++++++++++++++++++++ src/control/SequentialSwitch.ts | 2 +- src/core/Plug.ts | 18 +++++++- src/core/PlugType.ts | 2 + src/core/RackSerializer.ts | 2 + src/ui/Library.ts | 2 + synt.schema.json | 1 + 7 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 src/control/Clock.ts diff --git a/src/control/Clock.ts b/src/control/Clock.ts new file mode 100644 index 0000000..e7a20ac --- /dev/null +++ b/src/control/Clock.ts @@ -0,0 +1,80 @@ +import Konva from 'konva'; +import Mod from '../core/Mod'; +import PlugType from '../core/PlugType'; +import PlugPosition from '../core/PlugPosition'; +import ControlSignal from '../core/ControlSignal'; +import type Signals from '../core/Signals'; + +const MIN_FREQ = 0.5; +const MAX_FREQ = 10; + +export default class Clock extends Mod { + private intervalId: ReturnType | null = null; + + private tickValue: number = 0; + + private frequencyHz: number = 2; + + constructor() { + super(); + this.configure( + [PlugType.NULL, PlugType.CTRLIN, PlugType.CLKOUT, PlugType.NULL], + 'clk', + ); + } + + private startClock(): void { + this.stopClock(); + this.intervalId = setInterval(() => { + this.tickValue = this.tickValue === 0 ? 1 : 0; + this.pushOutput(PlugPosition.SOUTH, new ControlSignal(this.tickValue)); + }, 1000 / this.frequencyHz); + } + + private stopClock(): void { + if (this.intervalId !== null) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + override onSignalChanged(inputSignals: Signals): Signals { + const rateSignal = inputSignals[PlugPosition.EAST]; + if (rateSignal instanceof ControlSignal) { + this.frequencyHz = MIN_FREQ + rateSignal.value * (MAX_FREQ - MIN_FREQ); + if (this.intervalId !== null) { + this.startClock(); + } + } + return Array(this.plugs.items.length).fill(null) as Signals; + } + + protected override onLinked(plugPosition: number): void { + if (plugPosition === PlugPosition.SOUTH) { + this.startClock(); + } + } + + protected override onUnlinked(plugPosition: number): void { + if (plugPosition === PlugPosition.SOUTH) { + this.stopClock(); + } + } + + protected override onSnatched(): void { + this.stopClock(); + } + + override draw(group: Konva.Group): void { + group.add(new Konva.Text({ + x: 0, + y: group.height() / 2 - 7, + width: group.width(), + text: 'CLK', + fontSize: 13, + fontStyle: 'bold', + fill: 'cyan', + align: 'center', + })); + } +} diff --git a/src/control/SequentialSwitch.ts b/src/control/SequentialSwitch.ts index 9fd1acc..dc2404c 100644 --- a/src/control/SequentialSwitch.ts +++ b/src/control/SequentialSwitch.ts @@ -21,7 +21,7 @@ export default class SequentialSwitch extends Mod { this.configure( [ // NORTH (1 slot): - PlugType.CTRLIN, + PlugType.CLKIN, // EAST (8 slots, rows 0-7): PlugType.CTRLIN, PlugType.CTRLIN, PlugType.CTRLIN, PlugType.CTRLIN, PlugType.CTRLIN, PlugType.CTRLIN, PlugType.CTRLIN, PlugType.CTRLIN, diff --git a/src/core/Plug.ts b/src/core/Plug.ts index 8172d4f..d2819b4 100644 --- a/src/core/Plug.ts +++ b/src/core/Plug.ts @@ -31,6 +31,12 @@ export default class Plug { ) || ( PlugType.CTRLOUT === this.type && PlugType.CTRLIN === toPlug.type + ) || ( + PlugType.CLKIN === this.type + && PlugType.CLKOUT === toPlug.type + ) || ( + PlugType.CLKOUT === this.type + && PlugType.CLKIN === toPlug.type ) ) { return true; @@ -40,11 +46,11 @@ export default class Plug { } isOutput() { - return (this.type === PlugType.OUT || this.type === PlugType.CTRLOUT); + return (this.type === PlugType.OUT || this.type === PlugType.CTRLOUT || this.type === PlugType.CLKOUT); } isInput() { - return (this.type === PlugType.IN || this.type === PlugType.CTRLIN); + return (this.type === PlugType.IN || this.type === PlugType.CTRLIN || this.type === PlugType.CLKIN); } draw( @@ -76,6 +82,8 @@ export default class Plug { } else if ( PlugType.CTRLIN === this.type || PlugType.CTRLOUT === this.type + || PlugType.CLKIN === this.type + || PlugType.CLKOUT === this.type ) { this.drawCtrlPlug( group, @@ -189,6 +197,12 @@ export default class Plug { } else if (PlugType.CTRLOUT === this.type) { color1 = 'orange'; color2 = 'blue'; + } else if (PlugType.CLKIN === this.type) { + color1 = 'deeppink'; + color2 = 'cyan'; + } else if (PlugType.CLKOUT === this.type) { + color1 = 'cyan'; + color2 = 'deeppink'; } else { throw new Error('Invalid plug type'); } diff --git a/src/core/PlugType.ts b/src/core/PlugType.ts index 929c406..bda362e 100644 --- a/src/core/PlugType.ts +++ b/src/core/PlugType.ts @@ -7,6 +7,8 @@ const PlugType = { NULL: Symbol('null'), CTRLIN: Symbol('ctrlin'), CTRLOUT: Symbol('ctrlout'), + CLKIN: Symbol('clkin'), + CLKOUT: Symbol('clkout'), }; export default PlugType; diff --git a/src/core/RackSerializer.ts b/src/core/RackSerializer.ts index 7f08621..01cd955 100644 --- a/src/core/RackSerializer.ts +++ b/src/core/RackSerializer.ts @@ -24,6 +24,7 @@ import Panner from '../effect/Panner'; import Phaser from '../effect/Phaser'; import Reverb from '../effect/Reverb'; import HighPassFilter from '../filter/HighPassFilter'; +import Clock from '../control/Clock'; import SequentialSwitch from '../control/SequentialSwitch'; // --------------------------------------------------------------------------- @@ -35,6 +36,7 @@ type AnyModConstructor = new (...args: never[]) => Mod; const MOD_REGISTRY: Record = { Arpeggiator, Chorus, + Clock, ControlMeter, Flanger, Gate, diff --git a/src/ui/Library.ts b/src/ui/Library.ts index c2967e7..a386763 100644 --- a/src/ui/Library.ts +++ b/src/ui/Library.ts @@ -20,6 +20,7 @@ import SwitchOn from '../control/SwitchOn'; import ControlMeter from '../control/ControlMeter'; import MidiIn from '../control/MidiIn'; import Oscilloscope from '../control/Oscilloscope'; +import Clock from '../control/Clock'; import SequentialSwitch from '../control/SequentialSwitch'; import Speaker from '../output/Speaker'; @@ -48,6 +49,7 @@ const PROTOS: ProtoEntry[] = [ { Ctor: Gate, label: 'gate', category: 'control' }, { Ctor: SequentialSwitch, label: 'seq-sw', category: 'control' }, { Ctor: Arpeggiator, label: 'arp', category: 'control' }, + { Ctor: Clock, label: 'clock', category: 'control' }, { Ctor: Knob, label: 'knob', category: 'control' }, { Ctor: SwitchOn, label: 'switch', category: 'control' }, { Ctor: ControlMeter, label: 'ctrl-m', category: 'control' }, diff --git a/synt.schema.json b/synt.schema.json index 47e63eb..914076c 100644 --- a/synt.schema.json +++ b/synt.schema.json @@ -42,6 +42,7 @@ "enum": [ "Arpeggiator", "Chorus", + "Clock", "ControlMeter", "Flanger", "Gate", From f2c037f5a22150978630972957aa554c8001f3a6 Mon Sep 17 00:00:00 2001 From: pitpit Date: Fri, 5 Jun 2026 01:09:18 +0200 Subject: [PATCH 3/6] feat: fix Cloc and seq --- docs/05-module-reference.md | 44 ++++++++++++++++++++- public/demo.synt.yaml | 69 ++++++++++++++++++++++----------- src/control/Clock.ts | 14 ------- src/control/SequentialSwitch.ts | 7 ++-- 4 files changed, 93 insertions(+), 41 deletions(-) diff --git a/docs/05-module-reference.md b/docs/05-module-reference.md index 2c8d0d2..68d3db3 100644 --- a/docs/05-module-reference.md +++ b/docs/05-module-reference.md @@ -15,7 +15,7 @@ This document describes every audio module in synt — its purpose, plug layout, | 2 | SOUTH | Audio or control output | | 3 | WEST | Second control input / control output | -Plug types: `IN` audio input · `OUT` audio output · `CTRLIN` CV input · `CTRLOUT` CV output · `NULL` no plug. +Plug types: `IN` audio input · `OUT` audio output · `CTRLIN` CV input · `CTRLOUT` CV output · `CLKIN` clock input · `CLKOUT` clock output · `NULL` no plug. --- @@ -356,6 +356,48 @@ Connects to a hardware MIDI input device via the Web MIDI API. Translates incomi --- +### Clock + +**Source**: [src/control/Clock.ts](../src/control/Clock.ts) +**Base**: `Mod` + +A free-running clock pulse generator. Toggles a `CLKOUT` signal between 0 and 1 at a configurable rate. The clock starts when its SOUTH plug is connected and stops when it is disconnected or the module is removed from the rack. Rate CV on EAST adjusts the tick frequency while the clock is running. + +| Position | Type | Role | +|----------|------|------| +| NORTH | `NULL` | — | +| EAST | `CTRLIN` | Rate CV | +| SOUTH | `CLKOUT` | Clock pulse output | +| WEST | `NULL` | — | + +| Plug | Parameter | Mapping | +|------|-----------|---------| +| EAST | Frequency | `0.5 + value × 9.5` → 0.5–10 Hz | + +--- + +### SequentialSwitch + +**Source**: [src/control/SequentialSwitch.ts](../src/control/SequentialSwitch.ts) +**Base**: `Mod` +**Grid size**: 1 × 8 + +An 8-step sequential switch. On each rising or falling clock edge received on NORTH, it advances to the next step and forwards that step's CV input (from the corresponding EAST slot) to the WEST output. Each step's CV is supplied by a separate `CTRLIN` plug arranged top-to-bottom on the EAST face. + +The perimeter layout for this 1 × 8 module follows the standard clockwise convention (2 × (1 + 8) = 18 slots): + +| Face | Slots | Type | Role | +|------|-------|------|------| +| NORTH | 1 | `CLKIN` | Clock input | +| EAST | 8 (rows 0–7) | `CTRLIN` | Step 0–7 CV inputs | +| SOUTH | 1 | `NULL` | — | +| WEST | row 7 | `CTRLOUT` | Active step CV output | +| WEST | rows 0–6 | `NULL` | — | + +**Step advance**: triggers on any change of the incoming `ControlSignal` value (both edges). Connect a `Clock` SOUTH → SequentialSwitch NORTH; each toggle advances the step by one. + +--- + ### Gate **Source**: [src/control/Gate.ts](../src/control/Gate.ts) diff --git a/public/demo.synt.yaml b/public/demo.synt.yaml index 619d662..9a86dcb 100644 --- a/public/demo.synt.yaml +++ b/public/demo.synt.yaml @@ -1,31 +1,54 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/pitpit/synt/main/synt.schema.json synt: rack: - width: 10 + width: 6 height: 10 mods: - - type: TriangleOscillator - x: 1 - y: 3 - - type: Gate - x: 0 - y: 4 + - type: Clock + x: 3 + 'y': 0 - type: Knob + x: 4 + 'y': 0 + value: 0.095 + - type: SequentialSwitch + x: 3 + 'y': 1 + - type: Knob + x: 4 + 'y': 1 + value: 0.175 + - type: Knob + x: 4 + 'y': 2 + value: 0.815 + - type: Knob + x: 4 + 'y': 3 + value: 0.15 + - type: Knob + x: 4 + 'y': 4 + value: 0.83 + - type: Knob + x: 4 + 'y': 5 + value: 0.175 + - type: Knob + x: 4 + 'y': 6 + value: 0.895 + - type: Knob + x: 4 + 'y': 7 + value: 0.235 + - type: Knob + x: 4 + 'y': 8 + value: 0.91 + - type: SineOscillator x: 2 - y: 3 + 'y': 8 - type: Speaker - x: 1 - y: 5 - annotations: - - x: 400 - y: 400 - text: | - Welcome to Synt, the modular synthesizer! - - ⇑ ⇑ ⇑ Pan to the NORTH to get new modules to put in your Rack. - - Connect modules by dragging from an output plug to an input plug. - - Try by yourself: move the GATE module between the TRIANGLE oscillator and SPEAKER. Adjust the KNOB clicking|tapping and vertically dragging to change the oscillator's frequency. - - Double-click any module to open its settings. + x: 2 + 'y': 9 diff --git a/src/control/Clock.ts b/src/control/Clock.ts index e7a20ac..e33578b 100644 --- a/src/control/Clock.ts +++ b/src/control/Clock.ts @@ -1,4 +1,3 @@ -import Konva from 'konva'; import Mod from '../core/Mod'; import PlugType from '../core/PlugType'; import PlugPosition from '../core/PlugPosition'; @@ -64,17 +63,4 @@ export default class Clock extends Mod { protected override onSnatched(): void { this.stopClock(); } - - override draw(group: Konva.Group): void { - group.add(new Konva.Text({ - x: 0, - y: group.height() / 2 - 7, - width: group.width(), - text: 'CLK', - fontSize: 13, - fontStyle: 'bold', - fill: 'cyan', - align: 'center', - })); - } } diff --git a/src/control/SequentialSwitch.ts b/src/control/SequentialSwitch.ts index dc2404c..44660d2 100644 --- a/src/control/SequentialSwitch.ts +++ b/src/control/SequentialSwitch.ts @@ -38,7 +38,7 @@ export default class SequentialSwitch extends Mod { } override onSignalChanged(inputSignals: Signals): Signals { - // Index 0 = NORTH CTRLIN (clock) + // Index 0 = NORTH CLKIN (clock) const clockSignal = inputSignals[0]; if (clockSignal instanceof ControlSignal) { if (this.lastClockSignal === null || !clockSignal.eq(this.lastClockSignal)) { @@ -47,8 +47,9 @@ export default class SequentialSwitch extends Mod { } } - // Indices 4-11 = extended EAST CTRLIN for steps 0-7 - const stepSignal = inputSignals[4 + this.currentStep]; + // Step 0 → canonical EAST plug at index 1; steps 1-7 → extended plugs at indices 4-10 + const stepIndex = this.currentStep === 0 ? 1 : this.currentStep + 3; + const stepSignal = inputSignals[stepIndex]; const output: Signals = Array(this.plugs.items.length).fill(null) as Signals; if (stepSignal instanceof ControlSignal) { // Index 3 = WEST CTRLOUT From 9b234ea5718c1c5756fb833be8dfa6e7c17fc9b7 Mon Sep 17 00:00:00 2001 From: pitpit Date: Fri, 5 Jun 2026 01:47:23 +0200 Subject: [PATCH 4/6] feat: rename SequentialSwitch to Sequencer and update related references --- README.md | 27 +-------- docs/05-module-reference.md | 6 +- public/demo.synt.yaml | 57 +++++++++++++++---- .../{SequentialSwitch.ts => Sequencer.ts} | 2 +- src/core/RackSerializer.ts | 4 +- src/ui/Library.ts | 4 +- synt.schema.json | 2 +- 7 files changed, 55 insertions(+), 47 deletions(-) rename src/control/{SequentialSwitch.ts => Sequencer.ts} (97%) diff --git a/README.md b/README.md index 9aed566..ea8a862 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ An HTML report is generated in `playwright-report/` after each run. - [ ] LFO-to-CV — dedicated slow LFO with depth and rate knobs - [ ] Random / S&H — random voltage generator (stepped or smooth) - [ ] Function generator — slew-limited ramp (rise/fall times) -- [ ] Sequencer — step sequencer with CV and gate outputs (8 or 16 steps) +- [X>] Sequencer — step sequencer with CV and gate outputs (8 or 16 steps) - [ ] Arpeggiator — enhanced arpeggio patterns (already exists, keep improving) #### Filters @@ -155,31 +155,6 @@ An HTML report is generated in `playwright-report/` after each run. - [ ] Scope / Oscilloscope — visual display of an audio or CV waveform -### Built module - -#### Step sequencer - -- Clock / Tempo -- Sequential switch (8 steps) - -#### arpegiator -- **Clock / Tempo:** To drive the speed of the arpeggio. -- **Sequencer (8 or 16 steps):** To program the specific note intervals of your chord. -- **LFO-to-CV:** To create automated movement (like shifting octaves). -- **Mixer or Attenuverter:** To combine our note sequence with our octave shifts. -- **Quantizer:** To make sure all the raw voltages snap perfectly to musical notes. -- **An oscillator :** To actually hear the sound. - -Step A: Establishing the RhythmPatch the Clock Out into the Clock In of the Sequencer. Your sequencer is now stepping at the speed of your project's tempo. - -Step B: Programming the ChordOn your Sequencer, manually dial in the notes of a chord across the steps. For example, if you want a minor triad, dial the knobs to step voltages that represent the Root, Minor 3rd, 5th, and Octave.Patch the CV Out of the sequencer into Input 1 of your Mixer/Attenuverter. - -Step C: Creating the "Octave Jump" Feature (The Arpeggiator Magic)An arpeggiator often jumps up an octave on subsequent repeats. We can fake this using a slow LFO or a second sequencer.4. Take a stepped or square wave from your LFO-to-CV (set to a slow rate, like 1/4 the speed of your sequencer).5. Patch that LFO into Input 2 of your Mixer/Attenuverter. Use the level knob to calibrate it so that when the LFO goes high, it adds exactly $1\text{V}$ (which equals one octave in the standard $1\text{V/Oct}$ protocol). - -Step D: Keeping it in TuneBecause manual sequencer knobs and LFOs are imprecise, we need a musical safety net.6. Patch the Output of your Mixer (which is now your Chord CV + Octave Shift CV combined) into the input of the Quantizer.7. Select your desired scale on the Quantizer. It will instantly correct the mathematical voltages into perfect musical pitches. - -Step E: Making SoundPatch the Quantizer CV Out into the 1V/Oct Input of your Wavetable Oscillator.Patch the Gate Out of your Sequencer into the Gate Input of the ADSR envelope.Route the ADSR Output to the CV input of your VCA to shape the volume of each arpeggiated note. - ### UX diff --git a/docs/05-module-reference.md b/docs/05-module-reference.md index 68d3db3..cc29d4c 100644 --- a/docs/05-module-reference.md +++ b/docs/05-module-reference.md @@ -376,9 +376,9 @@ A free-running clock pulse generator. Toggles a `CLKOUT` signal between 0 and 1 --- -### SequentialSwitch +### Sequencer -**Source**: [src/control/SequentialSwitch.ts](../src/control/SequentialSwitch.ts) +**Source**: [src/control/Sequencer.ts](../src/control/Sequencer.ts) **Base**: `Mod` **Grid size**: 1 × 8 @@ -394,7 +394,7 @@ The perimeter layout for this 1 × 8 module follows the standard clockwise conve | WEST | row 7 | `CTRLOUT` | Active step CV output | | WEST | rows 0–6 | `NULL` | — | -**Step advance**: triggers on any change of the incoming `ControlSignal` value (both edges). Connect a `Clock` SOUTH → SequentialSwitch NORTH; each toggle advances the step by one. +**Step advance**: triggers on any change of the incoming `ControlSignal` value (both edges). Connect a `Clock` SOUTH → Sequencer NORTH; each toggle advances the step by one. --- diff --git a/public/demo.synt.yaml b/public/demo.synt.yaml index 9a86dcb..640c256 100644 --- a/public/demo.synt.yaml +++ b/public/demo.synt.yaml @@ -1,8 +1,8 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/pitpit/synt/main/synt.schema.json synt: rack: - width: 6 - height: 10 + width: 12 + height: 12 mods: - type: Clock x: 3 @@ -10,45 +10,78 @@ synt: - type: Knob x: 4 'y': 0 - value: 0.095 - - type: SequentialSwitch + value: 0.86 + - type: Sequencer x: 3 'y': 1 - type: Knob x: 4 'y': 1 - value: 0.175 + value: 0.08 - type: Knob x: 4 'y': 2 - value: 0.815 + value: 0.355 - type: Knob x: 4 'y': 3 - value: 0.15 + value: 0.45499999999999996 - type: Knob x: 4 'y': 4 - value: 0.83 + value: 0.18999999999999992 - type: Knob x: 4 'y': 5 - value: 0.175 + value: 0.16499999999999998 - type: Knob x: 4 'y': 6 - value: 0.895 + value: 0.355 - type: Knob x: 4 'y': 7 - value: 0.235 + value: 0.13499999999999998 - type: Knob x: 4 'y': 8 - value: 0.91 + value: 0.26 - type: SineOscillator x: 2 'y': 8 - type: Speaker + x: 2 + 'y': 11 + - type: Chorus x: 2 'y': 9 + - type: Phaser + x: 2 + 'y': 10 + - type: Knob + x: 3 + 'y': 9 + value: 0.915 + - type: Knob + x: 3 + 'y': 10 + value: 1 + annotations: + - x: 568.6591736935475 + 'y': 34.02402819750671 + text: > + Welcome to Synt, the modular synthesizer! + + + ⇑ ⇑ ⇑ Pan to the NORTH to get new modules to put in your Rack. + + + Connect modules by dragging from an output plug to an input plug. + + + Try by yourself: move the GATE module between the TRIANGLE oscillator + and SPEAKER. Adjust the KNOB clicking|tapping and vertically dragging to + change the oscillator's frequency. + + + Double-click any module to open its settings. diff --git a/src/control/SequentialSwitch.ts b/src/control/Sequencer.ts similarity index 97% rename from src/control/SequentialSwitch.ts rename to src/control/Sequencer.ts index 44660d2..578a5eb 100644 --- a/src/control/SequentialSwitch.ts +++ b/src/control/Sequencer.ts @@ -6,7 +6,7 @@ import type Signals from '../core/Signals'; const NUM_STEPS = 8; -export default class SequentialSwitch extends Mod { +export default class Sequencer extends Mod { currentStep: number = 0; private lastClockSignal: ControlSignal | null = null; diff --git a/src/core/RackSerializer.ts b/src/core/RackSerializer.ts index 01cd955..91f9dd5 100644 --- a/src/core/RackSerializer.ts +++ b/src/core/RackSerializer.ts @@ -25,7 +25,7 @@ import Phaser from '../effect/Phaser'; import Reverb from '../effect/Reverb'; import HighPassFilter from '../filter/HighPassFilter'; import Clock from '../control/Clock'; -import SequentialSwitch from '../control/SequentialSwitch'; +import Sequencer from '../control/Sequencer'; // --------------------------------------------------------------------------- // Mod registry @@ -49,7 +49,7 @@ const MOD_REGISTRY: Record = { Phaser, Reverb, SawtoothOscillator, - SequentialSwitch, + Sequencer, SineOscillator, Speaker, SquareOscillator, diff --git a/src/ui/Library.ts b/src/ui/Library.ts index a386763..29ce9d1 100644 --- a/src/ui/Library.ts +++ b/src/ui/Library.ts @@ -21,7 +21,7 @@ import ControlMeter from '../control/ControlMeter'; import MidiIn from '../control/MidiIn'; import Oscilloscope from '../control/Oscilloscope'; import Clock from '../control/Clock'; -import SequentialSwitch from '../control/SequentialSwitch'; +import Sequencer from '../control/Sequencer'; import Speaker from '../output/Speaker'; type ModConstructor = new () => Mod; @@ -47,7 +47,7 @@ const PROTOS: ProtoEntry[] = [ { Ctor: Reverb, label: 'reverb', category: 'effect' }, { Ctor: HighPassFilter, label: 'hp-flt', category: 'filter' }, { Ctor: Gate, label: 'gate', category: 'control' }, - { Ctor: SequentialSwitch, label: 'seq-sw', category: 'control' }, + { Ctor: Sequencer, label: 'seq-sw', category: 'control' }, { Ctor: Arpeggiator, label: 'arp', category: 'control' }, { Ctor: Clock, label: 'clock', category: 'control' }, { Ctor: Knob, label: 'knob', category: 'control' }, diff --git a/synt.schema.json b/synt.schema.json index 914076c..aab5d8f 100644 --- a/synt.schema.json +++ b/synt.schema.json @@ -55,7 +55,7 @@ "Phaser", "Reverb", "SawtoothOscillator", - "SequentialSwitch", + "Sequencer", "SineOscillator", "Speaker", "SquareOscillator", From 4465e248d14b6259f8b0e63edec529b564b2161c Mon Sep 17 00:00:00 2001 From: pitpit Date: Fri, 5 Jun 2026 01:49:23 +0200 Subject: [PATCH 5/6] feat: replace setInterval with setTimeout for clock scheduling --- src/control/Clock.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/control/Clock.ts b/src/control/Clock.ts index e33578b..303eca1 100644 --- a/src/control/Clock.ts +++ b/src/control/Clock.ts @@ -8,7 +8,7 @@ const MIN_FREQ = 0.5; const MAX_FREQ = 10; export default class Clock extends Mod { - private intervalId: ReturnType | null = null; + private intervalId: ReturnType | null = null; private tickValue: number = 0; @@ -22,17 +22,23 @@ export default class Clock extends Mod { ); } - private startClock(): void { - this.stopClock(); - this.intervalId = setInterval(() => { + private scheduleNext(): void { + this.intervalId = setTimeout(() => { + if (this.intervalId === null) return; this.tickValue = this.tickValue === 0 ? 1 : 0; this.pushOutput(PlugPosition.SOUTH, new ControlSignal(this.tickValue)); + this.scheduleNext(); }, 1000 / this.frequencyHz); } + private startClock(): void { + this.stopClock(); + this.scheduleNext(); + } + private stopClock(): void { if (this.intervalId !== null) { - clearInterval(this.intervalId); + clearTimeout(this.intervalId); this.intervalId = null; } } @@ -41,9 +47,6 @@ export default class Clock extends Mod { const rateSignal = inputSignals[PlugPosition.EAST]; if (rateSignal instanceof ControlSignal) { this.frequencyHz = MIN_FREQ + rateSignal.value * (MAX_FREQ - MIN_FREQ); - if (this.intervalId !== null) { - this.startClock(); - } } return Array(this.plugs.items.length).fill(null) as Signals; } From b1f1f2234d670e5bd4a9d72cdf4b02f7894dce52 Mon Sep 17 00:00:00 2001 From: pitpit Date: Fri, 5 Jun 2026 01:50:42 +0200 Subject: [PATCH 6/6] feat: remove Arpeggiator module --- README.md | 3 - docs/01-architecture.md | 3 - docs/03-testing-strategy.md | 2 +- docs/05-module-reference.md | 22 ----- public/test.synt.yaml | 5 +- src/control/Arpeggiator.ts | 48 ----------- src/core/RackSerializer.ts | 2 - src/ui/Library.ts | 2 - synt.schema.json | 1 - tests/unit/control/arpeggiator.test.ts | 114 ------------------------- 10 files changed, 2 insertions(+), 200 deletions(-) delete mode 100644 src/control/Arpeggiator.ts delete mode 100644 tests/unit/control/arpeggiator.test.ts diff --git a/README.md b/README.md index ea8a862..913b299 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,6 @@ See the live demo here: https://pitpit.github.io/synt ![Synt screenshot](https://raw.githubusercontent.com/pitpit/synt/main/public/img/screenshot1.png) -![Arpeggiator example](https://raw.githubusercontent.com/pitpit/synt/main/public/img/screenshot-arpegiator.png) - ## License Copyright (C) 2026 Damien Pitard @@ -113,7 +111,6 @@ An HTML report is generated in `playwright-report/` after each run. - [ ] Random / S&H — random voltage generator (stepped or smooth) - [ ] Function generator — slew-limited ramp (rise/fall times) - [X>] Sequencer — step sequencer with CV and gate outputs (8 or 16 steps) -- [ ] Arpeggiator — enhanced arpeggio patterns (already exists, keep improving) #### Filters - [ ] VCF low-pass — 12/24 dB/oct ladder-style low-pass filter with cutoff & resonance diff --git a/docs/01-architecture.md b/docs/01-architecture.md index c31b73c..ae608c0 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -130,7 +130,6 @@ Default plug layout: | Module | Plug layout | Behaviour | |--------|-------------|-----------| | `Knob` | NORTH: `NULL`, EAST: `CTRLOUT`, SOUTH: `NULL`, WEST: `CTRLOUT` | Mouse-wheel or vertical touch-drag changes value in [0, 1]. Emits a `ControlSignal` from both EAST and WEST. | -| `Arpeggiator` | NORTH: `NULL`, EAST: `CTRLIN`, SOUTH: `NULL`, WEST: `CTRLOUT` | Emits a stepped `ControlSignal` sequence; EAST control input maps 0–1 to the arpeggio clock interval. | | `Gate` | NORTH: `IN`, EAST: `NULL`, SOUTH: `OUT`, WEST: `NULL` | Extends `EffectMod` with a `ToneGain(1)` effect node — audio passes through at full volume. | | `SwitchOn` | NORTH: `IN`, EAST: `NULL`, SOUTH: `OUT`, WEST: `NULL` | Extends `EffectMod` with a `ToneGain(0)` — press on/off toggles gain between 1 and 0. | | `Keyboard` | NORTH: `NULL`, EAST: `NULL`, SOUTH: `NULL`, WEST: `CTRLOUT` | Visual keyboard display; in `src/control/Keyboard.ts` the exported class is currently named `Knob`. | @@ -197,7 +196,6 @@ classDiagram class SwitchOn class Gate class Knob - class Arpeggiator class Keyboard class StickyNote @@ -205,7 +203,6 @@ classDiagram Mod <|-- EffectMod Mod <|-- SinkMod Mod <|-- Knob - Mod <|-- Arpeggiator Mod <|-- Keyboard Mod <|-- StickyNote SourceMod <|-- Oscillator diff --git a/docs/03-testing-strategy.md b/docs/03-testing-strategy.md index 60783e6..547d3a8 100644 --- a/docs/03-testing-strategy.md +++ b/docs/03-testing-strategy.md @@ -15,7 +15,7 @@ Unit tests cover individual module classes in isolation. Real source classes are - **Tone.js** is mocked globally via `tests/__mocks__/tone.ts`. It exports `jest.fn()` factories so classes that `import … from 'tone'` receive lightweight fakes that expose `connect`, `disconnect`, and `dispose` spies — no real audio graph is created. - Jest is configured with `clearMocks: true` and `restoreMocks: true`, so spy state never leaks between tests. -- Time-dependent tests (e.g. `Arpeggiator`) use `jest.useFakeTimers()` / `jest.advanceTimersByTime()` in `beforeEach` / `afterEach`. +- Time-dependent tests use `jest.useFakeTimers()` / `jest.advanceTimersByTime()` in `beforeEach` / `afterEach`. --- diff --git a/docs/05-module-reference.md b/docs/05-module-reference.md index cc29d4c..1be3b52 100644 --- a/docs/05-module-reference.md +++ b/docs/05-module-reference.md @@ -312,28 +312,6 @@ Displays a visual on-screen keyboard image. Outputs a CV signal on WEST represen --- -### Arpeggiator - -**Source**: [src/control/Arpeggiator.ts](../src/control/Arpeggiator.ts) -**Base**: `Mod` - -A 4-step CV sequencer that cycles through a fixed sequence at a tempo controlled by EAST CV. Outputs the current step's value on WEST. - -| Position | Type | Role | -|----------|------|------| -| NORTH | `NULL` | — | -| EAST | `CTRLIN` | Tempo CV | -| SOUTH | `NULL` | — | -| WEST | `CTRLOUT` | Step CV output | - -| Plug | Parameter | Mapping | -|------|-----------|---------| -| EAST | Step interval | `(1 - value) × 1500` → 1500 ms (slow) to 0 ms (fast) — inverted | - -**Sequence**: `[0.3, 0.45, 0.55, 0.45]` repeating. The timer restarts whenever the tempo CV changes. - ---- - ### MidiIn **Source**: [src/control/MidiIn.ts](../src/control/MidiIn.ts) diff --git a/public/test.synt.yaml b/public/test.synt.yaml index 7947fb5..99edcef 100644 --- a/public/test.synt.yaml +++ b/public/test.synt.yaml @@ -28,9 +28,6 @@ synt: - type: Tremolo x: 2 y: 1 - - type: Arpeggiator - x: 3 - y: 1 - type: Panner x: 4 y: 1 @@ -81,7 +78,7 @@ synt: Connect modules by dragging from an output plug to an input plug. The top row contains oscillators (Sine, Square, Sawtooth, Triangle) that generate audio signals. Use the Gate to control when sound plays, and Vibrato/Tremolo for pitch and volume modulation. - Arpeggiator (arp) cycles through notes automatically — connect an Oscillator to arpeggiate chords. Panner (pan) pans the audio left/right — connect a Knob to its EAST plug and turn it to sweep the stereo field. + Panner (pan) pans the audio left/right — connect a Knob to its EAST plug and turn it to sweep the stereo field. Knobs adjust parameters — drag up/down to change values. SwitchOn modules toggle signals on or off. Speakers output the final audio signal. diff --git a/src/control/Arpeggiator.ts b/src/control/Arpeggiator.ts deleted file mode 100644 index 864a2a6..0000000 --- a/src/control/Arpeggiator.ts +++ /dev/null @@ -1,48 +0,0 @@ -import Mod from '../core/Mod'; -import PlugType from '../core/PlugType'; -import PlugPosition from '../core/PlugPosition'; -import ControlSignal from '../core/ControlSignal'; -import Signals from '../core/Signals'; - -const NUM_STEPS = 4; -const MIN_CLOCK_INTERVAL_MS = 0; -const MAX_CLOCK_INTERVAL_MS = 1500; - -export default class Arpeggiator extends Mod { - stepValues: number[] = [0.3, 0.45, 0.55, 0.45]; - - stepIndex: number = 0; - - clock: number = 750; - - timerId: ReturnType | null = null; - - constructor() { - super(); - this.configure([PlugType.NULL, PlugType.CTRLIN, PlugType.NULL, PlugType.CTRLOUT], 'arp'); - this.startTimer(); - } - - private startTimer(fireImmediately = false): void { - if (this.timerId !== null) { - clearInterval(this.timerId); - } - const tick = () => { - this.pushOutput(PlugPosition.WEST, new ControlSignal(this.stepValues[this.stepIndex])); - this.stepIndex = (this.stepIndex + 1) % NUM_STEPS; - }; - if (fireImmediately) { - tick(); - } - this.timerId = setInterval(tick, this.clock); - } - - onSignalChanged(inputSignals: Signals): Signals { - const ctrlSignal = inputSignals[PlugPosition.EAST]; - if (ctrlSignal instanceof ControlSignal) { - this.clock = Math.round(MAX_CLOCK_INTERVAL_MS - ctrlSignal.value * (MAX_CLOCK_INTERVAL_MS - MIN_CLOCK_INTERVAL_MS)); - this.startTimer(); - } - return [null, null, null, null]; - } -} diff --git a/src/core/RackSerializer.ts b/src/core/RackSerializer.ts index 91f9dd5..d2ad1f2 100644 --- a/src/core/RackSerializer.ts +++ b/src/core/RackSerializer.ts @@ -13,7 +13,6 @@ import Gate from '../control/Gate'; import Knob from '../control/Knob'; import SwitchOn from '../control/SwitchOn'; import MidiIn from '../control/MidiIn'; -import Arpeggiator from '../control/Arpeggiator'; import ControlMeter from '../control/ControlMeter'; import Oscilloscope from '../control/Oscilloscope'; import Chorus from '../effect/Chorus'; @@ -34,7 +33,6 @@ import Sequencer from '../control/Sequencer'; type AnyModConstructor = new (...args: never[]) => Mod; const MOD_REGISTRY: Record = { - Arpeggiator, Chorus, Clock, ControlMeter, diff --git a/src/ui/Library.ts b/src/ui/Library.ts index 29ce9d1..ee23103 100644 --- a/src/ui/Library.ts +++ b/src/ui/Library.ts @@ -14,7 +14,6 @@ import Panner from '../effect/Panner'; import Phaser from '../effect/Phaser'; import Reverb from '../effect/Reverb'; import HighPassFilter from '../filter/HighPassFilter'; -import Arpeggiator from '../control/Arpeggiator'; import Knob from '../control/Knob'; import SwitchOn from '../control/SwitchOn'; import ControlMeter from '../control/ControlMeter'; @@ -48,7 +47,6 @@ const PROTOS: ProtoEntry[] = [ { Ctor: HighPassFilter, label: 'hp-flt', category: 'filter' }, { Ctor: Gate, label: 'gate', category: 'control' }, { Ctor: Sequencer, label: 'seq-sw', category: 'control' }, - { Ctor: Arpeggiator, label: 'arp', category: 'control' }, { Ctor: Clock, label: 'clock', category: 'control' }, { Ctor: Knob, label: 'knob', category: 'control' }, { Ctor: SwitchOn, label: 'switch', category: 'control' }, diff --git a/synt.schema.json b/synt.schema.json index aab5d8f..7dec84d 100644 --- a/synt.schema.json +++ b/synt.schema.json @@ -40,7 +40,6 @@ "type": { "type": "string", "enum": [ - "Arpeggiator", "Chorus", "Clock", "ControlMeter", diff --git a/tests/unit/control/arpeggiator.test.ts b/tests/unit/control/arpeggiator.test.ts deleted file mode 100644 index ca5b751..0000000 --- a/tests/unit/control/arpeggiator.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import Arpeggiator from '../../../src/control/Arpeggiator'; -import Signals from '../../../src/core/Signals'; -import ControlSignal from '../../../src/core/ControlSignal'; -import PlugPosition from '../../../src/core/PlugPosition'; - -beforeEach(() => { - jest.useFakeTimers(); -}); - -afterEach(() => { - jest.useRealTimers(); -}); - -test('emits first step value on first tick at clock 750ms', () => { - const arp = new Arpeggiator(); - const spy = jest.spyOn(arp, 'pushOutput'); - - jest.advanceTimersByTime(750); // default clock = 750 ms - - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith( - PlugPosition.WEST, - expect.objectContaining({ value: 0.3 }), - ); -}); - -test('advances step on each tick', () => { - const arp = new Arpeggiator(); - const spy = jest.spyOn(arp, 'pushOutput'); - - jest.advanceTimersByTime(1500); // 2 ticks at 750 ms - - expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenNthCalledWith( - 1, - PlugPosition.WEST, - expect.objectContaining({ value: 0.3 }), - ); - expect(spy).toHaveBeenNthCalledWith( - 2, - PlugPosition.WEST, - expect.objectContaining({ value: 0.45 }), - ); -}); - -test('step index wraps back to 0 after 4 steps', () => { - const arp = new Arpeggiator(); - - jest.advanceTimersByTime(750 * 4); // 4 ticks at 750 ms - - expect(arp.stepIndex).toBe(0); -}); - -test('onSignalChanged maps value 0 to clock 1500ms', () => { - const arp = new Arpeggiator(); - const input: Signals = [null, new ControlSignal(0), null, null]; - - arp.onSignalChanged(input); - - expect(arp.clock).toBe(1500); -}); - -test('onSignalChanged maps value 1 to clock 150ms', () => { - const arp = new Arpeggiator(); - const input: Signals = [null, new ControlSignal(1), null, null]; - - arp.onSignalChanged(input); - - expect(arp.clock).toBe(0); -}); - -test('onSignalChanged maps value 0.5 to clock 825ms', () => { - const arp = new Arpeggiator(); - const input: Signals = [null, new ControlSignal(0.5), null, null]; - - arp.onSignalChanged(input); - - expect(arp.clock).toBe(750); -}); - -test('onSignalChanged restarts timer with new interval', () => { - const arp = new Arpeggiator(); - - // Set clock to max (1500 ms) - const input: Signals = [null, new ControlSignal(0), null, null]; - arp.onSignalChanged(input); - - const spy = jest.spyOn(arp, 'pushOutput'); - - jest.advanceTimersByTime(1000); // below 1500 ms threshold - expect(spy).toHaveBeenCalledTimes(0); - - jest.advanceTimersByTime(500); // reaches 1500 ms - expect(spy).toHaveBeenCalledTimes(1); -}); - -test('onSignalChanged returns null for all outputs', () => { - const arp = new Arpeggiator(); - const input: Signals = [null, new ControlSignal(0.5), null, null]; - - const output = arp.onSignalChanged(input); - - expect(output).toStrictEqual([null, null, null, null]); -}); - -test('non-ControlSignal on EAST is ignored', () => { - const arp = new Arpeggiator(); - const initialClock = arp.clock; - const input: Signals = [null, null, null, null]; - - arp.onSignalChanged(input); - - expect(arp.clock).toBe(initialClock); -});