Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
**[blog.psheon.me](https://blog.psheon.me)** · [English](https://blog.psheon.me/en) · [中文](https://blog.psheon.me/zh) · [RSS](https://blog.psheon.me/en/feed.xml)

An interactive blog. Every article builds one thing from scratch — a CNN, a Transformer, a diffusion model,
neuroevolution, a quadruped's walking policy, a SLAM system, a city of autonomous agents, a task scheduler, a path tracer — and
neuroevolution, a quadruped's walking policy, a SLAM system, a city of autonomous agents, a task scheduler, a path tracer,
an AI composer — and
ships with instruments you can train, open up and break right in the page. Apart from MuJoCo's physics in article 006,
everything is written in TypeScript and runs in the reader's tab: no TensorFlow.js, no ONNX Runtime, no server. Bilingual (繁體中文 / English).

從零實作的互動部落格:每一篇都能在瀏覽器裡訓練、拆開、弄壞。CNN、Transformer、擴散模型、神經演化、
機器狗走路策略、SLAM、自己過日子的小鎮居民、任務排程器、路徑追蹤器,全部用 TypeScript 從零寫起,不靠任何機器學習函式庫,
機器狗走路策略、SLAM、自己過日子的小鎮居民、任務排程器、路徑追蹤器、AI 作曲家,全部用 TypeScript 從零寫起,不靠任何機器學習函式庫,
也不靠伺服器。

![paul.notebook](https://blog.psheon.me/en/opengraph-image)
Expand All @@ -20,6 +21,7 @@ everything is written in TypeScript and runs in the reader's tab: no TensorFlow.

| № | English | 中文 |
| --- | --- | --- |
| 014 | [Train your own AI composer in three minutes: from noodling to calm](https://blog.psheon.me/en/posts/music-ai) | [三分鐘訓練你的 AI 作曲家:聽它從亂彈到療癒](https://blog.psheon.me/zh/posts/music-ai) |
| 013 | [A mini Figure AI in three minutes: one camera, one arm](https://blog.psheon.me/en/posts/head-camera) | [三分鐘打造迷你 Figure AI:只給它一顆相機、一隻手臂](https://blog.psheon.me/zh/posts/head-camera) |
| 012 | [Putting a path tracer in a 3D playground: crash a car, gun a plane, fly a helicopter](https://blog.psheon.me/en/posts/light-playground) | [把光追放進 3D 遊樂園:體驗開車碰撞、飛機加速、直升機飛行](https://blog.psheon.me/zh/posts/light-playground) |
| 011 | [How a picture clears from snow: a WebGPU path tracer from scratch](https://blog.psheon.me/en/posts/light-from-noise) | [一張圖怎麼從雪花變清晰:從零寫一個 WebGPU 路徑追蹤器](https://blog.psheon.me/zh/posts/light-from-noise) |
Expand Down Expand Up @@ -118,6 +120,7 @@ Conventions that keep pages fast and honest:
| `styles/launch-ui.css` | Glass, fade and hairline utilities from Launch UI |
| `public/sw.js` | The service worker (network first for pages, cache first for immutable assets) |
| `scripts/train-mnist` | One-off PyTorch training for article 001; exports weights and golden values for the tests |
| `scripts/music` | Packs article 014's chorales and its trained composer into `public/posts/music-ai/*.bin` |
| `scripts/light` | Packs the light series' assets (the playground, the vehicles, the character and its clips) into `public/posts/light-playground/*.bin`: geometry, skeleton and names only, never a texture |
| `tests` | Vitest suites, grouped by library and by article |
| `e2e` | Playwright smoke tests and axe accessibility checks |
Expand All @@ -142,4 +145,7 @@ Layout primitives and CSS utilities adapted from [Launch UI](https://www.launchu
AAPL price data in the trading article is daily closing prices for 2024. The Lite3 robot model and
walking policy in article 006 come from DEEP Robotics; their licences are in `public/lite3/`. The light
series' playground is the geometry of [Sketchbook](https://github.com/swift502/Sketchbook)'s world by
Jan Blaha (MIT); its textures are not used and `world.glb` is never committed.
Jan Blaha (MIT); its textures are not used and `world.glb` is never committed. Article 014 trains on
[Craig Sapp's digital edition of Bach's 370 four-part chorales](https://github.com/craigsapp/bach-370-chorales)
(CC BY-NC-SA 4.0); the tokens and the trained model in `public/posts/music-ai/` are derived from it and carry the
same licence.
25 changes: 25 additions & 0 deletions components/site/post-cover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,30 @@ function HeadCamera() {
);
}

/** № 014: a piano roll that starts as scattered notes and settles into four voices, the way the composer learns. */
function MusicAi() {
const rand = rng(23);
// Four voices on their own rows. On the left each note is thrown anywhere; by the right they have found their rows.
const notes: { x: number; y: number; w: number; v: number }[] = [];
const rows = [26, 42, 58, 74];
for (let v = 0; v < 4; v++) {
for (let x = 12; x < 150; ) {
const settled = Math.min(1, Math.max(0, (x - 30) / 70));
const stray = (rand() - 0.5) * 46 * (1 - settled);
const w = r1(6 + rand() * (4 + 12 * settled));
notes.push({ x: r1(x), y: r1(rows[v] + stray + (settled > 0.6 ? (rand() - 0.5) * 7 : 0)), w, v });
x += w + 3;
}
}
const ink = [S, S3, S2, "var(--chart-5)"];
return (
<>
{[12, 47, 82, 117, 150].map((x) => <path key={x} d={`M${x} 12 V88`} stroke={DIM} strokeWidth={0.8} strokeDasharray="2 4" />)}
{notes.map((n, i) => <rect key={i} x={n.x} y={n.y} width={n.w} height={3} rx={1.5} fill={ink[n.v]} opacity={0.9} />)}
</>
);
}

const covers: Record<string, () => ReactNode> = {
"cnn-from-scratch": Cnn,
"ai-flappy-bird": Flappy,
Expand All @@ -332,6 +356,7 @@ const covers: Record<string, () => ReactNode> = {
"light-from-noise": Light,
"light-playground": Playground,
"head-camera": HeadCamera,
"music-ai": MusicAi,
};

export function PostCover({ slug, no, className }: { slug: string; no: number; className?: string }) {
Expand Down
129 changes: 129 additions & 0 deletions content/posts/music-ai/components/blind-lab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"use client";

import { Check, Heart, Pause, Play, Shuffle } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { TimbrePicker } from "./composer-lab";
import { useLabels } from "./labels";
import { PianoRoll, VoiceLegend } from "./piano-roll";
import { usePlayer, useStopOnLeave } from "./player";
import type { BlindItem } from "./protocol";
import { useSharedWorker } from "./shared-worker";
import type { Timbre } from "./synth";

type Who = BlindItem["who"];
const WHO: Who[] = ["bach", "ai", "rules"];
const LETTERS = ["A", "B", "C"];

/** Fig. 03: Bach, the AI and five lines of maths, unlabelled. Guess who wrote what, and say which one you liked. */
export function BlindLab() {
const t = useLabels();
const [state, setState] = useState<"idle" | "loading" | "listening" | "revealed" | "failed">("idle");
const [items, setItems] = useState<BlindItem[]>([]);
const [guesses, setGuesses] = useState<(Who | null)[]>([null, null, null]);
const [favourite, setFavourite] = useState<number | null>(null);
const [timbre, setTimbre] = useState<Timbre>("pad");
const player = usePlayer();
useStopOnLeave();
const send = useSharedWorker((data) => {
if (data.type === "loading") setState("loading");
else if (data.type === "failed") setState("failed");
else if (data.type === "blind") { setItems(data.items); setState("listening"); }
});

const deal = () => {
player.stop();
setGuesses([null, null, null]);
setFavourite(null);
setItems([]);
send({ type: "blind" });
};

if (state === "idle" || state === "loading" || state === "failed")
return (
<div className="grid place-items-center gap-3 py-10 text-center text-sm">
<Button size="sm" onClick={deal} disabled={state === "loading"} data-testid="music-blind-deal"><Shuffle aria-hidden />{t.blindStart}</Button>
<p className="max-w-md text-muted-foreground">{state === "failed" ? t.failed : state === "loading" ? t.judgeLoading : t.blindHow}</p>
</div>
);

const revealed = state === "revealed";
const right = items.filter((item, i) => guesses[i] === item.who).length;

return (
<div className="grid gap-4 text-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-muted-foreground">{t.blindHow}</p>
<TimbrePicker value={timbre} onChange={(v) => { setTimbre(v); player.restyle(v); }} />
</div>

<div className="grid gap-3 md:grid-cols-3">
{items.map((item, i) => {
const key = `blind-${i}-${item.who}`, playing = player.playing === key;
const correct = guesses[i] === item.who;
return (
<div key={key} className={cn("grid content-start gap-2 rounded-sm border p-3", revealed ? (correct ? "border-signal/60" : "border-signal-2/60") : "border-border", "bg-background/40")}>
<div className="flex items-center justify-between gap-2">
<p className="label text-foreground">{LETTERS[i]}</p>
<Button size="sm" variant="outline" onClick={() => (playing ? player.stop() : void player.play(key, item.piece, timbre))} data-testid={`music-blind-play-${i}`}>
{playing ? <Pause aria-hidden /> : <Play aria-hidden />}{playing ? t.stopPlay : t.play}
</Button>
</div>

{/* No roll before the answers: a chorale and a block-chord machine look nothing alike, which would give it away. */}
{revealed && <PianoRoll piece={item.piece} label={t.rollPlain} playKey={key} className="h-20 w-full" />}

<fieldset className="grid gap-1.5" disabled={revealed}>
<legend className="label mb-1">{t.blindGuess}</legend>
<div className="flex flex-wrap gap-1.5">
{WHO.map((who) => (
<button key={who} type="button" aria-pressed={guesses[i] === who} onClick={() => setGuesses((g) => g.map((x, k) => (k === i ? who : x)))}
className={cn("tap rounded-sm border px-2 py-1 text-xs transition-colors disabled:opacity-60",
guesses[i] === who ? "border-signal bg-signal/10 text-signal" : "border-input text-muted-foreground hover:text-foreground")}>
{t.blindWho[who]}
</button>
))}
</div>
</fieldset>

<button type="button" aria-pressed={favourite === i} onClick={() => setFavourite(i)}
className={cn("tap inline-flex items-center gap-1.5 self-start rounded-sm border px-2 py-1 text-xs transition-colors",
favourite === i ? "border-signal-2 bg-signal-2/10 text-signal-2" : "border-input text-muted-foreground hover:text-foreground")}>
<Heart className={cn("size-3", favourite === i && "fill-current")} aria-hidden />{t.blindFavourite}
</button>

{revealed && (
<div className="grid gap-1 border-t border-border pt-2 font-mono text-xs">
<p className={correct ? "text-signal" : "text-signal-2"}>
{t.blindAnswer}: {t.blindWho[item.who]} · {correct ? t.blindCorrect : t.blindWrong}
</p>
<p className="text-muted-foreground">{t.parallels} {item.report.parallels.toFixed(1)} · {t.blindChords} {item.report.chords.toFixed(0)}</p>
</div>
)}
</div>
);
})}
</div>

{revealed && <VoiceLegend names={t.voices} />}

<div className="flex flex-wrap items-center gap-3" role="status">
{revealed ? (
<>
<span className="font-mono tabular" data-testid="music-blind-score">{t.blindScore(right)}</span>
{favourite !== null && items[favourite] && <span className="text-muted-foreground">{t.blindYours} {t.blindWho[items[favourite].who]}</span>}
<Button size="sm" variant="outline" onClick={deal}><Shuffle aria-hidden />{t.blindAgain}</Button>
</>
) : (
<>
<Button size="sm" onClick={() => { player.stop(); setState("revealed"); }} disabled={guesses.some((g) => g === null)} data-testid="music-blind-reveal">
<Check aria-hidden />{t.blindReveal}
</Button>
<span className="text-muted-foreground">{guesses.some((g) => g === null) ? t.blindNeedAll : t.blindWaiting}</span>
</>
)}
</div>
</div>
);
}
Loading
Loading