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
16 changes: 9 additions & 7 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ Paths below are relative to `src/`; `@/` aliases that directory.
| `visualizations/` | Pure trajectory geometry and visualization types |
| `utilities/` | Stateless helpers for arrays, randomness, and CSS classes |

TypeScript and JavaScript coexist. Built-in algorithms and editable function bodies
remain JavaScript. Third-party JavaScript comes from package imports.
Application modules use TypeScript. The eight built-in algorithm implementations
remain JavaScript because their raw source populates the JavaScript editor.
Third-party JavaScript comes from package imports; `types/` describes the browser
APIs missing from the libraries' declarations.

## UI and lifecycle

Expand Down Expand Up @@ -76,29 +78,29 @@ contain `{ key, frames }` or `{ key, error }`. Without Worker support, the same
handler runs on the main thread. Custom JavaScript has no security sandbox, and the
worker does not impose an execution or frame budget.

`sorting/algorithm-registry.mjs` holds frozen built-ins. Jotai stores custom
`sorting/algorithm-registry.ts` holds frozen built-ins. Jotai stores custom
additions and overrides separately and derives the combined catalog. Source is
compiled before updating state; duplicate IDs are rejected.
`sorting/algorithm-sources.mjs` imports readable source separately for the editor,
`sorting/algorithm-sources.ts` imports readable source separately for the editor,
so source strings are not included in the worker bundle.

## Audio

`audio/create-transport.ts` owns timing, position, direction, looping, and pending
resume invalidation. Its clock and effects are injected. Stop allows note tails
to finish; suspension/disposal silences owned nodes.
`audio/create-timbre-audio.mjs` connects transport events to synthesis and previews.
`audio/create-timbre-audio.ts` connects transport events to synthesis and previews.
Waveforms share the envelope in `state/envelope.ts`; the string preview is
illustrative rather than a sampled live waveform.

`audio/create-soundfont.ts` shares a sample cache between both players, deduplicates
requests, allows retries, and aborts fetches after ten seconds. Cache misses load
without playing a late note. `audio/create-timbre-soundfont.mjs` decodes MP3s with
without playing a late note. `audio/create-timbre-soundfont.ts` decodes MP3s with
the existing AudioContext and feeds buffers to Timbre. Disposal aborts requests
and ignores late results. The GeneralUser GS sample bank and instrument/note
mapping were retained during modernization.

`audio/timbre.mjs` imports `timbre/timbre.dev.js`, not its Node entry.
`audio/timbre.ts` imports `timbre/timbre.dev.js`, not its Node entry.
`pnpm-workspace.yaml` excludes unused `speaker` and `readable-stream` dependencies;
Vite maps the bundle's CommonJS `global` to `globalThis`. Timbre publishes a global
as a side effect, but application code uses its import. `package.json` permits
Expand Down
4 changes: 2 additions & 2 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ The [sorting catalog](sorting-algorithms.md) records candidates and implementati

1. Add `src/sorting/algorithms/<id>.mjs` with a default function and metadata,
following an existing implementation. Use its `AS` argument; see the [API](api.md).
2. Register its stable ID in `src/sorting/algorithm-registry.mjs`.
3. Add a raw-source import and entry in `src/sorting/algorithm-sources.mjs`.
2. Register its stable ID in `src/sorting/algorithm-registry.ts`.
3. Add a raw-source import and entry in `src/sorting/algorithm-sources.ts`.
4. Update the catalog, then run `pnpm run check` and `pnpm run test:browser`.

Keep the function body self-contained: imported helpers are unavailable when the
Expand Down
2 changes: 1 addition & 1 deletion docs/sorting-algorithms.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ keep appearing. This catalog covers the named sorts in Wikipedia's
plus additional variants, networks, and external sorts. Each algorithm name links to
its Wikipedia article or the relevant parent article when it has no separate page.

Implementation status checked against the [registry](../src/sorting/algorithm-registry.mjs)
Implementation status checked against the [registry](../src/sorting/algorithm-registry.ts)
on 2026-09-08. Only registered built-ins count as implemented; historical files and
custom editor examples do not.

Expand Down
2 changes: 1 addition & 1 deletion oxlint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export default defineConfig({
env: { node: false, browser: true },
},
{
files: ["src/worker.mjs"],
files: ["src/sorting/worker.ts"],
env: { browser: false, worker: true },
},
],
Expand Down
15 changes: 10 additions & 5 deletions src/audio/audio-settings.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import type { useStore } from "jotai";
import { settingsAtom } from "../state/settings";
import { settingsAtom, type Settings } from "../state/settings";
import { selectedWaveformAtom } from "../state/waveforms";

