Skip to content
🚧 Under active construction — content being developed and verified.

Website Architecture

The guide site is an Astro + Starlight docs project that ships as a static bundle to GitHub Pages. Every page is Markdown-with-JSX (.mdx); every audio example is a client-side WebAudio playback stack that drives the same C++ engine used by the plugin — compiled to WebAssembly and loaded lazily on the first Play click. Site infrastructure is deliberately thin: the interesting complexity lives in the shared code paths with the plugin, not in the docs framework.

The whole site is one astro.config.mjs. defineConfig sets the deploy origin and base path, then hands off to the Starlight integration which supplies the docs UI, sidebar, search index, and content-collection wiring.

site: 'https://poly.jk.digital',
redirects: {
	'/appendix-architecture': '/appendix-plugin-architecture',
},
Site origin, base path, and one legacy redirect — the whole non-Starlight surface

base: '/poly' is the first thing that has to be right on this project: GitHub Pages serves the site under the /poly subpath (the branded canonical URL is https://poly.jk.digital/, via the site/public/CNAME custom-domain file), and every asset URL (samples, WASM, presets JSON) resolves against import.meta.env.BASE_URL so nothing 404s under the subpath.

The sidebar is fully hand-authored — chapters in reading order, appendices in reference order — so the reader isn’t dependent on file-tree alphabetical accidents.

items: [
	{ label: 'Preset Reference', slug: 'appendix-presets' },
	{
		label: 'Euclidean Reference',
		slug: 'appendix-euclidean-reference',
	},
	{
		label: 'Timing Model',
		slug: 'appendix-timing-model',
	},
	{
		label: 'MIDI Note Mapping',
		slug: 'appendix-midi-mapping',
	},
	{
		label: 'Parameter Reference',
		slug: 'appendix-parameters',
	},
	{
		label: 'Plugin Architecture',
		slug: 'appendix-plugin-architecture',
	},
	{
		label: 'Website Architecture',
		slug: 'appendix-website-architecture',
	},
	{
		label: 'Testing Architecture',
		slug: 'appendix-testing-architecture',
	},
	{
		label: 'Design Decisions',
		slug: 'appendix-design-decisions',
	},
	{
		label: 'References',
		slug: 'appendix-references',
	},
	{
		label: 'Credits & Licenses',
		slug: 'credits',
	},
],
Appendices sidebar — reference material, cross-cutting concerns, and this file

Content is loaded through Starlight’s content-collection loader. This one declaration wires every site/src/content/docs/*.mdx file into Astro’s content pipeline with the Starlight schema (frontmatter fields like title, description, table-of-contents behavior).

export const collections = {
	docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
};
The whole content-collection wiring — one loader, one schema, all pages

Each chapter or appendix is a plain .mdx file that imports a small set of Astro components — PolyPatch for parameter tables, PolyPreviewCard for audible examples, CodeSnippet for the build-time source excerpts you’re reading right now, EuclideanDiagram for rhythm visualizations. Frontmatter carries the page title and description; everything else is prose.

Preset data — lane counts, note assignments, BPMs, macro values, the readable name of each preset — is authored in C++ (engine/src/presets.cpp) and consumed by three surfaces: the site preview cards, the WASM Try It modal, and the chapter MDX pages. A single source of truth avoids the class of bug where a rename in C++ silently drifts from a hand-copied JS table.

site/scripts/generate-presets-json.mjs builds a small engine-only CMake tree (build-presets/), runs the emitter, validates the JSON shape, and writes the output alongside both consumer locations. It runs as the site’s prebuild step so a stale JSON can’t ship.

ensureEmitter();
const raw = runEmitter();
let parsed;
try {
  parsed = JSON.parse(raw);
} catch (err) {
  fail(`emitter output is not valid JSON: ${err.message}`);
}
validate(parsed);

const serialized = `${JSON.stringify(parsed, null, 2)}\n`;
mkdirSync(dirname(OUT_PATH), { recursive: true });
writeFileSync(OUT_PATH, serialized);
log(`wrote ${parsed.presetCount} presets → ${OUT_PATH}`);
mkdirSync(dirname(WEBUI_OUT_PATH), { recursive: true });
writeFileSync(WEBUI_OUT_PATH, serialized);
log(`wrote ${parsed.presetCount} presets → ${WEBUI_OUT_PATH}`);
Prebuild step — emit, parse, validate, and write to both the module-graph and public webui locations

The emitter binary depends only on poly_engine, so a Linux CI runner never needs the VST3 SDK to regenerate presets. When the emitter changes shape, the validate() helper (upstream in the same file) rejects unknown schemaVersion values so the failure is a build error rather than a silent card breakage.

Sample audio is served from site/public/samples/ — WAV/FLAC files organized by percussion role (kick/, snare/, hat/, …). A single manifest.json lists every sample with its file path, role label, license, and the MIDI notes it should trigger on.

The sample loader picks a buffer for a MIDI note using a two-step fallback: prefer a sample whose declared role matches the calling lane’s role, then fall back to any sample that lists the note. This resolves manifest note collisions (e.g. note 36 = kick and cajon, note 43 = darbuka and tom) by using the preset’s own lane role as a tiebreaker.

// Exported so PolyPreviewCard and the sample-equivalence gate can compute the
// expected file for a given (note, preferredRole) without duplicating this
// algorithm. wasm-host.js mirrors this exact 2-step lookup so both surfaces
// pick the same file for the same lane. Any change here MUST be mirrored in
// webui/wasm-host.js::pickEntryForNote.
export function pickEntryForNote(
  manifest: Manifest,
  note: number,
  preferredRole?: string,
): SampleEntry | undefined {
  if (preferredRole) {
    const roleMatch = manifest.samples.find(
      (entry) =>
        entry.role === preferredRole && entry.midiNotes.includes(note),
    );
    if (roleMatch) return roleMatch;
  }
  return manifest.samples.find((entry) => entry.midiNotes.includes(note));
}
Role-first, note-fallback sample selection — how a jazz cymbal lane picks ride vs china for note 51

This is the exact algorithm the WASM Try It modal now mirrors (see the plugin-side wasm-host.js), so card Play and Try It Play both pick the same sample file for a given (role, note) pair — not just the same MIDI events.

The playback stack is deliberately small: a shared WASM engine module loaded lazily on the first Play click, a scratch engine context per playback session, and a WebAudio scheduler that runs a ~140ms lookahead loop against the engine’s output.

getSharedEngine() guarantees one Module load per page. Emscripten resolves poly_engine.wasm via import.meta.url on the JS module; since the JS is imported through a Blob URL to sidestep Vite’s module resolver, locateFile is overridden to point back at the real static asset path.

export function loadEngineModule(baseUrl: string): Promise<EngineModule> {
  if (modulePromise) return modulePromise;
  const url = resolveEngineJsUrl(baseUrl);
  modulePromise = (async () => {
    const factory = await importEngineFactory(url);
    // Emscripten resolves poly_engine.wasm via import.meta.url of the JS
    // module. Loading through a Blob would derive a bogus blob: sibling URL,
    // so we override locateFile to point back at the original static asset
    // path.
    return factory({
      locateFile: (path: string) => new URL(path, url).href,
    });
  })();
  return modulePromise;
}

export async function getSharedEngine(
  baseUrl: string,
): Promise<{ Module: EngineModule; ctx: number }> {
  const Module = await loadEngineModule(baseUrl);
  if (sharedCtx === null) sharedCtx = Module._poly_create();
  return { Module, ctx: sharedCtx };
}
Lazy-load the WASM engine once per page, cache the Module + a shared context

Real playback allocates a fresh engine context in createEngineScheduler.start() — the shared context is reserved for dump-mode snapshots that must not perturb playback state.

The scheduler is a WebAudio-scheduler-pattern loop: on each tick (~30ms), advance the engine forward until its output covers the next ~140ms of wall time, then schedule every emitted note against AudioContext.currentTime.

function renderChunk(): void {
  if (engineCtx === 0) return;
  const end = nextPpq + CHUNK_QUARTERS;
  const jumped = renderIter === 0 ? 1 : 0;
  const count = Module._poly_render(
    engineCtx,
    nextPpq,
    end,
    bpm,
    SAMPLE_RATE,
    BLOCK_SIZE,
    1, // playing
    0, // looping
    0.0,
    0.0,
    jumped,
  );
  if (count > 0) {
    const events = readEngineEvents(Module, engineCtx, count);
    for (const e of events) {
      const fireTime = startTime + e.ppq * secPerBeat;
      playVoice(fireTime, e);
    }
  }
  nextPpq = end;
  renderIter += 1;
}

function pump(): void {
  if (!running) return;
  const deadlinePpq =
    (context.currentTime - startTime + lookAheadMs / 1000) / secPerBeat;
  // Bound iterations so a wild deadline can't stall the tick.
  let safety = 0;
  while (running && nextPpq < deadlinePpq && safety++ < 256) {
    renderChunk();
  }
  tickTimer = setTimeout(pump, tickMs);
}
~140ms lookahead — pump() drives renderChunk() which drives _poly_render() one quarter at a time

Each _poly_render call covers one quarter-note (CHUNK_QUARTERS = 1) — well under the engine’s kMaxEventsPerBlock = 256 cap even for dense presets — so a single render can never overflow the event buffer. nextPpq is the only accumulated state; timing is derived from AudioContext.currentTime, not measured, so glitchy tab throttling degrades to silence instead of drift.

Two Astro components carry almost every audible or tabular example on the site.

PolyPatch is a static component: a titled block with a MDX <slot />. It’s used for parameter tables and short “here’s the exact patch” sidebars. No JavaScript, no state — pure MDX-in-Astro composition.

PolyPreviewCard is the interactive one. Each card carries a preset name; two buttons (Play + Try It) share the audio pipeline. Play uses the sample-loader + engine-scheduler path against the shared WASM module. Try It opens a sandboxed iframe running the same WASM engine inside the plugin’s actual web UI.

The wiring inside startCard() is small: the sample loader gets a role-hinted note map, the scheduler is created with the loader + the resolved preset + a shared voice-bus for RMS parity checks.

const loader = createSampleLoader({
  manifest,
  context: ctx,
  fetcher: browserFetcher,
  baseUrl: samplesBaseUrl,
  onDecoded: () => { audioProbe.sampleBuffersDecoded += 1; },
  preferredRoles,
});
const laneNotes = resolved.lanes.map((l) => l.note);
const scheduler = createEngineScheduler({
  Module: engineModule,
  presetIndex: resolved.index,
  bpm: resolved.bpm,
  context: ctx,
  loader,
  laneNotes,
  destination: voiceBus ?? ctx.destination,
  onNoteScheduled: (fireTime, event) => {
    audioProbe.scheduledNoteTimes.push(fireTime);
    audioProbe.scheduledNoteBeats.push(event.beat);
  },
});
Card Play wiring — sample loader + engine scheduler, one shared voice bus, role-hinted note resolution

Everything else in the component is stateful UI: mute-per-lane chips, error-state banner, dump-mode SMF export (equivalence gates), the modal iframe.

The two paths — card Play and Try It modal — must be audibly identical for the same preset. Two classes of drift are watched:

  • MIDI equivalence — SMF-level dumps from both surfaces are byte-identical by construction (both run through the same _poly_render call). Enforced by the S13 equivalence gate.
  • Sample selection equivalence — the role-first fallback in pick-entry-for-note is mirrored inside webui/wasm-host.js, and a Playwright gate (added in the M044 sample-selection parity fix) asserts the two paths pick the same sample file for every (role, note) pair across all preset cards.

Everything else — BPM, macro values, lane muting, playback timing — flows through the shared preset-patterns.ts resolver so there is no independent copy to drift.

.github/workflows/deploy-site.yml builds the site on every push to main and deploys to GitHub Pages. A post-deploy job then runs scripts/site-verify-remote.sh against the live URL — every gate that runs locally in site-verify-local.sh (audio, preset consistency, MIDI equivalence, sample-selection equivalence, macro-diff, lane-mute, control audit, console errors) also runs remote so a deployment regression fails visibly rather than shipping silently.

The testing architecture appendix goes deeper into what each gate actually asserts, when it runs, and how the deliberate-break proofs are structured.

Preview audio uses CC0 and CC-BY drum samples. Every sample is credited on theCredits & Licenses page.