Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/instructions/lint.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,30 @@ items.forEach(function(item) { process(item); });
- No floating promises — always `await` or explicitly `.catch()` async calls.
- No unused variables (warn).

### Preventing Unsafe Debug-State Access

- Never read debug/diagnostic objects through untyped APIs (implicit `any` or unresolved method types).
- If a class exposes debug state, define and export an explicit interface for the return shape.
- Type the producing method return value (for example: `getDebugState(): DebugState`).
- Consume debug state only through that typed contract, not via casts like `as any`.
- If a typed contract is not available yet, prefer rendering a minimal safe fallback string/value instead of unsafe property access.

```ts
// ✅ preferred: typed producer + typed consumer
export interface LibraryDebugState {
open: boolean;
scrollY: number;
}

getDebugState(): LibraryDebugState {
return { open: this.isOpen, scrollY: this.scrollY };
}

// ❌ avoid: unresolved/untyped call chain used in template expressions
const state = rack.library?.getDebugState();
label.text = `${state.open} ${state.scrollY}`;
```

Project override: `@typescript-eslint/no-unused-vars` is configured as a warning (not an error), which is useful for temporary scaffolding while iterating.

## Running the Linter
Expand Down
8 changes: 3 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,6 @@ An HTML report is generated in `playwright-report/` after each run.
- https://www.youtube.com/shorts/kY7BvhUemLE
- https://teenage.engineering/store/po-33
- [ ] create duplicable/instanciable modules (to build more complex instruments)
- [ ] Add a file player Mod?
- [ ] Protect against link loop?
- [X] when connecting a knob on a Mod, it should set its value to the linked Mod value (and animate)

#### Oscillators & Sources
Expand Down Expand Up @@ -183,11 +181,11 @@ Step E: Making SoundPatch the Quantizer CV Out into the 1V/Oct Input of your Wav

### UX

- [ ] redesign StickyNote to note be in the Rack anymore and be on a layer above. Save the position in yaml export and restore it.
- [ ] multi selection & multi drag'n drop for mass deletion
- [ ] 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?
- [ ] Ability to rotate Mod on Rack?
- [X] redesign StickyNote to note be in the Rack anymore and be on a layer above. Save the position in yaml export and restore it.
- [X] import/export current value of the Knob in yaml
- [X] extend the rotate/swipe zone of the knob to the outer circle
- [X] extends the zone to click or tap the switchOn to the outter-square
Expand All @@ -199,8 +197,8 @@ Step E: Making SoundPatch the Quantizer CV Out into the 1V/Oct Input of your Wav

### Codebase

- [X] Use vite preview instead of http-server for e2e tests
- [ ] Enable again e2e test for firefox "AudioContext reaches running state after user gesture and Tone.start() does not reject"
- [X] Use vite preview instead of http-server for e2e tests
- [X] Add a public licence
- [X] Switch from Gibberish to Tone.js?
- [X] Improve the src/ subtree structure to make it easier to understand
Expand Down
5 changes: 1 addition & 4 deletions src/control/Knob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,7 @@ export default class Knob extends Mod implements KnobMemoryConsumer {
x,
y,
});
if (this.innerCircle) {
this.innerCircle.draw();
}
this.pinCircle.draw();
this.pinCircle.getLayer()?.batchDraw();
}
}

Expand Down
95 changes: 77 additions & 18 deletions src/control/Oscilloscope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import ControlSignal from '../core/ControlSignal';
import Signals from '../core/Signals';