// Audio clock callbacks read current values without subscribing or owning a store.
export function createAudioSettings(store: ReturnType<typeof useStore>) {
function getSelected<Key extends keyof Settings>(key: Key): Settings[Key];
function getSelected(key: string, fallback?: unknown): unknown;
function getSelected(key: string, fallback?: unknown) {
const selected = store.get(settingsAtom);
return Object.hasOwn(selected, key) ? selected[key as keyof Settings] : fallback;
}
return {
getSelected(key: string, fallback?: unknown) {
const selected = store.get(settingsAtom);
return Object.hasOwn(selected, key) ? selected[key as keyof typeof selected] : fallback;
},
getSelected,
getSelectedWaveformInfo: () => store.get(selectedWaveformAtom),
getTempoString: () => `bpm${store.get(settingsAtom).tempo} l16`,
};
}

export type AudioSettings = ReturnType<typeof createAudioSettings>;
43 changes: 27 additions & 16 deletions src/audio/create-player.mjs → src/audio/create-player.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
import { createTimbreAudio } from "./create-timbre-audio.mjs";
import type { AudioSettings } from "./audio-settings";
import type { createSoundfont } from "./create-soundfont";
import type { PlayerState } from "../state/players";
import type { SortFrame } from "../sorting/sort-types";
import type { VisualizationType } from "../visualizations/visualization-types";

type Options = {
settings: AudioSettings;
getMidiNumber: (value: number) => number;
soundfont: ReturnType<typeof createSoundfont>;
isLooping: () => boolean;
onUpdate: (state: PlayerState) => void;
};
import { createTimbreAudio } from "./create-timbre-audio.ts";
import { createTransport } from "./create-transport.ts";
import { timbre } from "./timbre.mjs";
import { timbre } from "./timbre.ts";
import { visualizations } from "../visualizations/visualization-types.ts";
import { createMidiBytes } from "../midi/create-midi-bytes.mjs";
import { createMidiBytes } from "../midi/create-midi-bytes.ts";
import { drawStringPreview } from "./string-preview.ts";

// Owns transport and audio resources; React renders the published chart data.
export function createPlayer({ settings, getMidiNumber, soundfont, isLooping, onUpdate }) {
let data = [];
/** @type {import('../visualizations/visualization-types.ts').VisualizationType} */
let renderer = "bar";
export function createPlayer({ settings, getMidiNumber, soundfont, isLooping, onUpdate }: Options) {
let data: SortFrame[] = [];
let renderer: VisualizationType = "bar";
let disposed = false;
const audio = createTimbreAudio(
timbre,
Expand Down Expand Up @@ -39,30 +51,29 @@ export function createPlayer({ settings, getMidiNumber, soundfont, isLooping, on
onStart: audio.start,
onSuspend: audio.suspend,
onFrame(index) {
audio.playFrame(data[index]);
audio.playFrame(data[index]!);
draw();
},
});
audio.refresh();
/** @param {import('../visualizations/visualization-types.ts').VisualizationType} name */
const setVisualization = (name) => {
const setVisualization = (name: VisualizationType) => {
if (!Object.hasOwn(visualizations, name)) return;
renderer = name;
draw();
};
return {
setData(value) {
setData(value: SortFrame[]) {
if (disposed) return;
data = value;
transport.setLength(data.length);
draw();
},
setVisualization,
seek(value) {
seek(value: number) {
transport.seek(value);
draw();
},
async action(action) {
async action(action: string) {
await transport.whenReady(audio.resume(), () => {
if (action === "stop") transport.stop();
else if (action === "play" || action === "reverse") transport.play(action === "reverse");
Expand All @@ -82,13 +93,13 @@ export function createPlayer({ settings, getMidiNumber, soundfont, isLooping, on
setTempo: transport.setTempo,
setVolume: audio.setVolume,
refresh: audio.refresh,
plot(canvas) {
plot(canvas: HTMLCanvasElement | null) {
if (!canvas) return;
canvas.getContext("2d").clearRect(0, 0, canvas.width, canvas.height);
canvas.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height);
if (settings.getSelected("waveform") === "string") drawStringPreview(canvas);
else audio.plot({ target: canvas, background: "rgba(255,255,255,0)" });
},
getMidiBytes: (tempo, channel, instrument) =>
getMidiBytes: (tempo: number, channel: number, instrument: number) =>
createMidiBytes(data, getMidiNumber, tempo, channel, instrument),
destroy() {
if (disposed) return;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import type { Timbre, TimbreNode, PlotOptions } from "timbre/timbre.dev.js";
import type { AudioSettings } from "./audio-settings";
import type { createSoundfont } from "./create-soundfont";
import type { SortFrame } from "../sorting/sort-types";

// The legacy engine is injected here; this adapter has no DOM or store ownership.
// Keep its synthesis, timing, and soundfont gain unchanged during UI migration.
export function createTimbreAudio(timbre, settings, getMidiNumber, isPlaying, soundfont) {
let env;
let generator;
export function createTimbreAudio(
timbre: Timbre,
settings: AudioSettings,
getMidiNumber: (value: number) => number,
isPlaying: () => boolean,
soundfont: ReturnType<typeof createSoundfont>,
) {
let env: TimbreNode | null = null;
let generator: TimbreNode | null = null;
let disposed = false;

function suspend() {
Expand All @@ -12,36 +23,36 @@ export function createTimbreAudio(timbre, settings, getMidiNumber, isPlaying, so

return {
resume: () => timbre.fn._audioContext.resume(),
createClock(tick) {
createClock(tick: () => void) {
const interval = timbre("interval", { interval: settings.getTempoString() }, tick);
return {
start: () => interval.start(),
stop: () => interval.stop(),
setTempo: (tempo) => interval.set({ interval: tempo }),
setTempo: (tempo: string) => interval.set({ interval: tempo }),
dispose() {
interval.removeAllListeners();
interval.removeAll();
},
};
},
start() {
if (!disposed && settings.getSelected("audioType") === "waveform") generator.play();
if (!disposed && settings.getSelected("audioType") === "waveform") generator?.play();
},
playFrame(frame) {
playFrame(frame: SortFrame) {
if (disposed) return;
const audioType = settings.getSelected("audioType");
for (const item of frame.arr) {
if (!item.play) continue;
const midi = getMidiNumber(item.value);
if (!(midi >= 0 && midi < 128)) continue;
if (audioType === "waveform") generator.noteOn(midi, 64);
if (audioType === "waveform") generator?.noteOn(midi, 64);
else if (audioType === "soundfont") {
soundfont.play(midi, settings.getSelected("volume") * 1.5);
}
}
},
setVolume(volume) {
if (!disposed) generator.set({ mul: volume });
setVolume(volume: number) {
if (!disposed) generator?.set({ mul: volume });
},
refresh() {
if (disposed) return;
Expand All @@ -59,9 +70,9 @@ export function createTimbreAudio(timbre, settings, getMidiNumber, isPlaying, so
if (!isPlaying()) this.pause();
});
if (wave.gen === "OscGen") generator.set("osc", timbre(settings.getSelected("waveform")));
if (isPlaying() && settings.getSelected("audioType") === "waveform") generator.play();
if (isPlaying() && settings.getSelected("audioType") === "waveform") generator?.play();
},
plot(options) {
plot(options: PlotOptions) {
generator?.osc?.plot(options);
},
suspend,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type { Timbre } from "timbre/timbre.dev.js";
import { createSoundfont } from "./create-soundfont.ts";

// Native decoding replaces JSONP and the bundled JS MP3 decoder. Continue routing
// samples through Timbre so they share the existing mixer with waveform playback.
export function createTimbreSoundfont(timbre) {
export function createTimbreSoundfont(timbre: Timbre) {
return createSoundfont({
decode: (bytes) => timbre.fn._audioContext.decodeAudioData(bytes),
createSample(decoded) {
const left = decoded.getChannelData(0);
const right = decoded.numberOfChannels > 1 ? decoded.getChannelData(1) : left;
const mix = Float32Array.from(left, (value, index) => (value + right[index]) / 2);
const mix = Float32Array.from(left, (value, index) => (value + right[index]!) / 2);
const sample = timbre("buffer", {
buffer: { samplerate: decoded.sampleRate, buffer: [mix, left, right] },
}).on("ended", function () {
Expand Down
File renamed without changes.
8 changes: 4 additions & 4 deletions src/components/dialogs/algorithm-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import {
addAlgorithmAtom,
editAlgorithmAtom,
} from "../../state/algorithm-overrides.ts";
import { algorithms } from "../../sorting/algorithm-registry.mjs";
import { sources } from "../../sorting/algorithm-sources.mjs";
import { algorithms } from "../../sorting/algorithm-registry.ts";
import { sources } from "../../sorting/algorithm-sources.ts";
import { getFunctionBody } from "../../sorting/sort-requests.ts";
import type { createCodeEditor } from "./create-code-editor.mjs";
import type { createCodeEditor } from "./create-code-editor.ts";
import { PlayerDialog as Dialog } from "./player-dialog.tsx";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Input } from "@/components/ui/input";
Expand All @@ -29,7 +29,7 @@ export function AlgorithmDialog({ adding, onClose }: { adding: boolean; onClose:
useEffect(() => {
let cancelled = false;
let instance: ReturnType<typeof createCodeEditor> | undefined;
void import("./create-code-editor.mjs")
void import("./create-code-editor.ts")
.then(({ createCodeEditor }) => {
if (cancelled) return;
instance = createCodeEditor(host.current!);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import javascriptWorkerUrl from "ace-builds/src-min-noconflict/worker-javascript
// Let Vite emit the worker and resolve it at both / and /audio-sort/.
ace.config.setModuleUrl("ace/mode/javascript_worker", javascriptWorkerUrl);

export function createCodeEditor(element) {
export function createCodeEditor(element: HTMLElement) {
const editor = ace.edit(element);
editor.setOptions({
theme: "ace/theme/monokai",
Expand All @@ -17,7 +17,13 @@ export function createCodeEditor(element) {
const session = editor.getSession();
session.on("changeMode", () => {
// The editor contains a function body with AS supplied by the runner.
session.$worker?.call("changeOptions", [{ esversion: 11, globals: { AS: false } }]);
// Ace creates this worker after setting the mode, but omits it from EditSession's types.
const worker = (
session as typeof session & {
$worker?: { call(command: string, args: unknown[]): void };
}
).$worker;
worker?.call("changeOptions", [{ esversion: 11, globals: { AS: false } }]);
});
session.setMode("ace/mode/javascript");
return editor;
Expand Down
2 changes: 1 addition & 1 deletion src/components/playground/playground-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function useActions() {
instrument,
);
saveAs(
new Blob([Uint8Array.from(bytes as string, (char) => char.charCodeAt(0))], {
new Blob([Uint8Array.from(bytes, (char) => char.charCodeAt(0))], {
type: "audio/midi",
}),
`${filename}.mid`,
Expand Down
8 changes: 4 additions & 4 deletions src/hooks/use-players.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useEffect, useMemo, useState } from "react";
import { useAtomValue, useStore } from "jotai";
import { createPlayer } from "../audio/create-player.mjs";
import { createTimbreSoundfont } from "../audio/create-timbre-soundfont.mjs";
import { timbre } from "../audio/timbre.mjs";
import { createPlayer } from "../audio/create-player.ts";
import { createTimbreSoundfont } from "../audio/create-timbre-soundfont.ts";
import { timbre } from "../audio/timbre.ts";
import { createAudioSettings } from "../audio/audio-settings";
import { createHelpers } from "../midi/create-helpers.mjs";
import { createHelpers } from "../midi/create-helpers.ts";
import { settingsAtom } from "../state/settings";
import { selectedWaveformAtom } from "../state/waveforms";
import { playbackPreferencesAtom } from "../state/playback-preferences";
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/use-sort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export function useSort(session: AudioSession | null) {
accept(handleSortRequest(request));
return;
}
worker = new Worker(new URL("../sorting/worker.mjs", import.meta.url), { type: "module" });
worker = new Worker(new URL("../sorting/worker.ts", import.meta.url), { type: "module" });
worker.addEventListener("message", (event: MessageEvent<SortResponse>) => accept(event.data));
worker.addEventListener("error", (event) => accept({ key, error: event.message }));
worker.postMessage(request);
Expand Down
14 changes: 9 additions & 5 deletions src/midi/create-helpers.mjs → src/midi/create-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import type { AudioSettings } from "../audio/audio-settings";
import { scales } from "./scales.ts";

export function createHelpers(settings, dependencies = { scales }) {
export function createHelpers(
settings: Pick<AudioSettings, "getSelected">,
dependencies = { scales },
) {
return {
getMidiNumber(playValue) {
const scale = dependencies.scales[settings.getSelected("scale")];
getMidiNumber(playValue: number) {
const scale = dependencies.scales[settings.getSelected("scale")]!;
const degrees = scale.degrees;
const noteAt = (position) =>
degrees[position % degrees.length] +
const noteAt = (position: number) =>
degrees[position % degrees.length]! +
Math.floor(position / degrees.length) * scale.pitchesPerOctave;
return (
noteAt(playValue) +
Expand Down
Loading