From 88bef4d34eb18e206c51ce11966902739a9cc817 Mon Sep 17 00:00:00 2001 From: Trent Eriksen <124685398+Grashopr-888@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:52:59 -0700 Subject: [PATCH 1/9] feat(design): dark design system, NI-register display type, and the layout shell Co-Authored-By: Claude Fable 5 --- public/favicon.svg | 10 + src/components/Mark.astro | 26 ++ src/components/SiteFooter.astro | 123 +++++++++ src/components/SiteHeader.astro | 189 ++++++++++++++ src/config.ts | 56 ++++ src/layouts/BaseLayout.astro | 51 ++++ src/lib/format.ts | 32 +++ src/lib/url.ts | 22 ++ src/styles/global.css | 444 ++++++++++++++++++++++++++++++++ 9 files changed, 953 insertions(+) create mode 100644 public/favicon.svg create mode 100644 src/components/Mark.astro create mode 100644 src/components/SiteFooter.astro create mode 100644 src/components/SiteHeader.astro create mode 100644 src/config.ts create mode 100644 src/layouts/BaseLayout.astro create mode 100644 src/lib/format.ts create mode 100644 src/lib/url.ts create mode 100644 src/styles/global.css diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..41735a4 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/components/Mark.astro b/src/components/Mark.astro new file mode 100644 index 0000000..d8c6d7d --- /dev/null +++ b/src/components/Mark.astro @@ -0,0 +1,26 @@ +--- +/** The site mark: an ember filament (brand) with an amber weight (Lichtspiel) + * and two teal chimes (Windchime), the two projects hanging from one line. */ +interface Props { + size?: number; + class?: string; +} +const { size = 18, class: klass } = Astro.props; +const h = Math.round((size * 18) / 16); +--- + + diff --git a/src/components/SiteFooter.astro b/src/components/SiteFooter.astro new file mode 100644 index 0000000..2361086 --- /dev/null +++ b/src/components/SiteFooter.astro @@ -0,0 +1,123 @@ +--- +import { SITE, SHOW_EMAIL } from '../config'; +import { url } from '../lib/url'; +import Mark from './Mark.astro'; + +const year = new Date().getFullYear(); +--- + + + + diff --git a/src/components/SiteHeader.astro b/src/components/SiteHeader.astro new file mode 100644 index 0000000..6ee4a56 --- /dev/null +++ b/src/components/SiteHeader.astro @@ -0,0 +1,189 @@ +--- +import { NAV, SITE } from '../config'; +import { url, isActive } from '../lib/url'; +import Mark from './Mark.astro'; + +const here = Astro.url.pathname; +--- + + + + + + diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..53c7a1c --- /dev/null +++ b/src/config.ts @@ -0,0 +1,56 @@ +/** + * SITE CONFIG — identity, links, and navigation. + */ +export const SITE = { + wordmark: 'Current Projects', + author: 'Trent Eriksen', + role: 'Technical Product Manager / Product Engineer', + description: + 'Current projects by Trent Eriksen. Windchime and Lichtspiel: ML audio research meeting ' + + 'interactive AV prototyping, documented in depth.', + handle: 'Grashopr-888', + githubUrl: 'https://github.com/Grashopr-888', + repoUrl: 'https://github.com/Grashopr-888/current-projects', + // Personal email kept here but not rendered publicly by default. + email: 'starduststereo@gmail.com', +} as const; + +export const SHOW_EMAIL = false; + +/** Top navigation. Projects lead as their own tabs; supporting views follow. + * `accent` colors a tab in its project hue so the project tabs stand out. */ +export const NAV: ReadonlyArray<{ + label: string; + href: string; + accent?: 'windchime' | 'lichtspiel'; +}> = [ + { label: 'Overview', href: '/' }, + { label: 'Windchime', href: '/projects/windchime', accent: 'windchime' }, + { label: 'Lichtspiel', href: '/projects/lichtspiel', accent: 'lichtspiel' }, + { label: 'How I Work', href: '/how-i-work' }, + { label: 'Releases', href: '/releases' }, + { label: 'Research', href: '/research' }, + { label: 'Incidents', href: '/incidents' }, + { label: 'About', href: '/about' }, + { label: 'Splice', href: '/splice' }, +]; + +/** Canonical per-project metadata shared across surfaces. */ +export const PROJECT_META = { + windchime: { + label: 'Windchime', + kind: 'Voice-conditioned audiovisual installation', + accent: 'var(--wc)', + logo: '/img/windchime-mark.svg', + art: '/img/windchime-rider.jpg', + artAlt: 'Windchime technical rider: installation render with labeled components', + }, + lichtspiel: { + label: 'Lichtspiel', + kind: 'Live audiovisual assistant for Ableton', + accent: 'var(--ls)', + logo: '/img/lichtspiel-mark.svg', + art: '/img/lichtspiel-hero.jpg', + artAlt: 'Lichtspiel prism mark over a dark low-poly landscape', + }, +} as const; diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro new file mode 100644 index 0000000..498fb9e --- /dev/null +++ b/src/layouts/BaseLayout.astro @@ -0,0 +1,51 @@ +--- +import '@fontsource-variable/inter'; +import '@fontsource-variable/archivo/wdth.css'; +import '@fontsource/ibm-plex-mono/400.css'; +import '@fontsource/ibm-plex-mono/500.css'; +import '../styles/global.css'; + +import SiteHeader from '../components/SiteHeader.astro'; +import SiteFooter from '../components/SiteFooter.astro'; +import { SITE } from '../config'; +import { url } from '../lib/url'; + +interface Props { + title?: string; + description?: string; + accent?: 'windchime' | 'lichtspiel' | 'shared' | 'brand'; +} +const { title, description = SITE.description, accent } = Astro.props; + +const pageTitle = title ? `${title} · ${SITE.wordmark}` : `${SITE.wordmark} · ${SITE.author}`; +const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site).href : Astro.url.pathname; +--- + + + + + + + + {pageTitle} + + + + + + + + + + + + + + + +
+ +
+ + + diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..925f9cc --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,32 @@ +/** Shared date/label formatting so every surface renders dates identically. */ + +// Dates in frontmatter are authored as calendar days (YYYY-MM-DD) and parsed as UTC +// midnight. Formatting in UTC keeps the displayed day identical to what was written, +// regardless of the build machine's local timezone. +export function fmtDate(d: Date): string { + return new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }).format(d); +} + +export function fmtMonth(d: Date): string { + return new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + timeZone: 'UTC', + }).format(d); +} + +export function isoDate(d: Date): string { + return d.toISOString().slice(0, 10); +} + +/** "3 min read" from a body string. */ +export function readingTime(body: string | undefined): string { + const words = (body ?? '').trim().split(/\s+/).filter(Boolean).length; + const mins = Math.max(1, Math.round(words / 220)); + return `${mins} min read`; +} diff --git a/src/lib/url.ts b/src/lib/url.ts new file mode 100644 index 0000000..54f664e --- /dev/null +++ b/src/lib/url.ts @@ -0,0 +1,22 @@ +/** + * Base-path-safe URL builder. + * + * The site deploys under a base path on GitHub Pages project sites + * (e.g. `/product-lifecycle`). Astro does NOT auto-prefix arbitrary hrefs, so + * every internal link is routed through url() to stay correct in dev, on Pages, + * and on a custom domain — change only astro.config's BASE and everything follows. + */ +export function url(path = '/'): string { + const base = import.meta.env.BASE_URL.replace(/\/+$/, ''); + const p = path.startsWith('/') ? path : `/${path}`; + const out = `${base}${p}`; + return out === '' ? '/' : out; +} + +/** True when `href` matches the current pathname (for nav active state). */ +export function isActive(current: string, href: string): boolean { + const target = url(href).replace(/\/$/, ''); + const here = current.replace(/\/$/, ''); + if (target === url('/').replace(/\/$/, '')) return here === target; // Home: exact only + return here === target || here.startsWith(`${target}/`); +} diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..37c445f --- /dev/null +++ b/src/styles/global.css @@ -0,0 +1,444 @@ +/* ============================================================================= + CURRENT PROJECTS — design system. Dark-only (#121214), electric-blue accent + (#0033ff). Display type is Archivo in its expanded width, set in uppercase + for headlines: an industrial, extended-grotesque register in the spirit of + Native Instruments' wordmarks. Inter carries body text. + Token and class names are preserved so every component re-skins automatically. + ========================================================================== */ + +/* ---- design tokens: dark only -------------------------------------------- */ +:root { + /* type */ + --font-display: 'Archivo Variable', 'Inter Variable', -apple-system, system-ui, sans-serif; + --font-body: 'Inter Variable', -apple-system, system-ui, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace; + /* Archivo's variable width axis: full extension for headline set */ + --display-stretch: 125%; + + /* fluid type scale */ + --step--1: clamp(0.79rem, 0.77rem + 0.09vw, 0.84rem); + --step-0: clamp(0.95rem, 0.92rem + 0.15vw, 1.03rem); + --step-1: clamp(1.14rem, 1.08rem + 0.28vw, 1.3rem); + --step-2: clamp(1.37rem, 1.28rem + 0.45vw, 1.63rem); + --step-3: clamp(1.64rem, 1.5rem + 0.68vw, 2.05rem); + --step-4: clamp(1.96rem, 1.75rem + 1.04vw, 2.6rem); + --step-5: clamp(2.36rem, 2.05rem + 1.55vw, 3.35rem); + --step-6: clamp(2.83rem, 2.35rem + 2.4vw, 4.4rem); + + /* spacing */ + --sp-1: 0.25rem; + --sp-2: 0.5rem; + --sp-3: 0.75rem; + --sp-4: 1rem; + --sp-5: 1.5rem; + --sp-6: 2rem; + --sp-7: 3rem; + --sp-8: 4.5rem; + --sp-9: 7rem; + + /* structure */ + --measure: 68ch; + --container: 74rem; + --container-wide: 84rem; + --radius: 8px; + --radius-lg: 14px; + --hair: 1px; + + /* ---- palette: DARK (Splice gray-90 base) ---- */ + --bg: #121214; + --bg-tint: #1b1b1d; + --surface: #1b1b1d; + --surface-2: #232426; + --ink: #ffffff; + --ink-soft: #e5e5e8; + --muted: #a6a8ad; + --faint: #86888f; + --hairline: #2b2c30; + --hairline-strong: #3d3f45; + + --brand: #5a8bff; /* readable blue for text/links/marks on dark */ + --brand-solid: #0033ff; /* Splice electric blue — solid fills */ + --brand-solid-hover: #1e4bff; + --brand-soft: #17223d; + --wc: #5a8bff; /* Windchime — the flagship blue */ + --wc-soft: #17223d; + --ls: #ff8a4c; /* Lichtspiel — warm contrast */ + --ls-soft: #2e1c10; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow: 0 8px 28px -12px rgba(0, 0, 0, 0.7); + --shadow-lg: 0 28px 70px -30px rgba(0, 0, 0, 0.8); + + /* status tones */ + --t-positive: #4ecb84; + --t-warn: #e0a54a; + --t-danger: #ff7a5c; + --t-info: #5a8bff; + + color-scheme: dark; +} + +/* ---- reset-ish ----------------------------------------------------------- */ +*, +*::before, +*::after { + box-sizing: border-box; +} +* { + margin: 0; +} +html { + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; + scroll-padding-top: 5rem; +} +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} + +body { + font-family: var(--font-body); + font-size: var(--step-0); + line-height: 1.6; + color: var(--ink); + background: var(--bg); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + min-height: 100dvh; + overflow-x: hidden; +} + +/* ---- typography ---------------------------------------------------------- */ +h1, +h2, +h3, +h4 { + font-family: var(--font-display); + font-weight: 700; + line-height: 1.08; + letter-spacing: -0.01em; + color: var(--ink); + text-wrap: balance; +} +/* NI-style headline set: uppercase, extended width, heavy weight */ +h1, +h2 { + text-transform: uppercase; + font-stretch: var(--display-stretch); + line-height: 1.02; +} +h1 { + font-size: var(--step-5); + font-weight: 800; + letter-spacing: -0.015em; +} +h2 { + font-size: var(--step-3); + font-weight: 750; + letter-spacing: -0.008em; +} +h3 { + font-size: var(--step-2); + font-stretch: 110%; +} +h4 { + font-size: var(--step-1); + font-stretch: 110%; +} + +p { + text-wrap: pretty; +} + +a { + color: inherit; + text-decoration: none; +} +a:not(.plain) { + color: var(--brand); + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 0.16em; + text-decoration-color: color-mix(in oklab, var(--brand) 40%, transparent); + transition: text-decoration-color 0.15s ease; +} +a:not(.plain):hover { + text-decoration-color: var(--brand); +} + +strong { + font-weight: 650; + color: var(--ink); +} +small { + font-size: var(--step--1); +} + +code, +kbd, +samp, +pre { + font-family: var(--font-mono); + font-size: 0.88em; +} +:not(pre) > code { + background: var(--surface-2); + border: var(--hair) solid var(--hairline); + border-radius: 4px; + padding: 0.1em 0.36em; + font-size: 0.84em; +} + +hr { + border: none; + border-top: var(--hair) solid var(--hairline); + margin: var(--sp-6) 0; +} + +::selection { + background: color-mix(in oklab, var(--brand) 28%, transparent); +} + +:focus-visible { + outline: 2px solid var(--brand); + outline-offset: 3px; + border-radius: 3px; +} + +/* ---- reusable motifs ----------------------------------------------------- */ + +/* eyebrow / section label — bright, bold, NI-flavoured uppercase */ +.label { + font-family: var(--font-display); + font-size: 0.92rem; + font-weight: 740; + font-stretch: 112%; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--ink-soft); + display: inline-flex; + align-items: baseline; + gap: 0.5em; +} +.label .idx { + color: var(--brand); + font-family: var(--font-mono); + font-weight: 500; +} + +/* hairline rule with a brand tick */ +.rule { + position: relative; + border-top: var(--hair) solid var(--hairline); + margin: var(--sp-6) 0; +} +.rule::before { + content: ''; + position: absolute; + top: -2px; + left: 0; + width: 24px; + height: 3px; + border-radius: 2px; + background: var(--brand); +} + +/* chips / tags — pill */ +.chip { + display: inline-flex; + align-items: center; + gap: 0.4em; + font-family: var(--font-mono); + font-size: 0.72rem; + letter-spacing: 0.01em; + padding: 0.22em 0.66em; + border: var(--hair) solid var(--hairline-strong); + border-radius: 100px; + color: var(--muted); + background: var(--surface); + white-space: nowrap; +} +.chip[data-product='windchime'] { + color: var(--wc); + border-color: color-mix(in oklab, var(--wc) 45%, var(--hairline)); + background: color-mix(in oklab, var(--wc-soft) 55%, var(--surface)); +} +.chip[data-product='lichtspiel'] { + color: var(--ls); + border-color: color-mix(in oklab, var(--ls) 45%, var(--hairline)); + background: color-mix(in oklab, var(--ls-soft) 55%, var(--surface)); +} + +/* per-project accent binding */ +[data-accent='windchime'] { + --accent: var(--wc); + --accent-soft: var(--wc-soft); +} +[data-accent='lichtspiel'] { + --accent: var(--ls); + --accent-soft: var(--ls-soft); +} +[data-accent='shared'], +[data-accent='brand'] { + --accent: var(--brand); + --accent-soft: var(--brand-soft); +} + +/* ---- buttons (Splice pills) --------------------------------------------- */ +.btn { + display: inline-flex; + align-items: center; + gap: 0.45rem; + font-family: var(--font-display); + font-weight: 740; + font-stretch: 112%; + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.82rem; + line-height: 1; + padding: 0.78rem 1.4rem; + border-radius: 100px; + border: 1px solid var(--hairline-strong); + background: var(--surface); + color: var(--ink); + cursor: pointer; + transition: + transform 0.15s ease, + background 0.15s ease, + border-color 0.15s ease, + box-shadow 0.15s ease; +} +.btn:hover { + transform: translateY(-1px); + border-color: var(--muted); +} +.btn-primary { + background: var(--brand-solid); + border-color: var(--brand-solid); + color: #fff; +} +.btn-primary:hover { + background: var(--brand-solid-hover); + border-color: var(--brand-solid-hover); + box-shadow: 0 10px 26px -10px color-mix(in oklab, var(--brand-solid) 70%, transparent); +} + +/* ---- layout helpers ------------------------------------------------------ */ +.container { + width: 100%; + max-width: var(--container); + margin-inline: auto; + padding-inline: var(--sp-5); +} +.container-wide { + max-width: var(--container-wide); +} +.measure { + max-width: var(--measure); +} +.stack > * + * { + margin-top: var(--flow, var(--sp-4)); +} +.cluster { + display: flex; + flex-wrap: wrap; + gap: var(--sp-2); + align-items: center; +} +.grid-auto { + display: grid; + gap: var(--sp-4); + grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr)); +} +.visually-hidden { + position: absolute !important; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} +.skip-link { + position: absolute; + left: var(--sp-4); + top: -3rem; + z-index: 100; + background: var(--brand-solid); + color: #fff; + padding: 0.5rem 0.9rem; + border-radius: var(--radius); + transition: top 0.15s ease; +} +.skip-link:focus { + top: var(--sp-4); +} +.card { + background: var(--surface); + border: var(--hair) solid var(--hairline); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +/* ---- prose --------------------------------------------------------------- */ +.prose { + max-width: var(--measure); + color: var(--ink-soft); +} +.prose > * + * { + margin-top: var(--sp-4); +} +.prose h2 { + margin-top: var(--sp-7); + font-size: var(--step-2); +} +.prose h3 { + margin-top: var(--sp-6); + font-size: var(--step-1); +} +.prose h2 + *, +.prose h3 + * { + margin-top: var(--sp-3); +} +.prose ul, +.prose ol { + padding-left: 1.2rem; +} +.prose li + li { + margin-top: 0.35rem; +} +.prose li::marker { + color: var(--brand); +} +.prose blockquote { + border-left: 3px solid var(--brand); + padding: 0.2rem 0 0.2rem var(--sp-4); + color: var(--muted); +} +.prose blockquote p { + margin: 0; +} +.prose strong { + color: var(--ink); +} +.prose pre { + background: var(--surface-2); + border: var(--hair) solid var(--hairline); + border-radius: var(--radius); + padding: var(--sp-4); + overflow-x: auto; + font-size: 0.84em; + line-height: 1.5; +} From f50981233161852d69e3463cbacd764e79d43989 Mon Sep 17 00:00:00 2001 From: Trent Eriksen <124685398+Grashopr-888@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:52:59 -0700 Subject: [PATCH 2/9] feat(model): typed content collections with build-time cross-references Co-Authored-By: Claude Fable 5 --- src/content.config.ts | 245 ++++++++++++++++++++++++++++++++++++++++++ src/lib/content.ts | 149 +++++++++++++++++++++++++ src/lib/taxonomy.ts | 63 +++++++++++ 3 files changed, 457 insertions(+) create mode 100644 src/content.config.ts create mode 100644 src/lib/content.ts create mode 100644 src/lib/taxonomy.ts diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 0000000..79d98fe --- /dev/null +++ b/src/content.config.ts @@ -0,0 +1,245 @@ +import { defineCollection, reference, z } from 'astro:content'; +import { glob } from 'astro/loaders'; + +/** + * CONTENT MODEL + * ----------------------------------------------------------------------------- + * The site treats process artifacts as *typed data*, not hand-assembled pages. + * Every collection below is a first-class product record with a validated shape, + * and records cross-reference each other with `reference()` so relationships are + * checked at build time — a dangling "this decision shipped in that release" link + * fails the build. That is the point: the narrative cannot drift from the data. + * + * Adding a new project later = add its slug to PRODUCTS and drop in content. + */ + +const PRODUCTS = ['windchime', 'lichtspiel', 'shared'] as const; +const product = z.enum(PRODUCTS); + +/** A pointer to evidence — kept abstract so we can cite a PR title, a metric, or a doc + * without ever needing to expose the underlying private source. */ +const evidenceLink = z.object({ + label: z.string(), + href: z.string().optional(), + note: z.string().optional(), +}); + +/** Redaction posture carried on any record that may originate from private material. + * Drives the private→public review checklist and the ingestion/redaction pipeline. */ +const redactionStatus = z + .enum(['clean', 'sanitized', 'needs-review', 'placeholder']) + .default('clean'); + +/* ── projects ──────────────────────────────────────────────────────────────── */ +const projects = defineCollection({ + loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/projects' }), + schema: ({ image }) => + z.object({ + title: z.string(), + summary: z.string(), + status: z.enum(['exploring', 'active', 'shipped', 'maintained', 'archived']), + timeframe: z.string(), // e.g. "2025 – present" + role: z.string(), + collaborators: z.array(z.string()).default([]), + thesis: z.string(), + problem: z.string(), + audience: z.string(), + constraints: z.array(z.string()).default([]), + outcomes: z.array(z.string()).default([]), + public_visibility_note: z.string().optional(), + featured: z.boolean().default(false), + order: z.number().default(0), + accent: z.string().optional(), // per-project accent color (hsl/hex) + tech: z.array(z.string()).default([]), + languages: z.array(z.string()).default([]), // exhaustive programming-language list + hero: image().optional(), + related_research: z.array(reference('research')).default([]), + related_decisions: z.array(reference('decisions')).default([]), + related_releases: z.array(reference('releases')).default([]), + related_incidents: z.array(reference('incidents')).default([]), + }), +}); + +/* ── research notes ────────────────────────────────────────────────────────── */ +const research = defineCollection({ + loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/research' }), + schema: z.object({ + title: z.string(), + product, + date: z.coerce.date(), + source_type: z.enum([ + 'discovery', + 'interview', + 'market-scan', + 'experiment', + 'literature', + 'usability', + 'field-notes', + 'synthesis', + ]), + summary: z.string(), + questions: z.array(z.string()).default([]), + insights: z.array(z.string()).default([]), + implications: z.array(z.string()).default([]), + evidence_links: z.array(evidenceLink).default([]), + tags: z.array(z.string()).default([]), + provenance: z.string().optional(), // where this was distilled from (for ingestion) + redaction_status: redactionStatus, + }), +}); + +/* ── decision records (ADR-style) ──────────────────────────────────────────── */ +const decisions = defineCollection({ + loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/decisions' }), + schema: z.object({ + title: z.string(), + product, + date: z.coerce.date(), + status: z.enum(['proposed', 'accepted', 'superseded', 'deprecated', 'rejected']), + context: z.string(), + options_considered: z + .array(z.object({ option: z.string(), tradeoffs: z.string().optional() })) + .default([]), + decision: z.string(), + rationale: z.string(), + consequences: z.string(), + linked_milestones: z.array(reference('milestones')).default([]), + linked_artifacts: z.array(reference('artifacts')).default([]), + supersedes: reference('decisions').optional(), + tags: z.array(z.string()).default([]), + }), +}); + +/* ── releases ──────────────────────────────────────────────────────────────── */ +const releases = defineCollection({ + loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/releases' }), + schema: z.object({ + title: z.string(), + product, + version_or_label: z.string(), + date: z.coerce.date(), + status: z.enum(['planned', 'in-progress', 'shipped', 'rolled-back']), + summary: z.string(), + customer_value: z.string(), + included_work: z.array(z.string()).default([]), + notable_risks: z.array(z.string()).default([]), + followups: z.array(z.string()).default([]), + linked_incidents: z.array(reference('incidents')).default([]), + linked_decisions: z.array(reference('decisions')).default([]), + tags: z.array(z.string()).default([]), + }), +}); + +/* ── incidents (blameless postmortems) ─────────────────────────────────────── */ +const incidents = defineCollection({ + loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/incidents' }), + schema: z.object({ + title: z.string(), + product, + date: z.coerce.date(), + severity: z.enum(['sev1', 'sev2', 'sev3', 'minor']), + summary: z.string(), + impact: z.string(), + detection: z.string(), + response: z.string(), + root_cause: z.string(), + fix: z.string(), + followup_actions: z + .array( + z.object({ + action: z.string(), + owner: z.string().optional(), + status: z.enum(['open', 'done', 'wontfix']).default('open'), + }) + ) + .default([]), + status: z.enum(['resolved', 'monitoring', 'open']), + blameless_note: z.string().optional(), + linked_release: reference('releases').optional(), + tags: z.array(z.string()).default([]), + }), +}); + +/* ── changelog entries ─────────────────────────────────────────────────────── */ +const changelog = defineCollection({ + loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/changelog' }), + schema: z.object({ + title: z.string(), + date: z.coerce.date(), + product, + category: z.enum(['feature', 'improvement', 'fix', 'research', 'ops', 'docs', 'performance']), + summary: z.string(), + linked_release: reference('releases').optional(), + linked_project: reference('projects').optional(), + tags: z.array(z.string()).default([]), + }), +}); + +/* ── milestones (roadmap items) ────────────────────────────────────────────── */ +const milestones = defineCollection({ + loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/milestones' }), + schema: z.object({ + title: z.string(), + product, + horizon: z.enum(['now', 'next', 'later', 'shipped']), + date: z.coerce.date().optional(), + target: z.string().optional(), // human label e.g. "Q3 2025" + status: z.enum(['planned', 'in-progress', 'shipped', 'paused', 'dropped']).default('planned'), + summary: z.string(), + theme: z.string().optional(), // strategic pillar this ladders up to + confidence: z.enum(['low', 'medium', 'high']).optional(), + linked_releases: z.array(reference('releases')).default([]), + order: z.number().default(0), + }), +}); + +/* ── artifacts (screenshots, diagrams, checklists…) ────────────────────────── */ +const artifacts = defineCollection({ + loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/artifacts' }), + schema: ({ image }) => + z.object({ + title: z.string(), + product, + date: z.coerce.date().optional(), + type: z.enum([ + 'screenshot', + 'diagram', + 'mockup', + 'checklist', + 'doc', + 'video', + 'render', + 'metric', + ]), + summary: z.string(), + media: image().optional(), + alt: z.string().optional(), + caption: z.string().optional(), + external_href: z.string().optional(), + redaction_status: redactionStatus, + tags: z.array(z.string()).default([]), + }), +}); + +/* ── glossary ──────────────────────────────────────────────────────────────── */ +const glossary = defineCollection({ + loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/glossary' }), + schema: z.object({ + term: z.string(), + product: product.optional(), + definition: z.string(), + related: z.array(z.string()).default([]), + }), +}); + +export const collections = { + projects, + research, + decisions, + releases, + incidents, + changelog, + milestones, + artifacts, + glossary, +}; diff --git a/src/lib/content.ts b/src/lib/content.ts new file mode 100644 index 0000000..bb7741d --- /dev/null +++ b/src/lib/content.ts @@ -0,0 +1,149 @@ +import { getCollection, type CollectionEntry } from 'astro:content'; +import { SEVERITY_TONE, STATUS_TONE, type Tone } from './taxonomy'; + +export type Product = 'windchime' | 'lichtspiel'; + +export interface TimelineEvent { + date: Date; + kind: string; + tone?: Tone; + title: string; + summary?: string; + href?: string; +} + +const byDateDesc = (a: { data: { date: Date } }, b: { data: { date: Date } }): number => + b.data.date.getTime() - a.data.date.getTime(); + +const byOrder = (a: { data: { order?: number } }, b: { data: { order?: number } }): number => + (a.data.order ?? 0) - (b.data.order ?? 0); + +export interface ProjectBundle { + decisions: CollectionEntry<'decisions'>[]; + releases: CollectionEntry<'releases'>[]; + incidents: CollectionEntry<'incidents'>[]; + research: CollectionEntry<'research'>[]; + milestones: CollectionEntry<'milestones'>[]; +} + +/** Everything attached to one product, sorted for display. */ +export async function projectBundle(product: Product): Promise { + const [decisions, releases, incidents, research, milestones] = await Promise.all([ + getCollection('decisions', ({ data }) => data.product === product), + getCollection('releases', ({ data }) => data.product === product), + getCollection('incidents', ({ data }) => data.product === product), + getCollection('research', ({ data }) => data.product === product), + getCollection('milestones', ({ data }) => data.product === product), + ]); + decisions.sort(byDateDesc); + releases.sort(byDateDesc); + incidents.sort(byDateDesc); + research.sort(byDateDesc); + milestones.sort(byOrder); + return { decisions, releases, incidents, research, milestones }; +} + +/** Merge releases, incidents, and shipped milestones into one reverse-chronological stream. */ +export function toTimeline(b: ProjectBundle): TimelineEvent[] { + const events: TimelineEvent[] = []; + for (const r of b.releases) { + events.push({ + date: r.data.date, + kind: 'Release', + tone: STATUS_TONE[r.data.status] ?? 'positive', + title: r.data.title, + summary: r.data.summary, + }); + } + for (const i of b.incidents) { + events.push({ + date: i.data.date, + kind: `Incident · ${i.data.severity}`, + tone: SEVERITY_TONE[i.data.severity] ?? 'warn', + title: i.data.title, + summary: i.data.summary, + }); + } + for (const m of b.milestones) { + if (m.data.horizon === 'shipped' && m.data.date) { + events.push({ + date: m.data.date, + kind: 'Milestone', + tone: 'positive', + title: m.data.title, + summary: m.data.summary, + }); + } + } + events.sort((a, b) => b.date.getTime() - a.date.getTime()); + return events; +} + +/* ── cross-product collection queries (for the aggregate pages) ────────────── */ + +export async function allReleases(): Promise[]> { + const r = await getCollection('releases'); + r.sort(byDateDesc); + return r; +} + +export async function allIncidents(): Promise[]> { + const r = await getCollection('incidents'); + r.sort(byDateDesc); + return r; +} + +export async function allResearch(): Promise[]> { + const r = await getCollection('research'); + r.sort(byDateDesc); + return r; +} + +export async function allDecisions(): Promise[]> { + const r = await getCollection('decisions'); + r.sort(byDateDesc); + return r; +} + +export async function allMilestones(): Promise[]> { + const r = await getCollection('milestones'); + r.sort(byOrder); + return r; +} + +/** Reverse-chronological changelog stream: one entry per release + explicit changelog items. */ +export async function changelogStream(): Promise< + Array<{ + date: Date; + product: string; + category: string; + title: string; + summary: string; + id: string; + }> +> { + const [releases, changelog] = await Promise.all([ + getCollection('releases'), + getCollection('changelog'), + ]); + const stream = [ + ...releases.map((r) => ({ + date: r.data.date, + product: r.data.product, + category: r.data.status === 'shipped' ? 'release' : r.data.status, + title: r.data.title, + summary: r.data.customer_value, + id: `rel-${r.id}`, + })), + ...changelog.map((c) => ({ + date: c.data.date, + product: c.data.product, + category: c.data.category, + title: c.data.title, + summary: c.data.summary, + id: `log-${c.id}`, + })), + ]; + stream.sort((a, b) => b.date.getTime() - a.date.getTime()); + return stream; +} diff --git a/src/lib/taxonomy.ts b/src/lib/taxonomy.ts new file mode 100644 index 0000000..b1ded1f --- /dev/null +++ b/src/lib/taxonomy.ts @@ -0,0 +1,63 @@ +/** Maps enum values from the content model to visual tones + human labels, + * so a status string renders consistently wherever it appears. */ + +export type Tone = 'neutral' | 'positive' | 'warn' | 'danger' | 'info'; + +export const STATUS_TONE: Record = { + // project + exploring: 'info', + active: 'positive', + shipped: 'positive', + maintained: 'neutral', + archived: 'neutral', + // release / milestone + planned: 'neutral', + 'in-progress': 'warn', + 'rolled-back': 'danger', + paused: 'warn', + dropped: 'neutral', + // decision + proposed: 'info', + accepted: 'positive', + superseded: 'neutral', + deprecated: 'neutral', + rejected: 'danger', + // incident + resolved: 'positive', + monitoring: 'warn', + open: 'danger', +}; + +export const SEVERITY_TONE: Record = { + sev1: 'danger', + sev2: 'danger', + sev3: 'warn', + minor: 'neutral', +}; + +export const CATEGORY_LABEL: Record = { + feature: 'Feature', + improvement: 'Improvement', + fix: 'Fix', + research: 'Research', + ops: 'Ops', + docs: 'Docs', + performance: 'Performance', +}; + +export const HORIZON_LABEL: Record = { + now: 'Now', + next: 'Next', + later: 'Later', + shipped: 'Shipped', +}; + +/** "in-progress" → "In progress", "sev1" → "Sev1". */ +export function label(value: string): string { + return value.replace(/[-_]/g, ' ').replace(/^\w/, (c) => c.toUpperCase()); +} + +export function toneFor(kind: 'status' | 'severity', value: string): Tone { + const map = kind === 'severity' ? SEVERITY_TONE : STATUS_TONE; + return map[value] ?? 'neutral'; +} From 1af2b6ac3cdc0f5d72eca474832346b32c7944b8 Mon Sep 17 00:00:00 2001 From: Trent Eriksen <124685398+Grashopr-888@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:52:59 -0700 Subject: [PATCH 3/9] feat(components): records, roadmap kanban, timeline, diagrams, commit grids, and the monome twin Co-Authored-By: Claude Fable 5 --- src/components/Badge.astro | 54 +++++ src/components/CommitGrid.astro | 280 ++++++++++++++++++++++ src/components/ComparisonTable.astro | 136 +++++++++++ src/components/EvidenceTile.astro | 73 ++++++ src/components/FlowDiagram.astro | 123 ++++++++++ src/components/Kanban.astro | 164 +++++++++++++ src/components/LatentMap.astro | 337 +++++++++++++++++++++++++++ src/components/MonomeTwin.astro | 234 +++++++++++++++++++ src/components/ProjectCard.astro | 170 ++++++++++++++ src/components/RecordCard.astro | 147 ++++++++++++ src/components/Section.astro | 37 +++ src/components/SpecSheet.astro | 92 ++++++++ src/components/StatTile.astro | 41 ++++ src/components/Timeline.astro | 126 ++++++++++ 14 files changed, 2014 insertions(+) create mode 100644 src/components/Badge.astro create mode 100644 src/components/CommitGrid.astro create mode 100644 src/components/ComparisonTable.astro create mode 100644 src/components/EvidenceTile.astro create mode 100644 src/components/FlowDiagram.astro create mode 100644 src/components/Kanban.astro create mode 100644 src/components/LatentMap.astro create mode 100644 src/components/MonomeTwin.astro create mode 100644 src/components/ProjectCard.astro create mode 100644 src/components/RecordCard.astro create mode 100644 src/components/Section.astro create mode 100644 src/components/SpecSheet.astro create mode 100644 src/components/StatTile.astro create mode 100644 src/components/Timeline.astro diff --git a/src/components/Badge.astro b/src/components/Badge.astro new file mode 100644 index 0000000..6efe035 --- /dev/null +++ b/src/components/Badge.astro @@ -0,0 +1,54 @@ +--- +import type { Tone } from '../lib/taxonomy'; + +interface Props { + tone?: Tone; + dot?: boolean; + class?: string; +} +const { tone = 'neutral', dot = true, class: klass } = Astro.props; +--- + + + {dot && + + diff --git a/src/components/CommitGrid.astro b/src/components/CommitGrid.astro new file mode 100644 index 0000000..f41055e --- /dev/null +++ b/src/components/CommitGrid.astro @@ -0,0 +1,280 @@ +--- +/** + * GitHub-style contribution grid for one project: weeks as columns, weekdays as + * rows, cell intensity by commit count. Hovering a day shows date, count, and + * the sanitized subject lines for that day (native title tooltip, no JS). + */ +import type { DayActivity } from '../lib/signals'; + +interface Props { + label: string; + accent: 'windchime' | 'lichtspiel'; + days: Record; + /** ISO dates bounding the shared time axis so multiple grids align. */ + first: string; + last: string; +} +const { label, accent, days, first, last } = Astro.props; + +const DAY_MS = 86_400_000; +const toUTC = (iso: string) => new Date(`${iso}T00:00:00Z`); +const iso = (d: Date) => d.toISOString().slice(0, 10); + +/* Align the axis to the Sunday on/before `first` so week columns are calendar weeks. */ +const start = toUTC(first); +start.setUTCDate(start.getUTCDate() - start.getUTCDay()); +const end = toUTC(last); + +interface Cell { + date: string; + inRange: boolean; + count: number; + title: string; + level: number; +} +const weeks: Cell[][] = []; +const monthLabels: Array<{ index: number; label: string }> = []; +const fmt = new Intl.DateTimeFormat('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', +}); +const monthFmt = new Intl.DateTimeFormat('en-US', { month: 'short', timeZone: 'UTC' }); + +let cursor = new Date(start); +let lastMonth = ''; +while (cursor.getTime() <= end.getTime() || cursor.getUTCDay() !== 0) { + if (cursor.getUTCDay() === 0) { + weeks.push([]); + const m = monthFmt.format(cursor); + if (m !== lastMonth) { + monthLabels.push({ index: weeks.length - 1, label: m }); + lastMonth = m; + } + } + const date = iso(cursor); + const inRange = cursor.getTime() >= toUTC(first).getTime() && cursor.getTime() <= end.getTime(); + const activity = days[date]; + const count = inRange ? (activity?.count ?? 0) : 0; + const level = count === 0 ? 0 : count <= 2 ? 1 : count <= 5 ? 2 : count <= 9 ? 3 : 4; + let title = `${fmt.format(cursor)} · ${count} commit${count === 1 ? '' : 's'}`; + if (activity?.subjects.length) { + title += '\n' + activity.subjects.map((s) => `• ${s}`).join('\n'); + const more = activity.count - activity.subjects.length; + if (more > 0) title += `\n… and ${more} more`; + } + weeks.at(-1)!.push({ date, inRange, count, title, level }); + cursor = new Date(cursor.getTime() + DAY_MS); + if (weeks.length > 120) break; // safety: never render an unbounded grid +} +--- + +
+
+ + {label} +
+
+
+ {monthLabels.map((m) => {m.label})} +
+ +
+
+ Less + {[0, 1, 2, 3, 4].map((l) => )} + More +
+
+ + + + + + diff --git a/src/components/ComparisonTable.astro b/src/components/ComparisonTable.astro new file mode 100644 index 0000000..d81ed80 --- /dev/null +++ b/src/components/ComparisonTable.astro @@ -0,0 +1,136 @@ +--- +interface Row { + dimension: string; + windchime: string; + lichtspiel: string; +} +interface Props { + rows: Row[]; +} +const { rows } = Astro.props; +--- + +
+
+ + + Windchime + + + Lichtspiel + +
+ { + rows.map((r) => ( +
+ + {r.dimension} + + + + {r.windchime} + + + + {r.lichtspiel} + +
+ )) + } +
+ + diff --git a/src/components/EvidenceTile.astro b/src/components/EvidenceTile.astro new file mode 100644 index 0000000..ff967bc --- /dev/null +++ b/src/components/EvidenceTile.astro @@ -0,0 +1,73 @@ +--- +import { url } from '../lib/url'; + +interface Props { + eyebrow: string; + title: string; + href: string; + external?: boolean; +} +const { eyebrow, title, href, external = false } = Astro.props; +const dest = external ? href : url(href); +--- + + + {eyebrow} + {title} + + + + + diff --git a/src/components/FlowDiagram.astro b/src/components/FlowDiagram.astro new file mode 100644 index 0000000..5c639b9 --- /dev/null +++ b/src/components/FlowDiagram.astro @@ -0,0 +1,123 @@ +--- +/** A system-boundary architecture diagram: a labeled pipeline that fans out to outputs. + * CSS-only, responsive, theme-aware. No external images. */ +interface Stage { + label: string; + note?: string; +} +interface Props { + stages: Stage[]; + outputs?: Stage[]; + caption?: string; +} +const { stages, outputs = [], caption } = Astro.props; +--- + +
+
+ { + stages.map((s, i) => ( + <> +
+ {s.label} + {s.note && {s.note}} +
+ {(i < stages.length - 1 || outputs.length > 0) && ( +
+ {caption &&
{caption}
} +
+ + diff --git a/src/components/Kanban.astro b/src/components/Kanban.astro new file mode 100644 index 0000000..0016527 --- /dev/null +++ b/src/components/Kanban.astro @@ -0,0 +1,164 @@ +--- +import type { CollectionEntry } from 'astro:content'; +import Badge from './Badge.astro'; +import { STATUS_TONE, label as lbl } from '../lib/taxonomy'; + +interface Props { + milestones: CollectionEntry<'milestones'>[]; + showProduct?: boolean; +} +const { milestones, showProduct = false } = Astro.props; + +const columns = [ + { key: 'now', label: 'Now' }, + { key: 'next', label: 'Next' }, + { key: 'later', label: 'Later' }, + { key: 'shipped', label: 'Shipped' }, +] as const; + +const inColumn = (key: string) => + milestones + .filter((m) => m.data.horizon === key) + .sort((a, b) => (a.data.order ?? 0) - (b.data.order ?? 0)); +--- + +
+ { + columns.map((c) => ( +
+
+ {c.label} + {inColumn(c.key).length} +
+
+ {inColumn(c.key).map((m) => ( +
+ {m.data.theme && {m.data.theme}} +

{m.data.title}

+

{m.data.summary}

+
+ + {lbl(m.data.status)} + + {m.data.confidence && confidence: {m.data.confidence}} + {showProduct && ( + + {m.data.product} + + )} +
+
+ ))} +
+
+ )) + } +
+ + diff --git a/src/components/LatentMap.astro b/src/components/LatentMap.astro new file mode 100644 index 0000000..08362bb --- /dev/null +++ b/src/components/LatentMap.astro @@ -0,0 +1,337 @@ +--- +/** + * An interactive 3D rendering of retrieval in the joint text-audio embedding + * space, in the manner of the installation's corpus-map rail (a rotatable 3D + * projection of stem embeddings with the prompt placed among its retrievals). + * Drag to rotate; it slowly auto-rotates when idle. Points are deterministic + * synthetic clusters (seeded LCG), so no real corpus geometry ships. All text + * lives outside the plot so nothing overlaps the embeddings. + */ +const ROLES = [ + { name: 'drums', c: '#e0a54a', cx: -0.55, cy: 0.4, cz: -0.2, n: 11, spread: 0.28 }, + { name: 'bass', c: '#4ecb84', cx: -0.62, cy: -0.42, cz: 0.3, n: 9, spread: 0.24 }, + { name: 'keys', c: '#b48bff', cx: 0.1, cy: 0.58, cz: 0.45, n: 10, spread: 0.26 }, + { name: 'texture', c: '#5a8bff', cx: 0.55, cy: -0.08, cz: -0.4, n: 12, spread: 0.32 }, + { name: 'field', c: '#5ad0c0', cx: -0.05, cy: -0.55, cz: -0.5, n: 10, spread: 0.28 }, + { name: 'vocals', c: '#ff7a5c', cx: 0.66, cy: 0.5, cz: 0.15, n: 8, spread: 0.22 }, +]; + +/** Deterministic LCG so the layout is identical on every build. */ +let seed = 42; +function rnd(): number { + seed = (seed * 1664525 + 1013904223) % 4294967296; + return seed / 4294967296; +} + +interface Pt { + x: number; + y: number; + z: number; + c: string; + role: string; +} +const points: Pt[] = []; +for (const r of ROLES) { + for (let i = 0; i < r.n; i++) { + points.push({ + x: r.cx + (rnd() + rnd() - 1) * r.spread, + y: r.cy + (rnd() + rnd() - 1) * r.spread, + z: r.cz + (rnd() + rnd() - 1) * r.spread, + c: r.c, + role: r.name, + }); + } +} +const query = { x: 0.06, y: 0.04, z: -0.02 }; +const dist = (p: Pt): number => Math.hypot(p.x - query.x, p.y - query.y, p.z - query.z); +const nearestPerRole = new Map(); +points.forEach((p, i) => { + const cur = nearestPerRole.get(p.role); + if (cur === undefined || dist(p) < dist(points[cur]!)) nearestPerRole.set(p.role, i); +}); +const selected = [...nearestPerRole.values()] + .sort((a, b) => dist(points[a]!) - dist(points[b]!)) + .slice(0, 4); + +const DATA = { points, query, selected }; +--- + +
+
+ Drag to rotate · 3D projection of the joint text-audio embedding space + synthetic points, not the private corpus +
+
+ +
+
+ { + ROLES.map((r) => ( + + + {r.name} + + )) + } + + + spoken prompt, embedded + + + + selected (one per role) + +
+
+ How retrieval works underneath: contrastive audio-language pretraining gives text and audio a + shared embedding space, so a transcribed prompt and every stem are just L2-normalized vectors. + Cosine similarity over a FAISS exact index ranks the neighbourhood, and the one-per-role + selection policy keeps at most one stem per instrument role for timbral variety. In the + installation, the corpus map rail shows the real projection (UMAP, PCA, or t-SNE) of the live + index. +
+
+ + + + diff --git a/src/components/MonomeTwin.astro b/src/components/MonomeTwin.astro new file mode 100644 index 0000000..e608af7 --- /dev/null +++ b/src/components/MonomeTwin.astro @@ -0,0 +1,234 @@ +--- +/** + * A faithful, static rendering of the projects' monome digital twin, generated + * from the same drawing rules as the real twin component: a 16×8 varibright + * grid on the incandescent amber LED ramp with per-cell level readouts, and + * two arc encoders drawn as brushed-aluminum knobs inside a dark 64-LED ring, + * rings above the grid. Deterministic frame, no images, no JS. + */ +interface Props { + caption?: string; +} +const { caption } = Astro.props; + +const COLS = 16; +const ROWS = 8; +const CELL = 30; +const GAP = 6; +const PAD = 16; +const RING_R = 30; +const RING_BEZEL = RING_R + 10; +const RING_SPACING = RING_BEZEL * 2 + 16; +const RING_LEDS = 64; + +/* The twin's LED color ramps, verbatim from the source. */ +const ledColor = (level: number): string => { + const t = Math.max(0, Math.min(15, level)) / 15; + return `rgb(${Math.round(38 + t * 217)},${Math.round(33 + t * 146)},${Math.round(28 + t * 79)})`; +}; +const arcLedColor = (level: number): string => { + const t = Math.max(0, Math.min(1, level / 15)); + return `rgb(${Math.round(44 + t * 211)},${Math.round(45 + t * 147)},${Math.round(47 + t * 75)})`; +}; + +/** A deterministic performance-feedback-style frame: a soft wave plus a bright + * scene-position column, the shape the twin idles in between gestures. */ +function gridLevel(x: number, y: number): number { + const wave = Math.sin(x * 0.5 + y * 0.75) * Math.cos(x * 0.18 - y * 0.4); + let v = Math.round(3.5 + wave * 3.5); + if (x === 5) v = Math.max(v, y === 2 ? 15 : 11); // scene-position marker column + if (x === 11 && y === 5) v = 15; // a held gesture highlight + return Math.max(0, Math.min(15, v)); +} +const cells: Array<{ x: number; y: number; level: number }> = []; +for (let y = 0; y < ROWS; y++) + for (let x = 0; x < COLS; x++) cells.push({ x, y, level: gridLevel(x, y) }); + +/* Arc ring LED levels, using the twin's own test/marker patterns: + ring 0 = fill with a bright head (an encoder mid-sweep), ring 1 = position + marker with dim octant ticks. */ +function arcLevel(ring: number, i: number): number { + if (ring === 0) { + const head = 40; + if (i === head) return 15; + return i <= head ? 9 : 0; + } + const pos = 12; + const dist = Math.min((i - pos + RING_LEDS) % RING_LEDS, (pos - i + RING_LEDS) % RING_LEDS); + if (dist === 0) return 15; + if (dist === 1) return 9; + return i % 8 === 0 ? 4 : 0; +} + +/* Layout, mirroring the twin's rebuild(): rings above, grid below. */ +const arcCy = PAD + RING_BEZEL; +const gridOy = PAD + RING_BEZEL * 2 + PAD; +const gridOx = PAD; +const W = COLS * (CELL + GAP) - GAP + PAD * 2; +const H = gridOy + ROWS * (CELL + GAP) - GAP + PAD; +const ringCx = (e: number): number => gridOx + RING_BEZEL + e * RING_SPACING; + +interface Tick { + x1: number; + y1: number; + x2: number; + y2: number; + color: string; + w: number; +} +const ticks: Tick[][] = [0, 1].map((e) => + Array.from({ length: RING_LEDS }, (_, i) => { + const level = arcLevel(e, i); + const a = (i / RING_LEDS) * Math.PI * 2 - Math.PI / 2; + const cx = ringCx(e); + return { + x1: cx + Math.cos(a) * (RING_R - 7), + y1: arcCy + Math.sin(a) * (RING_R - 7), + x2: cx + Math.cos(a) * (RING_R + 7), + y2: arcCy + Math.sin(a) * (RING_R + 7), + color: arcLedColor(level), + w: level >= 15 ? 3.5 : 2, + }; + }) +); +--- + +
+

Digital twin: grid 128 (16×8, varibright) + arc 2 · performance

+

+ grid ● hardware: 16×8 (128) · 2 quads · varibright 0-15 · tilt ✓ + arc ● hardware: 2 enc × 64 LED (varibright) · push per-enc +

+
+ + + + + + + + + + + {/* arc encoders: aluminum knob, dark LED track, bezel edge, 64 tick LEDs */} + { + [0, 1].map((e) => ( + + + + + {ticks[e]!.map((t) => ( + + ))} + + enc {e} + + + )) + } + + {/* the varibright grid, incandescent ramp + level readouts (as drawn by the twin) */} + { + cells.map((c) => { + const px = gridOx + c.x * (CELL + GAP); + const py = gridOy + c.y * (CELL + GAP); + return ( + + + 8 ? 'rgba(46,28,10,0.55)' : 'rgba(255,228,196,0.3)'} + > + {c.level} + + + ); + }) + } + +
+ {caption &&
{caption}
} +
+ + diff --git a/src/components/ProjectCard.astro b/src/components/ProjectCard.astro new file mode 100644 index 0000000..39afb1e --- /dev/null +++ b/src/components/ProjectCard.astro @@ -0,0 +1,170 @@ +--- +import type { CollectionEntry } from 'astro:content'; +import { url } from '../lib/url'; +import { PROJECT_META } from '../config'; +import { STATUS_TONE, label as lbl } from '../lib/taxonomy'; +import Badge from './Badge.astro'; + +interface Props { + project: CollectionEntry<'projects'>; +} +const { project } = Astro.props; +const d = project.data; +const slug = project.id; +const meta = PROJECT_META[slug as keyof typeof PROJECT_META]; +--- + + + + { + meta?.art && ( +
+ {meta.artAlt} +
+ ) + } +
+ {meta?.kind ?? d.role} + {lbl(d.status)} +
+
+ {meta?.logo && } +

{d.title}

+
+

{d.summary}

+ { + d.languages.length > 0 && ( +
+ Languages + + {d.languages.map((t) => ( + {t} + ))} + +
+ ) + } +
+ {d.timeframe} + Explore +
+
+ + diff --git a/src/components/RecordCard.astro b/src/components/RecordCard.astro new file mode 100644 index 0000000..5bced96 --- /dev/null +++ b/src/components/RecordCard.astro @@ -0,0 +1,147 @@ +--- +import Badge from './Badge.astro'; +import type { Tone } from '../lib/taxonomy'; +import { fmtDate } from '../lib/format'; + +interface Props { + tone: Tone; + badge: string; + title: string; + date: Date; + product?: string; + anchor?: string; + meta?: string; +} +const { tone, badge, title, date, product, anchor, meta } = Astro.props; +--- + +
+ + + {badge} + { + product && product !== 'shared' && ( + + {product} + + ) + } + + {title} + + {meta && {meta}} + + + + +
+
+ + diff --git a/src/components/Section.astro b/src/components/Section.astro new file mode 100644 index 0000000..7197d2a --- /dev/null +++ b/src/components/Section.astro @@ -0,0 +1,37 @@ +--- +interface Props { + index?: string; + label: string; + title?: string; + id?: string; + class?: string; +} +const { index, label, title, id, class: klass } = Astro.props; +--- + +
+
+

+ {index && {index}} + {index && ' · '}{label} +

+ {title &&

{title}

} +
+
+ +
+
+ + diff --git a/src/components/SpecSheet.astro b/src/components/SpecSheet.astro new file mode 100644 index 0000000..7c9b5c4 --- /dev/null +++ b/src/components/SpecSheet.astro @@ -0,0 +1,92 @@ +--- +interface Row { + term: string; + value?: string; + values?: string[]; +} +interface Props { + rows: Row[]; + class?: string; +} +const { rows, class: klass } = Astro.props; +--- + +
+ { + rows + .filter((r) => r.value || (r.values && r.values.length)) + .map((r) => ( +
+
{r.term}
+
+ {r.values ? ( +
    + {r.values.map((v) => ( +
  • {v}
  • + ))} +
+ ) : ( + r.value + )} +
+
+ )) + } +
+ + diff --git a/src/components/StatTile.astro b/src/components/StatTile.astro new file mode 100644 index 0000000..0f1f8bc --- /dev/null +++ b/src/components/StatTile.astro @@ -0,0 +1,41 @@ +--- +interface Props { + value: string; + label: string; + note?: string; +} +const { value, label, note } = Astro.props; +--- + +
+
{value}
+
{label}
+ {note &&
{note}
} +
+ + diff --git a/src/components/Timeline.astro b/src/components/Timeline.astro new file mode 100644 index 0000000..1d5e545 --- /dev/null +++ b/src/components/Timeline.astro @@ -0,0 +1,126 @@ +--- +import { fmtDate, isoDate } from '../lib/format'; +import { url } from '../lib/url'; +import type { TimelineEvent } from '../lib/content'; + +interface Props { + events: TimelineEvent[]; +} +const { events } = Astro.props; +--- + +
    + { + events.map((e) => ( +
  1. + +
    + + + {e.href ? {e.title} : e.title} + {e.summary &&

    {e.summary}

    } +
    +
  2. + )) + } +
+ + From 91c24ca2e788d8e433028b1aa7d448d11a8805eb Mon Sep 17 00:00:00 2001 From: Trent Eriksen <124685398+Grashopr-888@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:52:59 -0700 Subject: [PATCH 4/9] =?UTF-8?q?feat(content):=20Windchime=20and=20Lichtspi?= =?UTF-8?q?el=20process=20records=20=E2=80=94=20decisions,=20releases,=20i?= =?UTF-8?q?ncidents,=20research,=20roadmap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../ls-capability-adaptive-monome.md | 32 ++++ src/content/decisions/ls-consolidation.md | 31 ++++ src/content/decisions/ls-curation-tiers.md | 32 ++++ .../decisions/ls-graceful-degradation.md | 30 ++++ src/content/decisions/ls-runtime-purity.md | 31 ++++ .../decisions/ls-visual-param-vector.md | 29 ++++ .../decisions/wc-bounded-visitor-session.md | 39 +++++ .../decisions/wc-guarded-livecoding.md | 33 ++++ .../decisions/wc-monome-digital-twin.md | 40 +++++ src/content/decisions/wc-planner-failover.md | 38 +++++ .../decisions/wc-retrieval-as-variable.md | 31 ++++ .../wc-retrieval-in-process-library.md | 38 +++++ src/content/glossary/clap.md | 9 ++ src/content/glossary/guarded-planner.md | 9 ++ src/content/glossary/max-for-live.md | 8 + src/content/glossary/monome.md | 8 + src/content/glossary/runtime-purity.md | 8 + src/content/glossary/serialosc.md | 8 + src/content/glossary/soak-test.md | 8 + src/content/glossary/strudel.md | 8 + .../ls-encoder-press-scene-switch.md | 32 ++++ src/content/incidents/ls-feeder-poll-wedge.md | 39 +++++ src/content/incidents/ls-grid-coverage.md | 38 +++++ .../ls-scene-arrange-same-template.md | 40 +++++ .../incidents/ls-validation-regression.md | 39 +++++ src/content/incidents/wc-audio-runaway.md | 50 ++++++ .../incidents/wc-csrf-origin-allowlist.md | 43 ++++++ .../incidents/wc-monome-foreign-prefix.md | 34 +++++ .../incidents/wc-monome-hotplug-reattach.md | 43 ++++++ .../incidents/wc-participant-audio-suspend.md | 33 ++++ .../incidents/wc-soak-negative-radius.md | 41 +++++ .../incidents/wc-sqlite-cross-thread.md | 41 +++++ src/content/incidents/wc-stale-phase-gate.md | 41 +++++ .../incidents/wc-stopped-audio-ringback.md | 35 +++++ .../milestones/ls-grid-coverage-gate.md | 13 ++ src/content/milestones/ls-hackathon-ship.md | 16 ++ src/content/milestones/ls-hardware-pass.md | 16 ++ src/content/milestones/ls-lineage-backport.md | 15 ++ src/content/milestones/ls-live-repro.md | 13 ++ src/content/milestones/ls-m4l-device.md | 13 ++ src/content/milestones/wc-asr-audit.md | 16 ++ src/content/milestones/wc-audio-soak.md | 16 ++ src/content/milestones/wc-corpus.md | 16 ++ .../milestones/wc-coverage-interventions.md | 15 ++ .../milestones/wc-hardware-endurance.md | 15 ++ src/content/milestones/wc-install-shipped.md | 16 ++ .../milestones/wc-per-family-tuning.md | 16 ++ .../wc-selection-policy-ablation.md | 16 ++ .../milestones/wc-sound-mode-tuning.md | 16 ++ src/content/projects/lichtspiel.md | 133 ++++++++++++++++ src/content/projects/windchime.md | 142 ++++++++++++++++++ src/content/releases/ls-consolidation-v1.md | 29 ++++ src/content/releases/ls-generative-track.md | 28 ++++ src/content/releases/ls-idiom-layer.md | 26 ++++ src/content/releases/ls-live-api-probe.md | 25 +++ src/content/releases/ls-monome-integration.md | 26 ++++ src/content/releases/ls-node-bridge.md | 25 +++ src/content/releases/ls-p5-runtime.md | 26 ++++ .../releases/ls-scene-launch-retrieval.md | 25 +++ src/content/releases/wc-audio-safety.md | 29 ++++ src/content/releases/wc-corpus-expansion.md | 30 ++++ src/content/releases/wc-demo-mode.md | 29 ++++ src/content/releases/wc-eval-capture.md | 29 ++++ src/content/releases/wc-install-features.md | 30 ++++ src/content/releases/wc-install-mode-v1.md | 28 ++++ src/content/releases/wc-install-soak.md | 33 ++++ .../releases/wc-instrument-design-language.md | 24 +++ src/content/releases/wc-original-scenes.md | 27 ++++ src/content/releases/wc-participant-flow.md | 28 ++++ src/content/releases/wc-retrieval-backends.md | 33 ++++ .../releases/wc-sound-modes-reembed.md | 31 ++++ src/content/releases/wc-study-readiness.md | 23 +++ .../releases/wc-umbrella-scaffold-v0-1.md | 29 ++++ .../releases/wc-visual-engine-alignment.md | 29 ++++ .../releases/wc-visual-runtime-selector.md | 30 ++++ src/content/research/ls-fallback-ladder.md | 34 +++++ .../research/ls-monome-latent-instrument.md | 34 +++++ src/content/research/ls-not-another-vj.md | 26 ++++ src/content/research/wc-alm-audit.md | 32 ++++ .../research/wc-content-aware-reembed.md | 35 +++++ .../research/wc-user-study-instruments.md | 36 +++++ 81 files changed, 2391 insertions(+) create mode 100644 src/content/decisions/ls-capability-adaptive-monome.md create mode 100644 src/content/decisions/ls-consolidation.md create mode 100644 src/content/decisions/ls-curation-tiers.md create mode 100644 src/content/decisions/ls-graceful-degradation.md create mode 100644 src/content/decisions/ls-runtime-purity.md create mode 100644 src/content/decisions/ls-visual-param-vector.md create mode 100644 src/content/decisions/wc-bounded-visitor-session.md create mode 100644 src/content/decisions/wc-guarded-livecoding.md create mode 100644 src/content/decisions/wc-monome-digital-twin.md create mode 100644 src/content/decisions/wc-planner-failover.md create mode 100644 src/content/decisions/wc-retrieval-as-variable.md create mode 100644 src/content/decisions/wc-retrieval-in-process-library.md create mode 100644 src/content/glossary/clap.md create mode 100644 src/content/glossary/guarded-planner.md create mode 100644 src/content/glossary/max-for-live.md create mode 100644 src/content/glossary/monome.md create mode 100644 src/content/glossary/runtime-purity.md create mode 100644 src/content/glossary/serialosc.md create mode 100644 src/content/glossary/soak-test.md create mode 100644 src/content/glossary/strudel.md create mode 100644 src/content/incidents/ls-encoder-press-scene-switch.md create mode 100644 src/content/incidents/ls-feeder-poll-wedge.md create mode 100644 src/content/incidents/ls-grid-coverage.md create mode 100644 src/content/incidents/ls-scene-arrange-same-template.md create mode 100644 src/content/incidents/ls-validation-regression.md create mode 100644 src/content/incidents/wc-audio-runaway.md create mode 100644 src/content/incidents/wc-csrf-origin-allowlist.md create mode 100644 src/content/incidents/wc-monome-foreign-prefix.md create mode 100644 src/content/incidents/wc-monome-hotplug-reattach.md create mode 100644 src/content/incidents/wc-participant-audio-suspend.md create mode 100644 src/content/incidents/wc-soak-negative-radius.md create mode 100644 src/content/incidents/wc-sqlite-cross-thread.md create mode 100644 src/content/incidents/wc-stale-phase-gate.md create mode 100644 src/content/incidents/wc-stopped-audio-ringback.md create mode 100644 src/content/milestones/ls-grid-coverage-gate.md create mode 100644 src/content/milestones/ls-hackathon-ship.md create mode 100644 src/content/milestones/ls-hardware-pass.md create mode 100644 src/content/milestones/ls-lineage-backport.md create mode 100644 src/content/milestones/ls-live-repro.md create mode 100644 src/content/milestones/ls-m4l-device.md create mode 100644 src/content/milestones/wc-asr-audit.md create mode 100644 src/content/milestones/wc-audio-soak.md create mode 100644 src/content/milestones/wc-corpus.md create mode 100644 src/content/milestones/wc-coverage-interventions.md create mode 100644 src/content/milestones/wc-hardware-endurance.md create mode 100644 src/content/milestones/wc-install-shipped.md create mode 100644 src/content/milestones/wc-per-family-tuning.md create mode 100644 src/content/milestones/wc-selection-policy-ablation.md create mode 100644 src/content/milestones/wc-sound-mode-tuning.md create mode 100644 src/content/projects/lichtspiel.md create mode 100644 src/content/projects/windchime.md create mode 100644 src/content/releases/ls-consolidation-v1.md create mode 100644 src/content/releases/ls-generative-track.md create mode 100644 src/content/releases/ls-idiom-layer.md create mode 100644 src/content/releases/ls-live-api-probe.md create mode 100644 src/content/releases/ls-monome-integration.md create mode 100644 src/content/releases/ls-node-bridge.md create mode 100644 src/content/releases/ls-p5-runtime.md create mode 100644 src/content/releases/ls-scene-launch-retrieval.md create mode 100644 src/content/releases/wc-audio-safety.md create mode 100644 src/content/releases/wc-corpus-expansion.md create mode 100644 src/content/releases/wc-demo-mode.md create mode 100644 src/content/releases/wc-eval-capture.md create mode 100644 src/content/releases/wc-install-features.md create mode 100644 src/content/releases/wc-install-mode-v1.md create mode 100644 src/content/releases/wc-install-soak.md create mode 100644 src/content/releases/wc-instrument-design-language.md create mode 100644 src/content/releases/wc-original-scenes.md create mode 100644 src/content/releases/wc-participant-flow.md create mode 100644 src/content/releases/wc-retrieval-backends.md create mode 100644 src/content/releases/wc-sound-modes-reembed.md create mode 100644 src/content/releases/wc-study-readiness.md create mode 100644 src/content/releases/wc-umbrella-scaffold-v0-1.md create mode 100644 src/content/releases/wc-visual-engine-alignment.md create mode 100644 src/content/releases/wc-visual-runtime-selector.md create mode 100644 src/content/research/ls-fallback-ladder.md create mode 100644 src/content/research/ls-monome-latent-instrument.md create mode 100644 src/content/research/ls-not-another-vj.md create mode 100644 src/content/research/wc-alm-audit.md create mode 100644 src/content/research/wc-content-aware-reembed.md create mode 100644 src/content/research/wc-user-study-instruments.md diff --git a/src/content/decisions/ls-capability-adaptive-monome.md b/src/content/decisions/ls-capability-adaptive-monome.md new file mode 100644 index 0000000..837fd1f --- /dev/null +++ b/src/content/decisions/ls-capability-adaptive-monome.md @@ -0,0 +1,32 @@ +--- +title: Capability-adaptive monome mapping +product: lichtspiel +date: 2026-06-01 +status: accepted +context: >- + The performer owns two classes of monome hardware (a Grid 64 with an Arc 2 and a Grid + 128 with an Arc 4) that differ in size and capabilities. Sketches were authored against + specific hardware, and hard-coding any one layout would strand the others. +options_considered: + - option: Author each sketch for one fixed device size + tradeoffs: Simplest to write; strands the other hardware and needs per-device forks + - option: Port each sketch twice, once per device class + tradeoffs: Full fidelity on both; doubles the work and drifts out of sync over time + - option: A logical idiom layer that folds down and extends up automatically + tradeoffs: One port per sketch; requires an abstraction, but every sketch inherits it +decision: >- + A sketch declares its control intent as logical idioms. The idiom layer maps that intent + one-to-one when the hardware matches, folds (couples or pages) controls onto smaller + hardware so nothing becomes unreachable, and lights bonus controls on larger hardware. +rationale: >- + Adaptation belongs in one shared layer, not in every sketch. Coupling keeps every logical + control reachable on a smaller device, which matters more for playability than a perfect + one-to-one layout. +consequences: >- + New sketches get hardware adaptation for free and never carry per-device branches. The cost + is a real abstraction to maintain, verified by a headless smoke suite that exercises both a + small and a large profile. +tags: [monome, hardware, adaptation, architecture] +--- + +This decision is why Lichtspiel runs on four device classes from one codebase. It came from a play session where a smaller device left some controls unreachable, which made "never drop controllability" the guiding rule. diff --git a/src/content/decisions/ls-consolidation.md b/src/content/decisions/ls-consolidation.md new file mode 100644 index 0000000..36800a6 --- /dev/null +++ b/src/content/decisions/ls-consolidation.md @@ -0,0 +1,31 @@ +--- +title: Consolidate three forks onto the newest tree, and restore the dropped rigor +product: lichtspiel +date: 2026-06-11 +status: accepted +context: >- + Collaboration had forked the project into three lines: the team's pre-AI base, a + rigorous solo generator with real validation and curation, and a newer tree with a + much better UX and a new generative pipeline, but whose validation had been stubbed out. +options_considered: + - option: Ship the newest tree as-is (best UX) + tradeoffs: Fastest; silently loses the validation and curation that keep generation safe + - option: Ship the rigorous fork (best safety) + tradeoffs: Safe; throws away the superior UX and the new pipeline + - option: Base on the newest tree, restore the dropped rigor commit by commit + tradeoffs: Most work; keeps the best of both and leaves an auditable trail +decision: >- + Base the consolidation on the newest tree and restore the validation and curation from + the rigorous fork, each restoration as its own reviewable commit. +rationale: >- + The best UX and the best safety came from different forks. Merging judgment, not just + code, was the only way to keep both, and doing it in discrete commits kept it auditable. +consequences: >- + A single coherent build across 43 commits in four days. It immediately surfaced a + regression (stubbed validation gates), which was then fixed. See the linked incident. +tags: [process, judgment, consolidation] +--- + +The consolidation is the clearest evidence of product judgment in either project: three +diverging lines, a decision about which qualities to keep, and a disciplined, reviewable +path to one build. diff --git a/src/content/decisions/ls-curation-tiers.md b/src/content/decisions/ls-curation-tiers.md new file mode 100644 index 0000000..fe101c4 --- /dev/null +++ b/src/content/decisions/ls-curation-tiers.md @@ -0,0 +1,32 @@ +--- +title: Keep and Promote curation tiers for generated scenes +product: lichtspiel +date: 2026-06-11 +status: accepted +context: >- + Generated visual scenes vary in quality and should not silently join the trusted corpus. + The team needed a way to keep a good generation for the current session and, separately, + to graduate a proven one into the committed set of scenes. +options_considered: + - option: Auto-commit every generated scene that passes validation + tradeoffs: Zero friction; the corpus fills with unproven scenes and quality drifts + - option: Discard every generation at the end of the session + tradeoffs: Keeps the corpus clean; loses good scenes and any path to grow the library + - option: Two tiers, a session Keep and a committed Promote + tradeoffs: A little more UI and a deliberate human step; clean corpus, nothing good lost +decision: >- + Generated scenes surface behind a banner with two actions. Keep holds a scene in local + session state that survives reload, and Promote moves the file into the committed tier + through an explicit, human-curated step. +rationale: >- + Validation proves a scene runs; it does not prove a scene is worth keeping. A human taste + step is the right gate for the corpus, and separating a session keep from a permanent + promote matches how a performer actually works. +consequences: >- + The trusted corpus only grows on a deliberate human action, and a session can still hold + onto promising scenes without polluting it. The first user-approved graduate came from a + live session, which validated the flow. +tags: [generation, curation, corpus, workflow] +--- + +Keep and Promote is the human taste gate on top of automated validation. It keeps generation useful without letting it dilute the hand-curated scenes the performance depends on. diff --git a/src/content/decisions/ls-graceful-degradation.md b/src/content/decisions/ls-graceful-degradation.md new file mode 100644 index 0000000..2e6a7c6 --- /dev/null +++ b/src/content/decisions/ls-graceful-degradation.md @@ -0,0 +1,30 @@ +--- +title: Graceful degradation to a browser-only runtime +product: lichtspiel +date: 2026-05-30 +status: accepted +context: >- + A live audiovisual system stacks several fragile dependencies (Ableton, a Max device, a + Node bridge, a Python service, and monome hardware). Any of them can be missing or drop + mid-show, and a hard dependency on all of them would make the instrument undemonstrable. +options_considered: + - option: Require the full stack to be present for the runtime to start + tradeoffs: Simplest assumptions; a single missing piece takes the whole demo down + - option: Make each layer optional and reduce it to a safe control message + tradeoffs: More care at each boundary; the runtime always has something safe to play +decision: >- + The p5 runtime runs fully browser-only with no Ableton, bridge, or model service. Every + experimental layer reduces to a safe control message (a scene id, a parameter vector, or a + morph target). If the bridge appears the runtime auto-connects, and if it drops it reconnects. +rationale: >- + For a live instrument, staying up is worth more than any single feature. Reducing every layer + to the same small control vocabulary means an absent or failed layer degrades the experience + rather than ending it. +consequences: >- + The demo can start from nothing but a browser and gain capability as layers come online. This + shaped the whole architecture toward optional, reconnecting layers rather than a monolith, and + made testing each layer in isolation straightforward. +tags: [reliability, architecture, degradation, runtime] +--- + +Graceful degradation is the companion rule to runtime purity: purity keeps models off the stage, degradation keeps missing layers from taking the show down. Together they are why the instrument is dependable enough to actually perform on. diff --git a/src/content/decisions/ls-runtime-purity.md b/src/content/decisions/ls-runtime-purity.md new file mode 100644 index 0000000..7e1a81a --- /dev/null +++ b/src/content/decisions/ls-runtime-purity.md @@ -0,0 +1,31 @@ +--- +title: 'Runtime purity: no model or network calls on the performance path' +product: lichtspiel +date: 2026-06-08 +status: accepted +context: >- + Lichtspiel generates visuals with a language model, but it is played live on stage. + A model call mid-performance means unpredictable latency and a hard dependency on a + network that hackathon venues rarely provide reliably. +options_considered: + - option: Call the model live to react to the performance + tradeoffs: Most responsive on paper; unpredictable latency; fails without a network + - option: All generation at authoring time; runtime plays only validated artifacts + tradeoffs: Requires a curation step; runtime is deterministic, fast, and offline-safe +decision: >- + Draw a hard line: the performance runtime never calls a model or the network. Every + generative step happens at authoring time and produces a validated artifact the + runtime can play deterministically. +rationale: >- + On stage, predictability beats cleverness. A visual that renders every frame with no + external dependency is worth more than one that occasionally stutters waiting on a model. +consequences: >- + The runtime degrades gracefully to browser-only with no Ableton, bridge, or model + service. It also forced a clean split between an authoring pipeline and a play pipeline, + which made validation and curation natural rather than bolted-on. +tags: [architecture, performance, reliability] +--- + +"Runtime purity" is the constraint that most shaped Lichtspiel. It is the reason the +instrument is dependable enough to play live, and the reason the AI complexity all lives +safely behind the stage rather than on it. diff --git a/src/content/decisions/ls-visual-param-vector.md b/src/content/decisions/ls-visual-param-vector.md new file mode 100644 index 0000000..4e56d2a --- /dev/null +++ b/src/content/decisions/ls-visual-param-vector.md @@ -0,0 +1,29 @@ +--- +title: The VisualParamVector shared control contract +product: lichtspiel +date: 2026-05-30 +status: accepted +context: >- + Many layers (Max, the bridge, the monome mapping, keyboard input, and every visual scene) + needed a common language for control. Without a fixed contract, each new input source or + scene would invent its own parameter shape and the layers would drift apart. +options_considered: + - option: Let each scene define its own parameter set + tradeoffs: Maximum expressive freedom per scene; no interchangeable inputs, constant glue code + - option: A fixed vector of sixteen normalized parameters plus a scene id + tradeoffs: A shared surface every layer speaks; scenes must map their intent onto it +decision: >- + Adopt a single VisualParamVector of sixteen normalized parameters plus a scene id as the + one control surface every template understands, defined in the shared schemas package. +rationale: >- + A narrow, stable contract lets any input source (hardware, keyboard, generation, or + automation) drive any scene interchangeably. Normalizing to a fixed range keeps smoothing, + mapping, and generation simple across the whole system. +consequences: >- + Inputs and scenes became interchangeable, and generated scenes were constrained to the same + sixteen keys with no new fields. The ceiling is sixteen parameters, which is a deliberate + trade of raw expressivity for a dependable shared interface. +tags: [contract, schemas, architecture, parameters] +--- + +The parameter vector is the quiet spine of the system. Fixing it early is what let hardware, keyboard, and later code generation all drive the same scenes without bespoke adapters. diff --git a/src/content/decisions/wc-bounded-visitor-session.md b/src/content/decisions/wc-bounded-visitor-session.md new file mode 100644 index 0000000..d873a6e --- /dev/null +++ b/src/content/decisions/wc-bounded-visitor-session.md @@ -0,0 +1,39 @@ +--- +title: Give each walk-up visitor a bounded turn, not an endless loop +product: windchime +date: 2026-07-05 +status: accepted +context: >- + For an unattended gallery the piece has to orient a stranger in seconds, give them + a satisfying turn, and hand off to the next person with no operator present. An + open-ended free-play loop has no natural end and no reset. +options_considered: + - option: Endless free-play loop + tradeoffs: >- + Simplest to build, but there is no natural handoff, one visitor can camp + indefinitely, and the piece never resets its state or volume for the next person. + - option: Fixed wall-clock timer per visitor + tradeoffs: >- + Predictable length, but it can cut someone off mid-thought or leave dead time + when they finish early. + - option: A bounded, staged turn with a set number of spoken prompts, a recap, and an automatic reset + tradeoffs: >- + More states to build and test, but it gives a clear arc and a clean handoff + back to a resting state. +decision: >- + Each visitor gets a staged turn with an explicit start, a set number of spoken prompts, a + closing recap, and an automatic reset back to an attract state. +rationale: >- + A bounded turn gives a stranger a beginning, middle, and end they can feel, and the + automatic reset guarantees the next visitor starts clean. It also makes the whole + experience testable as a repeatable lifecycle rather than an open session. +consequences: >- + The lifecycle became the spine of install mode and the target of the reliability + soak. It also introduced distinct states (attract, onboarding, live, recap) that + each needed their own robustness handling for an unattended run. +tags: [installation, lifecycle, ux] +--- + +Treating a visit as a bounded turn rather than a loop is what made the piece +exhibitable without an operator. The bounded prompt count and the recap are not just +pacing, they are the mechanism that resets the piece for the next stranger. diff --git a/src/content/decisions/wc-guarded-livecoding.md b/src/content/decisions/wc-guarded-livecoding.md new file mode 100644 index 0000000..aed5fd9 --- /dev/null +++ b/src/content/decisions/wc-guarded-livecoding.md @@ -0,0 +1,33 @@ +--- +title: 'Guard the model behind a schema: never let it emit raw code' +product: windchime +date: 2026-05-16 +status: accepted +context: >- + The installation live-codes music from a language model. Letting the model write + executable audio code directly is fast to prototype but unaccountable and unsafe + in a room full of speakers. A single bad generation can produce noise or silence. +options_considered: + - option: Model emits Strudel/audio code directly + tradeoffs: Most flexible; unbounded and unsafe; hard to validate before it plays + - option: Model fills a validated JSON schema; a compiler renders code + tradeoffs: Slightly less expressive; every output is inspectable and safe by construction + - option: No model (fully deterministic templates only) + tradeoffs: Perfectly safe; loses the responsiveness that makes the piece feel alive +decision: >- + The model selects from curated template families and fills a validated schema. A + validator and compiler render approved patterns; raw model code never reaches the + audio engine. +rationale: >- + In an unattended installation, safety and inspectability outrank raw expressiveness. + A schema is a contract the whole system can reason about. +consequences: >- + Every musical result is bounded and reviewable, and the same guard enabled the + three-tier planner failover. The cost is a curation burden: the template families + are hand-authored. +tags: [safety, architecture, llm] +--- + +This is the decision the rest of Windchime's reliability rests on. Because the model +can only fill a schema, the system can validate, fall back, and reason about output +without ever trusting generated code. diff --git a/src/content/decisions/wc-monome-digital-twin.md b/src/content/decisions/wc-monome-digital-twin.md new file mode 100644 index 0000000..6606154 --- /dev/null +++ b/src/content/decisions/wc-monome-digital-twin.md @@ -0,0 +1,40 @@ +--- +title: Mirror the monome hardware with an on-screen digital twin +product: windchime +date: 2026-05-22 +status: accepted +context: >- + The visuals branch drives monome Grid and Arc LEDs over a hardware bridge. + Developing, testing, and operating the piece cannot depend on the physical + controllers always being attached and correctly seated. +options_considered: + - option: Hardware-only, with no on-screen representation + tradeoffs: >- + Nothing extra to build, but no way to develop or verify LED behaviour without a + device attached, and no operator view of what the hardware is showing. + - option: An approximate on-screen visualization + tradeoffs: >- + Cheap to draw, but it can drift from what the hardware actually displays, so it + cannot be trusted for verification. + - option: A byte-identical on-screen mirror driven by the same LED frame, with trace replay + tradeoffs: >- + It has to render exactly what is sent to the device, but it enables + hardware-free development, regression, and an operator view. +decision: >- + The app renders a virtual monome whose LED state is byte-identical to the frame sent + to the physical device, and it records or replays input traces so gestures can be + exercised with no hardware present. +rationale: >- + A faithful twin lets sketches be built and regression-tested with no device attached, + gives operators a live view of device state, and keeps on-screen and on-hardware + behaviour provably in sync rather than merely similar. +consequences: >- + Visual and behaviour regression can run headless in CI, and the twin doubles as the + verification surface when hardware is unavailable. It later became part of the + onboarding tour, where a visitor watches the twins light up. +tags: [hardware, monome, tooling] +--- + +Insisting the on-screen mirror be byte-identical, not just illustrative, is what +lets it stand in for the hardware during development and testing. A twin you can +trust is a twin you can regress against. diff --git a/src/content/decisions/wc-planner-failover.md b/src/content/decisions/wc-planner-failover.md new file mode 100644 index 0000000..057fd4e --- /dev/null +++ b/src/content/decisions/wc-planner-failover.md @@ -0,0 +1,38 @@ +--- +title: Three-tier failover for the code planner +product: windchime +date: 2026-05-16 +status: accepted +context: >- + The planner turns a voice into a chosen template family and its parameters using a + language model. In an unattended installation a single hosted model is a single + point of failure, and a dropped network must never strand a visitor mid-turn. +options_considered: + - option: Hosted model only + tradeoffs: >- + Best planning quality, but a network or API outage stops the piece entirely. + - option: Local model only + tradeoffs: >- + No network dependency, but weaker planning and still a single point of failure + if the local model misbehaves. + - option: Hosted model, then a local model, then a deterministic in-code fill + tradeoffs: >- + More paths to maintain, but the piece can always produce a valid plan. +decision: >- + The planner tries a hosted model first with prompt caching, falls back to a local + model in JSON mode, and finally to a deterministic in-code template fill. Every + output is validated before a compiler renders it. +rationale: >- + Degrading through progressively more local options keeps the installation + responsive and always able to answer, even fully offline. The validate-then-compile + boundary means no tier can emit unapproved output, whichever one produced the plan. +consequences: >- + The piece keeps running through a network outage, and each run records which tier + planned it. A later operator selector exposed the same chain in the UI, so a dead + host degrades to the next tier instead of stranding a visitor. +tags: [architecture, reliability, planner] +--- + +The failover chain is a reliability decision dressed as an LLM detail. What it buys +is a piece that never goes silent because a remote service did, and a guarantee that +every tier still passes through the same validation gate. diff --git a/src/content/decisions/wc-retrieval-as-variable.md b/src/content/decisions/wc-retrieval-as-variable.md new file mode 100644 index 0000000..d7d7197 --- /dev/null +++ b/src/content/decisions/wc-retrieval-as-variable.md @@ -0,0 +1,31 @@ +--- +title: Make the audio-language model an exchangeable variable +product: windchime +date: 2026-06-28 +status: accepted +context: >- + Windchime retrieves sound by matching a voice to audio embeddings. Which + audio-language model does the matching is a research question in its own right, + but the installation around it must stay identical to compare fairly. +options_considered: + - option: Hard-code one audio-language model + tradeoffs: Simplest; makes the model impossible to study as a variable + - option: Abstract retrieval behind one interface + a model-independent corpus DB + tradeoffs: More upfront design; turns "which model" into a controlled experiment +decision: >- + Route every audio-language model through a single embedding-backend interface and + a model-independent corpus database, with audio embedded offline once per + configuration. Heavy models sit behind a lightweight text-only sidecar. The + backend hot-swaps at runtime with a liveness probe and auto-revert. +rationale: >- + Holding the whole installation constant and varying only the model is the only + way to attribute a difference to the model rather than the plumbing. +consequences: >- + Six deployable configurations became possible, and the same seam powers an offline + audit harness. It also imposed discipline: nothing about the corpus format may leak + a model's assumptions. +tags: [architecture, research, retrieval] +--- + +Turning "which model" into a config toggle is what made the research tractable, and +it kept the installation shippable while the science continued underneath it. diff --git a/src/content/decisions/wc-retrieval-in-process-library.md b/src/content/decisions/wc-retrieval-in-process-library.md new file mode 100644 index 0000000..a88c77f --- /dev/null +++ b/src/content/decisions/wc-retrieval-in-process-library.md @@ -0,0 +1,38 @@ +--- +title: Import retrieval as a library, not a network service +product: windchime +date: 2026-05-16 +status: accepted +context: >- + The livecode server needs semantic retrieval on every utterance. Retrieval could be + its own networked microservice or an in-process library constructed inside the + server that already handles the request. +options_considered: + - option: A separate retrieval microservice over HTTP + tradeoffs: >- + Clean process isolation, but an extra network hop, another process to supervise, + and network failure modes sitting on the visitor hot path. + - option: An in-process library shared across the server's worker threads + tradeoffs: >- + One fewer moving part and no hop, but the engine must be thread-safe and stay + read-only after it is built. +decision: >- + Retrieval is installed as a package and constructed once in-process, shared + read-only across the server's worker threads. The exception is a small set of + dependency-conflicting heavy backends, which run as a minimal text-only sidecar + because they genuinely cannot share the process. +rationale: >- + Keeping the default retrieval in-process removes a network hop and a supervised + process from the visitor hot path, which matters for both latency and reliability in + an unattended piece. Sidecars are reserved only for models that cannot coexist in + one environment. +consequences: >- + The shared engine had to be made safe to use across threads, which surfaced a real + bug during bring-up. In the common case the piece stays a single process, and only + the active heavy backend needs its sidecar running. +tags: [architecture, retrieval, deployment] +--- + +The default path is a library call, not a service call, which keeps the hot path +short and supervised by one process. Networked sidecars exist only where a model's +dependency stack forces them, not as the general pattern. diff --git a/src/content/glossary/clap.md b/src/content/glossary/clap.md new file mode 100644 index 0000000..beb3364 --- /dev/null +++ b/src/content/glossary/clap.md @@ -0,0 +1,9 @@ +--- +term: CLAP +product: windchime +definition: >- + Contrastive Language-Audio Pretraining. A model that maps text and audio into one shared + space, so a spoken phrase can retrieve matching sound. Used frozen, as a feature extractor, + with no training. +related: [] +--- diff --git a/src/content/glossary/guarded-planner.md b/src/content/glossary/guarded-planner.md new file mode 100644 index 0000000..499daf0 --- /dev/null +++ b/src/content/glossary/guarded-planner.md @@ -0,0 +1,9 @@ +--- +term: Guarded planner +product: windchime +definition: >- + The pattern where a language model fills a validated JSON schema instead of writing code + directly; a compiler then renders safe output. It keeps generation inspectable and prevents + the model from ever emitting raw code into a live installation. +related: [strudel] +--- diff --git a/src/content/glossary/max-for-live.md b/src/content/glossary/max-for-live.md new file mode 100644 index 0000000..22f5e0b --- /dev/null +++ b/src/content/glossary/max-for-live.md @@ -0,0 +1,8 @@ +--- +term: Max for Live +product: lichtspiel +definition: >- + Ableton Live's embedded visual-programming environment (Max/MSP). Lichtspiel uses a thin Max + device as its bridge into the running set, reading clips, scenes, and transport. +related: [] +--- diff --git a/src/content/glossary/monome.md b/src/content/glossary/monome.md new file mode 100644 index 0000000..59ec4fb --- /dev/null +++ b/src/content/glossary/monome.md @@ -0,0 +1,8 @@ +--- +term: monome +product: shared +definition: >- + A minimalist grid-and-encoder hardware controller: a Grid of backlit buttons and an Arc of + rotary encoders. Both projects treat it as an expressive instrument, not a bank of switches. +related: [serialosc] +--- diff --git a/src/content/glossary/runtime-purity.md b/src/content/glossary/runtime-purity.md new file mode 100644 index 0000000..3a5ebc9 --- /dev/null +++ b/src/content/glossary/runtime-purity.md @@ -0,0 +1,8 @@ +--- +term: Runtime purity +product: lichtspiel +definition: >- + Lichtspiel's rule that the live performance path never calls a model or the network. All AI + happens at authoring time, so the visuals never stutter waiting on anything, even offline. +related: [] +--- diff --git a/src/content/glossary/serialosc.md b/src/content/glossary/serialosc.md new file mode 100644 index 0000000..2d9d8e3 --- /dev/null +++ b/src/content/glossary/serialosc.md @@ -0,0 +1,8 @@ +--- +term: serialosc +product: shared +definition: >- + The small background service that connects monome hardware to software over OSC, so a + browser or app can read button presses and light the LEDs. +related: [monome] +--- diff --git a/src/content/glossary/soak-test.md b/src/content/glossary/soak-test.md new file mode 100644 index 0000000..3679ee2 --- /dev/null +++ b/src/content/glossary/soak-test.md @@ -0,0 +1,8 @@ +--- +term: Soak test +product: shared +definition: >- + A long-duration reliability run (hours, not minutes) that exercises a system continuously + to surface slow leaks, timing drift, and rare failures a short test would never hit. +related: [] +--- diff --git a/src/content/glossary/strudel.md b/src/content/glossary/strudel.md new file mode 100644 index 0000000..4e056cd --- /dev/null +++ b/src/content/glossary/strudel.md @@ -0,0 +1,8 @@ +--- +term: Strudel +product: windchime +definition: >- + A browser-based live-coding environment for music (a JavaScript cousin of TidalCycles). + Windchime renders validated patterns into Strudel to produce sound in real time. +related: [guarded-planner] +--- diff --git a/src/content/incidents/ls-encoder-press-scene-switch.md b/src/content/incidents/ls-encoder-press-scene-switch.md new file mode 100644 index 0000000..7408d44 --- /dev/null +++ b/src/content/incidents/ls-encoder-press-scene-switch.md @@ -0,0 +1,32 @@ +--- +title: Encoder presses switched scenes mid-performance +product: lichtspiel +date: 2026-06-04 +severity: sev3 +status: resolved +summary: >- + Monome encoder clicks unpredictably switched the active visual scene during + play, because a legacy fallback mapping bound encoder presses to template + navigation. +impact: >- + Expressive presses doubled as navigation, so a performer leaning into an + encoder could yank the whole visual world out from under their set. +detection: >- + Reproduced during rehearsal play-throughs on the physical Arc. +response: >- + Audited every monome binding, found the legacy fallback that mapped presses to + random/next-template navigation and a grid region to scene select, and removed + the whole class of binding. +root_cause: >- + An early fallback mapping survived into the instrument era: encoder presses and + a grid region were still bound to template switching from before the idiom + layer existed. +fix: >- + Removed all template-switching from the monome mapping. Hardware drives + parameters only; navigation stays on the keyboard and in Ableton. +blameless_note: >- + This established a rule the idiom layer inherited: the instrument surface is + for expression, never for navigation, so no gesture can destroy the context it + is played in. +tags: [monome, mapping, performance] +--- diff --git a/src/content/incidents/ls-feeder-poll-wedge.md b/src/content/incidents/ls-feeder-poll-wedge.md new file mode 100644 index 0000000..db4a7c2 --- /dev/null +++ b/src/content/incidents/ls-feeder-poll-wedge.md @@ -0,0 +1,39 @@ +--- +title: Feeder poll loop wedged the live trigger path +product: lichtspiel +date: 2026-06-15 +severity: sev2 +status: resolved +summary: >- + The Ableton feeder process could stall so that live state stopped reaching the bridge and no + scene-launch or locator-crossing triggers fired, leaving the visuals unresponsive while Live + was still playing. +impact: >- + With the trigger path stalled, the visuals stopped following the set even though the music + played on. For a live instrument this is a show-stopping failure of the core loop. +detection: >- + Seen in the bridge log: only the OSC-sourced live state arrived and never the feeder source, + and no scene-launched or locator-crossed events fired despite active playback. +response: >- + Traced the stall to the feeder's polling loop, added a self-healing timeout, and documented a + manual restart as an immediate mitigation if it ever recurred. +root_cause: >- + A poll-loop wedge. A read against the control socket collided with the bridge's own snapshot + query, the interleaved bytes never parsed, the inactivity timeout never fired, and the loop + stayed stuck in a busy state. +fix: >- + The feeder's Ableton read now has an absolute settle timeout so it self-heals from a wedged + read. If it ever recurs, stopping and restarting the feeder process clears it. +followup_actions: + - action: Watch for recurrence now that the read has an absolute settle timeout + status: open + - action: Keep Session scene launches as the reliable trigger where locator crossings are suppressed + status: done +blameless_note: >- + Two independent readers sharing one control socket is a subtle race that only shows up under + live timing. Adding a self-healing timeout and a documented manual recovery is a proportionate, + honest response to an intermittent hang. +tags: [feeder, ableton, reliability, polling] +--- + +This is the kind of failure that only appears under live timing, which is why the fix is defensive rather than clever: an absolute timeout that recovers on its own, plus a one-line manual restart. The trigger path is the core loop, so its resilience matters more than its elegance. diff --git a/src/content/incidents/ls-grid-coverage.md b/src/content/incidents/ls-grid-coverage.md new file mode 100644 index 0000000..4f5bb22 --- /dev/null +++ b/src/content/incidents/ls-grid-coverage.md @@ -0,0 +1,38 @@ +--- +title: Generated scenes can pass while leaving the controller half-dead +product: lichtspiel +date: 2026-06-12 +severity: minor +status: monitoring +summary: >- + The playability gate checks that monome control idioms are present, not that they + cover the surface, so a thin mapping (six faders on an eight-column grid) passes and + leaves dead columns and idle encoders. +impact: >- + A generated scene can be technically valid but underwhelming to play. Part of the + instrument sits unused. A quality gap, not a crash. +detection: >- + Found in live testing while playing generated scenes and noticing unmapped hardware. +response: >- + Logged with a concrete fix direction (require full lane and encoder coverage) rather + than a vague "improve generation." +root_cause: >- + The gate was written to confirm idioms exist, which is necessary but not sufficient for + a satisfying instrument mapping. +fix: >- + Proposed: extend the gate to require coverage of all grid lanes and encoders. Not yet + implemented, tracked as the next roadmap item. +followup_actions: + - action: Extend the playability gate to assert coverage + status: open + - action: Confirm the related concurrent-generation mitigations on a live rig + status: open +blameless_note: >- + A good gate that turned out to be too permissive is a normal iteration of a quality bar, + not a defect in judgment. +linked_release: ls-generative-track +tags: [generation, quality, known-issue] +--- + +Kept deliberately open and honest: this is a known limitation with a clear fix path, and +showing it is more credible than pretending the generation gate is already perfect. diff --git a/src/content/incidents/ls-scene-arrange-same-template.md b/src/content/incidents/ls-scene-arrange-same-template.md new file mode 100644 index 0000000..2a17db3 --- /dev/null +++ b/src/content/incidents/ls-scene-arrange-same-template.md @@ -0,0 +1,40 @@ +--- +title: Scene then arrangement generated the same template +product: lichtspiel +date: 2026-06-12 +severity: sev3 +status: monitoring +summary: >- + Capturing a Session scene and generating, then capturing an Arrangement region and + generating with auto-generate on, produced the same visual template instead of a new one + conditioned on the latest capture. +impact: >- + A performer expecting fresh imagery from the newest capture got a repeat, which undermines + trust in the generate-on-capture flow during a play session. +detection: >- + Found during a live play session while exercising the capture-then-generate path back to + back, comparing the scene result against the arrangement result. +response: >- + Rather than guess a single cause, three plausible causes were instrumented and two guards + were landed, with a live reproduction planned to confirm which cause was real. +root_cause: >- + Not yet confirmed. Three hypotheses are open: the capture source not switching to the newest + file, two generations colliding with no in-flight guard, and near-identical audio yielding a + near-identical brief. A missing context-set call was also found on the auto path. +fix: >- + Request-source instrumentation logs the path each generation actually used, a latest-wins + queue serializes concurrent generations so a stale run cannot shadow a newer one, and the + missing context-set call was folded into the auto-generate path. +followup_actions: + - action: Run the live reproduction and confirm the second generation names the newest capture + status: open + - action: If sources are correct but results still match, compare the two provenance banners and vibe logs + status: open +blameless_note: >- + Concurrent long-running generations without a guard is an easy gap to leave under time + pressure. Instrumenting first and confirming with a live repro, rather than assuming a fix, + is the right way to close an intermittent issue like this. +tags: [generation, capture, concurrency, monitoring] +--- + +This stays in monitoring because the guards landed but the live reproduction has not yet confirmed the root cause. The instrumentation is the real win: the next occurrence will name the capture each generation used, turning a vague "it repeated" into a precise diagnosis. diff --git a/src/content/incidents/ls-validation-regression.md b/src/content/incidents/ls-validation-regression.md new file mode 100644 index 0000000..61f8833 --- /dev/null +++ b/src/content/incidents/ls-validation-regression.md @@ -0,0 +1,39 @@ +--- +title: Consolidation re-exposed a stubbed validation path +product: lichtspiel +date: 2026-06-11 +severity: sev3 +status: resolved +summary: >- + The newer fork chosen as the consolidation base had its code-generation validation + gates stubbed out with TODOs. Basing on it meant generated visuals could ship unchecked. +impact: >- + Without real gates, a generated scene could reach the stage without passing type, + lint, playability, or render checks, the exact failure the rigor was meant to prevent. +detection: >- + Found during the consolidation itself, by comparing the two forks' generation paths + rather than assuming the newer one was complete. +response: >- + Treated it as a first-class restoration task in the consolidation plan rather than a + footnote: a real validator, wired into the generation flow, with a bounded self-repair loop. +root_cause: >- + The fork had prioritised UX and a new pipeline and left validation as a stub; the gap + only mattered once that fork became the base everything else built on. +fix: >- + A real validation script (strict type-check, an allow-list lint, a playability marker + check, and a headless render smoke test) shelled from the generator with up to three + self-repair passes before failing. +followup_actions: + - action: Prove the rebuilt path end-to-end with a first generated scene + status: done + - action: Strengthen the playability gate to check coverage, not just presence + status: open +blameless_note: >- + Stubbing validation to move fast in a hackathon fork is a reasonable local choice; the + risk only appeared at integration. Catching it there is the system working as intended. +linked_release: ls-consolidation-v1 +tags: [validation, consolidation, postmortem] +--- + +**Verification.** The first scene through the rebuilt pipeline passed every gate at 60 fps, +the proof that the restored rigor was real and not just re-declared. diff --git a/src/content/incidents/wc-audio-runaway.md b/src/content/incidents/wc-audio-runaway.md new file mode 100644 index 0000000..4d51cb7 --- /dev/null +++ b/src/content/incidents/wc-audio-runaway.md @@ -0,0 +1,50 @@ +--- +title: 'Audio runaway: the installation got louder, then went silent' +product: windchime +date: 2026-07-03 +severity: sev2 +status: resolved +summary: >- + During testing the sound occasionally built up "like a feedback loop," then quit + entirely, recoverable only by a hard page refresh. It was not feedback but + unbounded voice accumulation. +impact: >- + In a gallery this would mean a jarring loud build-up followed by dead speakers until + someone reloaded the page, unacceptable for an unattended piece. +detection: >- + Caught in extended manual testing before exhibition, then reproduced deterministically + by replaying the exact sequence of generated patterns. +response: >- + Reproduced under instrumentation, measured the voice count and output peak over time, + and traced the mechanism rather than treating the symptom. +root_cause: >- + Full-length multi-minute stems were retriggered every pattern cycle with no duration + bound. Voices stacked to roughly 870 simultaneous source nodes after about 18 minutes; + the audio render clock degraded to a few percent of real time while the context still + reported "running," so output effectively died. Peaks reached about 2.8× full scale. + The "roar" was hard clipping. +fix: >- + A three-layer defence, shipped as the audio-safety release: an always-on master limiter + (a NaN-proof ceiling), apply-time clamps bounding each voice's ring-out, and a 1 Hz + watchdog that rescues the engine in place on NaN, clock collapse, or prolonged silence. +followup_actions: + - action: Verify by replaying the exact incident post-fix + status: done + - action: Preserve the runaway texture as an opt-in "wild mode" (speakers still protected) + status: done + - action: Extend the long-duration soak harness to the audio path itself + status: open +blameless_note: >- + The bug was a reasonable default (play the whole stem) meeting an unreasonable rate + (every cycle). The lesson is about bounding resources under repetition, not about blame. +linked_release: wc-audio-safety +tags: [audio, reliability, postmortem] +--- + +**Verification.** Replaying the same plans after the fix capped live voices at about 12 +(down from 872) and the master peak at about 0.36 (down from 2.8). An injected-NaN test +confirmed the watchdog restores sound with no refresh and no new user gesture. + +The most product-minded part of the resolution: the runaway texture was genuinely +interesting, so rather than delete it, it was preserved as a deliberate, speaker-safe +"wild mode", and the pre-fix behaviour was tagged in git so it can be studied bit-for-bit. diff --git a/src/content/incidents/wc-csrf-origin-allowlist.md b/src/content/incidents/wc-csrf-origin-allowlist.md new file mode 100644 index 0000000..8b1c8ce --- /dev/null +++ b/src/content/incidents/wc-csrf-origin-allowlist.md @@ -0,0 +1,43 @@ +--- +title: State-changing endpoints accepted cross-site requests +product: windchime +date: 2026-05-23 +severity: sev3 +summary: >- + The livecode server's state-changing endpoints had permissive cross-origin settings, + so a page open in the same browser could have triggered recording or generation + without the operator's intent. +impact: >- + While the stack runs on a developer machine, a malicious page visited in the same + browser could have started the microphone or spent model-API budget. No exploit was + observed; this was a latent exposure. +detection: >- + Found during a security review of the endpoints while unifying the services under + the umbrella. +response: >- + Threat-modelled the state-changing endpoints and added an Origin check rather than + relying on permissive cross-origin response rules. +root_cause: >- + Open cross-origin settings block reading a response but do not stop a simple + cross-site POST from being sent. With no Origin check, the state-changing endpoints + trusted any caller. +fix: >- + Every state-changing endpoint now checks the browser Origin against an allowlist of + the installation's own service URLs. Non-browser callers, which send no Origin, still + pass, so command-line scripts and the microphone runtime are unaffected. +followup_actions: + - action: Keep the launcher port list and the Origin allowlist in sync whenever a service port changes + status: open + - action: Add a bearer token or an authenticating proxy before any non-loopback exposure + status: open +status: resolved +blameless_note: >- + A permissive default is common for a localhost dev tool. Adding the Origin check is a + proportionate mitigation for the current trust model, and it is documented so it gets + revisited before any wider exposure. +tags: [security, csrf, endpoints] +--- + +A defensive fix for a piece that lives on a developer machine but points a microphone +at a room. The Origin allowlist closes the cross-site path while deliberately leaving +non-browser tooling, which sends no Origin, free to work. diff --git a/src/content/incidents/wc-monome-foreign-prefix.md b/src/content/incidents/wc-monome-foreign-prefix.md new file mode 100644 index 0000000..1f32215 --- /dev/null +++ b/src/content/incidents/wc-monome-foreign-prefix.md @@ -0,0 +1,34 @@ +--- +title: Monome dead after another app used it +product: windchime +date: 2026-06-04 +severity: sev3 +status: resolved +summary: >- + The monome hardware was completely unresponsive in Windchime whenever another + monome application had used the controllers first, because serialosc persists + the previous app's message prefix. +impact: >- + Total controller unresponsiveness (no input, no LEDs) until the daemon was + restarted by hand, exactly the kind of hidden state that would strand an + unattended installation. +detection: >- + Reproduced after switching between Windchime and another monome application on + the same machine. +response: >- + Traced the silent input to the OSC prefix each device was still emitting under, + then made the bridge tolerant of any prefix instead of documenting a restart + ritual. +root_cause: >- + serialosc persists each device's message prefix across application exits and + daemon restarts. Devices kept emitting under the other app's prefix, and the + bridge's literal-prefix matcher dropped every message. +fix: >- + Suffix-matched device input so messages flow under any persisted prefix, plus a + self-healing re-grab that reasserts Windchime's own prefix on the device. +blameless_note: >- + Shared hardware means shared mutable state in the daemon layer. The bridge now + assumes any ambient prefix state is possible, which is the only assumption that + survives a machine other people also use. +tags: [monome, serialosc, hardware] +--- diff --git a/src/content/incidents/wc-monome-hotplug-reattach.md b/src/content/incidents/wc-monome-hotplug-reattach.md new file mode 100644 index 0000000..50285a7 --- /dev/null +++ b/src/content/incidents/wc-monome-hotplug-reattach.md @@ -0,0 +1,43 @@ +--- +title: Monome went silent after an unplug and would not recover +product: windchime +date: 2026-05-24 +severity: sev2 +summary: >- + Unplugging and replugging a monome controller during a session left it detached and + unresponsive, recoverable only by restarting the hardware bridge. +impact: >- + In a live setting a bumped or reseated cable would drop the controller for the rest + of the show unless someone restarted the bridge, which is not acceptable for an + unattended piece. +detection: >- + Reproduced during hardware testing. After a device detach and reattach, the bridge + kept reporting the device lost and no input or LED output resumed. +response: >- + Inspected the device-discovery handshake to see why a returning device was never + re-adopted. +root_cause: >- + The bridge armed its device-notification subscription once and deduplicated + advertisements, so after a detach it never re-armed the subscription or re-pointed a + returning device back at itself. +fix: >- + The bridge now re-arms its device-notification subscription after every attach and + detach and runs a short periodic re-poll as a backup, so a replugged device + re-attaches within a few seconds with no restart. Redundant device chatter is + suppressed by deduplicating only the attach event, not the re-pointing. +followup_actions: + - action: Make input matching tolerant of a foreign address prefix left behind by another monome app + status: done + - action: Recover the host-side audio input path after a USB re-enumeration + status: done +status: resolved +blameless_note: >- + Hot-plug recovery is easy to miss when the first plug works cleanly. The fix makes + the bridge self-healing, which is the right posture for hardware that will get + bumped in a gallery. +tags: [hardware, monome, reliability] +--- + +Hardware in a gallery gets touched, so the bridge had to treat a mid-session unplug as +normal rather than fatal. Re-arming discovery on every device event, plus a periodic +re-poll, turned a restart-only failure into an automatic few-second recovery. diff --git a/src/content/incidents/wc-participant-audio-suspend.md b/src/content/incidents/wc-participant-audio-suspend.md new file mode 100644 index 0000000..f115706 --- /dev/null +++ b/src/content/incidents/wc-participant-audio-suspend.md @@ -0,0 +1,33 @@ +--- +title: Audio silently stayed suspended between trials +product: windchime +date: 2026-05-24 +severity: sev2 +status: resolved +summary: >- + Audio went silent between participant trials and never came back: the + AudioContext was suspended on every stop cycle and the resume ran from a server + event with no user gesture, so the browser silently refused it. +impact: >- + Trial-blocking during the first participant tests. Every session after the + first stop played nothing, with no error anywhere. +detection: >- + Manual participant runs the same day; the transcript and visuals advanced while + the room stayed silent. +response: >- + Reproduced the suspend/resume cycle, confirmed the browser's autoplay policy + was rejecting the gesture-less resume, and reworked the lifecycle the same day. +root_cause: >- + Each stop-and-replace cycle suspended and resumed the AudioContext, and the + resume was issued from an SSE handler where no user gesture exists, so the + context stayed suspended; a cross-origin unlock gap compounded it. +fix: >- + Removed the suspend/resume cycle entirely so the context stays running for the + whole session, eagerly loaded the audio engine, and added a prominent unlock + banner that verifies the resume actually took effect. +blameless_note: >- + Browser autoplay policy is part of the runtime contract. Any audio lifecycle + design that requires a resume must prove it holds a user gesture at that + moment, and the fix that survives is the one that stops needing the resume. +tags: [audio, browser, lifecycle] +--- diff --git a/src/content/incidents/wc-soak-negative-radius.md b/src/content/incidents/wc-soak-negative-radius.md new file mode 100644 index 0000000..c291d1b --- /dev/null +++ b/src/content/incidents/wc-soak-negative-radius.md @@ -0,0 +1,41 @@ +--- +title: A negative-radius draw call, found by a 6-hour soak +product: windchime +date: 2026-07-06 +severity: sev3 +status: resolved +summary: >- + A purpose-built browser harness drove 879 synthetic visitors through the full session + lifecycle for six hours. It surfaced exactly one real bug, a rare negative-radius + drawing exception in the "computing" sequence. +impact: >- + A single-frame render exception during the retrieval wait; visible only under sustained, + rapid cycling, never yet by a real visitor, but the kind of thing that erodes an + unattended install over a full day. +detection: >- + Automated: the soak harness logged every render exception. The negative-radius count + registered six occurrences across nearly 2,000 synthetic generations. +response: >- + Traced the exception to sub-frame timing skew between the animation clock and the + wall clock, and matched the repository's existing guarding idiom rather than inventing a new one. +root_cause: >- + A radius derived from elapsed time could momentarily go negative when two clocks + disagreed by a sub-frame amount, producing an invalid arc. +fix: >- + A one-line lower-bound guard on the radius, consistent with the author's existing style. + The negative-radius count went from six to zero on the next run. +followup_actions: + - action: Document the two issues the soak deliberately did NOT patch (a slow memory creep; a rare synthetic-only dropped start) + status: done + - action: Extend soaking to the audio and microphone paths + status: open +blameless_note: >- + A clean soak that finds one small bug is a success, not a disappointment. The value is + the confidence that everything else held (zero wedges, zero crashes, flat memory). +linked_release: wc-install-mode-v1 +tags: [reliability, soak, testing, postmortem] +--- + +The headline result was the _absence_ of failures: zero wedges, zero crashes, zero browser +leaks, and byte-perfect separation between synthetic and real study data across roughly +1,900 generations. Finding one guard to add is what a soak is _for_. diff --git a/src/content/incidents/wc-sqlite-cross-thread.md b/src/content/incidents/wc-sqlite-cross-thread.md new file mode 100644 index 0000000..d7394e6 --- /dev/null +++ b/src/content/incidents/wc-sqlite-cross-thread.md @@ -0,0 +1,41 @@ +--- +title: Retrieval failed on every request after the first +product: windchime +date: 2026-05-16 +severity: sev3 +summary: >- + The first retrieval query in the pipeline worked, then every request after it + failed. The corpus database connection was created on one thread and then reused on + the server's other worker threads. +impact: >- + During bring-up the end-to-end pipeline was unusable past a single query, which + blocked browser testing of the whole voice-to-audio path. +detection: >- + Surfaced immediately in end-to-end browser testing. The first generate succeeded and + the second raised a thread-ownership error from the database driver. +response: >- + Traced the failure to a single shared query engine that was constructed on one + thread and then called from the server's request threads. +root_cause: >- + The corpus database connection enforces same-thread use by default. One engine + instance was shared across the server's worker threads, which the driver refuses. +fix: >- + The engine opens its connection with the same-thread check disabled. This is safe + here because the engine is read-only after construction, so there are no concurrent + writes to race. +followup_actions: + - action: Confirm the shared engine stays read-only after construction so the relaxed check remains safe + status: done + - action: Keep retrieval in-process rather than splitting it into a separate service + status: done +status: resolved +blameless_note: >- + The same-thread guard is a sensible database default meeting a reasonable server + pattern of one shared, read-only engine. The lesson is about matching connection + settings to the threading model, not about blame. +tags: [retrieval, threading, database] +--- + +A classic backend bug caught the moment the pipeline ran end to end. The fix was one +connection flag, made safe by an invariant that already held: the engine only reads +after it is built, so relaxing the thread check races nothing. diff --git a/src/content/incidents/wc-stale-phase-gate.md b/src/content/incidents/wc-stale-phase-gate.md new file mode 100644 index 0000000..7be9031 --- /dev/null +++ b/src/content/incidents/wc-stale-phase-gate.md @@ -0,0 +1,41 @@ +--- +title: A closed kiosk page could leave the audio gate stuck +product: windchime +date: 2026-07-12 +severity: sev2 +summary: >- + If the kiosk page died or was closed during a showcase, a shared phase gate could + stay latched, leaving the installation's audio in the wrong state for the next + visitor. +impact: >- + On an unattended kiosk this could strand the piece in a stuck phase with no operator + present to clear it, which defeats the whole point of a self-resetting visitor + lifecycle. +detection: >- + Found by the synthetic-visitor soak, which drives hundreds of sessions and exercises + abrupt page exits that manual testing rarely reaches. +response: >- + Added an explicit reset on the normal exit path and a best-effort reset that still + fires while the page is being torn down. +root_cause: >- + The phase gate was only cleared on a graceful showcase exit. A page that was closed + or crashed never sent that signal, so the gate could remain latched. +fix: >- + The controller posts an idle phase on showcase exit, and the page fires a page-hide + beacon to a reset endpoint, so a dying page cannot leave the gate stuck. A beacon is + used because it survives the page being torn down, where an ordinary request would + not. +followup_actions: + - action: Keep the page-hide reset covering future kiosk auto-boot paths + status: open +status: resolved +blameless_note: >- + Abrupt page death is exactly the kind of edge a long unattended run hits and a short + manual test misses. The soak surfacing this before exhibition is the intended payoff, + not a failing. +tags: [reliability, kiosk, lifecycle] +--- + +A reliability fix the soak paid for. Because a crashed page never runs its normal +cleanup, the recovery had to ride a page-hide beacon, the one signal that still fires +as the page goes away. diff --git a/src/content/incidents/wc-stopped-audio-ringback.md b/src/content/incidents/wc-stopped-audio-ringback.md new file mode 100644 index 0000000..ce3ba35 --- /dev/null +++ b/src/content/incidents/wc-stopped-audio-ringback.md @@ -0,0 +1,35 @@ +--- +title: Ended sessions rang back to life +product: windchime +date: 2026-07-12 +severity: sev3 +status: resolved +summary: >- + Deliberately ended sessions came back audibly, twice by different mechanisms: + first the safety watchdog "rescued" intentional silence by replaying the last + plan, and later long in-flight voices that stop() had only gain-masked rang + back when the next visitor's volume reset un-cut the master. +impact: >- + Broke the install's quiet endings: the afterglow between visitors carried + trailing audio from the previous session, undermining the bounded-session + design. +detection: >- + Heard during install-mode session cycling; the second mechanism surfaced a week + after the first fix, during the install-features audio pass. +response: >- + Fixed in two stages a week apart, each verified by cycling sessions and + listening through the afterglow. +root_cause: >- + Two designs shared one wrong assumption, that silencing equals stopping. The + watchdog treated intentional silence as a fault to rescue, and stop() masked + output gain while long voices kept playing underneath, ready to reappear when + the master volume reset. +fix: >- + The watchdog now respects an intentional-silence flag, and stop() purges the + audio graph, severing already-sounding voices instead of masking them. +blameless_note: >- + A safety system needs a way to be told "this silence is on purpose," and a stop + path needs to make the state true rather than inaudible. Both fixes made the + system's beliefs match the room. +tags: [audio, watchdog, install] +--- diff --git a/src/content/milestones/ls-grid-coverage-gate.md b/src/content/milestones/ls-grid-coverage-gate.md new file mode 100644 index 0000000..f64150b --- /dev/null +++ b/src/content/milestones/ls-grid-coverage-gate.md @@ -0,0 +1,13 @@ +--- +title: Make the playability gate assert coverage +product: lichtspiel +horizon: next +status: planned +target: Next +theme: Quality +confidence: high +summary: >- + Extend the generation gate to require that a scene maps all grid lanes and encoders, + not merely that control idioms are present. +order: 2 +--- diff --git a/src/content/milestones/ls-hackathon-ship.md b/src/content/milestones/ls-hackathon-ship.md new file mode 100644 index 0000000..1db280b --- /dev/null +++ b/src/content/milestones/ls-hackathon-ship.md @@ -0,0 +1,16 @@ +--- +title: Ship at the Ableton Hackathon +product: lichtspiel +horizon: shipped +status: shipped +date: 2026-06-15 +target: Jun 2026 +theme: Delivery +summary: >- + A working live instrument demonstrated at an Ableton hackathon (Music Hackspace, hosted at + Berklee College of Music, Boston, June 2026), consolidated from three forks into one build. +linked_releases: + - ls-consolidation-v1 + - ls-generative-track +order: 4 +--- diff --git a/src/content/milestones/ls-hardware-pass.md b/src/content/milestones/ls-hardware-pass.md new file mode 100644 index 0000000..c523c27 --- /dev/null +++ b/src/content/milestones/ls-hardware-pass.md @@ -0,0 +1,16 @@ +--- +title: Hardware verification pass on the consolidated build +product: lichtspiel +horizon: next +status: planned +target: Next +theme: Quality +confidence: medium +summary: >- + The three-fork consolidation was verified against the automated gates but not + yet against a full physical rig. This pass runs the consolidated build with a + real Ableton set, bridge, and monome Grid and Arc, confirming the + capability-adaptive mapping and the open generation-quality mitigations on + hardware. +order: 2 +--- diff --git a/src/content/milestones/ls-lineage-backport.md b/src/content/milestones/ls-lineage-backport.md new file mode 100644 index 0000000..95a99c5 --- /dev/null +++ b/src/content/milestones/ls-lineage-backport.md @@ -0,0 +1,15 @@ +--- +title: Fold Windchime's newer visual work back across the lineage +product: lichtspiel +horizon: later +status: planned +target: Later +theme: Platform +confidence: low +summary: >- + Lichtspiel and Windchime share one animation lineage, and Windchime's side has + since grown a Three.js runtime and a much larger scene corpus. This backport + brings the shared parameter-vector contract up to date in both directions so + scene families can travel across projects without bespoke adapters. +order: 5 +--- diff --git a/src/content/milestones/ls-live-repro.md b/src/content/milestones/ls-live-repro.md new file mode 100644 index 0000000..311f07e --- /dev/null +++ b/src/content/milestones/ls-live-repro.md @@ -0,0 +1,13 @@ +--- +title: Confirm open gate mitigations on a live rig +product: lichtspiel +horizon: now +status: in-progress +target: Ongoing +theme: Quality +confidence: medium +summary: >- + Reproduce and confirm the concurrent-generation and source-switching mitigations on + real hardware before closing the open generation-quality issues. +order: 1 +--- diff --git a/src/content/milestones/ls-m4l-device.md b/src/content/milestones/ls-m4l-device.md new file mode 100644 index 0000000..de1c59e --- /dev/null +++ b/src/content/milestones/ls-m4l-device.md @@ -0,0 +1,13 @@ +--- +title: Package as a Max for Live device +product: lichtspiel +horizon: later +status: planned +target: Later +theme: Distribution +confidence: low +summary: >- + Turn the instrument into a distributable Max for Live device so a performer can drop it + into a set without standing up the full development environment. +order: 3 +--- diff --git a/src/content/milestones/wc-asr-audit.md b/src/content/milestones/wc-asr-audit.md new file mode 100644 index 0000000..60f552c --- /dev/null +++ b/src/content/milestones/wc-asr-audit.md @@ -0,0 +1,16 @@ +--- +title: Audit the transcription stage with live speech +product: windchime +horizon: next +status: planned +target: Next +theme: Research +confidence: medium +summary: >- + The retrieval audit runs on synthetic text prompts, which bypasses ASR entirely. + In the room, recognition variation interacts with accent and dialect before the + embedding stage ever sees a word, so the next audit pass feeds live spoken input + through faster-whisper and measures how transcription shifts what each visitor + can reach. +order: 5 +--- diff --git a/src/content/milestones/wc-audio-soak.md b/src/content/milestones/wc-audio-soak.md new file mode 100644 index 0000000..58c6999 --- /dev/null +++ b/src/content/milestones/wc-audio-soak.md @@ -0,0 +1,16 @@ +--- +title: Soak the audio path and watchdog +product: windchime +horizon: next +status: planned +target: Next +theme: Reliability +confidence: medium +summary: >- + Extend the synthetic-visitor soak harness to exercise the live audio path and + the watchdog under load: real Strudel playback, stop-and-replace cycles between + visitors, and forced engine faults that the watchdog must rescue in place. The + 6-hour visitor-lifecycle soak deliberately excluded audio; this closes the top + remaining reliability gap. +order: 3 +--- diff --git a/src/content/milestones/wc-corpus.md b/src/content/milestones/wc-corpus.md new file mode 100644 index 0000000..a65f37e --- /dev/null +++ b/src/content/milestones/wc-corpus.md @@ -0,0 +1,16 @@ +--- +title: Grow and re-embed the audio corpus +product: windchime +horizon: now +status: in-progress +target: Ongoing +theme: Corpus +confidence: high +summary: >- + Expand the artist-authored stem library (now ~369 stems across eleven instrument + roles) and re-embed it per audio-language model configuration using content-aware + windowing, so each stem is embedded from the window where the music actually + plays. Every rebuild appends an index epoch to the corpus database, keeping + retrieval results attributable to the exact index they ran against. +order: 1 +--- diff --git a/src/content/milestones/wc-coverage-interventions.md b/src/content/milestones/wc-coverage-interventions.md new file mode 100644 index 0000000..2bcb54a --- /dev/null +++ b/src/content/milestones/wc-coverage-interventions.md @@ -0,0 +1,15 @@ +--- +title: Turn the coverage audit into a corpus design tool +product: windchime +horizon: now +status: in-progress +target: Ongoing +theme: Corpus +confidence: medium +summary: >- + Re-run the retrieval audit as the corpus grows and act on it from the corpus + side: stems that no audio-language model configuration reaches become candidates + for re-description or re-segmentation, and prompt categories that saturate early + signal where the prompt set itself should be extended. +order: 2 +--- diff --git a/src/content/milestones/wc-hardware-endurance.md b/src/content/milestones/wc-hardware-endurance.md new file mode 100644 index 0000000..79df5f3 --- /dev/null +++ b/src/content/milestones/wc-hardware-endurance.md @@ -0,0 +1,15 @@ +--- +title: Hardware-endurance pass for the monome +product: windchime +horizon: later +status: planned +target: Later +theme: Reliability +confidence: low +summary: >- + A dedicated full-exhibition-day endurance test of the monome Grid and Arc: + hot-plug recovery under real gallery conditions, behaviour on marginal USB + connections, and verification that the serialosc bridge's self-healing device + grab holds up across a day of visitor traffic. +order: 6 +--- diff --git a/src/content/milestones/wc-install-shipped.md b/src/content/milestones/wc-install-shipped.md new file mode 100644 index 0000000..b11360b --- /dev/null +++ b/src/content/milestones/wc-install-shipped.md @@ -0,0 +1,16 @@ +--- +title: Ship the unattended installation +product: windchime +horizon: shipped +status: shipped +date: 2026-07-05 +target: Jul 2026 +theme: Reliability +summary: >- + The full kiosk lifecycle plus the three-layer audio safety fix, the point at which + Windchime became something that could be left running in a gallery. +linked_releases: + - wc-install-mode-v1 + - wc-audio-safety +order: 4 +--- diff --git a/src/content/milestones/wc-per-family-tuning.md b/src/content/milestones/wc-per-family-tuning.md new file mode 100644 index 0000000..18b601a --- /dev/null +++ b/src/content/milestones/wc-per-family-tuning.md @@ -0,0 +1,16 @@ +--- +title: Per-family tuning across the visual corpus +product: windchime +horizon: later +status: planned +target: Later +theme: Visuals +confidence: medium +summary: >- + A tuning pass across the visual corpus (now 69 scene families spanning p5.js and + Three.js) so idle composition, exposure, and framing read as deliberately as the + best scenes do, with each family checked against the authoring guide's + interaction rules: expressive clicks, LED acknowledgements everywhere, and + whole-grid life. +order: 7 +--- diff --git a/src/content/milestones/wc-selection-policy-ablation.md b/src/content/milestones/wc-selection-policy-ablation.md new file mode 100644 index 0000000..a612418 --- /dev/null +++ b/src/content/milestones/wc-selection-policy-ablation.md @@ -0,0 +1,16 @@ +--- +title: Ablate the one-per-role selection policy +product: windchime +horizon: later +status: planned +target: Later +theme: Research +confidence: low +summary: >- + The selection policy admits at most one stem per instrument role, which + guarantees timbral variety but also shapes what the corpus can offer. The + planned ablation removes the role constraint and runs retrieval over an + unsegmented corpus, to separate what the audio-language models do from what the + policy imposes on top of them. +order: 8 +--- diff --git a/src/content/milestones/wc-sound-mode-tuning.md b/src/content/milestones/wc-sound-mode-tuning.md new file mode 100644 index 0000000..801e914 --- /dev/null +++ b/src/content/milestones/wc-sound-mode-tuning.md @@ -0,0 +1,16 @@ +--- +title: Settle the exhibition posture for sound modes +product: windchime +horizon: next +status: planned +target: Next +theme: Experience +confidence: medium +summary: >- + The installation has three sound modes (Soundscape, Focused, and Responsive) + that trade ambient layering against loudness-verified tightness and one-shot + responsiveness. The open tuning decision is whether an exhibition day fixes one + mode or rotates them per session, and what the defaults for layer caps and + transition modes should be under each choice. +order: 4 +--- diff --git a/src/content/projects/lichtspiel.md b/src/content/projects/lichtspiel.md new file mode 100644 index 0000000..82b5b88 --- /dev/null +++ b/src/content/projects/lichtspiel.md @@ -0,0 +1,133 @@ +--- +title: Lichtspiel +summary: >- + A live audiovisual instrument for Ableton: session-aware p5.js scenes generated + at authoring time, validated by a five-gate chain, and played from a monome grid + and arc, with no model call ever on the render path. +status: shipped +timeframe: May to June 2026 +role: Product & engineering lead on a hackathon team, then solo consolidation +collaborators: + - A small hackathon team +thesis: >- + A performer should not have to learn a second craft to have visuals. Lichtspiel + derives musical features from the Ableton set itself through an MIR pipeline, + then uses those features plus natural-language prompts to generate code-based + p5.js animation, so the set drives set-aware visuals with no node-based tool + like TouchDesigner to learn and no model call on the performance path. +problem: >- + Performers in Ableton have no way to drive expressive, code-native visuals that + understand the structure of their set (clips, scenes, sections) rather than + just its loudness. Existing VJ tools map an audio envelope; they don't know the + music. +audience: Live electronic performers and producers working inside Ableton Live. +constraints: + - Built in a hackathon (a repeatable 3 to 4 minute demo had to work on stage) + - The performance runtime may never call a model or the network on the render path + - Degrade gracefully (run browser-only with no Ableton, no bridge, no hardware) + - Adapt to whatever monome is plugged in (Grid 64 / Arc 2 up to Grid 128 / Arc 4) + - One person's judgment had to reconcile three diverging forks +outcomes: + - Shipped a working live instrument at an Ableton hackathon run by Music Hackspace, hosted at Berklee College of Music (Boston, June 2026) + - Consolidated three diverging forks into one coherent build across 43 commits in four days + - Generated visuals pass a five-gate validation chain before they can play + - Reused Windchime's animation core (one lineage, two products) +public_visibility_note: >- + Source code stays private, and a hackathon collaborator is kept unnamed here. + What's shown is process (the decisions, the consolidation, and the delivery + discipline), not implementation. +featured: true +order: 2 +tech: + - TypeScript + - p5.js + - Vite + - Node (WebSocket bridge) + - Max for Live + - Python / FastAPI + - CLAP + librosa + - Claude (authoring-time codegen) + - monome (serialosc) +languages: + - TypeScript + - JavaScript + - p5.js + - Python + - Max (Max for Live patching) + - HTML/CSS +--- + +## The opportunity: not another VJ plugin + +VJ tools map an audio envelope; they do not know that this is the B-section or that the +performer just launched a new scene. Lichtspiel reads the Ableton Live set itself (clips, +scenes, locators, transport) and uses that structure to choose and shape code-native +browser visuals, with the monome as a latent-space instrument. It was built at an Ableton +hackathon run by **Music Hackspace**, hosted at **Berklee College of Music** (Boston, June +2026), under a hard constraint: a repeatable few-minute performance that could not fail on +stage. + +## How it works, at the boundary + +Ableton talks to a thin Max for Live shell, which feeds a Node bridge that normalises and +routes set state to a **p5.js runtime** for rendering and a **Python service** for +retrieval and authoring-time generation. One rule governs the design (**runtime purity**): +the performance path never calls a model or the network, and if the bridge, Ableton, or the +model service disappears, the visuals keep running browser-only and reconnect when they +return. + +## The monome as an instrument + +Visual scenes are single-file sketches composed from reusable **idioms**: a fader bank, arc +macros, a step sequencer, a cell-painter. The hardware layer is **capability-adaptive**: it +detects the connected device and folds a four-encoder sketch down onto a two-encoder Arc, or +adapts up to a larger grid. An on-screen **digital twin** mirrors every LED, so the piece is +fully playable with no hardware at all, which is also how it survives an unreliable stage. + +## Discovery + +Mapping visuals to section and scene changes makes them feel composed rather than merely +reactive. Authoring settled into three set-conditioned modes: **Sync** (the live audio's +character becomes a scene), **Dream** (a text prompt becomes a scene), and **Fuse**. And +generation needs curation to matter: a generated scene stays disposable until a human keeps +it, with a _Keep_ / _Promote_ flow moving the good ones into a committed tier. + +## UX choices, tested in rehearsal + +The instrument is designed around what a performer can afford to think about mid-set: +nothing on the monome navigates, everything expresses. That rule came from rehearsal, where +encoder presses left over from an early fallback mapping kept yanking the active scene out +from under the performer; the fix removed template switching from the hardware entirely and +became a standing design rule the idiom layer inherited. The capability-adaptive mapping +(folding a four-encoder sketch onto a two-encoder Arc) and the on-screen digital twin exist +for the same reason: the show must be playable on whatever hardware survives the trip, or +none at all. + +Later passes added a hover-help tutorial layer over every control and an instrument-style +visual overhaul, both responses to watching a new user freeze in front of an unlabeled +surface. Curation got the same treatment: a generated scene stays disposable until a +deliberate Keep, because trusting a fresh generation on stage is a risk a performer should +opt into, not inherit. + +## The three-fork consolidation + +The most instructive part of Lichtspiel is a **judgment call**, not a feature. The +project forked into three lines: the team's pre-AI base, a rigorous solo generator with real +validation and curation, and a newer tree with a much better UX and a Python "vibe" pipeline +whose validation had been stubbed out. I based the consolidation on the newest tree and +**restored the dropped rigor** from the other fork, each restoration its own reviewable +commit. The decision record and the incident that followed are below. + +## Validation + +Generated visuals are not trusted by default. Every one runs a **five-gate chain** (strict +type-checking, an allow-list lint, a monome-playability marker check, and a headless render +smoke test) inside a bounded self-repair loop that retries up to three times before giving +up. The first scene through the rebuilt pipeline passed every gate at 60 fps. + +## What I'd do next + +- Close the **grid-coverage gap**: the playability gate checks that controls _exist_, not that + they _cover_ the surface, so thin mappings can pass. (Tracked as an open incident.) +- Package the instrument as a distributable **Max for Live device**. +- Fold more of Windchime's newer visual work back across the shared lineage. diff --git a/src/content/projects/windchime.md b/src/content/projects/windchime.md new file mode 100644 index 0000000..b444b42 --- /dev/null +++ b/src/content/projects/windchime.md @@ -0,0 +1,142 @@ +--- +title: Windchime +summary: >- + A voice-conditioned audiovisual installation in which audio-language models + retrieve real stems from a closed, artist-authored corpus and render them as + live-coded sound, generative visuals, and monome light. +status: active +timeframe: May 2026 to present +role: Solo (product, engineering, and research) +collaborators: [] +thesis: >- + Retrieval, not generation, is the honest interface between a voice and a sound + library: reflect what a person actually said back to them through real material, + in real time, and never go silent. +problem: >- + Windchime uses audio-language models as retrieval curators. A visitor's words are + embedded and matched against a closed, artist-authored corpus of stems, and each + model surfaces combinations of real recordings that neither the artist nor the + visitor would have specified. Nothing in the audio path is synthesized, so agency + stays distributed by design: the artist authors the corpus and its mappings, the + visitor initiates every retrieval, and the model orders access to the material. + The hard problem is making that three-way split legible and rewarding to an + untrained visitor within seconds, in a system reliable enough to run unattended + for a full exhibition day. +audience: >- + Gallery visitors (untrained, one interaction each) and the operator running an + unattended installation. +constraints: + - Unattended 6 to 8 hour exhibition runs, with automatic recovery at every layer and no operator present + - Audio may never stop. The planner degrades from a hosted LLM to a local model to a deterministic template, and a persistent watchdog rescues the sound engine in place + - 'One-shot visitors: the first spoken prompt must produce a legible audible and visible change, with no instructions' + - Physical hardware on the control path (a monome Grid and Arc over serialosc), including hot-plug recovery + - 'Retrieval only: a closed, artist-authored stem corpus. Nothing in the audio path is synthesized' + - Speech is transcribed on-device and never recorded +outcomes: + - '6-hour unattended soak: 879 synthetic visitors, zero wedges, zero crashes' + - Audio runaway that peaked at 2.8× full-scale brought down to 0.36 after a three-layer safety fix + - Corpus grown from 130 to ~369 stems across eleven instrument roles + - The audio-language model made an exchangeable variable, with six deployable configurations behind one model-agnostic embedding interface +public_visibility_note: >- + Source code, the audio corpus, and in-progress research write-ups stay private. + What's shown here is process (decisions, releases, incidents, and the shape of + the research), not implementation. +featured: true +order: 1 +tech: + - Python + - FastAPI + - faster-whisper ASR + - Audio-language models (CLAP family, CLaMP 3) + - FAISS + - Strudel live-coding + - p5.js + - Three.js + - monome (serialosc) +languages: + - Python + - TypeScript + - JavaScript + - p5.js + - Three.js + - Strudel (pattern DSL) + - HTML/CSS + - Shell +--- + +## The opportunity + +Most interactive installations treat a visitor as a trigger for fixed rules. Windchime +asks whether an untrained person can shape a piece of music and its visual world by +speaking, with the reply drawn entirely from **real recorded material**: the bet is that +retrieval over an artist-authored corpus is a more accountable and more surprising +interface than generation. Windchime is exhibited at **Gray Area** (San Francisco, 2026) +and builds on **Live Muse**, an earlier, distinct installation shown at **Mutaciones** in +Barcelona, adapting parts of its stem library and code. + +## How it works, at the boundary + +A visitor speaks; faster-whisper transcribes on-device (no audio is recorded), the active +audio-language model embeds the transcript, and cosine similarity over a FAISS exact index +returns candidates that a **one-per-role selection policy** narrows to at most one stem per +instrument role. A **guarded planner** fills a validated schema that compiles to a Strudel +pattern, so the language model never emits raw code, and a failover chain (hosted LLM, then +local model, then deterministic template) keeps sound playing fully offline. The same +selection drives parameterized p5.js sketch families, a Three.js runtime, and the LEDs of a +monome Grid and Arc, all inside **bounded, staged sessions** with an explicit beginning and +end. + +## Discovery + +Descriptive language retrieves tightly while imagistic language retrieves loosely, so the +drift became an intentional reward for playful input rather than a defect to fight. A +visitor gives the piece seconds, which means legibility has to come from the system's +response, not from instructions. And the corpus is the instrument: it is hand-built from my +own studio stems and field recordings, its character is the product's character, and that +is why it stays private. + +## Options I weighed + +Retrieve rather than generate: retrieval reflects real authored material, is inspectable, +and keeps the audio path deterministic, so generation stayed out of the critical path +entirely. Bounded, staged sessions beat an always-on jam because they give a stranger a +beginning, a middle, and an end. And the planner fills a guarded schema instead of writing +code, because raw model output in a room full of speakers is a reliability and safety +liability. Each of these is documented as a decision record below. + +## UX choices, tested in the room + +Every visitor-facing choice answers the same constraint: a stranger gives the piece seconds. +The interface explains itself through response rather than instruction: a microphone VU +meter proves the system is hearing you, a full-screen computing cascade covers retrieval +latency so the wait reads as intent, an arrival flash on the monome LEDs marks the moment +sound lands, and two collapsible rails (a live digital twin of the hardware and a +three-dimensional corpus map) let a curious visitor see what the system is doing without +being required to. + +Testing kept rewriting the design. The first participant sessions silenced the audio +between trials, which produced the always-running AudioContext lifecycle, stuck-microphone +auto-recovery, and an unlock banner that verifies sound is actually flowing. Study sessions +with standardised instruments and interviews shaped the six-prompt bounded session and its +recap, and the narrated onboarding tour with its side-chained tutorial bed came directly +from watching people walk up cold during install rehearsals. The remaining open question +that testing surfaced (which sound mode an exhibition day should run) is on the roadmap as +its own decision. + +## Building for the room, not the demo + +The hard part of an installation is hour six with no operator in sight, not the first +minute. The runtime is built around the planner failover chain and a persistent **audio +watchdog** that rescues the sound engine in place, with no page refresh and no new visitor +gesture required, and the whole visitor lifecycle is exercised by a synthetic-visitor soak +harness before it faces a real room. + +## Validation + +Reliability is proven, not asserted: a purpose-built browser harness drove hundreds of +synthetic gallery visitors through the entire session lifecycle without touching the +installation code or polluting real study data. The experience itself is studied with +standardised instruments (usability scales, per-trial ratings, and interviews) run as a +proper protocol, and a systematic audit measures how ALM choice conditions access to the +corpus. Method is summarised in the Research archive; results stay private while under +review. diff --git a/src/content/releases/ls-consolidation-v1.md b/src/content/releases/ls-consolidation-v1.md new file mode 100644 index 0000000..2231af7 --- /dev/null +++ b/src/content/releases/ls-consolidation-v1.md @@ -0,0 +1,29 @@ +--- +title: The consolidated build +product: lichtspiel +version_or_label: consolidation-v1 +date: 2026-06-15 +status: shipped +summary: >- + One coherent build reconciling three forks (the newest tree's UX with the rigorous + fork's validation and curation restored) delivered across 43 commits in four days. +customer_value: >- + A single instrument that is both pleasant to use and safe to play live, instead of + three partial versions each missing something the others had. +included_work: + - Rebase onto the newest tree + - Restore validation gates and the generated → promoted curation tier + - Fold the restored rigor into the Python authoring pipeline +notable_risks: + - Restoring rigor re-exposed a stubbed validation path (fixed, see incident) +followups: + - Package the instrument as a distributable Max for Live device +linked_decisions: + - ls-consolidation +linked_incidents: + - ls-validation-regression +tags: [consolidation, delivery] +--- + +This release is a delivery story more than a feature story: it is what disciplined +consolidation of divergent work looks like when it has to happen fast and stay auditable. diff --git a/src/content/releases/ls-generative-track.md b/src/content/releases/ls-generative-track.md new file mode 100644 index 0000000..bf87507 --- /dev/null +++ b/src/content/releases/ls-generative-track.md @@ -0,0 +1,28 @@ +--- +title: 'Constrained p5 code generation: Sync, Dream, Fuse' +product: lichtspiel +version_or_label: generative-track +date: 2026-06-14 +status: shipped +summary: >- + Three authoring modes that turn the live set into new visual scenes: Sync (the audio's + "vibe" becomes a scene), Dream (a text prompt becomes a scene), and Fuse. +customer_value: >- + A performer can conjure a fresh, playable visual scene conditioned on what they're + actually playing, without writing code and without risking a broken scene on stage. +included_work: + - Audio "vibe" extraction (CLAP + librosa features) feeding generation + - Text-prompt generation conditioned on the live set + - Five-gate validation chain with a bounded self-repair loop + - Keep / Promote curation tiers for generated scenes +notable_risks: + - The playability gate checks that controls exist, not that they cover the surface +followups: + - Extend the gate to require full grid and encoder coverage +linked_incidents: + - ls-grid-coverage +tags: [generation, authoring, validation] +--- + +The generative track is what makes Lichtspiel feel alive to author, but it only shipped +because generation is fenced by validation and curation, never trusted by default. diff --git a/src/content/releases/ls-idiom-layer.md b/src/content/releases/ls-idiom-layer.md new file mode 100644 index 0000000..b260ce8 --- /dev/null +++ b/src/content/releases/ls-idiom-layer.md @@ -0,0 +1,26 @@ +--- +title: Animation corpus and the monome idiom layer +product: lichtspiel +version_or_label: phase-4.5-idioms +date: 2026-06-02 +status: shipped +summary: >- + A reusable control layer (faderBank, arcMacros, stepSequencer, cellPaint) that lets a + scene declare its control intent once and adapt to any monome combination. +customer_value: >- + Crafted sketches keep their hand-tuned control feel on any hardware, so the performer + never loses reach of a control when moving between a small and a large device. +included_work: + - Four capability-aware idioms plus a compose function, as a pure control and LED layer + - Faithful ports of nine sketch families plus a hand-built hero scene + - Folding so a large-hardware sketch couples down onto a smaller device, nothing dropped + - Adapt-up so a small-hardware sketch lights bonus controls on a larger device + - A gestural panel and variant browser, with a headless idiom smoke suite +notable_risks: + - Closing the full round trip (folding the extended set back down) is left as future work +followups: + - Grid 128 and Arc 4 hot-swap verification pass still outstanding +tags: [monome, idioms, corpus, adaptation, variants] +--- + +The idiom layer is Lichtspiel's underlying representation for control. By separating a sketch's control intent from any specific hardware, it removed the need for per-sketch, per-device branches and made every future scene inherit hardware adaptation for free. diff --git a/src/content/releases/ls-live-api-probe.md b/src/content/releases/ls-live-api-probe.md new file mode 100644 index 0000000..ef868a3 --- /dev/null +++ b/src/content/releases/ls-live-api-probe.md @@ -0,0 +1,25 @@ +--- +title: Max for Live Live API probe +product: lichtspiel +version_or_label: phase-3-live-api-probe +date: 2026-05-30 +status: shipped +summary: >- + A thin Max for Live device that reads the Live Set's transport, selected track, scene, + and clip and streams them to the runtime, with device dials moving visual parameters. +customer_value: >- + The visuals start to reflect what is actually happening in the Live Set, and a performer + can nudge parameters from the Ableton device without leaving Live. +included_work: + - A Live API helper that emits a stable session-state snapshot, guarded to degrade to defaults + - An OSC receiver in the bridge for state, scene, and parameter addresses + - Device dials mapped to visual parameters and buttons mapped to scenes + - Read paths for the playing clip, clip color, and selected-track device names +notable_risks: + - Arrangement property names are best-effort and need in-set verification +followups: + - MIDI content summary deferred to a later phase +tags: [ableton, max-for-live, osc, live-api] +--- + +This is the layer that makes Lichtspiel Live-native rather than a generic visualizer. It was verified reading real transport and track state from a working Ableton set, which proved the Max shell could stay thin while the heavy logic lived downstream. diff --git a/src/content/releases/ls-monome-integration.md b/src/content/releases/ls-monome-integration.md new file mode 100644 index 0000000..bfd3cce --- /dev/null +++ b/src/content/releases/ls-monome-integration.md @@ -0,0 +1,26 @@ +--- +title: Monome integration with capability-adaptive folding +product: lichtspiel +version_or_label: phase-4-monome +date: 2026-05-31 +status: shipped +summary: >- + Grid and arc control of the visuals over serialosc, with the surface detecting which + device is connected and adapting between a Grid 64 or 128 and an Arc 2 or 4. +customer_value: >- + A performer plays the visuals on whatever monome hardware they own, and the LEDs mirror + the performance so the controller reads as an instrument, not a remote. +included_work: + - A pure-Node serialosc layer with device discovery and hot-plug recovery + - A capability matrix per device (cells, quads, varibright, tilt, encoders, push) + - Profile-aware column-fader mapping that adapts to grid width and encoder count + - A digital-twin dashboard that mirrors LEDs and runs diagnostic sweeps + - Rate-limiting so a fast encoder spin cannot flood or freeze the browser +notable_risks: + - Self-healing recovery restarts the daemon, which briefly blips every attached device +followups: + - Grid 128 and Arc 4 hot-swap still to be eyeballed on hardware at this stage +tags: [monome, hardware, serialosc, adaptation] +--- + +Hardware-verified on the real Grid 64 and Arc 2, this is where the controller became central to the concept. The capability matrix meant one codebase could drive four different device classes instead of hard-coding a single layout. diff --git a/src/content/releases/ls-node-bridge.md b/src/content/releases/ls-node-bridge.md new file mode 100644 index 0000000..99308a2 --- /dev/null +++ b/src/content/releases/ls-node-bridge.md @@ -0,0 +1,25 @@ +--- +title: The Node bridge between Max and p5 +product: lichtspiel +version_or_label: phase-2-node-bridge +date: 2026-05-30 +status: shipped +summary: >- + A Node WebSocket hub that carries validated messages between Max for Live, the p5 + runtime, and a CLI, rejecting malformed payloads before they reach the visuals. +customer_value: >- + The visual runtime only ever receives well-formed control messages, so a bad input + upstream cannot corrupt or crash the performance. +included_work: + - Loopback WebSocket server with a p5 client and reconnect-with-backoff + - JSON validation against shared schemas, with readable rejection errors + - Message logging and an HTTP status route + - A CLI sender for scenes, parameters, state, and retrieval, for testing without Ableton +notable_risks: + - A silently dropped invalid message could hide an upstream formatting bug +followups: + - OSC route stubs left in place for the Max and monome phases +tags: [bridge, websocket, validation, node] +--- + +The bridge is where the system's contracts are enforced. Making it validate every message and reject bad ones (rather than forwarding them) meant later layers could be trusted to speak the same protocol or be cleanly rejected. diff --git a/src/content/releases/ls-p5-runtime.md b/src/content/releases/ls-p5-runtime.md new file mode 100644 index 0000000..92ab99a --- /dev/null +++ b/src/content/releases/ls-p5-runtime.md @@ -0,0 +1,26 @@ +--- +title: The p5 visual runtime (browser-only engine) +product: lichtspiel +version_or_label: phase-1-p5-runtime +date: 2026-05-30 +status: shipped +summary: >- + A standalone p5.js visual engine that renders scenes in the browser with no Ableton, + no bridge, and no model service required. +customer_value: >- + The performer can open a page and immediately see and play visuals, so the system is + usable and demonstrable even when nothing else in the stack is running. +included_work: + - Template registry, message bus, and smoothed parameter interpolation + - Seeded RNG so any visual is reproducible from a seed + - Keyboard fallback for scene switching, distance, mutation, and lock + - Five initial scenes ported from the Processing corpus, verified at 60 fps + - A diagnostics panel (frame rate, active template, live parameter readout) +notable_risks: + - A template that throws mid-frame must not kill the host loop +followups: + - Add a screenshot and frame-rate smoke test to replace the structural smoke +tags: [runtime, p5, rendering, browser-only] +--- + +The p5 runtime is the heart of the demo and the one layer that always runs. Because it degrades to browser-only, every later capability (bridge, Ableton, monome, generation) arrived as an enhancement rather than a dependency. diff --git a/src/content/releases/ls-scene-launch-retrieval.md b/src/content/releases/ls-scene-launch-retrieval.md new file mode 100644 index 0000000..f0a0d74 --- /dev/null +++ b/src/content/releases/ls-scene-launch-retrieval.md @@ -0,0 +1,25 @@ +--- +title: Scene-launch and locator auto-retrieval, live in Ableton +product: lichtspiel +version_or_label: phase-5a-auto-retrieval +date: 2026-06-04 +status: shipped +summary: >- + The first on-the-fly audiovisual trigger: launching a Session scene or crossing an + Arrangement locator auto-loads a fresh, immediately playable visual variant. +customer_value: >- + Visuals hot-swap per song section as the set plays, so the imagery follows the + arrangement on its own while the performer keeps playing music. +included_work: + - Max outlets emitting scene-launch and locator-crossing events, forward-only with a seek guard + - Bridge decoding of both events into wire messages broadcast to the runtime + - Runtime template picking that respects the on-screen lock, with mapped or random modes + - A live and simulated event-source toggle so the flow demos without Ableton +notable_risks: + - On a heavy 24-track set, detection can lag one to two seconds and Live can get sluggish +followups: + - Planned move to event-driven Live API observers instead of per-tick polling +tags: [ableton, retrieval, live, triggers] +--- + +This closed the core loop the whole project was aiming at: the music drives the image without manual intervention. It was verified end-to-end in a real Ableton set, with a known performance refinement (polling latency on large sets) logged as the next target. diff --git a/src/content/releases/wc-audio-safety.md b/src/content/releases/wc-audio-safety.md new file mode 100644 index 0000000..63e797e --- /dev/null +++ b/src/content/releases/wc-audio-safety.md @@ -0,0 +1,29 @@ +--- +title: 'Audio safety: limiter, clamps, and a watchdog' +product: windchime +version_or_label: soundstate-feedback-v1 +date: 2026-07-03 +status: shipped +summary: >- + A three-layer defence against audio runaway: an always-on master limiter, apply-time + safety clamps on every voice, and a 1 Hz watchdog that rescues the engine in place. +customer_value: >- + The installation can no longer roar or go silent. Speakers are protected and the + sound recovers itself without a page refresh or a new visitor gesture. +included_work: + - Master limiter chain (compressor into a NaN-proof ceiling) + - Per-voice ring-out bounds and effect/gain literal caps + - Audio watchdog with soft and deep in-place rescue + - '"Wild mode": the runaway texture preserved as an opt-in, speakers still protected' +notable_risks: + - The audio path itself is not yet covered by the long-duration soak harness +followups: + - Soak the audio path and watchdog specifically over 6 to 8 hours +linked_incidents: + - wc-audio-runaway +tags: [audio, reliability, safety] +--- + +Shipped directly in response to the audio-runaway incident. The fix was deliberately +layered so that no single failure (a poisoned value, a stuck clock, prolonged silence) +can take the sound down. diff --git a/src/content/releases/wc-corpus-expansion.md b/src/content/releases/wc-corpus-expansion.md new file mode 100644 index 0000000..9f0a132 --- /dev/null +++ b/src/content/releases/wc-corpus-expansion.md @@ -0,0 +1,30 @@ +--- +title: Corpus growth through repeatable studio batches +product: windchime +version_or_label: corpus-batches +date: 2026-07-17 +status: shipped +summary: >- + The retrieval corpus grew through successive studio-stem batches, ingested with a + skip sentinel so re-runs are idempotent, and documented by a full multi-backend + re-embed runbook. +customer_value: >- + A larger and better-labelled sound library gives every spoken phrase more and more + varied material to reach, while the repeatable ingest keeps that growth safe to + redo and easy to audit. +included_work: + - Successive batch ingests sorted into the category layout + - A skip sentinel that makes re-ingesting a batch idempotent + - A documented re-embed runbook covering every backend + - Index-history entries recording each corpus-addition re-index +notable_risks: + - Adding stems means re-embedding every backend to keep cross-backend comparisons fair +followups: + - Fold staged attract-only stems into the retrieval corpus in a future batch +tags: [corpus, retrieval, data] +--- + +Corpus growth is a recurring operational act, not a one-off, so it was built to be +safe to repeat. The skip sentinel and the per-backend re-embed runbook are what let +the library expand without silently desyncing the indexes the retrieval study +depends on. diff --git a/src/content/releases/wc-demo-mode.md b/src/content/releases/wc-demo-mode.md new file mode 100644 index 0000000..1aa1552 --- /dev/null +++ b/src/content/releases/wc-demo-mode.md @@ -0,0 +1,29 @@ +--- +title: Demo mode for operator-run showcases +product: windchime +version_or_label: demo-mode +date: 2026-06-24 +status: shipped +summary: >- + An operator-facing presentation layer over the runtime, with a branded overlay, + intro and outro visual-memory flashes, a logo reveal, per-section audio beds, and + a closing recap. +customer_value: >- + A presenter can run the piece as a polished showcase during a talk or a visit, + with reachable controls for sound mode, transition, and demo set, and playback + that keeps going while the screen is being recorded. +included_work: + - Branded demo overlay with intro and outro flashes and a logo reveal + - A demo set picker and per-section beds + - Sound-mode and transition selectors placed in the demo header + - Operator controls and a presentation theme +notable_risks: + - A manual operator flow; the unattended visitor lifecycle is a later, separate build +followups: + - Generalize the showcase machinery into an unattended install mode +tags: [demo, ui, presentation] +--- + +Demo mode gave the runtime a stage presence for live showings. It also became the +proving ground for the machinery (flashes, beds, recap, set picker) that the +unattended install lifecycle would later inherit and harden. diff --git a/src/content/releases/wc-eval-capture.md b/src/content/releases/wc-eval-capture.md new file mode 100644 index 0000000..c566535 --- /dev/null +++ b/src/content/releases/wc-eval-capture.md @@ -0,0 +1,29 @@ +--- +title: User-study capture app, protocol to dashboard +product: windchime +version_or_label: eval-v0.1 +date: 2026-05-20 +status: shipped +summary: >- + A standalone study-capture application shipped end to end in one day: the study + protocol, a standardized UX questionnaire plus custom construct-grouped + instruments, automatic event logging, a participant-facing survey UI, and a + review dashboard, with synthetic test data to prove the pipeline. +customer_value: >- + Study sessions capture themselves. The runtime emits events into the capture + app, so evidence about how visitors actually experience the piece accumulates + without an experimenter transcribing anything. +included_work: + - Study protocol and consent flow modelled as data, not documents + - Standardized usability scale plus custom construct-grouped instruments + - Automatic session and event logging from the live runtime + - A participant survey UI and a review dashboard over captured sessions + - Same-day sibling integration, including a remote stop endpoint for trials +followups: + - Capture full session conditions so every session is self-describing +tags: [research, evaluation, tooling] +--- + +The capture app is what makes the research protocol operational: the same +installation a visitor uses becomes the instrument that records the study, with +no manual bookkeeping between a trial and its data. diff --git a/src/content/releases/wc-install-features.md b/src/content/releases/wc-install-features.md new file mode 100644 index 0000000..3ea5689 --- /dev/null +++ b/src/content/releases/wc-install-features.md @@ -0,0 +1,30 @@ +--- +title: Install-mode feature wave +product: windchime +version_or_label: install-features +date: 2026-07-12 +status: shipped +summary: >- + A single-day install-mode wave: a microphone VU meter in the visitor bar, a + narrated onboarding tour with pre-generated voice clips relayed step by step, a + selectable LLM planner backend (hosted or local) with safe revert, five new + Grid-and-Arc scene families, compact header pills, and a full install audio + map. +customer_value: >- + A visitor walking up cold now gets narrated orientation, visible proof the + microphone hears them, and continuous sound design (attract beds that + alternate, a tutorial bed side-chained under the narration, and an idle bed + after each visit) instead of silence between sessions. +included_work: + - Microphone input VU meter in the visitor bar + - Narrated tour with pre-generated voice clips, stepped by the runtime + - Selectable LLM planner backend (hosted or local) with safe revert + - Five new Grid+Arc scene families, growing that corpus from 38 to 43 + - Compact header pills for the visitor-facing status row + - Install audio map with attract alternation, side-chained tutorial bed, and idle bed +tags: [install, experience, audio] +--- + +The wave closed the gap between "reliable" and "welcoming": the install already +survived a day unattended, and after this it could also greet, orient, and hold +the room's atmosphere between visitors. diff --git a/src/content/releases/wc-install-mode-v1.md b/src/content/releases/wc-install-mode-v1.md new file mode 100644 index 0000000..0fa52e0 --- /dev/null +++ b/src/content/releases/wc-install-mode-v1.md @@ -0,0 +1,28 @@ +--- +title: 'Install mode: the unattended visitor lifecycle' +product: windchime +version_or_label: install-mode-v1 +date: 2026-07-05 +status: shipped +summary: >- + A full kiosk lifecycle for an unattended gallery: armed → narrated onboarding → + live session with a bounded number of spoken prompts → a recap → an idle attract screen. +customer_value: >- + The piece can run all day with no operator. A stranger walks up, is oriented in + seconds, has a bounded turn, and the installation resets itself for the next person. +included_work: + - Staged session states with explicit start and end + - TTS-narrated onboarding tour + - Per-visitor volume reset, microphone VU meter, and record gating + - Idle attract board +notable_risks: + - A slow server-side memory creep over many hours (multi-day concern, not a single-day one) +followups: + - Prove a full day of operation with a synthetic-visitor soak +linked_incidents: + - wc-soak-negative-radius +tags: [installation, lifecycle, ops] +--- + +Install mode turned a runtime into an exhibitable installation. It is also what made a +disciplined reliability soak both possible and necessary. diff --git a/src/content/releases/wc-install-soak.md b/src/content/releases/wc-install-soak.md new file mode 100644 index 0000000..ed694de --- /dev/null +++ b/src/content/releases/wc-install-soak.md @@ -0,0 +1,33 @@ +--- +title: Synthetic-visitor soak, 879 visitors over six hours +product: windchime +version_or_label: soak-v1 +date: 2026-07-06 +status: shipped +summary: >- + A real-browser soak harness drove 879 synthetic visitors through the full + install-mode lifecycle over six hours: zero wedges, zero crashes, zero render + exceptions, flat health metrics, and byte-identical real data stores, with all + synthetic traffic siloed behind environment-gated seams. +customer_value: >- + Reliability stopped being a claim. The exact build that faces a gallery was + demonstrated to survive a full unattended day of visitor traffic, and the one + bug the run surfaced was fixed and re-verified before the harness was retired. +included_work: + - Playwright-driven synthetic visitors exercising the entire session lifecycle + - Environment-gated isolation seams so synthetic traffic never touches real data + - Health and memory telemetry across the full six-hour window + - One render bug found, fixed, and re-verified (the negative-radius incident) + - Two low-severity follow-ups documented rather than patched +notable_risks: + - The run deliberately excluded the live audio path, which remains the top gap +linked_incidents: + - wc-soak-negative-radius +followups: + - Extend the harness to the audio path and watchdog +tags: [reliability, testing, install] +--- + +The harness lives in its own repository and treats the installation as a black +box, which is what makes the result honest: nothing in the product was modified +to pass its own test. diff --git a/src/content/releases/wc-instrument-design-language.md b/src/content/releases/wc-instrument-design-language.md new file mode 100644 index 0000000..ec1d997 --- /dev/null +++ b/src/content/releases/wc-instrument-design-language.md @@ -0,0 +1,24 @@ +--- +title: One instrument design language across four apps +product: windchime +version_or_label: design-tokens-v1 +date: 2026-06-17 +status: shipped +summary: >- + All four applications (dev UI, visuals, live-coding UI, and the study capture + app) were reskinned onto a single shared set of warm-amber "instrument" design + tokens, and the umbrella's submodule remotes were migrated to their permanent + hosting. +customer_value: >- + A visitor moving between the visitor bar, the visuals, and a survey sees one + instrument, not four developer tools. Every later mode (demo, install) builds + on this shared identity. +included_work: + - Shared design-token set applied across all four app surfaces + - The study app's survey theme matched to the instrument language + - Submodule remote migration and umbrella pin updates +tags: [design, platform] +--- + +The reskin looks cosmetic in the diff and structural in hindsight: demo mode and +install mode are both built directly on this token set. diff --git a/src/content/releases/wc-original-scenes.md b/src/content/releases/wc-original-scenes.md new file mode 100644 index 0000000..6f24253 --- /dev/null +++ b/src/content/releases/wc-original-scenes.md @@ -0,0 +1,27 @@ +--- +title: Original Three.js scene waves +product: windchime +version_or_label: originals-o1-o4 +date: 2026-07-17 +status: shipped +summary: >- + Four waves of original Three.js scenes: two abstract and kinetic sets, then two + waves of city scenes tied to places where the corpus's field recordings were + made, bringing the visual corpus to 69 families. An authoring guide now + codifies the interaction rules the earlier scenes taught. +customer_value: >- + The visuals gained a body of original, place-rooted work rather than a library + of adapted sketches, and every new scene answers to the same rules: expressive + clicks, LED acknowledgement for every gesture, and the whole grid alive. +included_work: + - Waves O1 and O2, abstract and kinetic originals + - Waves O3 and O4, city scenes rooted in the corpus's recording locations + - Authoring guide codifying monome interaction rules for all future scenes + - Regression gates kept green across the full scene corpus +followups: + - Per-family tuning pass for idle composition and exposure +tags: [visuals, three-js, corpus] +--- + +The city waves close a loop inside the piece itself: the field recordings in the +audio corpus and the scenes on screen now come from the same places. diff --git a/src/content/releases/wc-participant-flow.md b/src/content/releases/wc-participant-flow.md new file mode 100644 index 0000000..1e6b9db --- /dev/null +++ b/src/content/releases/wc-participant-flow.md @@ -0,0 +1,28 @@ +--- +title: Participant mode and onboarding flow +product: windchime +version_or_label: participant-flow +date: 2026-05-28 +status: shipped +summary: >- + The dev rig became something a stranger could sit down at: a dev/participant + mode toggle with one-click trial sessions, a two-stage consent onboarding flow, + stuck-microphone auto-recovery, a live audio-activity meter, a transcript panel, + and a six-prompt study configuration. +customer_value: >- + The earliest release where someone other than the builder could run the piece. + Everything a participant needs (consent, orientation, recovery from a wedged + mic) happens in the interface, with no operator standing by. +included_work: + - Dev / participant mode toggle with a one-click trial session + - Two-stage consent and onboarding, served same-origin + - Stuck-microphone detection and automatic recovery + - Live audio-activity meter, master volume, and a transcript panel + - Six-prompt study configuration for bounded trial sessions +linked_incidents: + - wc-participant-audio-suspend +tags: [experience, research, reliability] +--- + +Participant mode is the hinge between a developer tool and an installation: the +first version of the bounded visitor session that install mode later formalised. diff --git a/src/content/releases/wc-retrieval-backends.md b/src/content/releases/wc-retrieval-backends.md new file mode 100644 index 0000000..5417551 --- /dev/null +++ b/src/content/releases/wc-retrieval-backends.md @@ -0,0 +1,33 @@ +--- +title: Exchangeable retrieval backends with an offline audit harness +product: windchime +version_or_label: retrieval-backends +date: 2026-06-28 +status: shipped +summary: >- + Retrieval moved behind a single embedding-backend interface over a + model-independent corpus database, with several audio-language model + configurations selectable at runtime, a multilingual configuration added, and an + offline harness that measures how each one behaves over a fixed corpus and + prompt set. +customer_value: >- + The installation can run identically while the audio-language model is changed as + a controlled variable, which keeps the piece stable for visitors and makes the + model itself something that can be studied. +included_work: + - An embedding-backend interface over one shared, model-independent corpus DB + - Audio embedded offline once per configuration; heavy models behind a text-only sidecar + - A runtime backend toggle with a liveness probe and auto-revert + - A multilingual configuration plus an offline distributional-audit harness +notable_risks: + - One model carries a research-evaluation licence and stays restricted to offline, audit-only use + - Keeping several backends warm for instant switching costs some memory +followups: + - Reuse the same seam to run the backend as a hidden, logged study condition +tags: [retrieval, architecture, research] +--- + +Making the audio-language model exchangeable turned an implementation detail into a +first-class experimental variable, without disturbing the installation around it. +The same interface that lets an operator toggle retrieval backends also powers an +offline harness for characterising each configuration. diff --git a/src/content/releases/wc-sound-modes-reembed.md b/src/content/releases/wc-sound-modes-reembed.md new file mode 100644 index 0000000..a262cc0 --- /dev/null +++ b/src/content/releases/wc-sound-modes-reembed.md @@ -0,0 +1,31 @@ +--- +title: Sound modes and a content-aware re-embed +product: windchime +version_or_label: sound-modes-reembed +date: 2026-07-07 +status: shipped +summary: >- + A family of retrieval-and-playback presets (soundscape, focused, responsive, plus + an immediacy set that seeks late-entry stems to where their audio actually begins) + landed alongside a surgical re-embed that repairs stems whose index window was + silent or unrepresentative, with index-epoch provenance recorded in the corpus. +customer_value: >- + The operator can shape how the audio behaves, from an ambient wash to a tighter, + more immediate response, and a spoken phrase now reliably reaches stems by their + real content rather than by dead air at the start of a file. +included_work: + - Sound-mode presets bundling retrieval and playback settings, toggle-able live + - A loudness sidecar precompute (has-audio flag, per-clip gain, best window) + - A content-aware re-embed of only the compromised rows, per backend + - Append-only index-epoch provenance stamped into every audit record +notable_risks: + - Per-clip normalization gain is computed and broadcast but not yet applied per sample in the pattern +followups: + - Apply per-sample gain once templates support per-sample rather than per-voice gain +tags: [audio, retrieval, provenance] +--- + +The sound modes gave the audio a set of legible characters an operator can choose +between. The re-embed fixed a quieter problem underneath: some stems had been +indexed from silence, so they were effectively unreachable until repaired from a +more representative window. diff --git a/src/content/releases/wc-study-readiness.md b/src/content/releases/wc-study-readiness.md new file mode 100644 index 0000000..6eda282 --- /dev/null +++ b/src/content/releases/wc-study-readiness.md @@ -0,0 +1,23 @@ +--- +title: Self-describing study sessions +product: windchime +version_or_label: study-readiness +date: 2026-07-11 +status: shipped +summary: >- + The study capture app began recording the full condition of every session + (interface variant, audio-language model configuration, sound mode, and layer + cap) with a backend-aware recap, and the audit prompt set was expanded to a + balanced, versioned coverage edition. +customer_value: >- + Every captured session now carries its own experimental conditions, so analysis + never has to reconstruct what the system was doing from timestamps and logs. +included_work: + - Session-condition capture (variant, model, sound mode, cap) in the eval app + - Backend-aware session recap for the experimenter + - Balanced, versioned coverage prompt set for the retrieval audit +tags: [research, evaluation] +--- + +Small on the surface, this is the release that makes the research reproducible: +conditions travel with the data instead of living in a lab notebook. diff --git a/src/content/releases/wc-umbrella-scaffold-v0-1.md b/src/content/releases/wc-umbrella-scaffold-v0-1.md new file mode 100644 index 0000000..93abe84 --- /dev/null +++ b/src/content/releases/wc-umbrella-scaffold-v0-1.md @@ -0,0 +1,29 @@ +--- +title: Umbrella repo that unifies the four service branches +product: windchime +version_or_label: 'v0.1' +date: 2026-05-23 +status: shipped +summary: >- + The first umbrella repo binding the retrieval, livecode, animation, and eval + siblings as submodules behind one launcher, a shared dev UI shell, and a real + control bus. +customer_value: >- + One command brings the whole installation up on a laptop, so the piece can be + built, demoed, and tested as a single system instead of four repositories wired + together by hand each time. +included_work: + - Submodule layout unifying the four sibling services + - A unified launcher that starts all four services together + - A dev UI shell plus an end-to-end smoke target + - A control bus so livecode publishes and animation consumes +notable_risks: + - Localhost-only trust model; state-changing endpoints still needed an Origin allowlist before any wider exposure +followups: + - Layer operator demo and unattended visitor flows on top of the scaffold +tags: [architecture, tooling, scaffold] +--- + +The umbrella turned four independently useful repos into one runnable installation. +Everything that followed, from demo mode to the unattended kiosk lifecycle, was +built on this seam of a single launcher, a shared UI, and a typed control bus. diff --git a/src/content/releases/wc-visual-engine-alignment.md b/src/content/releases/wc-visual-engine-alignment.md new file mode 100644 index 0000000..85a45b3 --- /dev/null +++ b/src/content/releases/wc-visual-engine-alignment.md @@ -0,0 +1,29 @@ +--- +title: Visual engine alignment across the shared lineage +product: windchime +version_or_label: visuals-alignment +date: 2026-06-23 +status: shipped +summary: >- + A seven-phase, two-day overhaul that ported the sibling project's mature visual + engine into Windchime: a param-model bridge (one shared visual parameter vector + with layered-target smoothing), a device-adaptive on-screen hardware twin, a + corpus migration adding roughly 30 audio-reactive scene families, a hands-free + takeover auto-performer, and a latent-space corpus map. +customer_value: >- + The visuals stopped being a fixed sketch and became a corpus: dozens of scene + families driven by one parameter contract, playable by hand, by audio, or by + the auto-performer, with a map that shows a visitor where they are in the + corpus. +included_work: + - Param-model bridge with a shared visual parameter vector and layered-target smoothing + - Device-adaptive on-screen twin of the connected monome hardware + - Corpus migration adding ~30 audio-reactive scene families (24 net new) + - Takeover auto-performer with Off / Auto / Listen modes + - Latent-space corpus map (UMAP / PCA / t-SNE) with sketch upload + - Responsive canvas and per-mode theming +tags: [visuals, architecture, platform] +--- + +This is the largest single block in Windchime's visual history and the direct +foundation of demo mode, which shipped the following day on top of it. diff --git a/src/content/releases/wc-visual-runtime-selector.md b/src/content/releases/wc-visual-runtime-selector.md new file mode 100644 index 0000000..af286d4 --- /dev/null +++ b/src/content/releases/wc-visual-runtime-selector.md @@ -0,0 +1,30 @@ +--- +title: Boot-time visual runtime selector and a Three.js scene set +product: windchime +version_or_label: visual-runtime-selector +date: 2026-07-16 +status: shipped +summary: >- + The visuals app now offers two selectable runtimes at boot, the current p5 system + and an experimental Three.js runtime behind the same host interface, and the Three + runtime gained the full ported family corpus plus a set of original 3D scenes. +customer_value: >- + The piece can be shown on either renderer for a direct fidelity comparison, one + runtime per session, with no change to hardware behaviour or interaction, so the + choice is purely about how it looks. +included_work: + - A full-screen runtime selector before any renderer or audio init, with a URL parameter to pin it for kiosk boots + - A second host implementation sharing the existing host contract (params, LED flush, mount semantics) + - The p5 family corpus ported to Three.js siblings, plus original 3D scenes + - Per-runtime dynamic imports, so a session loads only the runtime it selected +notable_risks: + - The newest scenes still await an in-person audition and per-family fidelity tuning + - A physical-hardware pass is pending for the latest scenes, so far verified only through the on-screen twin +followups: + - Audition the new scenes on the rig and tune idle framing and exposure per family +tags: [visuals, rendering, three-js] +--- + +Two renderers now sit behind one contract, chosen at boot, so the same installation +can be compared side by side on p5 and Three.js. The Three runtime reuses the p5 +host semantics rather than forking them, which is what keeps the comparison honest. diff --git a/src/content/research/ls-fallback-ladder.md b/src/content/research/ls-fallback-ladder.md new file mode 100644 index 0000000..bf07b88 --- /dev/null +++ b/src/content/research/ls-fallback-ladder.md @@ -0,0 +1,34 @@ +--- +title: The fallback ladder that keeps a live demo alive +product: lichtspiel +date: 2026-06-05 +source_type: discovery +summary: >- + A synthesis of what actually kept the system demonstrable through a fragile live stack: a + ladder of fallbacks where every dependency has a safe substitute below it. +questions: + - Which parts of the stack are most likely to fail during a live demo? + - What is the minimum that must keep working for the demo to survive a failure? + - How should each layer behave when the layer above or below it is missing? +insights: + - The runtime running browser-only means a demo can start with nothing but a page open + - Every experimental layer reduces to the same safe control message, so a failure degrades rather than ends + - When a Max outlet path went dead, a feeder path bypassed it and the triggers kept firing + - An on-screen emulator stands in for absent hardware, emitting the same event shapes +implications: + - Design each dependency with an explicit substitute one rung down the ladder + - Prefer reconnecting, optional layers over a monolith that must be fully present to run + - Reliability work is a feature for a live instrument, not overhead +evidence_links: + - label: Degradation section of the architecture notes + note: Every layer reduces to a safe control message and the runtime is browser-only + - label: Live session where the Max outlet path was dead + note: The feeder bypassed it so scene and locator triggers still reached the runtime +tags: [reliability, discovery, degradation, live-demo] +redaction_status: clean +provenance: >- + Synthesized from architecture notes, troubleshooting docs, and observations during live + sessions. Process-level only, with no code, secrets, or private paths. +--- + +The discovery was that reliability on a live stack is not one feature but a ladder: for every layer that can fail, there is a defined rung below it that still plays something. Naming the ladder explicitly turned scattered fallbacks into a design principle that shaped how every new layer was added. diff --git a/src/content/research/ls-monome-latent-instrument.md b/src/content/research/ls-monome-latent-instrument.md new file mode 100644 index 0000000..e4edbf7 --- /dev/null +++ b/src/content/research/ls-monome-latent-instrument.md @@ -0,0 +1,34 @@ +--- +title: The monome as a latent-space instrument +product: lichtspiel +date: 2026-06-02 +source_type: field-notes +summary: >- + Notes from hands-on hardware sessions on making a monome grid and arc feel like an + instrument for playing a visual space, rather than a bank of remote-control buttons. +questions: + - What makes a grid and arc read as an instrument instead of a control panel? + - How should controls behave when the connected hardware is smaller than a sketch expects? + - What feedback does a performer need to trust that a gesture landed? +insights: + - LED feedback matters as much as input; the surface must mirror the performance to feel alive + - Folding grid columns felt natural, but early on the arc turns did not fold, leaving objects frozen + - The rule that emerged is to never leave a control unreachable on smaller hardware + - The controller should never switch scenes; navigation belongs to the keyboard and Ableton +implications: + - Coupling and paging were added so every logical control stays reachable when hardware shrinks + - A live gestural panel shows the connected device and its coupling so the mapping is legible + - Keeping the controller purely expressive, never structural, preserved the instrument feel +evidence_links: + - label: Hands-on Grid 64 and Arc 2 play sessions + note: Surfaced that grid folded but arc turns did not, which drove the coupling work + - label: Idiom layer smoke suite + note: Asserts folding, paging, and adapt-up across a small and a large profile +tags: [monome, usability, instrument, adaptation] +redaction_status: clean +provenance: >- + Distilled from internal hardware-session notes and design docs. Process and design + observations only, with no code, device identifiers, or private paths. +--- + +The strongest finding was that expressivity and reachability, not raw control count, make a controller feel like an instrument. The moment a performer's turn left some objects frozen, it stopped feeling like playing, which is what pushed coupling and paging into the shared idiom layer. diff --git a/src/content/research/ls-not-another-vj.md b/src/content/research/ls-not-another-vj.md new file mode 100644 index 0000000..b0296b0 --- /dev/null +++ b/src/content/research/ls-not-another-vj.md @@ -0,0 +1,26 @@ +--- +title: 'Why not "another VJ plugin": finding the wedge' +product: lichtspiel +date: 2026-06-05 +source_type: market-scan +summary: >- + A quick scan of live-visual tools to locate a defensible wedge under hackathon time + pressure. The gap: tools react to an audio envelope, but none understand the structure + of the set or treat the controller as an instrument. +questions: + - What do existing VJ and live-visual tools actually map to? + - Where is there room for something a performer would call an instrument, not an effect? +insights: + - Almost everything maps to loudness or a simple audio envelope + - Nothing reads the set's structure (clips, scenes, sections) as the driver + - Nothing treats a hardware controller as a way to play a latent visual space +implications: + - Position deliberately as session-aware mapping + code-native visuals + monome-as-instrument + - Say the "no" out loud ("not another VJ plugin") to keep scope honest under time pressure +tags: [discovery, positioning, market] +redaction_status: clean +--- + +The value of this scan was less the competitive detail and more the **decision to name what +Lichtspiel is not.** A sharp "no" is what kept a hackathon build from sprawling into a +generic effects box. diff --git a/src/content/research/wc-alm-audit.md b/src/content/research/wc-alm-audit.md new file mode 100644 index 0000000..c565c34 --- /dev/null +++ b/src/content/research/wc-alm-audit.md @@ -0,0 +1,32 @@ +--- +title: Auditing audio-language models as a controlled variable +product: windchime +date: 2026-06-28 +source_type: experiment +summary: >- + The research method holds the whole installation constant and varies only the + audio-language model that matches a voice to sound, then measures how differently + each configuration behaves over the same corpus and the same prompts. +questions: + - Does the choice of audio-language model change which stems a voice can reach? + - Are some configurations more stable under paraphrase, or across languages? + - How evenly does each model cover the catalogue versus concentrating on a few stems? +insights: + - A single embedding interface plus a model-independent corpus DB makes "which model" a clean toggle + - Embedding audio offline, once per configuration, keeps the runtime light and the comparison fair + - Distributional metrics (catalog coverage, selection concentration, dispersion, ranking overlap) describe behaviour without needing ground-truth labels +implications: + - The same seam that enables the science also hardens the product (liveness probe, auto-revert) + - Provenance matters. Every measurement must be attributable to the exact index epoch it ran against +evidence_links: + - label: 'Decision: the audio-language model as an exchangeable variable' + note: See the linked decision record +tags: [research, retrieval, methodology] +redaction_status: sanitized +provenance: Distilled to process only. No results, figures, or venue are published while the work is under review. +--- + +This note describes **method, not results.** The contribution is the experimental design: +treat the audio-language model as a controlled variable, measure distributional behaviour +over a fixed, artist-authored corpus, and keep every artifact attributable to the index +epoch it was computed on. Findings and write-ups remain private while under review. diff --git a/src/content/research/wc-content-aware-reembed.md b/src/content/research/wc-content-aware-reembed.md new file mode 100644 index 0000000..4ce51ee --- /dev/null +++ b/src/content/research/wc-content-aware-reembed.md @@ -0,0 +1,35 @@ +--- +title: Content-aware re-embedding and index-epoch provenance +product: windchime +date: 2026-07-07 +source_type: experiment +summary: >- + A method for repairing a retrieval index when a fixed indexing window lands on + silence or an unrepresentative part of a stem, together with a provenance scheme + that records which index epoch every measurement was computed against. +questions: + - When a stem is indexed from a fixed window, how often does that window miss the stem's actual sound? + - Can only the affected rows be repaired without disturbing the rest of a frozen index? + - How do we keep every downstream measurement attributable to the exact index it ran against? +insights: + - Late-entry material, meaning files that open with long silence, can yield an embedding of essentially nothing under a fixed head window + - Reading each stem from its most representative window, chosen via a lightweight loudness sidecar, targets the repair to only the compromised rows + - An append-only index history distinguishes recurring corpus-addition re-indexes from one-off surgical re-embeds +implications: + - Retrieval quality depends as much on the indexing window as on the model, so the window is a first-class design choice + - Stamping each analysis with its index epoch keeps results reproducible and prevents accidentally comparing numbers across different indexes + - The repair is per-backend and deliberately skips a backend whose indexer already reads whole files, since its issue is different in kind +evidence_links: + - label: Content-aware re-embed and provenance method + note: Method captured from the retrieval change notes; the measurements are withheld +tags: [retrieval, indexing, provenance] +redaction_status: sanitized +provenance: >- + Distilled to method only. No coverage numbers, cosine values, or audit results are + published while the work is under review. +--- + +This note describes method, not results. The idea is that an embedding is only as +good as the window it was computed over, so repairing a compromised index means +re-reading each affected stem from where its sound actually lives and recording, in +the data itself, which index epoch every later measurement belongs to. diff --git a/src/content/research/wc-user-study-instruments.md b/src/content/research/wc-user-study-instruments.md new file mode 100644 index 0000000..c980c25 --- /dev/null +++ b/src/content/research/wc-user-study-instruments.md @@ -0,0 +1,36 @@ +--- +title: Instrument design for a moderated proof-of-concept study +product: windchime +date: 2026-05-20 +source_type: usability +summary: >- + The design of a moderated, repeatable evaluation of the installation: a short + standardized UX questionnaire, a set of custom items grouped by construct, brief + per-trial ratings, open responses, and a semi-structured interview, with + counterbalanced trial order and distribution-first statistics. +questions: + - Does a spoken phrase feel meaningfully related to the sound the system returns? + - Can a participant tell how their voice shaped the result, and do they feel some control? + - How do prior musical and live-coding experience shape a participant's expectations? +insights: + - Pairing a validated short UX questionnaire with custom, construct-grouped items covers both general experience and installation-specific facets without an over-long session + - Five short per-trial ratings capture in-the-moment reactions that a single end-of-session form would blur together + - A verbatim briefing, a practice trial, and counterbalanced order separate learning the interface from judging the piece +implications: + - With small expert samples, reporting distributions with bootstrap confidence intervals is more honest than significance testing + - Describing retrieved and generated audio as one continuous sound field avoids leading participants toward a distinction they cannot reliably hear + - Anonymous identifiers, a separate rating device, and pre-registered coding categories reduce demand characteristics and experimenter expectancy +evidence_links: + - label: Study protocol and instrument definitions + note: Method and instrument structure only; no participant data or findings +tags: [research, evaluation, methodology] +redaction_status: sanitized +provenance: >- + Method and instrument design only. No participant results, sample sizes, or venue + are included. +--- + +This note captures how the evaluation was designed to be trustworthy, not what it +found. The choices that matter are structural: counterbalancing, a practice trial, +distribution-first statistics for small samples, and a briefing script read the same +way for every participant. From 77aed48e3e29898167165f3dc42e2622569e726d Mon Sep 17 00:00:00 2001 From: Trent Eriksen <124685398+Grashopr-888@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:52:59 -0700 Subject: [PATCH 5/9] feat(ingest): sanitized git-activity pipeline behind a redaction gate Co-Authored-By: Claude Fable 5 --- scripts/README.md | 45 +++++ scripts/config.ts | 55 ++++++ scripts/ingest-git.ts | 175 +++++++++++++++++ scripts/ingest-github.ts | 139 +++++++++++++ scripts/ingest-research.ts | 104 ++++++++++ scripts/lib/redact.ts | 155 +++++++++++++++ scripts/lib/util.ts | 83 ++++++++ scripts/redact.ts | 71 +++++++ src/data/snapshots/git/index.json | 54 ++++++ .../git/lichtspiel_github_trent.json | 52 +++++ .../snapshots/git/windchime-animation.json | 37 ++++ src/data/snapshots/git/windchime-eval.json | 24 +++ src/data/snapshots/git/windchime-full.json | 182 ++++++++++++++++++ .../snapshots/git/windchime-livecode.json | 52 +++++ .../snapshots/git/windchime-retrieval.json | 105 ++++++++++ src/data/snapshots/git/windchime-soak.json | 23 +++ src/data/snapshots/github/index.json | 13 ++ .../github/lichtspiel_github_trent.json | 23 +++ src/data/snapshots/github/windchime-full.json | 23 +++ src/lib/signals.ts | 99 ++++++++++ 20 files changed, 1514 insertions(+) create mode 100644 scripts/README.md create mode 100644 scripts/config.ts create mode 100644 scripts/ingest-git.ts create mode 100644 scripts/ingest-github.ts create mode 100644 scripts/ingest-research.ts create mode 100644 scripts/lib/redact.ts create mode 100644 scripts/lib/util.ts create mode 100644 scripts/redact.ts create mode 100644 src/data/snapshots/git/index.json create mode 100644 src/data/snapshots/git/lichtspiel_github_trent.json create mode 100644 src/data/snapshots/git/windchime-animation.json create mode 100644 src/data/snapshots/git/windchime-eval.json create mode 100644 src/data/snapshots/git/windchime-full.json create mode 100644 src/data/snapshots/git/windchime-livecode.json create mode 100644 src/data/snapshots/git/windchime-retrieval.json create mode 100644 src/data/snapshots/git/windchime-soak.json create mode 100644 src/data/snapshots/github/index.json create mode 100644 src/data/snapshots/github/lichtspiel_github_trent.json create mode 100644 src/data/snapshots/github/windchime-full.json create mode 100644 src/lib/signals.ts diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..66d590c --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,45 @@ +# Ingestion & redaction pipeline + +These scripts pull **sanitized evidence** from the private product repos into the public +showcase. The governing rule: raw pulls are quarantined and never committed; only redacted +aggregates reach `src/data/snapshots/`, and the site reads only from there. + +``` +private repos ──▶ ingest-* ──┬──▶ .private/ingest/** RAW, gitignored + └──▶ src/data/snapshots/** SANITIZED, committable + ▲ + every string passes through lib/redact.ts +``` + +## Commands + +| Command | What it does | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `npm run ingest:git` | Reads local git history for each repo in `scripts/config.ts`. Writes sanitized activity (commit counts, monthly cadence, release tags — **no commit subjects**) to `src/data/snapshots/git/`. Raw commit lists go to `.private/ingest/git/`. | +| `npm run ingest:github` | Via the `gh` CLI, pulls issue/PR/label/milestone/release **metadata** (never bodies). Degrades gracefully if `gh` is missing or unauthenticated. | +| `npm run ingest:research -- --src [--product windchime\|lichtspiel\|shared]` | Scans a notes directory, redacts aggressively, and writes review-required **candidates** to `.private/research-candidates/`. Nothing is auto-published — a human promotes the good ones into `src/content/research/`. | +| `npm run ingest` | Runs `ingest:git` then `ingest:github`. | +| `npm run redact:check [dir]` | Scans a tree (default `src/content`) for secrets, internal hosts, emails, and large code blocks. Exits non-zero if anything is found — use it as a pre-publish gate. Add `--write` to emit redacted copies under `.private/redacted/`. | + +## Configuration + +`scripts/config.ts` lists the source repos. Paths resolve from `$HOME` at runtime (override +with `PL_REPOS_ROOT`), so no machine-specific path or username is committed. Add a project by +adding one entry. + +## The redaction engine (`scripts/lib/redact.ts`) + +A single function every ingested string flows through. It removes, and logs, high-confidence +secrets (API keys, tokens, private-key blocks, `KEY=value` assignments), and optionally +internal hostnames/IPs, emails, and code blocks over a line threshold. It **defaults to +redaction when uncertain** — over-redacting a sanitized aggregate is cheap; leaking is not. + +Tune it in one place: `SECRET_RULES` / `HOST_RULES` for patterns, `PUBLIC_EMAILS` in +`config.ts` for intentional exceptions. + +## What is safe to commit + +- ✅ `src/data/snapshots/**` — sanitized aggregates. +- ❌ `.private/**` — raw pulls, research candidates, redacted dumps. Gitignored; never commit. + +Run `npm run redact:check` before publishing. diff --git a/scripts/config.ts b/scripts/config.ts new file mode 100644 index 0000000..42ed9e4 --- /dev/null +++ b/scripts/config.ts @@ -0,0 +1,55 @@ +import os from 'node:os'; +import path from 'node:path'; + +export type Product = 'windchime' | 'lichtspiel'; + +/** + * Source repos live as siblings under this root. Resolved from $HOME at runtime, + * so no machine-specific absolute path (or username) is ever committed. + * Override with `PL_REPOS_ROOT=/some/where`. + */ +const ROOT = process.env.PL_REPOS_ROOT ?? os.homedir(); + +export interface RepoSource { + label: string; + product: Product; + dir: string; // directory name under ROOT + githubRepo?: string; // "owner/name", if you want GitHub metadata too +} + +/** The private repos this showcase draws sanitized evidence from. */ +export const REPOS: RepoSource[] = [ + { + label: 'windchime (umbrella)', + product: 'windchime', + dir: 'windchime-full', + githubRepo: 'Grashopr-888/windchime', + }, + { label: 'windchime-retrieval', product: 'windchime', dir: 'windchime-retrieval' }, + { label: 'windchime-livecode', product: 'windchime', dir: 'windchime-livecode' }, + { label: 'windchime-animation', product: 'windchime', dir: 'windchime-animation' }, + { label: 'windchime-eval', product: 'windchime', dir: 'windchime-eval' }, + { label: 'windchime-soak', product: 'windchime', dir: 'windchime-soak' }, + { + label: 'lichtspiel', + product: 'lichtspiel', + dir: 'lichtspiel_github_trent', + githubRepo: 'Grashopr-888/lichtspiel', + }, +]; + +export function repoPath(src: RepoSource): string { + return path.join(ROOT, src.dir); +} + +export const PATHS = { + /** RAW, unsanitized pulls — MUST stay gitignored (.private/ is in .gitignore). */ + raw: '.private/ingest', + /** Sanitized, committable aggregates the site may read. */ + snapshots: 'src/data/snapshots', + /** Research-note candidates for human review before promotion into src/content. */ + researchCandidates: '.private/research-candidates', +}; + +/** Emails that are intentionally public (won't be redacted from ingested text). */ +export const PUBLIC_EMAILS: string[] = []; diff --git a/scripts/ingest-git.ts b/scripts/ingest-git.ts new file mode 100644 index 0000000..7959bb1 --- /dev/null +++ b/scripts/ingest-git.ts @@ -0,0 +1,175 @@ +/** + * ingest-git — read local git history for each source repo and emit sanitized + * activity snapshots the site can publish. + * + * Trust boundary: + * • RAW (full commits incl. subjects) → .private/ingest/git/*.raw.json [gitignored] + * • SAFE (counts, cadence, tags only) → src/data/snapshots/git/*.json [committable] + * + * Commit subjects ship ONLY in day-level form, and only after passing the redact + * engine plus a conservative scrub (dash normalization, truncation, and a filter + * that drops anything mentioning people or addresses). Aggregates and (redacted) + * tag names ship as before. + */ +import path from 'node:path'; +import { REPOS, repoPath, PATHS, type Product } from './config'; +import { git, isGitRepo, exists, writeJson, header, log, warn } from './lib/util'; +import { redact, summarize, type Finding } from './lib/redact'; + +const US = '\x1f'; + +interface Commit { + hash: string; + date: string; + author: string; + subject: string; +} + +interface DayActivity { + count: number; + /** Up to MAX_DAY_SUBJECTS sanitized subject lines for hover detail. */ + subjects: string[]; +} + +interface GitSnapshot { + note: string; + label: string; + product: Product; + dir: string; + commitCount: number; + firstDate: string | null; + lastDate: string | null; + authors: number; + months: Record; + days: Record; + tags: Array<{ name: string; date: string }>; +} + +const MAX_DAY_SUBJECTS = 4; +const MAX_SUBJECT_LEN = 72; + +/** Sanitize one commit subject for public day-level display, or drop it (null). */ +function sanitizeSubject(subject: string, findings: Finding[]): string | null { + // Anything that might reference a person or an address stays private. + if (/co-authored|signed-off|merge branch|merge pull|@/i.test(subject)) return null; + // In-progress research writing stays private: drop anything paper-adjacent. + if (/submission|camera.?ready|overleaf|neurips|\bpaper\b|\bvenue\b|manuscript/i.test(subject)) + return null; + const r = redact(subject, { hosts: true, emails: true }); + findings.push(...r.findings); + let s = r.text + .replace(/[—–]/g, '-') // site prose style: no em/en dashes anywhere visible + .replace(/\s+/g, ' ') + .trim(); + if (!s) return null; + if (s.length > MAX_SUBJECT_LEN) s = `${s.slice(0, MAX_SUBJECT_LEN - 1)}…`; + return s; +} + +function readCommits(repo: string): Commit[] { + const out = git(repo, ['log', `--format=%H${US}%ad${US}%an${US}%s`, '--date=short']); + if (!out) return []; + return out.split('\n').map((line) => { + const [hash, date, author, subject] = line.split(US); + return { hash, date, author, subject: subject ?? '' }; + }); +} + +function readTags(repo: string): Array<{ name: string; date: string }> { + const out = git(repo, [ + 'tag', + `--format=%(refname:short)${US}%(creatordate:short)`, + '--sort=creatordate', + ]); + if (!out) return []; + return out + .split('\n') + .filter(Boolean) + .map((line) => { + const [name, date] = line.split(US); + return { name, date }; + }); +} + +function main(): void { + const findings: Finding[] = []; + const index: Array< + Pick + > = []; + + for (const src of REPOS) { + const repo = repoPath(src); + if (!exists(repo) || !isGitRepo(repo)) { + warn(`skip ${src.label} — not a git repo at ${src.dir}`); + continue; + } + header(`${src.label}`); + + const commits = readCommits(repo); + const tagsRaw = readTags(repo); + + // RAW (gitignored) — keep everything for local inspection. + writeJson(path.join(PATHS.raw, 'git', `${src.dir}.raw.json`), { + repo: src.dir, + commits, + tags: tagsRaw, + }); + + // SAFE — aggregates, day-level sanitized activity, + redacted tag names. + const months: Record = {}; + const days: Record = {}; + const authors = new Set(); + for (const c of commits) { + const ym = c.date.slice(0, 7); + months[ym] = (months[ym] ?? 0) + 1; + authors.add(c.author); + const day = (days[c.date] ??= { count: 0, subjects: [] }); + day.count += 1; + if (day.subjects.length < MAX_DAY_SUBJECTS) { + const s = sanitizeSubject(c.subject, findings); + if (s) day.subjects.push(s); + } + } + const tags = tagsRaw.map((t) => { + const r = redact(t.name, { hosts: true, emails: true }); + findings.push(...r.findings); + return { name: r.text, date: t.date }; + }); + + const snap: GitSnapshot = { + note: 'Sanitized aggregate - day-level counts plus redacted, truncated subject lines. Raw pull stays gitignored.', + label: src.label, + product: src.product, + dir: src.dir, + commitCount: commits.length, + firstDate: commits.at(-1)?.date ?? null, + lastDate: commits[0]?.date ?? null, + authors: authors.size, + months: Object.fromEntries(Object.entries(months).sort(([a], [b]) => a.localeCompare(b))), + days: Object.fromEntries(Object.entries(days).sort(([a], [b]) => a.localeCompare(b))), + tags, + }; + writeJson(path.join(PATHS.snapshots, 'git', `${src.dir}.json`), snap); + index.push({ + label: src.label, + product: src.product, + commitCount: snap.commitCount, + firstDate: snap.firstDate, + lastDate: snap.lastDate, + }); + log(`${commits.length} commits · ${authors.size} author(s) · ${tags.length} tag(s)`); + } + + writeJson(path.join(PATHS.snapshots, 'git', 'index.json'), { + note: 'Per-repo sanitized git activity index.', + repos: index, + }); + + const summary = summarize(findings); + header('done'); + log(`wrote ${index.length} snapshot(s) to ${PATHS.snapshots}/git`); + if (Object.keys(summary).length) log('redactions:', JSON.stringify(summary)); + else log('redactions: none needed in tag names'); +} + +main(); diff --git a/scripts/ingest-github.ts b/scripts/ingest-github.ts new file mode 100644 index 0000000..a7c7d9d --- /dev/null +++ b/scripts/ingest-github.ts @@ -0,0 +1,139 @@ +/** + * ingest-github — pull sanitized issue/PR/label/milestone/release *metadata* via the + * GitHub CLI (`gh`). Bodies and comments are never requested, so private prose and + * pasted code cannot ride along. Names that could carry detail are redacted. + * + * • RAW → .private/ingest/github/*.raw.json [gitignored] + * • SAFE → src/data/snapshots/github/*.json [committable] + * + * Degrades gracefully: if `gh` is missing or unauthenticated, it writes a note and + * exits 0 so the pipeline never hard-fails on an optional dependency. + */ +import path from 'node:path'; +import { REPOS, PATHS } from './config'; +import { trySh, writeJson, header, log, warn } from './lib/util'; +import { redact, summarize, type Finding } from './lib/redact'; + +function ghJson(args: string[]): T | null { + const out = trySh('gh', args); + if (out == null) return null; + try { + return JSON.parse(out) as T; + } catch { + return null; + } +} + +interface Labeled { + state?: string; + labels?: Array<{ name: string }>; +} + +function tally(items: Labeled[]) { + const state = { open: 0, closed: 0, total: items.length }; + const labels: Record = {}; + for (const it of items) { + if (it.state?.toLowerCase() === 'open') state.open++; + else state.closed++; + for (const l of it.labels ?? []) labels[l.name] = (labels[l.name] ?? 0) + 1; + } + return { state, labels }; +} + +function main(): void { + const auth = trySh('gh', ['auth', 'status']); + if (auth == null) { + warn('`gh` CLI not available or not authenticated — writing a placeholder and skipping.'); + writeJson(path.join(PATHS.snapshots, 'github', 'index.json'), { + note: 'GitHub ingestion skipped: `gh` CLI unavailable/unauthenticated at ingest time. Re-run `npm run ingest:github` once authenticated.', + repos: [], + }); + return; + } + + const findings: Finding[] = []; + const index: Array<{ repo: string; product: string }> = []; + + for (const src of REPOS) { + if (!src.githubRepo) continue; + header(`${src.label} (${src.githubRepo})`); + const R = src.githubRepo; + + const issues = ghJson([ + 'issue', + 'list', + '-R', + R, + '--state', + 'all', + '--limit', + '500', + '--json', + 'number,state,labels', + ]); + const prs = ghJson([ + 'pr', + 'list', + '-R', + R, + '--state', + 'all', + '--limit', + '500', + '--json', + 'number,state,labels', + ]); + const releases = ghJson>([ + 'release', + 'list', + '-R', + R, + '--json', + 'tagName,name,publishedAt', + ]); + + if (issues == null && prs == null && releases == null) { + warn(`no access to ${R} (private or not found) — skipping`); + continue; + } + + // RAW (gitignored) + writeJson(path.join(PATHS.raw, 'github', `${src.dir}.raw.json`), { + repo: R, + issues, + prs, + releases, + }); + + // SAFE — counts, label distribution, redacted release names/dates. No titles, no bodies. + const relClean = (releases ?? []).map((rel) => { + const r = redact(rel.name || rel.tagName, { hosts: true, emails: true }); + findings.push(...r.findings); + return { name: r.text, date: (rel.publishedAt ?? '').slice(0, 10) }; + }); + + writeJson(path.join(PATHS.snapshots, 'github', `${src.dir}.json`), { + note: 'Sanitized GitHub metadata — no titles or bodies, only counts, labels, and redacted release names.', + label: src.label, + product: src.product, + repo: R, + issues: issues ? tally(issues) : null, + prs: prs ? tally(prs) : null, + releases: relClean, + }); + index.push({ repo: R, product: src.product }); + log( + `issues:${issues?.length ?? '—'} prs:${prs?.length ?? '—'} releases:${releases?.length ?? '—'}` + ); + } + + writeJson(path.join(PATHS.snapshots, 'github', 'index.json'), { + note: 'Per-repo sanitized GitHub metadata index.', + repos: index, + }); + const summary = summarize(findings); + header('done'); + if (Object.keys(summary).length) log('redactions:', JSON.stringify(summary)); +} + +main(); diff --git a/scripts/ingest-research.ts b/scripts/ingest-research.ts new file mode 100644 index 0000000..0e605f9 --- /dev/null +++ b/scripts/ingest-research.ts @@ -0,0 +1,104 @@ +/** + * ingest-research — scan a local notes directory, redact aggressively, and emit + * research-note *candidates* for human review. Nothing is auto-published. + * + * Usage: + * tsx scripts/ingest-research.ts --src [--product windchime|lichtspiel|shared] + * + * Output: .private/research-candidates/*.md [gitignored] + * A human reviews each candidate, tightens it, and moves the good ones into + * src/content/research/ — the promotion step is deliberately manual. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { PATHS, PUBLIC_EMAILS } from './config'; +import { walk, writeText, slugify, header, log, warn, exists } from './lib/util'; +import { redact, summarize } from './lib/redact'; + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +function firstHeading(md: string): string | null { + const m = md.match(/^#{1,3}\s+(.+)$/m); + return m ? m[1].trim() : null; +} + +function firstParagraph(md: string): string { + const body = md.replace(/^---[\s\S]*?---/, ''); // drop any frontmatter + for (const block of body.split(/\n\s*\n/)) { + const t = block.trim(); + if (t && !t.startsWith('#') && !t.startsWith('```')) return t.replace(/\s+/g, ' '); + } + return ''; +} + +function main(): void { + const src = arg('src'); + const product = (arg('product') ?? 'shared') as 'windchime' | 'lichtspiel' | 'shared'; + if (!src) { + warn('required: --src (no default — research folders are sensitive)'); + process.exitCode = 2; + return; + } + if (!exists(src)) { + warn(`source directory not found: ${src}`); + process.exitCode = 2; + return; + } + + const files = walk(src, ['.md', '.txt', '.markdown']); + header(`scanning ${files.length} file(s) under ${src}`); + const totalFindings: Record = {}; + let written = 0; + + for (const file of files) { + const raw = fs.readFileSync(file, 'utf8'); + const { text, findings } = redact(raw, { + hosts: true, + emails: true, + maxCodeLines: 4, + allowEmails: PUBLIC_EMAILS, + }); + for (const [k, v] of Object.entries(summarize(findings))) + totalFindings[k] = (totalFindings[k] ?? 0) + v; + + const title = firstHeading(raw) ?? path.basename(file).replace(/\.[^.]+$/, ''); + const date = fs.statSync(file).mtime.toISOString().slice(0, 10); + const summary = redact(firstParagraph(raw), { allowEmails: PUBLIC_EMAILS }).text.slice(0, 280); + const slug = slugify(`${product}-${title}`); + + const frontmatter = [ + '---', + `title: ${JSON.stringify(title)}`, + `product: ${product}`, + `date: ${date}`, + 'source_type: field-notes', + `summary: ${JSON.stringify(summary || 'TODO: write a one-line synthesis.')}`, + 'insights: []', + 'implications: []', + `provenance: ${JSON.stringify(`ingested from ${path.basename(file)} (${findings.length} redaction(s))`)}`, + 'redaction_status: needs-review', + '---', + '', + `> CANDIDATE — review, tighten, and remove this banner before promoting to src/content/research.`, + `> ${findings.length} item(s) were redacted during ingestion.`, + '', + text.replace(/^---[\s\S]*?---/, '').trim(), + '', + ].join('\n'); + + writeText(path.join(PATHS.researchCandidates, `${slug}.md`), frontmatter); + written++; + log(`${path.basename(file)} → ${slug}.md (${findings.length} redaction(s))`); + } + + header('done'); + log( + `wrote ${written} candidate(s) to ${PATHS.researchCandidates} — NOT published; review before promoting.` + ); + if (Object.keys(totalFindings).length) log('redactions:', JSON.stringify(totalFindings)); +} + +main(); diff --git a/scripts/lib/redact.ts b/scripts/lib/redact.ts new file mode 100644 index 0000000..ea34170 --- /dev/null +++ b/scripts/lib/redact.ts @@ -0,0 +1,155 @@ +/** + * REDACTION ENGINE — the single chokepoint every ingested string flows through + * before it can be written anywhere committable. + * + * Philosophy: default to redaction when uncertain. It is far better to over-redact + * a sanitized aggregate (which a human then reviews) than to leak a secret, an + * internal hostname, or a block of private source into a public repo. + * + * `redact()` returns the cleaned text plus a list of findings, so callers can log + * what was removed and reviewers can audit the pipeline. + */ + +export interface Finding { + kind: string; + sample: string; // a short, already-masked hint of what matched — never the full secret +} + +export interface RedactResult { + text: string; + findings: Finding[]; +} + +export interface RedactOptions { + /** Redact IPv4 addresses and internal hostnames (default true). */ + hosts?: boolean; + /** Redact email addresses, except any in `allowEmails` (default true). */ + emails?: boolean; + /** Collapse fenced/indented code blocks longer than this many lines (default 6). */ + maxCodeLines?: number; + /** Emails that are intentionally public and should NOT be redacted. */ + allowEmails?: string[]; +} + +/** A short masked hint, e.g. "sk-a…9f" — enough to audit, not enough to reuse. */ +function hint(s: string): string { + const t = s.trim(); + if (t.length <= 8) return '•'.repeat(t.length); + return `${t.slice(0, 3)}…${t.slice(-2)}`; +} + +/** High-confidence secret patterns — always redacted regardless of options. */ +const SECRET_RULES: Array<{ kind: string; re: RegExp }> = [ + { kind: 'anthropic-key', re: /sk-ant-[A-Za-z0-9\-_]{16,}/g }, + { kind: 'openai-key', re: /\bsk-[A-Za-z0-9]{20,}\b/g }, + { kind: 'github-token', re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g }, + { kind: 'aws-access-key', re: /\bAKIA[0-9A-Z]{16}\b/g }, + { kind: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g }, + { kind: 'jwt', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g }, + { kind: 'bearer-token', re: /\bBearer\s+[A-Za-z0-9\-._~+/]{16,}=*/g }, + { + kind: 'private-key-block', + re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, + }, + // KEY=value / SECRET: value style assignments with a plausible secret value + { + kind: 'secret-assignment', + re: /\b([A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD|CREDENTIAL)[A-Z0-9_]*)\s*[=:]\s*["']?[A-Za-z0-9\-_./+]{12,}["']?/g, + }, +]; + +const HOST_RULES: Array<{ kind: string; re: RegExp }> = [ + { kind: 'ipv4', re: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g }, + { + kind: 'internal-hostname', + re: /\b[a-z0-9-]+\.(?:local|internal|lan|corp|home)\b(?::\d+)?/gi, + }, + // dead private mirror flagged during recon — never surface it + { kind: 'private-remote', re: /codeberg\.org\/[A-Za-z0-9_\-]+\/[A-Za-z0-9_\-]+/g }, +]; + +const EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g; + +function applyRules( + text: string, + rules: Array<{ kind: string; re: RegExp }>, + findings: Finding[] +): string { + let out = text; + for (const { kind, re } of rules) { + out = out.replace(re, (m) => { + findings.push({ kind, sample: hint(m) }); + return `[REDACTED:${kind}]`; + }); + } + return out; +} + +/** Collapse long code blocks to a one-line marker so private source can't ride along. */ +function redactCodeBlocks(text: string, maxLines: number, findings: Finding[]): string { + // fenced ``` blocks + const fenced = /```[^\n]*\n([\s\S]*?)```/g; + let out = text.replace(fenced, (_full, body: string) => { + const lines = body.split('\n').length; + if (lines > maxLines) { + findings.push({ kind: 'code-block', sample: `${lines} lines` }); + return `\n[REDACTED:code-block — ${lines} lines summarized out]\n`; + } + return _full; + }); + + // runs of >maxLines indented (4-space / tab) lines that look like a code dump + const linesArr = out.split('\n'); + const result: string[] = []; + let run: string[] = []; + const flush = () => { + if (run.length > maxLines) { + findings.push({ kind: 'code-block', sample: `${run.length} indented lines` }); + result.push(`[REDACTED:code-block — ${run.length} indented lines summarized out]`); + } else { + result.push(...run); + } + run = []; + }; + for (const line of linesArr) { + if (/^(?: {4}|\t)\S/.test(line)) run.push(line); + else { + flush(); + result.push(line); + } + } + flush(); + return result.join('\n'); +} + +export function redact(text: string, opts: RedactOptions = {}): RedactResult { + const { hosts = true, emails = true, maxCodeLines = 6, allowEmails = [] } = opts; + const findings: Finding[] = []; + let out = text; + + out = applyRules(out, SECRET_RULES, findings); + if (hosts) out = applyRules(out, HOST_RULES, findings); + if (emails) { + out = out.replace(EMAIL_RE, (m) => { + if (allowEmails.includes(m.toLowerCase())) return m; + findings.push({ kind: 'email', sample: hint(m) }); + return '[REDACTED:email]'; + }); + } + out = redactCodeBlocks(out, maxCodeLines, findings); + + return { text: out, findings }; +} + +/** Convenience: true if `text` contains anything the engine would redact. */ +export function hasSensitive(text: string, opts?: RedactOptions): boolean { + return redact(text, opts).findings.length > 0; +} + +/** Roll findings up into a `{kind: count}` summary for logging. */ +export function summarize(findings: Finding[]): Record { + return findings.reduce>((acc, f) => { + acc[f.kind] = (acc[f.kind] ?? 0) + 1; + return acc; + }, {}); +} diff --git a/scripts/lib/util.ts b/scripts/lib/util.ts new file mode 100644 index 0000000..8852fc9 --- /dev/null +++ b/scripts/lib/util.ts @@ -0,0 +1,83 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +export function ensureDir(dir: string): void { + fs.mkdirSync(dir, { recursive: true }); +} + +export function writeJson(file: string, data: unknown): void { + ensureDir(path.dirname(file)); + fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n'); +} + +export function writeText(file: string, text: string): void { + ensureDir(path.dirname(file)); + fs.writeFileSync(file, text); +} + +export function exists(p: string): boolean { + return fs.existsSync(p); +} + +export function isGitRepo(dir: string): boolean { + return fs.existsSync(path.join(dir, '.git')); +} + +/** Run git in a repo and return trimmed stdout. Throws on failure. */ +export function git(repo: string, args: string[]): string { + return execFileSync('git', ['-C', repo, ...args], { + encoding: 'utf8', + maxBuffer: 128 * 1024 * 1024, + }).trim(); +} + +/** Run a command; return stdout, or null if it fails (for optional tools like gh). */ +export function trySh(cmd: string, args: string[]): string | null { + try { + return execFileSync(cmd, args, { encoding: 'utf8', maxBuffer: 128 * 1024 * 1024 }).trim(); + } catch { + return null; + } +} + +/** Recursively list files under `dir` matching one of `exts`, skipping noise dirs. */ +export function walk( + dir: string, + exts: string[], + skip = new Set(['node_modules', '.git', 'dist']) +): string[] { + const out: string[] = []; + const stack = [dir]; + while (stack.length) { + const cur = stack.pop()!; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(cur, { withFileTypes: true }); + } catch { + continue; + } + for (const e of entries) { + const full = path.join(cur, e.name); + if (e.isDirectory()) { + if (!skip.has(e.name) && !e.name.startsWith('.')) stack.push(full); + } else if (exts.some((x) => e.name.toLowerCase().endsWith(x))) { + out.push(full); + } + } + } + return out; +} + +export const log = (...a: unknown[]): void => console.log(' ', ...a); +export const header = (s: string): void => console.log(`\n▸ ${s}`); +export const warn = (s: string): void => console.log(` ! ${s}`); + +/** Turn a string into a filesystem-safe slug. */ +export function slugify(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); +} diff --git a/scripts/redact.ts b/scripts/redact.ts new file mode 100644 index 0000000..4d7a57e --- /dev/null +++ b/scripts/redact.ts @@ -0,0 +1,71 @@ +/** + * redact — CLI over the redaction engine. Two jobs: + * 1. Pre-publish gate: `tsx scripts/redact.ts [dir]` (default: src/content) + * Scans text files, reports findings, exits 1 if anything sensitive is found. + * 2. Sanitize a tree: `tsx scripts/redact.ts --write` + * Writes redacted copies under .private/redacted/ for inspection. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { walk, writeText, header, log, warn } from './lib/util'; +import { redact, summarize, type Finding } from './lib/redact'; + +const TEXT_EXTS = [ + '.md', + '.mdx', + '.markdown', + '.txt', + '.json', + '.yml', + '.yaml', + '.astro', + '.ts', + '.tsx', + '.js', +]; + +function main(): void { + const args = process.argv.slice(2); + const write = args.includes('--write'); + const target = args.find((a) => !a.startsWith('--')) ?? 'src/content'; + + if (!fs.existsSync(target)) { + warn(`target not found: ${target}`); + process.exitCode = 2; + return; + } + + const files = fs.statSync(target).isDirectory() ? walk(target, TEXT_EXTS) : [target]; + header(`redact:check — ${files.length} file(s) under ${target}`); + + const all: Finding[] = []; + let flaggedFiles = 0; + + for (const file of files) { + const raw = fs.readFileSync(file, 'utf8'); + const { text, findings } = redact(raw); + if (findings.length) { + flaggedFiles++; + all.push(...findings); + log(`⚠ ${path.relative(process.cwd(), file)} — ${JSON.stringify(summarize(findings))}`); + } + if (write) { + const dest = path.join('.private', 'redacted', path.relative(process.cwd(), file)); + writeText(dest, text); + } + } + + header('done'); + if (all.length === 0) { + log('clean — no secrets, hosts, emails, or large code blocks found.'); + return; + } + log( + `found ${all.length} item(s) across ${flaggedFiles} file(s): ${JSON.stringify(summarize(all))}` + ); + if (write) log('redacted copies written under .private/redacted/'); + // Non-zero exit so this can gate CI / a pre-publish check. + process.exitCode = 1; +} + +main(); diff --git a/src/data/snapshots/git/index.json b/src/data/snapshots/git/index.json new file mode 100644 index 0000000..e2d906e --- /dev/null +++ b/src/data/snapshots/git/index.json @@ -0,0 +1,54 @@ +{ + "note": "Per-repo sanitized git activity index.", + "repos": [ + { + "label": "windchime (umbrella)", + "product": "windchime", + "commitCount": 85, + "firstDate": "2026-05-23", + "lastDate": "2026-07-17" + }, + { + "label": "windchime-retrieval", + "product": "windchime", + "commitCount": 23, + "firstDate": "2026-05-07", + "lastDate": "2026-07-17" + }, + { + "label": "windchime-livecode", + "product": "windchime", + "commitCount": 31, + "firstDate": "2026-05-16", + "lastDate": "2026-05-24" + }, + { + "label": "windchime-animation", + "product": "windchime", + "commitCount": 6, + "firstDate": "2026-05-21", + "lastDate": "2026-05-23" + }, + { + "label": "windchime-eval", + "product": "windchime", + "commitCount": 3, + "firstDate": "2026-05-20", + "lastDate": "2026-05-20" + }, + { + "label": "windchime-soak", + "product": "windchime", + "commitCount": 2, + "firstDate": "2026-07-06", + "lastDate": "2026-07-06" + }, + { + "label": "lichtspiel", + "product": "lichtspiel", + "commitCount": 43, + "firstDate": "2026-06-11", + "lastDate": "2026-06-15" + } + ] +} diff --git a/src/data/snapshots/git/lichtspiel_github_trent.json b/src/data/snapshots/git/lichtspiel_github_trent.json new file mode 100644 index 0000000..6386605 --- /dev/null +++ b/src/data/snapshots/git/lichtspiel_github_trent.json @@ -0,0 +1,52 @@ +{ + "note": "Sanitized aggregate - day-level counts plus redacted, truncated subject lines. Raw pull stays gitignored.", + "label": "lichtspiel", + "product": "lichtspiel", + "dir": "lichtspiel_github_trent", + "commitCount": 43, + "firstDate": "2026-06-11", + "lastDate": "2026-06-15", + "authors": 1, + "months": { + "2026-06": 43 + }, + "days": { + "2026-06-11": { + "count": 14, + "subjects": [ + "feat(takeover): v2 - ♪ LISTEN mode + drive slider (musically-aware hand…", + "docs: roadmap revision round 2 + the polling truth + the Takeover-LISTE…", + "fix(ui): surface the takeover row - the restyle's third display:none ca…", + "chore: vendor the four saved mapping presets (force-added)" + ] + }, + "2026-06-12": { + "count": 20, + "subjects": [ + "feat(corpus): promote mitShadowLab", + "fix(monome): full hardware adaptation - twin expands for Grid 128/Arc 4…", + "feat: demo features - presentation mode (D1), upload-your-own (D2), lat…", + "feat(corpus): promote crystallineHaze, wfVoyager, wfCentrifuge, obsidia…" + ] + }, + "2026-06-13": { + "count": 4, + "subjects": [ + "docs: record the silver-face UI overhaul", + "feat(ui): re-skin generated banner, capture steps, mapping sections & i…", + "feat(ui): warm the monome twin LEDs grey→amber", + "feat(ui): silver-face reskin - chassis, faceplate tokens, brand & type" + ] + }, + "2026-06-15": { + "count": 5, + "subjects": [ + "docs(ui-design): note comprehensive tutorial hover coverage", + "feat(ui): comprehensive tutorial-mode hover help", + "docs: feeder self-heal + UI review-pass refinements", + "feat(ui): review pass - bolder labels, 3D bevels, collapsible Generate/…" + ] + } + }, + "tags": [] +} diff --git a/src/data/snapshots/git/windchime-animation.json b/src/data/snapshots/git/windchime-animation.json new file mode 100644 index 0000000..dca896d --- /dev/null +++ b/src/data/snapshots/git/windchime-animation.json @@ -0,0 +1,37 @@ +{ + "note": "Sanitized aggregate - day-level counts plus redacted, truncated subject lines. Raw pull stays gitignored.", + "label": "windchime-animation", + "product": "windchime", + "dir": "windchime-animation", + "commitCount": 6, + "firstDate": "2026-05-21", + "lastDate": "2026-05-23", + "authors": 1, + "months": { + "2026-05": 6 + }, + "days": { + "2026-05-21": { + "count": 2, + "subjects": [ + "Bind bridge sockets to loopback by default", + "Initial commit - v0.1.1: walking skeleton + variant generation" + ] + }, + "2026-05-22": { + "count": 3, + "subjects": [ + "v0.1.3 - alt-impls per idiom family", + "Document audio-never-ported rule + corpus audio audit", + "v0.1.2 - corpus expansion, variants, gestural dictionary" + ] + }, + "2026-05-23": { + "count": 1, + "subjects": [ + "v0.1.4 - two new idioms: pasHalloweenV3 + parquetDeformation" + ] + } + }, + "tags": [] +} diff --git a/src/data/snapshots/git/windchime-eval.json b/src/data/snapshots/git/windchime-eval.json new file mode 100644 index 0000000..fb9f57d --- /dev/null +++ b/src/data/snapshots/git/windchime-eval.json @@ -0,0 +1,24 @@ +{ + "note": "Sanitized aggregate - day-level counts plus redacted, truncated subject lines. Raw pull stays gitignored.", + "label": "windchime-eval", + "product": "windchime", + "dir": "windchime-eval", + "commitCount": 3, + "firstDate": "2026-05-20", + "lastDate": "2026-05-20", + "authors": 1, + "months": { + "2026-05": 3 + }, + "days": { + "2026-05-20": { + "count": 3, + "subjects": [ + "v0.1.1: test sessions, phase push, /dev view, vendored deps, full-stack…", + "Restyle UI to match windchime-livecode aesthetic; add references.md; qu…", + "v0.1 scaffold: study protocol, UEQ-S + custom instruments, auto-logging…" + ] + } + }, + "tags": [] +} diff --git a/src/data/snapshots/git/windchime-full.json b/src/data/snapshots/git/windchime-full.json new file mode 100644 index 0000000..4f58b81 --- /dev/null +++ b/src/data/snapshots/git/windchime-full.json @@ -0,0 +1,182 @@ +{ + "note": "Sanitized aggregate - day-level counts plus redacted, truncated subject lines. Raw pull stays gitignored.", + "label": "windchime (umbrella)", + "product": "windchime", + "dir": "windchime-full", + "commitCount": 85, + "firstDate": "2026-05-23", + "lastDate": "2026-07-17", + "authors": 1, + "months": { + "2026-05": 31, + "2026-06": 35, + "2026-07": 19 + }, + "days": { + "2026-05-23": { + "count": 18, + "subjects": [ + "fix: bump livecode pin for eval-integration _post_event URL fix", + "docs: TESTING_GUIDE.md - exercise every shipped capability", + "feat(p7.1): autoSwitch off/random/idiom + livecode logging + pin bumps", + "fix(p7): grid collapse - main was flowing into auto track when manual-b…" + ] + }, + "2026-05-24": { + "count": 8, + "subjects": [ + "fix: cross-origin audio bug - verify resume + add prominent unlock bann…", + "fix: bump livecode pin for audio-race fix (post-hush settle)", + "fix: audio stops between trials (ctx-suspend trap) + apple-music note", + "fix: no-audio in participant mode - eager-load Strudel + unlock bridge" + ] + }, + "2026-05-27": { + "count": 3, + "subjects": [ + "feat(p7.3.1): same-origin dev UI + 2-stage consent + participant polish", + "fix(p7.3): don't ask for audio twice + auto-recover stuck mic state", + "feat(p7.3): participant onboarding flow + UX polish + waiting state" + ] + }, + "2026-05-28": { + "count": 2, + "subjects": [ + "feat(participant): live audio-activity meter + reset volume on session …", + "feat(p7.3.2): master volume + transcript panel + 6 prompts + eval-sessi…" + ] + }, + "2026-06-04": { + "count": 1, + "subjects": [ + "chore: bump animation pin → a8d207d (monome prefix-robust + self-healin…" + ] + }, + "2026-06-17": { + "count": 2, + "subjects": [ + "chore: migrate submodule remotes Codeberg → GitHub (Grashopr-888)", + "feat(dev-ui): warm-amber instrument reskin + bump siblings (Phase 0/0c)" + ] + }, + "2026-06-22": { + "count": 6, + "subjects": [ + "chore: bump animation submodule -> Phase 5 Batch A (c2742d5)", + "chore: bump animation submodule -> Phase 5 pasArcgrid port (15f72ff)", + "chore: bump animation submodule -> Phase 4 param-model bridge (75ec58e)", + "docs: param-bridge + takeover reference (diagrams + proposal)" + ] + }, + "2026-06-23": { + "count": 12, + "subjects": [ + "feat(dev-ui): Demo mode operator controls + presentation; pin animation…", + "feat(dev-ui): Demo mode (D1) - ✦ Demo toggle; pin animation; plan doc", + "chore(phase7): A5 + C shipped - Phase 7 complete; pin animation+eval; d…", + "chore(phase7): B part-2 shipped - pin animation+livecode; update handof…" + ] + }, + "2026-06-24": { + "count": 6, + "subjects": [ + "dev-UI demo-audio menu; docs; bump anim/livecode pins", + "dev-UI: ⏏ End recap button; bump anim/livecode pins", + "dev-UI: Sound mode selector + tutorial in participant; bump pins", + "feat(demo): demo set picker + outro rail-collapse; sound-modes plan; pi…" + ] + }, + "2026-06-25": { + "count": 2, + "subjects": [ + "Bump retrieval submodule pin → ee2926d (classify_bundle.py util)", + "Demo polish: dev-ui theme swap + keep playback during kiosk recording; …" + ] + }, + "2026-06-28": { + "count": 1, + "subjects": [ + "Phase 11: pluggable retrieval backbones - dev-UI model toggle + submodu…" + ] + }, + "2026-06-29": { + "count": 5, + "subjects": [ + "chore: bump retrieval pin -> audit notebook", + "chore: bump retrieval pin -> aadec93 (Phase-A audit harness)", + "feat: CLaMP 3 retrieval backend (Stage 2) - submodule pin + launcher + …", + "bump livecode pin: prompts-latent mixed-dim fix" + ] + }, + "2026-07-03": { + "count": 4, + "subjects": [ + "feat(dev-ui): wild-mode toggle; docs: confirmed audio diagnosis + fixes…", + "chore: bump animation -> 67e5bb2, livecode -> 347d00a (resolving-led ar…", + "docs: audio feedback/death investigation handoff", + "feat(dev-ui): SEQ menu + thinking-sequence controls + audio-unlock hard…" + ] + }, + "2026-07-05": { + "count": 2, + "subjects": [ + "feat(dev-ui): INSTALL mode shell - button, session gating, visitor volu…", + "fix(dev): pin the launcher to Apple's python3 - stops recurring mic TCC…" + ] + }, + "2026-07-06": { + "count": 2, + "subjects": [ + "docs: audio-immediacy plan - silent-lead-in stems + the \"Immediate\" sou…", + "chore(soak): bump animation + livecode submodules" + ] + }, + "2026-07-07": { + "count": 2, + "subjects": [ + "docs: Stage-3 re-embed executed + index-epoch provenance + retrieval pin", + "audio immediacy: dev-ui (6 sound modes, backbone-in-install, SEQ defaul…" + ] + }, + "2026-07-11": { + "count": 2, + "subjects": [ + "retrieval pin: phase-a-v3 prompt set (ea91f9e)", + "eval participant flow: condition capture + backend-aware recap; dev-ui …" + ] + }, + "2026-07-12": { + "count": 4, + "subjects": [ + "pins: animation 2bdb071 - Stage B (truly-3D variant staging + saved var…", + "dev-ui: visual-runtime boot selector + Three.js stage pins", + "audio-corpus session: stems_712 (292→330) + the install audio map; pins", + "install features: mic VU meter, tour narration relay, llm selector, com…" + ] + }, + "2026-07-16": { + "count": 1, + "subjects": [] + }, + "2026-07-17": { + "count": 2, + "subjects": [ + "pin: bump animation + livecode submodules" + ] + } + }, + "tags": [ + { + "name": "v0.1", + "date": "2026-05-23" + }, + { + "name": "soundstate-feedback-v1", + "date": "2026-07-03" + }, + { + "name": "install-mode-v1", + "date": "2026-07-05" + } + ] +} diff --git a/src/data/snapshots/git/windchime-livecode.json b/src/data/snapshots/git/windchime-livecode.json new file mode 100644 index 0000000..28912bf --- /dev/null +++ b/src/data/snapshots/git/windchime-livecode.json @@ -0,0 +1,52 @@ +{ + "note": "Sanitized aggregate - day-level counts plus redacted, truncated subject lines. Raw pull stays gitignored.", + "label": "windchime-livecode", + "product": "windchime", + "dir": "windchime-livecode", + "commitCount": 31, + "firstDate": "2026-05-16", + "lastDate": "2026-05-24", + "authors": 1, + "months": { + "2026-05": 31 + }, + "days": { + "2026-05-16": { + "count": 19, + "subjects": [ + "docs: v0.1.3 changelog entry + Windchime Audio product roadmap", + "web: surface the active transcript in the textarea on every generation", + "web: overlay actually hides when dismissed", + "web: audio-unlock overlay so mic-driven generations actually play" + ] + }, + "2026-05-19": { + "count": 1, + "subjects": [ + "docs: DEMO_QUICKSTART.md - three terminals, one click, you're live" + ] + }, + "2026-05-20": { + "count": 1, + "subjects": [ + "v0.1.4: eval-app integration + remote /stop endpoint" + ] + }, + "2026-05-23": { + "count": 9, + "subjects": [ + "fix(eval-integration): _post_event was POSTing to / not /event", + "feat(p7.1): operator-set visual auto-switch mode + per-run logging", + "feat(p6): pattern transition modes - replace / overlap / stack", + "debug(web): timestamp the hush/eval/playing logs" + ] + }, + "2026-05-24": { + "count": 1, + "subjects": [ + "compiler: add `field` to KNOWN_STEM_TYPES" + ] + } + }, + "tags": [] +} diff --git a/src/data/snapshots/git/windchime-retrieval.json b/src/data/snapshots/git/windchime-retrieval.json new file mode 100644 index 0000000..6c8ba0b --- /dev/null +++ b/src/data/snapshots/git/windchime-retrieval.json @@ -0,0 +1,105 @@ +{ + "note": "Sanitized aggregate - day-level counts plus redacted, truncated subject lines. Raw pull stays gitignored.", + "label": "windchime-retrieval", + "product": "windchime", + "dir": "windchime-retrieval", + "commitCount": 23, + "firstDate": "2026-05-07", + "lastDate": "2026-07-17", + "authors": 1, + "months": { + "2026-05": 10, + "2026-06": 6, + "2026-07": 7 + }, + "days": { + "2026-05-07": { + "count": 2, + "subjects": [ + "Add personal stem library support and multi-library toggling", + "Initial working prototype - voice-driven stem retrieval" + ] + }, + "2026-05-09": { + "count": 2, + "subjects": [ + "Update README: document all three versions and master stem library", + "Add build_master_stems.py and update .gitignore for master stems" + ] + }, + "2026-05-16": { + "count": 4, + "subjects": [ + "README: link to the Windchime Audio product roadmap in livecode repo", + "QueryEngine: open sqlite with check_same_thread=False", + "Rename to windchime-retrieval; document per-branch naming convention", + "Make windchime pip-installable for sibling repos" + ] + }, + "2026-05-20": { + "count": 1, + "subjects": [ + "runtime: optional event-emission to a windchime-eval app" + ] + }, + "2026-05-24": { + "count": 1, + "subjects": [ + "Add corpus-ingest helper + `field` category" + ] + }, + "2026-06-24": { + "count": 1, + "subjects": [ + "Add classify_bundle.py - sort a stem drop into master_stems//" + ] + }, + "2026-06-28": { + "count": 1, + "subjects": [ + "feat: pluggable retrieval backbones (Stage 0/1)" + ] + }, + "2026-06-29": { + "count": 4, + "subjects": [ + "docs(audit): self-contained backbone-audit notebook (Colab-ready)", + "feat(retrieval): Phase-A offline audit harness (Stage 4)", + "feat(retrieval): CLaMP 3 multilingual backend (Stage 2)" + ] + }, + "2026-07-07": { + "count": 2, + "subjects": [ + "Stage 3: surgical content-aware re-embed + index-epoch provenance", + "docs: runtime backend-switching semantics (liveness probe + auto-revert)" + ] + }, + "2026-07-11": { + "count": 1, + "subjects": [ + "feat(audit): phase-a-v3 prompt set - stage-1 coverage expansion (80 bal…" + ] + }, + "2026-07-12": { + "count": 1, + "subjects": [ + "stems_712 corpus batch mapping (292→330, epochs 4+5)" + ] + }, + "2026-07-17": { + "count": 3, + "subjects": [ + "docs(corpus): full 6-backend re-embed runbook in ADDING_CORPUS", + "docs(corpus): document skip sentinel + record stems_712/stems_717 batch…", + "Ingest stems_717 batch (39 studio stems) + skip-sentinel support" + ] + } + }, + "tags": [ + { + "name": "v0.3-pre-split", + "date": "2026-05-16" + } + ] +} diff --git a/src/data/snapshots/git/windchime-soak.json b/src/data/snapshots/git/windchime-soak.json new file mode 100644 index 0000000..53613b4 --- /dev/null +++ b/src/data/snapshots/git/windchime-soak.json @@ -0,0 +1,23 @@ +{ + "note": "Sanitized aggregate - day-level counts plus redacted, truncated subject lines. Raw pull stays gitignored.", + "label": "windchime-soak", + "product": "windchime", + "dir": "windchime-soak", + "commitCount": 2, + "firstDate": "2026-07-06", + "lastDate": "2026-07-06", + "authors": 1, + "months": { + "2026-07": 2 + }, + "days": { + "2026-07-06": { + "count": 2, + "subjects": [ + "docs: source changes committed + pushed (arc fix + isolation seams land…", + "Install-mode reliability soak harness + 6h run results" + ] + } + }, + "tags": [] +} diff --git a/src/data/snapshots/github/index.json b/src/data/snapshots/github/index.json new file mode 100644 index 0000000..c022087 --- /dev/null +++ b/src/data/snapshots/github/index.json @@ -0,0 +1,13 @@ +{ + "note": "Per-repo sanitized GitHub metadata index.", + "repos": [ + { + "repo": "Grashopr-888/windchime", + "product": "windchime" + }, + { + "repo": "Grashopr-888/lichtspiel", + "product": "lichtspiel" + } + ] +} diff --git a/src/data/snapshots/github/lichtspiel_github_trent.json b/src/data/snapshots/github/lichtspiel_github_trent.json new file mode 100644 index 0000000..0d9387f --- /dev/null +++ b/src/data/snapshots/github/lichtspiel_github_trent.json @@ -0,0 +1,23 @@ +{ + "note": "Sanitized GitHub metadata — no titles or bodies, only counts, labels, and redacted release names.", + "label": "lichtspiel", + "product": "lichtspiel", + "repo": "Grashopr-888/lichtspiel", + "issues": { + "state": { + "open": 0, + "closed": 0, + "total": 0 + }, + "labels": {} + }, + "prs": { + "state": { + "open": 0, + "closed": 0, + "total": 0 + }, + "labels": {} + }, + "releases": [] +} diff --git a/src/data/snapshots/github/windchime-full.json b/src/data/snapshots/github/windchime-full.json new file mode 100644 index 0000000..9a26397 --- /dev/null +++ b/src/data/snapshots/github/windchime-full.json @@ -0,0 +1,23 @@ +{ + "note": "Sanitized GitHub metadata — no titles or bodies, only counts, labels, and redacted release names.", + "label": "windchime (umbrella)", + "product": "windchime", + "repo": "Grashopr-888/windchime", + "issues": { + "state": { + "open": 0, + "closed": 0, + "total": 0 + }, + "labels": {} + }, + "prs": { + "state": { + "open": 0, + "closed": 0, + "total": 0 + }, + "labels": {} + }, + "releases": [] +} diff --git a/src/lib/signals.ts b/src/lib/signals.ts new file mode 100644 index 0000000..7bb8c0a --- /dev/null +++ b/src/lib/signals.ts @@ -0,0 +1,99 @@ +/** + * Build-time reader for the sanitized git snapshots produced by `npm run ingest:git`. + * This is where the ingestion pipeline pays off in the UI: a real, redacted "shipping + * cadence" the site can render without ever touching private source. + */ +interface GitSnap { + label: string; + product: 'windchime' | 'lichtspiel'; + commitCount: number; + firstDate: string | null; + lastDate: string | null; + months: Record; + days?: Record; + tags: Array<{ name: string; date: string }>; +} + +const modules = import.meta.glob('../data/snapshots/git/*.json', { eager: true }) as Record< + string, + { default: unknown } +>; + +const snaps: GitSnap[] = Object.entries(modules) + .filter(([p]) => !p.endsWith('index.json')) + .map(([, m]) => m.default as GitSnap) + .filter((s) => s && typeof s.commitCount === 'number'); + +export interface MonthSignal { + month: string; + count: number; + windchime: number; + lichtspiel: number; +} + +export interface DayActivity { + count: number; + /** Sanitized subject lines (capped); `count` may exceed subjects.length. */ + subjects: string[]; +} + +export interface GitSignals { + available: boolean; + repoCount: number; + totalCommits: number; + firstDate: string | null; + lastDate: string | null; + months: MonthSignal[]; + byProduct: Record<'windchime' | 'lichtspiel', number>; + /** Per-product day-level activity, merged across that product's repos. */ + daysByProduct: Record<'windchime' | 'lichtspiel', Record>; + releaseTags: Array<{ name: string; date: string; product: string }>; +} + +const MAX_MERGED_SUBJECTS = 4; + +export function gitSignals(): GitSignals { + const monthMap: Record = {}; + const byProduct: Record<'windchime' | 'lichtspiel', number> = { windchime: 0, lichtspiel: 0 }; + const daysByProduct: GitSignals['daysByProduct'] = { windchime: {}, lichtspiel: {} }; + let totalCommits = 0; + let firstDate: string | null = null; + let lastDate: string | null = null; + const releaseTags: Array<{ name: string; date: string; product: string }> = []; + + for (const s of snaps) { + totalCommits += s.commitCount; + byProduct[s.product] += s.commitCount; + for (const [m, c] of Object.entries(s.months)) { + monthMap[m] ??= { windchime: 0, lichtspiel: 0 }; + monthMap[m][s.product] += c; + } + for (const [date, d] of Object.entries(s.days ?? {})) { + const cell = (daysByProduct[s.product][date] ??= { count: 0, subjects: [] }); + cell.count += d.count; + for (const subj of d.subjects) { + if (cell.subjects.length < MAX_MERGED_SUBJECTS) cell.subjects.push(subj); + } + } + if (s.firstDate && (!firstDate || s.firstDate < firstDate)) firstDate = s.firstDate; + if (s.lastDate && (!lastDate || s.lastDate > lastDate)) lastDate = s.lastDate; + for (const t of s.tags) releaseTags.push({ ...t, product: s.product }); + } + + const months = Object.entries(monthMap) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([month, v]) => ({ month, count: v.windchime + v.lichtspiel, ...v })); + releaseTags.sort((a, b) => b.date.localeCompare(a.date)); + + return { + available: snaps.length > 0, + repoCount: snaps.length, + totalCommits, + firstDate, + lastDate, + months, + byProduct, + daysByProduct, + releaseTags, + }; +} From fe0524d50abf2cff976b0d24b5b3516acd568ab2 Mon Sep 17 00:00:00 2001 From: Trent Eriksen <124685398+Grashopr-888@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:52:59 -0700 Subject: [PATCH 6/9] feat(pages): the twelve site pages, overview through the Splice fit case Co-Authored-By: Claude Fable 5 --- src/pages/about.astro | 191 ++++++++ src/pages/glossary.astro | 97 ++++ src/pages/how-i-work.astro | 707 +++++++++++++++++++++++++++++ src/pages/incidents.astro | 174 +++++++ src/pages/index.astro | 368 +++++++++++++++ src/pages/projects/[slug].astro | 771 ++++++++++++++++++++++++++++++++ src/pages/projects/index.astro | 46 ++ src/pages/releases.astro | 150 +++++++ src/pages/research.astro | 140 ++++++ src/pages/roadmap.astro | 216 +++++++++ src/pages/splice.astro | 529 ++++++++++++++++++++++ 11 files changed, 3389 insertions(+) create mode 100644 src/pages/about.astro create mode 100644 src/pages/glossary.astro create mode 100644 src/pages/how-i-work.astro create mode 100644 src/pages/incidents.astro create mode 100644 src/pages/index.astro create mode 100644 src/pages/projects/[slug].astro create mode 100644 src/pages/projects/index.astro create mode 100644 src/pages/releases.astro create mode 100644 src/pages/research.astro create mode 100644 src/pages/roadmap.astro create mode 100644 src/pages/splice.astro diff --git a/src/pages/about.astro b/src/pages/about.astro new file mode 100644 index 0000000..02d2e05 --- /dev/null +++ b/src/pages/about.astro @@ -0,0 +1,191 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import { SITE, SHOW_EMAIL } from '../config'; +import { url } from '../lib/url'; +--- + + +
+

About

+

About this work.

+ +
+

+ I'm {SITE.author}, a technical product manager and product engineer working in creative + technology, where interfaces, live performance, audio, and machine learning meet. I work the + way an early-stage product and engineering lead does. I stay close to the user, make the + roadmap calls, design and ship, then own what happens after release. +

+

+ This site is a running, public view of my current projects. It documents the product work in + depth: how a problem gets framed, how trade-offs get made, how something ships in small + validated steps, and how a bug becomes a postmortem and then a better system. + Windchime and + Lichtspiel are the current projects. +

+
+ +
+ On the source code +

+ This site documents process in depth (decisions, releases, incidents, and research method) + while the implementation and the audio corpus stay private. Where a detail is sensitive, it + is described at a higher level rather than shown. +

+
+ + + +
+ Contact +
+ GitHub + { + SHOW_EMAIL && ( + + Email + + ) + } +
+
+
+
+ + diff --git a/src/pages/glossary.astro b/src/pages/glossary.astro new file mode 100644 index 0000000..598f82c --- /dev/null +++ b/src/pages/glossary.astro @@ -0,0 +1,97 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import { getCollection } from 'astro:content'; + +const terms = (await getCollection('glossary')).sort((a, b) => + a.data.term.localeCompare(b.data.term) +); +--- + + +
+

§ Glossary

+

Field terms, in plain language.

+

+ A few terms that recur across the projects, defined for readers who don't live in audio ML or + live-performance tooling. +

+ +
+ { + terms.map((t) => ( +
+
+ {t.data.term} + {t.data.product && t.data.product !== 'shared' && ( + + {t.data.product} + + )} +
+
{t.data.definition}
+
+ )) + } +
+
+
+ + diff --git a/src/pages/how-i-work.astro b/src/pages/how-i-work.astro new file mode 100644 index 0000000..711614c --- /dev/null +++ b/src/pages/how-i-work.astro @@ -0,0 +1,707 @@ +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +import FlowDiagram from '../components/FlowDiagram.astro'; +import CommitGrid from '../components/CommitGrid.astro'; +import { url } from '../lib/url'; +import { fmtMonth } from '../lib/format'; +import { gitSignals } from '../lib/signals'; + +const sig = gitSignals(); +const span = + sig.firstDate && sig.lastDate + ? `${fmtMonth(new Date(sig.firstDate))} to ${fmtMonth(new Date(sig.lastDate))}` + : 'n/a'; + +type ArtifactLink = { label: string; href: string }; +const loop: Array<{ step: string; line: string; artifacts: ArtifactLink[] }> = [ + { + step: 'Discover', + line: 'Get close to the real problem. Talk to the user, scan the alternatives, and name what the thing is not.', + artifacts: [{ label: 'Research notes', href: '/research' }], + }, + { + step: 'Decide', + line: 'Weigh options in the open and write the trade-off down, so the reasoning survives the decision.', + artifacts: [ + { label: 'Windchime ADRs', href: '/projects/windchime#decisions' }, + { label: 'Lichtspiel ADRs', href: '/projects/lichtspiel#decisions' }, + ], + }, + { + step: 'Ship', + line: 'Small, validated releases against a checklist. Done means running in the room, not merged.', + artifacts: [{ label: 'Releases', href: '/releases' }], + }, + { + step: 'Learn', + line: 'Soak it, let it break, write the blameless postmortem, and fold the fix back into the system.', + artifacts: [{ label: 'Postmortems', href: '/incidents' }], + }, +]; + +/** The end-to-end pipeline, each stage anchored by a real artifact from the projects. */ +const pipeline = [ + { + stage: 'Signal', + what: 'A problem observed in the room or in the data, not invented at a desk.', + example: 'Poetic prompts retrieved loosely; visitors read the drift as play.', + }, + { + stage: 'Frame', + what: 'A one-page PRD-lite: problem, goal, non-goals, and a success metric stated up front.', + example: 'Install mode framed as "survives a full day with no operator."', + }, + { + stage: 'Decide', + what: 'Options weighed in the open and captured as a decision record.', + example: 'Retrieval over generation on the audio path, written as an ADR.', + }, + { + stage: 'Prototype', + what: 'The smallest real version that can prove the idea wrong.', + example: 'Voice-to-stem retrieval running end to end before any polish.', + }, + { + stage: 'Gate', + what: 'Nothing ships on trust: typed schemas, validation chains, redaction checks, soak runs.', + example: 'A 6-hour, 879-visitor synthetic soak before the gallery gets it.', + }, + { + stage: 'Ship & learn', + what: 'Releases framed by customer value; failures become blameless postmortems that feed the roadmap.', + example: 'The audio-runaway postmortem became a three-layer safety fix.', + }, +]; + +const templates = [ + { + name: 'PRD-lite', + purpose: 'One page before building anything non-trivial.', + body: `# : PRD-lite + +**Problem**: what's broken, for whom, and why now. +**Users / context**: who hits this, how often, in what situation. +**Goal**: the one outcome that means success. +**Non-goals**: what we are deliberately NOT doing. +**Approach**: the shape of the solution (a level up from implementation). +**Success metric**: how we'll know, stated before we build. +**Risks / unknowns**: what could make this wrong. +**Rollout**: how it ships, and how it rolls back.`, + }, + { + name: 'Decision record (ADR)', + purpose: 'Capture a judgment call so the reasoning outlives it.', + body: `# : ADR + +**Status**: proposed | accepted | superseded +**Context**: the forces in play; what makes this a real choice. +**Options considered**: each with its honest trade-offs. +**Decision**: what we chose. +**Rationale**: why this beat the alternatives. +**Consequences**: what this makes easy, and what it costs.`, + }, + { + name: 'Experiment note', + purpose: 'Keep discovery honest. Hypothesis first.', + body: `# : note + +**Hypothesis**: we believe X will cause Y, because Z. +**Method**: what we ran, on whom/what, how controlled. +**Measured**: the metric(s), defined up front. +**Result**: what actually happened (including "nothing"). +**Decision**: ship / iterate / drop, and why.`, + }, + { + name: 'Release checklist', + purpose: 'Pre-flight before anything ships.', + body: `# Release: