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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ name: CI

on:
push:
branches:
- main
pull_request:

permissions:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ node_modules/
packages/*/node_modules/
packages/native/target/
packages/native/*.node
packages/studio/dist/
.pi-subagents/
.DS_Store
*.log
2 changes: 2 additions & 0 deletions .pi/npm/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*
!.gitignore
3 changes: 3 additions & 0 deletions .pi/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"packages": ["npm:@noice-tech/pi-changelog"]
}
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,30 @@ Patch programs are control-rate automation: JavaScript never runs in the audio c

## Studio

Studio binds only to loopback with a random per-process capability URL. It visualizes the current signal chain, canonical values, program time, voice, and runtime status. It does not read or rewrite source files and exposes no patch editor or remote hosting surface.
Studio binds only to loopback with a random per-process capability URL. Its React interface combines the playable keyboard with an Ableton-style serial device rack, device browser, and inspector. Supported edits are written back to the one TypeScript entry file passed to the CLI; Studio is never a remote hosting surface and the browser cannot choose a path or send source code.

Literal scalar controls preview the sound while you drag. Releasing the pointer, pressing Enter, or leaving the input commits one validated source edit and creates one process-local undo entry. Adding, removing, replacing, or reordering oscillator/filter/LFO/effect blocks commits immediately. Studio formats edited source with `oxfmt` before validating and writing it. Studio compares source content and file identity immediately before an atomic replacement and verifies the result, so ordinary stale edits fail with a visible conflict. Portable filesystems do not provide an indivisible content-hash-and-replace primitive; a truly simultaneous external rename in the final replacement interval can still race, so avoid saving from an editor during the instant Studio commits.

### Editable TypeScript

Studio deliberately understands a small source grammar rather than guessing at arbitrary TypeScript:

- A static patch must be a directly default-exported object literal, optionally wrapped in `satisfies`, `as`, or parentheses.
- A `PatchProgram` may return one direct object literal, either as a concise arrow body or one function-scope `return`. Local statements before that return are allowed.
- Direct literal numbers, strings, booleans, objects, and dense arrays are editable. Missing optional literal fields appear as defaults and materialize only when changed.
- Variables, calls, arithmetic, conditional values, spreads, computed keys, imported patch objects, and indirect exports remain audible and visible but are marked **Computed** and read-only with a source location.
- Studio only rewrites the entry file. Symbolic links and non-`.ts`/`.tsx` entry files are playback-only.

For example, `resonance` is editable while the authored cutoff animation remains code-only:

```ts
filter: {
cutoffHz: 200 + movement * 2_400, // Computed in code
resonance: 0.45, // Editable literal
}
```

Undo and redo exist only for Studio transactions in the current process. An external file change clears affected Studio history rather than risking a snapshot overwrite. Disconnect invalidates queued browser edits; an atomic commit that has already begun is allowed to finish and shutdown drains it. Invalid external saves continue to retain the previous valid sound through the normal hot-reload behavior.

Use physical computer-key positions:

Expand All @@ -92,7 +115,7 @@ Use physical computer-key positions:

`A` begins at Ableton's displayed `C3`. New notes retrigger the envelope and filter LFO. Patchwave uses last-pressed priority: releasing the active key falls back to the most recently held key without retriggering, and releasing the final key starts the amplitude release. Leaving, hiding, or disconnecting the studio safely releases all notes. Velocity is not yet modeled.

Static patches become playable through a transient runtime frequency copy; the TypeScript file is never changed. Patch programs control their returned pitch directly, so use `voice.frequencyHz` when keyboard pitch should drive the source.
Keyboard performance never rewrites pitch into source: static patches use a transient runtime frequency copy. Patch programs control their returned pitch directly, so use `voice.frequencyHz` when keyboard pitch should drive the source. Studio parameter edits are a separate, explicit source transaction; a held keyboard note is never persisted as the authored `frequencyHz`.

## Source

Expand Down
16 changes: 13 additions & 3 deletions example/classic-bass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,23 @@ export default {
{ waveform: "sine", level: 0.75 },
],
filter: {
cutoffHz: 700,
resonance: 0.35,
mode: "bandpass",
cutoffHz: 609,
resonance: 0.31,
},
ampEnvelope: {
attackSeconds: 0.005,
releaseSeconds: 0.18,
},
},
effects: [{ type: "saturator", driveDb: 12, outputGainDb: -5, mix: 0.55 }],
effects: [
{ type: "saturator", driveDb: 24.6 },
{
type: "stereoDelay",
timeSeconds: 0.906,
feedback: 0.32,
damping: 0.16,
mix: 0.25,
},
],
} satisfies Patch;
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@patchwave/schema": "workspace:*",
"@patchwave/studio": "workspace:*",
"chokidar": "5.0.0",
"oxfmt": "0.59.0",
"tsx": "4.23.1"
},
"devDependencies": {
Expand Down
67 changes: 63 additions & 4 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import {
KeyboardState,
startStudioServer,
type StudioInput,
type StudioKeyboardInput,
type StudioServer,
type StudioServerOptions,
} from "@patchwave/studio";
import chokidar from "chokidar";
import { loadPatchModule, type PatchModule } from "./load-patch.js";
import { NativeDispatcher, type RuntimeAudioEngine } from "./native-dispatcher.js";
import { PatchRuntime } from "./patch-runtime.js";
import { StudioDocumentController } from "./studio-document.js";

const RELOAD_DEBOUNCE_MS = 100;
const RUNTIME_ERROR_POLL_MS = 250;
Expand Down Expand Up @@ -72,13 +74,15 @@ export async function runCli(dependencies: RunCliDependencies): Promise<number>
let studio: StudioServer | undefined;
let dispatcher: NativeDispatcher | undefined;
let runtime: PatchRuntime | undefined;
let document: StudioDocumentController | undefined;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
let runtimeErrorTimer: ReturnType<typeof setInterval> | undefined;
let signalHandlersInstalled = false;
let shutdownRequested = false;
let exitCode = 0;
let reloadRunning = false;
let reloadQueued = false;
let reliableDocumentKey = "";
const keyboard = new KeyboardState();
let resolveShutdown: () => void = () => undefined;
const shutdownPromise = new Promise<void>((resolvePromise) => {
Expand All @@ -98,8 +102,21 @@ export async function runCli(dependencies: RunCliDependencies): Promise<number>
};
const handleSigint = (): void => requestShutdown(0);
const handleSigterm = (): void => requestShutdown(0);
const state = (): unknown => ({ ...runtime?.snapshot(), keyboard: keyboard.snapshot() });
const state = (): unknown => ({
...runtime?.snapshot(),
keyboard: keyboard.snapshot(),
document: document?.snapshot(),
});
const publish = (): void => studio?.publish(state());
const publishDocument = (): void => {
publish();
const snapshot = document?.snapshot();
if (!snapshot) return;
const key = `${snapshot.revision}|${snapshot.phase}|${snapshot.canUndo}|${snapshot.canRedo}|${snapshot.diagnostic ?? ""}`;
if (key === reliableDocumentKey) return;
reliableDocumentKey = key;
studio?.send({ type: "document", protocol: 1, document: snapshot });
};
const releaseAll = (): void => {
keyboard.releaseAll();
runtime?.releaseVoice();
Expand Down Expand Up @@ -146,6 +163,15 @@ export async function runCli(dependencies: RunCliDependencies): Promise<number>
onProgramError: (message) => logger.error(`Patch program: ${message}`),
});

document = await StudioDocumentController.create({
path: configPath,
getCanonical: () => runtime!.snapshot().patch,
preview: (input) =>
runtime!.previewField(input.gestureId, input.baseRevision, input.path, input.value),
cancelPreview: (gestureId) => runtime!.cancelPreview(gestureId),
onChange: publishDocument,
});

try {
activeEngine.start();
} catch (error) {
Expand All @@ -155,6 +181,7 @@ export async function runCli(dependencies: RunCliDependencies): Promise<number>
studio = await createStudio({
onInput: (input) => {
if (shutdownRequested) return;
if (isEditInput(input)) return document!.handle(input);
const action = applyStudioInput(keyboard, input);
if (!runtime?.handleKeyboard(action)) {
logger.error("Studio input could not be applied; releasing the voice.");
Expand All @@ -163,7 +190,10 @@ export async function runCli(dependencies: RunCliDependencies): Promise<number>
}
publish();
},
onControllerClosed: releaseAll,
onControllerClosed: () => {
document?.cancelAllPreviews();
releaseAll();
},
getState: state,
});

Expand All @@ -173,15 +203,38 @@ export async function runCli(dependencies: RunCliDependencies): Promise<number>
try {
do {
reloadQueued = false;
let loadedRevision: string | undefined;
try {
loadedRevision = await document!.currentRevision().catch(() => undefined);
const trackRevision = loadedRevision !== undefined;
if (trackRevision) document?.markReloading();
const candidate = await load(configPath);
if (shutdownRequested) return;
if (await runtime!.stageReload(candidate)) {
if (trackRevision) {
const revisionAfterLoad = await document!.currentRevision();
if (revisionAfterLoad !== loadedRevision) {
reloadQueued = true;
continue;
}
}
const accepted = await runtime!.stageReload(candidate);
if (trackRevision) {
const stillCurrent = await document!.refreshAfterReload(accepted, loadedRevision!);
if (!stillCurrent) {
reloadQueued = true;
continue;
}
}
if (accepted) {
logger.log(`Reloaded ${candidate.kind} patch at frame ${runtime!.snapshot().frame}`);
} else {
logger.error("Patch reload was rejected. Keeping the previous patch program.");
}
} catch (error) {
const currentRevision = document!.snapshot().writable
? await document!.currentRevision().catch(() => loadedRevision)
: undefined;
if (currentRevision) await document!.refreshAfterReload(false, currentRevision);
logger.error(
`Patch reload failed: ${errorMessage(error)}. Keeping the previous patch program.`,
);
Expand Down Expand Up @@ -243,7 +296,9 @@ export async function runCli(dependencies: RunCliDependencies): Promise<number>
} finally {
if (debounceTimer) clearTimeout(debounceTimer);
if (runtimeErrorTimer) clearInterval(runtimeErrorTimer);
document?.cancelAllPreviews();
releaseAll();
await document?.drain();
runtime?.stop();
if (studio) {
try {
Expand Down Expand Up @@ -278,7 +333,11 @@ export async function runCli(dependencies: RunCliDependencies): Promise<number>
return exitCode;
}

function applyStudioInput(keyboard: KeyboardState, input: StudioInput) {
function isEditInput(input: StudioInput): input is Exclude<StudioInput, StudioKeyboardInput> {
return !["keyDown", "keyUp", "releaseAll"].includes(input.type);
}

function applyStudioInput(keyboard: KeyboardState, input: StudioKeyboardInput) {
switch (input.type) {
case "keyDown":
return keyboard.keyDown(input.code);
Expand Down
36 changes: 36 additions & 0 deletions packages/cli/src/patch-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { validatePatch } from "@patchwave/schema";
import type { PatchFieldPath, PatchScalar } from "@patchwave/studio";
import type { CanonicalPatch, LoadedPatch } from "./load-patch.js";
import { loadedPatch } from "./load-patch.js";

export type PatchPreview = Readonly<{
gestureId: string;
baseRevision: string;
path: PatchFieldPath;
value: PatchScalar;
}>;

export function applyPatchPreview(
patch: CanonicalPatch,
preview: PatchPreview | undefined,
): LoadedPatch {
if (!preview) return loadedPatch(patch);
const candidate: any = structuredClone(patch);
let target: any = candidate;
for (const segment of preview.path.slice(0, -1)) {
target = target?.[segment as any];
if (target === null || target === undefined || typeof target !== "object") {
throw new Error("Preview target no longer exists in the patch");
}
}
target[preview.path.at(-1) as any] = preview.value;
return loadedPatch(validatePatch(authorFromCanonical(candidate)));
}

function authorFromCanonical(patch: any): any {
// validatePatch rejects canonical null optionals, so remove only those absence markers.
const clone = structuredClone(patch);
if (clone.source.filter === null) delete clone.source.filter;
else if (clone.source.filter?.cutoffLfo === null) delete clone.source.filter.cutoffLfo;
return clone;
}
Loading