export default class Oscilloscope extends EffectMod {
private static readonly TARGET_FRAME_MS = 33;

private waveformLineLeft: Konva.Line | null = null;

private waveformLineRight: Konva.Line | null = null;
Expand All @@ -23,6 +25,12 @@ export default class Oscilloscope extends EffectMod {
/** Amplitude multiplier applied to the waveform. Range [0.1, 10]. */
private amplitude: number = 0.5;

private leftPoints: number[] = [];

private rightPoints: number[] = [];

private lastFrameTs = 0;

constructor() {
super();
this.configure([PlugType.IN, PlugType.CTRLIN, PlugType.OUT, PlugType.CTRLIN], 'scope');
Expand Down Expand Up @@ -74,8 +82,8 @@ export default class Oscilloscope extends EffectMod {
* Find the first rising zero-crossing in the buffer, leaving at least `samples`
* entries after the index. Returns 0 as fallback when no crossing is found.
*/
private findTriggerIndex(data: Float32Array): number {
const searchLimit = data.length - this.samples;
private findTriggerIndex(data: Float32Array, samples: number): number {
const searchLimit = data.length - samples;
for (let i = 1; i < searchLimit; i += 1) {
if (data[i - 1] < 0 && data[i] >= 0) {
return i;
Expand All @@ -84,6 +92,33 @@ export default class Oscilloscope extends EffectMod {
return 0;
}

private hasInputSource(): boolean {
return this.plugs.getPlug(PlugPosition.NORTH).mod !== null;
}

private fillPoints(
data: Float32Array,
triggerIdx: number,
samples: number,
points: number[],
padX: number,
dw: number,
midY: number,
ampScale: number,
): number[] {
const needed = samples * 2;
if (points.length !== needed) {
points.length = needed;
}
const stepX = samples > 1 ? dw / (samples - 1) : 0;

for (let i = 0; i < samples; i += 1) {
points[2 * i] = padX + i * stepX;
points[2 * i + 1] = midY - data[triggerIdx + i] * ampScale;
}
return points;
}

draw(group: Konva.Group): void {
this.group = group;
const w = group.width();
Expand Down Expand Up @@ -133,10 +168,14 @@ export default class Oscilloscope extends EffectMod {
});
clipGroup.add(this.waveformLineRight);

this.startAnimation(group);
if (this.hasInputSource()) {
this.startAnimation(group);
}
}

private startAnimation(group: Konva.Group): void {
if (this.animationFrameId !== null) return;

const w = group.width();
const h = group.height();
const padX = 10;
Expand All @@ -145,32 +184,47 @@ export default class Oscilloscope extends EffectMod {
const dh = h - 2 * padY;
const midY = padY + dh / 2;

const buildPoints = (data: Float32Array, triggerIdx: number, samples: number): number[] => {
const points: number[] = [];
for (let i = 0; i < samples; i += 1) {
points.push(padX + (i / (samples - 1)) * dw);
points.push(midY - data[triggerIdx + i] * this.amplitude * (dh / 2 - 2));
}
return points;
};

const animate = () => {
const animate = (timestamp: number) => {
this.animationFrameId = requestAnimationFrame(animate);

if (!this.effectNode || !this.waveformLineLeft || !this.waveformLineRight) return;
if (!this.hasInputSource()) return;
if (timestamp - this.lastFrameTs < Oscilloscope.TARGET_FRAME_MS) return;

this.lastFrameTs = timestamp;

const [left, right] = (this.effectNode as ToneAnalyser).getValue() as Float32Array[];
const samples = this.samples;
const samples = Math.max(16, Math.min(this.samples, left.length, right.length));
const ampScale = this.amplitude * (dh / 2 - 2);

const triggerIdxLeft = this.findTriggerIndex(left);
this.waveformLineLeft.points(buildPoints(left, triggerIdxLeft, samples));
const triggerIdxLeft = this.findTriggerIndex(left, samples);
this.waveformLineLeft.points(this.fillPoints(
left,
triggerIdxLeft,
samples,
this.leftPoints,
padX,
dw,
midY,
ampScale,
));

const triggerIdxRight = this.findTriggerIndex(right);
this.waveformLineRight.points(buildPoints(right, triggerIdxRight, samples));
const triggerIdxRight = this.findTriggerIndex(right, samples);
this.waveformLineRight.points(this.fillPoints(
right,
triggerIdxRight,
samples,
this.rightPoints,
padX,
dw,
midY,
ampScale,
));

group.getLayer()?.batchDraw();
};

this.lastFrameTs = 0;
this.animationFrameId = requestAnimationFrame(animate);
}

Expand All @@ -186,6 +240,11 @@ export default class Oscilloscope extends EffectMod {

protected override onUnlinked(plugPosition: number, prev: Mod): void {
if (plugPosition === PlugPosition.NORTH) {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.lastFrameTs = 0;
this.waveformLineLeft?.points([]);
this.waveformLineRight?.points([]);
this.waveformLineLeft?.getLayer()?.batchDraw();
Expand Down
58 changes: 43 additions & 15 deletions src/core/Mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ export default abstract class Mod {
group.on('dragstart', () => {
document.body.style.cursor = 'grab';
shadow.show();
shadowVisible = true;
shadowSlotX = this.x;
shadowSlotY = this.y;
shadow.moveToTop();
group.moveToTop();

Expand All @@ -142,6 +145,21 @@ export default abstract class Mod {
// to move the Mod back to this slot if dropped
let targetX = this.x;
let targetY = this.y;
let shadowVisible = false;
let shadowSlotX = targetX;
let shadowSlotY = targetY;
let drawScheduled = false;

const scheduleStageDraw = (): void => {
const stage = group.getStage();
if (!stage || drawScheduled) return;
drawScheduled = true;

requestAnimationFrame(() => {
drawScheduled = false;
stage.batchDraw();
});
};

group.on('dragend', () => {
document.body.style.cursor = '';
Expand All @@ -156,6 +174,7 @@ export default abstract class Mod {
this.events.emit('checkDeleteZone', group.x(), group.y(), this.width * slotWidth, this.height * slotHeight, deleteResult);
if (deleteResult.inDeleteZone) {
shadow.hide();
shadowVisible = false;
this.events.emit('delete');
return;
}
Expand Down Expand Up @@ -199,26 +218,36 @@ export default abstract class Mod {
this.events.emit('checkDeleteZone', group.x(), group.y(), this.width * slotWidth, this.height * slotHeight, moveResult);

if (moveResult.inDeleteZone) {
shadow.hide();
if (shadowVisible) {
shadow.hide();
shadowVisible = false;
scheduleStageDraw();
}
this.events.emit('deleteZoneChange', true);
group.getStage()?.batchDraw();
return;
}

this.events.emit('deleteZoneChange', false);

// Above the main rack (but not in delete zone): hide snap shadow
if (group.y() < 0) {
shadow.hide();
group.getStage()?.batchDraw();
if (shadowVisible) {
shadow.hide();
shadowVisible = false;
scheduleStageDraw();
}
return;
}

// Keep drag cursor locked until dragend
document.body.style.cursor = 'grab';

// Restore shadow visibility if it was hidden while above rack
shadow.show();
if (!shadowVisible) {
shadow.show();
shadowVisible = true;
scheduleStageDraw();
}

// Compute new position
let x = Math.round(group.x() / slotWidth);
Expand All @@ -228,22 +257,21 @@ export default abstract class Mod {

if (!this.rack.isBusy(x, y, this)) {
// Move the shadow to the current slot
shadow.position({
x: padding + x * slotWidth,
y: padding + y * slotHeight,
});
if (x !== shadowSlotX || y !== shadowSlotY) {
shadow.position({
x: padding + x * slotWidth,
y: padding + y * slotHeight,
});
shadowSlotX = x;
shadowSlotY = y;
scheduleStageDraw();
}

// Store the position, to move the Mod to this position
// if next slot is busy
targetX = x;
targetY = y;

const stage = group.getStage();
if (!stage) {
throw new Error('No Stage attached to this Konva Group');
}
stage.batchDraw();

this.events.emit('dragmove');
}
});
Expand Down
Loading
Loading