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: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ See [development documentation](docs/development.md) for browser tests and deplo
## Built With

- [timbre.js](https://mohayonao.github.io/timbre.js/) — Synthesizes tones and plays soundfont instruments for sorting playback.
- [D3](https://d3js.org/) — Draws SVG bars, markers, and paths using modular selection, scale, color, shape, and array utilities.
- [D3](https://d3js.org/) — Provides scale, color, path geometry, and array utilities; React renders the SVG charts.
- [Jotai](https://jotai.org/) — Stores selected audio and sorting settings independently of the UI.
- [React](https://react.dev/) — Renders settings, playback controls, and editing/export dialogs.
- [TanStack Start](https://tanstack.com/start) — Prerenders the React pages for static hosting.
Expand Down
19 changes: 11 additions & 8 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ TypeScript is introduced incrementally alongside `.mjs` modules. Vite handles
bundling; `pnpm run typecheck` checks `.ts` and `.tsx` application modules separately.
Built-in algorithm source and the Ace editor remain JavaScript for now.

Generator implementations live in `generators/patterns/`; visualization
implementations live in `visualizations/renderers/`. Their named registries sit
one level above, separate from the implementations they register.
Generator implementations live in `generators/patterns/`. React charts live in
`components/visualizations/`; pure trajectory geometry and visualization names live
in `visualizations/`. Neither the registry nor geometry code touches the DOM.

## UI

Expand All @@ -44,7 +44,7 @@ without JavaScript. `src/client.tsx` hydrates the document.
vanilla Jotai store. React owns settings, tabs, transport controls, counters,
sliders, and dialogs. Shared buttons, links, and option controls use Tailwind classes.
`styles/globals.css` holds the shadcn theme and document defaults;
`styles/visualizations.css` styles D3-owned SVG children. There is no `site.css`.
Chart components use Tailwind fill/stroke classes. There is no separate visualization stylesheet.
The light theme uses sky accents and neutral surfaces. Playground layout uses one
threshold (`lg`, 1024px): stacked below it, side-by-side above it. Chart heights
are fluid, bounded with `clamp()`, without height-specific media queries.
Expand All @@ -55,10 +55,13 @@ React reads its playback snapshots through `useSyncExternalStore`. Settings and
custom algorithms are read directly from Jotai; there is no mirrored settings cache.
Audio clocks and nodes remain outside React and Jotai.

[`create-player.mjs`](../src/controllers/create-player.mjs) owns
the contents of its D3 SVG and delegates transport and synthesis to
[`create-player.mjs`](../src/controllers/create-player.mjs) publishes recorded frames,
position, and renderer selection and delegates transport and synthesis to
`audio/create-transport.ts` and `audio/create-timbre-audio.mjs`.
React renders the surrounding controls and an empty SVG host, never chart children.
React subscribes to player snapshots and renders all SVG children, including markers
and the envelope diagram. Trajectory geometry is memoized by frame data; playback
only changes active colors. D3 remains for pure array, scale, color, and path helpers,
not selections or DOM mutations.
The waveform preview canvas has the same explicit imperative ownership.
Pointer capture supports dragging input values across data updates.

Expand All @@ -80,7 +83,7 @@ Cached-page suspension disconnects runtime effects, pauses audio, cancels worker
and pending resumes, and closes dialogs. Returning reconnects effects without
automatically playing. React continues to represent the same Jotai store.
Non-cached exits and Home effect cleanup unmount the playground, dispose owned resources, and
release native pointer listeners. Fresh runtime instances can reuse the store.
release chart pointer state. Fresh runtime instances can reuse the store.
The shared AudioContext stays library-owned.

All third-party JavaScript uses package imports. `audio/timbre.mjs` only re-exports the
Expand Down
2 changes: 1 addition & 1 deletion docs/audio-dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ not start playback.
`audio/create-timbre-audio.mjs` adapts the existing engine: interval creation,
ADSHR and oscillator/pluck nodes, MIDI note triggers, gain, preview plotting,
and node disposal. It accepts the engine and settings getters as dependencies.
The player factory now connects those modules to sliders, buttons, and D3.
The player factory connects those modules to React controls and chart snapshots.
`audio/create-soundfont.ts` now owns a controller-scoped sample cache shared by
both players. `audio/create-timbre-soundfont.mjs` decodes fetched MP3s with the
existing AudioContext and feeds stereo buffers into the existing Timbre mixer.
Expand Down
4 changes: 2 additions & 2 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ to `dist/audio-sort/` without a `public/` URL prefix. Use `/img/...` in source C
adjusts these URLs for the deployment path. Application modules and CSS stay in `src/`.

CSS is bundled and minified by Vite. `src/styles/globals.css` contains Tailwind and
shadcn theme tokens; component classes own layout and control styling. D3-specific
styles live in `src/styles/visualizations.css`.
shadcn theme tokens; component classes own layout, controls, and SVG chart styling.
React chart components live in `src/components/visualizations/`.

Add primitives with `pnpm dlx shadcn@latest add <component>`. `components.json`
selects Base UI, the Nova preset, neutral base colors, and Lucide icons. Keep shared
Expand Down
6 changes: 3 additions & 3 deletions docs/migration-handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ PR #47 migrated the whole playground; PR #48 polished icons, hover states, and t
- The playground uses a reusable vanilla Jotai store.
- `components/playground/sorting-playground.tsx` assembles settings, playback controls, and dialogs.
- `controllers/create-playground.mjs` owns worker/data coordination and audio subscriptions.
- `controllers/create-player.mjs` bridges the existing transport/audio modules and D3.
- React owns controls; D3 owns SVG children; audio draws the waveform canvas.
- `controllers/create-player.mjs` bridges transport/audio modules and publishes chart snapshots.
- React owns controls and SVG children; audio draws the waveform canvas.
- Settings/algorithm overrides stay in Jotai. Playback snapshots use `useSyncExternalStore`.
- Native ranges replace plugin sliders; native dialogs handle editing and MIDI export.
- Lazy Ace loading, invalid-source errors, focus restoration, cached-page suspension,
Expand Down Expand Up @@ -67,7 +67,7 @@ system font, and maps the light theme to Tailwind sky/neutral colors.

- `src/components/ui/`: shared primitives; `components/layout/`: header/footer.
- `src/components/playground/` and `src/components/dialogs/`: controls and dialogs; `src/controllers/`: runtime coordination.
- `src/styles/globals.css`: theme/document defaults; `visualizations.css`: D3 state styles.
- `src/styles/globals.css`: theme/document defaults; chart styling lives in React components.
- `site.css` and the `tw:` prefix are removed. Buttons and links own their Tailwind classes.
- Two playground layouts use one 1024px threshold; chart heights are fluid.
- Tab panels stay mounted for Ace/canvas lifetime. Slider thumbs use center alignment
Expand Down
4 changes: 3 additions & 1 deletion docs/modernization.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ editor runtime. Client navigation unmounts the playground when leaving Home.
The current UI refinement adds shadcn Base UI (Nova preset) before phase 8.
Shared primitives live in `components/ui/`; playground components, dialogs, and
runtime modules live in `components/playground/`, `components/dialogs/`, and `controllers/`, respectively. Tailwind component classes replace
`site.css`, with sky/neutral theme tokens and a small D3 stylesheet. One `lg`
`site.css`, with sky/neutral theme tokens. React now renders chart SVG children
and the envelope diagram with Tailwind classes; D3 only supplies pure utilities.
The intermediate D3 stylesheet and DOM renderer implementations are removed. One `lg`
threshold separates stacked and side-by-side layouts; chart heights use `clamp()`.
Review the larger controls, dialog behavior, and laptop/mobile layouts before merging.

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@
"d3-array": "^3.2.4",
"d3-color": "^3.1.0",
"d3-scale": "^4.0.2",
"d3-selection": "^3.0.0",
"d3-shape": "^3.2.0",
"file-saver": "^2.0.5",
"jotai": "^2.20.3",
Expand All @@ -59,6 +58,8 @@
"devDependencies": {
"@playwright/test": "^1.63.0",
"@tailwindcss/vite": "^4.3.3",
"@types/d3-color": "^3.1.3",
"@types/d3-shape": "^3.2.0",
"@types/node": "^26.4.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
Expand Down
32 changes: 23 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 0 additions & 42 deletions src/audio/envelope-diagram.ts

This file was deleted.

19 changes: 19 additions & 0 deletions src/audio/envelope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Envelope, EnvelopeKey } from "../state/envelope.ts";

export function formatEnvelopeValue(key: EnvelopeKey, value: number): string {
if (key === "s") return `${Math.round(value * 100)}%`;
return value < 1000 ? `${value} ms` : `${Number((value / 1000).toFixed(3))} s`;
}

// Verified against timbre@14.11.25/timbre.dev.js, register("adshr"):
// [0, [1,a], [s,d], [s,h], [0,r]]. Hold is at sustain level AFTER decay,
// unlike an AHDSR peak hold before decay.
export function getEnvelopePoints(e: Envelope) {
return [
[0, 0],
[e.a, 1],
[e.a + e.d, e.s],
[e.a + e.d + e.h, e.s],
[e.a + e.d + e.h + e.r, 0],
] as const;
}
11 changes: 4 additions & 7 deletions src/components/playground/sorting-playground.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { PlayerChart } from "../visualizations/player-chart";
import { PlayerSection } from "./player-section";
import { SortSidebar } from "./sort-sidebar";
import { Button } from "@/components/ui/button";
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import { useEffect, useLayoutEffect, useState, useSyncExternalStore } from "react";
import { Settings } from "./settings-controls.tsx";
import { Transport, Scrubber } from "./playback-controls.tsx";
import { AlgorithmDialog } from "../dialogs/algorithm-dialog.tsx";
Expand All @@ -11,8 +12,6 @@ type Modal = "sort" | "add-algorithm" | "midi-export" | null;
export function SortingPlayground({ runtime }: Props) {
const error = useSyncExternalStore(runtime.subscribe, () => runtime.getSnapshot().error);
const suspended = useSyncExternalStore(runtime.subscribe, () => runtime.getSnapshot().suspended);
const base = useRef<SVGSVGElement>(null);
const sort = useRef<SVGSVGElement>(null);
const [modal, setModal] = useState<Modal>(null);
const [exportId, setExportId] = useState<PlayerId>("base");
useEffect(
Expand All @@ -24,8 +23,6 @@ export function SortingPlayground({ runtime }: Props) {
);
useLayoutEffect(() => {
runtime.mount({
base: base.current,
sort: sort.current,
canvas: document.getElementById("waveform-canvas"),
});
return () => runtime.destroy();
Expand All @@ -47,7 +44,7 @@ export function SortingPlayground({ runtime }: Props) {
<div className="min-w-0">
<Transport runtime={runtime} id="base" />
<div id="base-chart" className="my-3 h-[clamp(12rem,24vh,17rem)]">
<svg ref={base} id="base-svg" aria-label="Input data visualization" />
<PlayerChart runtime={runtime} id="base" />
</div>
<Scrubber runtime={runtime} id="base" />
<div id="base-buttons" className="mt-3 flex flex-wrap justify-center gap-1">
Expand Down Expand Up @@ -88,7 +85,7 @@ export function SortingPlayground({ runtime }: Props) {
<div className="min-w-0">
<Transport runtime={runtime} id="sort" />
<div id="sort-chart" className="my-3 h-[clamp(12rem,24vh,17rem)]">
<svg ref={sort} id="sort-svg" aria-label="Sorting visualization" />
<PlayerChart runtime={runtime} id="sort" />
</div>
<Scrubber runtime={runtime} id="sort" />
</div>
Expand Down
18 changes: 3 additions & 15 deletions src/components/playground/waveform-controls.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { EnvelopeChart } from "../visualizations/envelope-chart";
import { Button } from "@/components/ui/button";
import { ValueSlider } from "@/components/value-slider";
import { useLayoutEffect, useRef } from "react";
import { Field } from "@base-ui/react/field";
import { useAtomValue, useSetAtom } from "jotai";
import type { createStore } from "jotai/vanilla";
import { settingsAtom, updateSettingAtom } from "../../state/settings.ts";
import { envelopeAtom, updateEnvelopeAtom, type EnvelopeKey } from "../../state/envelope.ts";
import { waveformDefaults, type WaveformId } from "../../state/waveforms.ts";
import { drawEnvelopeDiagram, formatEnvelopeValue } from "../../audio/envelope-diagram.ts";
import { formatEnvelopeValue } from "../../audio/envelope.ts";

type Store = ReturnType<typeof createStore>;
const controls: ReadonlyArray<{
Expand Down Expand Up @@ -63,23 +63,11 @@ export function WaveformControls({ store }: { store: Store }) {
const envelope = useAtomValue(envelopeAtom, { store });
const updateSetting = useSetAtom(updateSettingAtom, { store });
const updateEnvelope = useSetAtom(updateEnvelopeAtom, { store });
const diagram = useRef<SVGSVGElement>(null);
useLayoutEffect(() => {
if (diagram.current) drawEnvelopeDiagram(diagram.current, envelope);
}, [envelope]);

return (
<div className="grid grid-cols-2 gap-3">
<div className="waveform-section col-start-2 row-start-1 min-w-0">
<svg
ref={diagram}
id="envelope-diagram"
className="mb-2 h-16 w-full rounded-lg border bg-neutral-50 [&_text]:hidden"
role="img"
viewBox="35 25 350 135"
aria-label="Amplitude envelope"
preserveAspectRatio="none"
/>
<EnvelopeChart envelope={envelope} />
{/* The audio adapter alone draws this canvas; React never owns its pixels. */}
<canvas
id="waveform-canvas"
Expand Down
37 changes: 37 additions & 0 deletions src/components/visualizations/envelope-chart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { getEnvelopePoints, formatEnvelopeValue } from "../../audio/envelope";
import type { Envelope } from "../../state/envelope";

export function EnvelopeChart({ envelope: e }: { envelope: Envelope }) {
const total = e.a + e.d + e.h + e.r;
const points = getEnvelopePoints(e)
.map(([time, level]) => `${40 + (time / Math.max(1, total)) * 340},${155 - level * 125}`)
.join(" ");
const sustainY = 155 - e.s * 125;
return (
<svg
id="envelope-diagram"
className="mb-2 h-16 w-full rounded-lg border bg-neutral-50"
role="img"
viewBox="35 25 350 135"
preserveAspectRatio="none"
aria-label={`Amplitude envelope: attack ${formatEnvelopeValue("a", e.a)}, decay ${formatEnvelopeValue("d", e.d)}, sustain ${formatEnvelopeValue("s", e.s)} for ${formatEnvelopeValue("h", e.h)}, release ${formatEnvelopeValue("r", e.r)}.`}
>
<line className="stroke-neutral-200" x1="40" y1="30" x2="380" y2="30" />
<line className="stroke-neutral-200" x1="40" y1="155" x2="380" y2="155" />
<line
className="stroke-neutral-400"
strokeDasharray="4 4"
x1="40"
y1={sustainY}
x2="380"
y2={sustainY}
/>
<polyline
className="envelope-curve fill-none stroke-sky-700"
strokeWidth={2.5}
strokeLinejoin="round"
points={points}
/>
</svg>
);
}
Loading