Muse — Phase 1 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the Phase 1 “magic loop” of Muse: a Discord bot that turns a feeling (text) into an interesting chord progression — each chord spelled out, a one-line “why it feels that way”, and a rendered piano audio clip — and saves every fragment and progression to a local library.
Architecture: A single always-on Node.js/TypeScript process. A Discord message flows through a pure pipeline: Brain (Claude emits Roman numerals + concrete chord symbols + explanation) → Theory (tonal validates and spells each chord) → Card (text formatting) → MIDI (midi-writer-js) → Render (FluidSynth → WAV) → Library (SQLite). The Discord layer (discord.js) is a thin shell around a testable handleIntent() function; everything below the bot is unit-tested with injected fakes.
Tech Stack: Node.js 20+, TypeScript, Vitest, @anthropic-ai/sdk, zod, tonal, midi-writer-js, better-sqlite3, discord.js, FluidSynth (native binary) + a free piano soundfont.
Global Constraints
- Runtime: Node.js 20+ (uses built-in
node:test-free Vitest; ESM modules,"type": "module"). - Language: TypeScript, ESM. All imports use explicit
.jsextensions in source where required byNodeNextresolution is avoided by running tests/build throughtsx/Vitest — author plain.tsimports without extensions. - Claude model ID: exactly
claude-opus-4-8— never append a date suffix. - Claude SDK: use the official
@anthropic-ai/sdkonly. Never use rawfetchfor the API. - Project lives in a new top-level directory:
muse/at the repo root. - Chord spelling format in replies:
Symbol (N O T E S), e.g.Fmaj7 (F A C E). Chords joined by→. - Soundfont must be a redistribution-safe free piano: FluidR3_GM (MIT) or Salamander (public domain). Path supplied via env var; never commit the
.sf2file. - Secrets (Discord token, Anthropic key, soundfont path) come from environment variables only — never hardcoded, never committed.
.envis gitignored. - Every reply card includes, in order: the progression with each chord spelled, a one-line “why it feels that way”, and an attached audio clip.
Task 1: Project scaffold
Files:
- Create: muse/package.json
- Create: muse/tsconfig.json
- Create: muse/vitest.config.ts
- Create: muse/.gitignore
- Create: muse/.env.example
- Create: muse/src/types.ts
- Test: muse/src/smoke.test.ts
Interfaces:
- Consumes: nothing.
- Produces: SpelledChord and BrainChord types used by every later task:
- BrainChord = { roman: string; symbol: string }
- SpelledChord = { roman: string; symbol: string; notes: string[] }
- [ ] Step 1: Create
muse/package.json
{
"name": "muse",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.70.0",
"better-sqlite3": "^12.0.0",
"discord.js": "^14.16.0",
"midi-writer-js": "^3.1.1",
"tonal": "^6.4.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.11",
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}
- [ ] Step 2: Create
muse/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"noEmit": true,
"outDir": "dist"
},
"include": ["src"]
}
- [ ] Step 3: Create
muse/vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});
- [ ] Step 4: Create
muse/.gitignore
node_modules/
dist/
.env
*.sf2
*.wav
*.mid
muse.db
muse.db-*
scratch/
- [ ] Step 5: Create
muse/.env.example
# Discord bot token (from the Discord developer portal — Muse's own bot application)
DISCORD_TOKEN=
# The channel ID Muse listens to (right-click #muse channel → Copy Channel ID)
DISCORD_CHANNEL_ID=
# Anthropic API key
ANTHROPIC_API_KEY=
# Absolute path to a piano soundfont (.sf2), e.g. FluidR3_GM.sf2 or a Salamander SF2
MUSE_SOUNDFONT_PATH=
# Where to store the SQLite library and generated audio (defaults shown)
MUSE_DB_PATH=./muse.db
MUSE_AUDIO_DIR=./scratch
- [ ] Step 6: Create
muse/src/types.ts
/** A chord as the Brain emits it: a Roman numeral for display + a concrete symbol tonal can spell. */
export type BrainChord = {
roman: string;
symbol: string;
};
/** A chord after Theory has spelled it into note names. */
export type SpelledChord = {
roman: string;
symbol: string;
notes: string[];
};
- [ ] Step 7: Write the smoke test
muse/src/smoke.test.ts
import { describe, it, expect } from "vitest";
import type { SpelledChord } from "./types";
describe("scaffold", () => {
it("loads types and runs tests", () => {
const chord: SpelledChord = { roman: "I", symbol: "Cmaj7", notes: ["C", "E", "G", "B"] };
expect(chord.notes).toHaveLength(4);
});
});
- [ ] Step 8: Install deps and run the smoke test
Run: cd muse && npm install && npm test
Expected: Vitest runs, 1 test passes. (better-sqlite3 compiles a native module; if it fails, ensure build tools are present — on macOS, Xcode CLT.)
- [ ] Step 9: Commit
git add muse/package.json muse/package-lock.json muse/tsconfig.json muse/vitest.config.ts muse/.gitignore muse/.env.example muse/src/types.ts muse/src/smoke.test.ts
git commit -m "feat(muse): project scaffold for Phase 1"
Task 2: Theory module (tonal — spell chords)
Files:
- Create: muse/src/theory.ts
- Test: muse/src/theory.test.ts
Interfaces:
- Consumes: BrainChord, SpelledChord from ./types.
- Produces:
- spellChords(chords: BrainChord[]): SpelledChord[] — spells each symbol into note names via tonal; throws Error if any symbol is unknown/unspellable (empty notes).
- [ ] Step 1: Write the failing test
muse/src/theory.test.ts
import { describe, it, expect } from "vitest";
import { spellChords } from "./theory";
describe("spellChords", () => {
it("spells a major 7 chord as its four notes", () => {
const [chord] = spellChords([{ roman: "I", symbol: "Fmaj7" }]);
expect(chord.notes).toEqual(["F", "A", "C", "E"]);
expect(chord.roman).toBe("I");
expect(chord.symbol).toBe("Fmaj7");
});
it("spells a borrowed flat-VI major 7", () => {
const [chord] = spellChords([{ roman: "bVImaj7", symbol: "Abmaj7" }]);
expect(chord.notes).toEqual(["Ab", "C", "Eb", "G"]);
});
it("preserves order across a progression", () => {
const result = spellChords([
{ roman: "I", symbol: "Cmaj7" },
{ roman: "vi", symbol: "Am7" },
]);
expect(result.map((c) => c.symbol)).toEqual(["Cmaj7", "Am7"]);
});
it("throws on an unspellable symbol", () => {
expect(() => spellChords([{ roman: "?", symbol: "Xyz9" }])).toThrow(/Xyz9/);
});
});
- [ ] Step 2: Run the test to verify it fails
Run: cd muse && npx vitest run src/theory.test.ts
Expected: FAIL — cannot find module ./theory.
- [ ] Step 3: Write
muse/src/theory.ts
import { Chord } from "tonal";
import type { BrainChord, SpelledChord } from "./types";
/**
* Spell each chord's concrete symbol into note names using tonal.
* The Roman numeral is carried through untouched (display only).
* Throws if any symbol cannot be spelled, so the Brain can be asked to retry.
*/
export function spellChords(chords: BrainChord[]): SpelledChord[] {
return chords.map(({ roman, symbol }) => {
const notes = Chord.get(symbol).notes;
if (!notes || notes.length === 0) {
throw new Error(`Cannot spell chord symbol: ${symbol}`);
}
return { roman, symbol, notes };
});
}
- [ ] Step 4: Run the test to verify it passes
Run: cd muse && npx vitest run src/theory.test.ts
Expected: PASS (4 tests).
- [ ] Step 5: Commit
git add muse/src/theory.ts muse/src/theory.test.ts
git commit -m "feat(muse): theory module spells chord symbols via tonal"
Task 3: Card formatting
Files:
- Create: muse/src/card.ts
- Test: muse/src/card.test.ts
Interfaces:
- Consumes: SpelledChord from ./types.
- Produces:
- formatChord(chord: SpelledChord): string → "Fmaj7 (F A C E)"
- formatCard(input: { chords: SpelledChord[]; explanation: string }): string → multi-line card text (no audio; the bot attaches audio separately).
- [ ] Step 1: Write the failing test
muse/src/card.test.ts
import { describe, it, expect } from "vitest";
import { formatChord, formatCard } from "./card";
describe("formatChord", () => {
it("renders symbol and spaced notes", () => {
expect(formatChord({ roman: "I", symbol: "Fmaj7", notes: ["F", "A", "C", "E"] })).toBe(
"Fmaj7 (F A C E)",
);
});
});
describe("formatCard", () => {
it("joins chords with arrows and appends the explanation", () => {
const text = formatCard({
chords: [
{ roman: "I", symbol: "Fmaj7", notes: ["F", "A", "C", "E"] },
{ roman: "bVI", symbol: "Abmaj7", notes: ["Ab", "C", "Eb", "G"] },
],
explanation: "The Abmaj7 is borrowed from F minor — dreamy but bittersweet.",
});
expect(text).toContain("Fmaj7 (F A C E) → Abmaj7 (Ab C Eb G)");
expect(text).toContain("borrowed from F minor");
});
});
- [ ] Step 2: Run the test to verify it fails
Run: cd muse && npx vitest run src/card.test.ts
Expected: FAIL — cannot find module ./card.
- [ ] Step 3: Write
muse/src/card.ts
import type { SpelledChord } from "./types";
export function formatChord(chord: SpelledChord): string {
return `${chord.symbol} (${chord.notes.join(" ")})`;
}
export function formatCard(input: { chords: SpelledChord[]; explanation: string }): string {
const progression = input.chords.map(formatChord).join(" → ");
return `${progression}\n_${input.explanation}_`;
}
- [ ] Step 4: Run the test to verify it passes
Run: cd muse && npx vitest run src/card.test.ts
Expected: PASS (2 tests).
- [ ] Step 5: Commit
git add muse/src/card.ts muse/src/card.test.ts
git commit -m "feat(muse): card formatting with spelled chords + explanation"
Task 4: Brain — prompt + schema + Claude call
Files:
- Create: muse/src/brain.ts
- Test: muse/src/brain.test.ts
Interfaces:
- Consumes: @anthropic-ai/sdk, zod, BrainChord from ./types.
- Produces:
- BrainResult = { key: string; mode: string; chords: BrainChord[]; explanation: string }
- BRAIN_SCHEMA — a Zod schema validating a BrainResult.
- buildPrompt(intent: string): string
- generateProgression(intent: string, client: Anthropic): Promise<BrainResult> — calls client.messages.parse with the schema and returns the validated object.
- [ ] Step 1: Write the failing test
muse/src/brain.test.ts
import { describe, it, expect } from "vitest";
import { buildPrompt, BRAIN_SCHEMA } from "./brain";
describe("buildPrompt", () => {
it("embeds the user's feeling and asks for Roman numerals + concrete symbols", () => {
const prompt = buildPrompt("dark but hopeful, floating underwater");
expect(prompt).toContain("dark but hopeful, floating underwater");
expect(prompt.toLowerCase()).toContain("roman");
expect(prompt.toLowerCase()).toContain("symbol");
});
});
describe("BRAIN_SCHEMA", () => {
it("accepts a well-formed result", () => {
const ok = BRAIN_SCHEMA.parse({
key: "F",
mode: "Ionian",
chords: [{ roman: "I", symbol: "Fmaj7" }],
explanation: "Stays warm.",
});
expect(ok.chords[0].symbol).toBe("Fmaj7");
});
it("rejects a result with no chords", () => {
expect(() =>
BRAIN_SCHEMA.parse({ key: "F", mode: "Ionian", chords: [], explanation: "x" }),
).toThrow();
});
});
- [ ] Step 2: Run the test to verify it fails
Run: cd muse && npx vitest run src/brain.test.ts
Expected: FAIL — cannot find module ./brain.
- [ ] Step 3: Write
muse/src/brain.ts
import type Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import type { BrainChord } from "./types";
export const BRAIN_SCHEMA = z.object({
key: z.string(),
mode: z.string(),
chords: z
.array(z.object({ roman: z.string(), symbol: z.string() }))
.min(2)
.max(8),
explanation: z.string(),
});
export type BrainResult = {
key: string;
mode: string;
chords: BrainChord[];
explanation: string;
};
export function buildPrompt(intent: string): string {
return [
"You are a harmony engine for a songwriter who has strong intuition but is still",
"building chord-theory fluency. Given a feeling, return a genuinely interesting,",
"non-cliché chord progression that fits it. Favour modal interchange, borrowed chords,",
"and rich extensions (maj7, add9, slash chords) over I-V-vi-IV clichés.",
"",
"For each chord return BOTH:",
' - "roman": the Roman numeral in the chosen key (e.g. "I", "bVImaj7", "v7"),',
' - "symbol": the concrete chord symbol (e.g. "Fmaj7", "Abmaj7", "Eb/G").',
"Use standard chord symbols that a music library can parse. Return 2–8 chords.",
"Also return the key, the mode/scale it draws from, and a single-sentence",
'"explanation" of why it evokes the feeling, naming the borrowed/colour chords.',
"",
`The feeling: ${intent}`,
].join("\n");
}
export async function generateProgression(
intent: string,
client: Anthropic,
): Promise<BrainResult> {
const response = await client.messages.parse({
model: "claude-opus-4-8",
max_tokens: 1024,
messages: [{ role: "user", content: buildPrompt(intent) }],
output_config: { format: zodOutputFormat(BRAIN_SCHEMA) },
});
const parsed = response.parsed_output;
if (!parsed) {
throw new Error(`Brain returned no parseable progression (stop_reason: ${response.stop_reason})`);
}
return parsed;
}
- [ ] Step 4: Run the test to verify it passes
Run: cd muse && npx vitest run src/brain.test.ts
Expected: PASS (3 tests). (Only buildPrompt and BRAIN_SCHEMA are unit-tested; generateProgression makes a live API call and is exercised by the manual smoke test in Task 9.)
- [ ] Step 5: Commit
git add muse/src/brain.ts muse/src/brain.test.ts
git commit -m "feat(muse): Brain emits Roman numerals + symbols via Claude structured output"
Task 5: MIDI generation
Files:
- Create: muse/src/midi.ts
- Test: muse/src/midi.test.ts
Interfaces:
- Consumes: midi-writer-js, SpelledChord from ./types.
- Produces:
- progressionToMidi(chords: SpelledChord[], opts?: { tempo?: number }): Uint8Array — one block chord per bar; default tempo 72.
- [ ] Step 1: Write the failing test
muse/src/midi.test.ts
import { describe, it, expect } from "vitest";
import { progressionToMidi } from "./midi";
describe("progressionToMidi", () => {
it("produces a non-empty standard MIDI file with an MThd header", () => {
const bytes = progressionToMidi([
{ roman: "I", symbol: "Fmaj7", notes: ["F", "A", "C", "E"] },
{ roman: "bVI", symbol: "Abmaj7", notes: ["Ab", "C", "Eb", "G"] },
]);
expect(bytes.length).toBeGreaterThan(0);
// "MThd" = 0x4d 0x54 0x68 0x64
expect([bytes[0], bytes[1], bytes[2], bytes[3]]).toEqual([0x4d, 0x54, 0x68, 0x64]);
});
});
- [ ] Step 2: Run the test to verify it fails
Run: cd muse && npx vitest run src/midi.test.ts
Expected: FAIL — cannot find module ./midi.
- [ ] Step 3: Write
muse/src/midi.ts
import MidiWriter from "midi-writer-js";
import type { SpelledChord } from "./types";
const DEFAULT_OCTAVE = 4;
/** Render a progression as block chords, one chord per whole-note bar. */
export function progressionToMidi(
chords: SpelledChord[],
opts: { tempo?: number } = {},
): Uint8Array {
const track = new MidiWriter.Track();
track.setTempo(opts.tempo ?? 72);
for (const chord of chords) {
const pitches = chord.notes.map((n) => `${n}${DEFAULT_OCTAVE}`);
track.addEvent(
new MidiWriter.NoteEvent({ pitch: pitches, duration: "1" }), // "1" = whole note
);
}
const writer = new MidiWriter.Writer(track);
return writer.buildFile();
}
- [ ] Step 4: Run the test to verify it passes
Run: cd muse && npx vitest run src/midi.test.ts
Expected: PASS (1 test).
- [ ] Step 5: Commit
git add muse/src/midi.ts muse/src/midi.test.ts
git commit -m "feat(muse): render a progression to a MIDI file"
Task 6: Audio render (FluidSynth)
Files:
- Create: muse/src/render.ts
- Test: muse/src/render.test.ts
Interfaces:
- Consumes: node:child_process, node:fs, node:os, node:path.
- Produces:
- buildFluidsynthArgs(soundfontPath: string, midiPath: string, outWavPath: string): string[]
- renderMidiToAudio(midiBytes: Uint8Array, opts: { soundfontPath: string; outDir: string; basename: string }): Promise<string> — writes a temp .mid, spawns fluidsynth, resolves to the output .wav path. Rejects if FluidSynth exits non-zero or is not installed.
- [ ] Step 1: Write the failing test
muse/src/render.test.ts
import { describe, it, expect } from "vitest";
import { buildFluidsynthArgs } from "./render";
describe("buildFluidsynthArgs", () => {
it("builds a headless non-interactive render command", () => {
const args = buildFluidsynthArgs("/sf/piano.sf2", "/tmp/in.mid", "/out/clip.wav");
// -n no MIDI-in, -i no shell, -F render-to-file
expect(args).toContain("-n");
expect(args).toContain("-i");
expect(args).toContain("-F");
expect(args).toContain("/out/clip.wav");
expect(args).toContain("/sf/piano.sf2");
expect(args).toContain("/tmp/in.mid");
// soundfont must precede the midi file (fluidsynth positional order)
expect(args.indexOf("/sf/piano.sf2")).toBeLessThan(args.indexOf("/tmp/in.mid"));
});
});
- [ ] Step 2: Run the test to verify it fails
Run: cd muse && npx vitest run src/render.test.ts
Expected: FAIL — cannot find module ./render.
- [ ] Step 3: Write
muse/src/render.ts
import { spawn } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
export function buildFluidsynthArgs(
soundfontPath: string,
midiPath: string,
outWavPath: string,
): string[] {
// fluidsynth -ni -F <out.wav> <soundfont.sf2> <in.mid>
return ["-n", "-i", "-F", outWavPath, soundfontPath, midiPath];
}
export async function renderMidiToAudio(
midiBytes: Uint8Array,
opts: { soundfontPath: string; outDir: string; basename: string },
): Promise<string> {
const midiPath = join(opts.outDir, `${opts.basename}.mid`);
const wavPath = join(opts.outDir, `${opts.basename}.wav`);
await writeFile(midiPath, midiBytes);
const args = buildFluidsynthArgs(opts.soundfontPath, midiPath, wavPath);
await new Promise<void>((resolve, reject) => {
const proc = spawn("fluidsynth", args, { stdio: "ignore" });
proc.on("error", (err) =>
reject(new Error(`Failed to launch fluidsynth (is it installed?): ${err.message}`)),
);
proc.on("close", (code) =>
code === 0 ? resolve() : reject(new Error(`fluidsynth exited with code ${code}`)),
);
});
return wavPath;
}
- [ ] Step 4: Run the test to verify it passes
Run: cd muse && npx vitest run src/render.test.ts
Expected: PASS (1 test). (renderMidiToAudio needs the FluidSynth binary + a soundfont; it is exercised by the manual smoke test in Task 9, not unit-tested.)
- [ ] Step 5: Commit
git add muse/src/render.ts muse/src/render.test.ts
git commit -m "feat(muse): render MIDI to a WAV clip via FluidSynth"
Task 7: Library (SQLite persistence)
Files:
- Create: muse/src/library.ts
- Test: muse/src/library.test.ts
Interfaces:
- Consumes: better-sqlite3.
- Produces:
- openLibrary(path: string): Database — opens/creates the DB and ensures the schema.
- saveFragment(db, input: { source: string; kind: "text" | "voice"; content: string }): number — returns row id.
- saveProgression(db, input: { intent: string; key: string; mode: string; symbols: string[]; explanation: string; audioPath: string | null }): number
- listFragments(db): Array<{ id: number; source: string; kind: string; content: string; created_at: string }>
- Database type re-exported for callers.
- [ ] Step 1: Write the failing test
muse/src/library.test.ts
import { describe, it, expect } from "vitest";
import { openLibrary, saveFragment, saveProgression, listFragments } from "./library";
describe("library", () => {
it("saves and lists a text fragment", () => {
const db = openLibrary(":memory:");
const id = saveFragment(db, { source: "discord:123", kind: "text", content: "floaty" });
expect(id).toBeGreaterThan(0);
const rows = listFragments(db);
expect(rows).toHaveLength(1);
expect(rows[0].content).toBe("floaty");
});
it("saves a progression with its spelled symbols", () => {
const db = openLibrary(":memory:");
const id = saveProgression(db, {
intent: "dark but hopeful",
key: "F",
mode: "Ionian",
symbols: ["Fmaj7", "Abmaj7"],
explanation: "borrowed bVI",
audioPath: "/scratch/clip.wav",
});
expect(id).toBeGreaterThan(0);
});
});
- [ ] Step 2: Run the test to verify it fails
Run: cd muse && npx vitest run src/library.test.ts
Expected: FAIL — cannot find module ./library.
- [ ] Step 3: Write
muse/src/library.ts
import DatabaseConstructor from "better-sqlite3";
import type { Database } from "better-sqlite3";
export type { Database };
export function openLibrary(path: string): Database {
const db = new DatabaseConstructor(path);
db.pragma("journal_mode = WAL");
db.exec(`
CREATE TABLE IF NOT EXISTS fragments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
kind TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS progressions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
intent TEXT NOT NULL,
key TEXT NOT NULL,
mode TEXT NOT NULL,
symbols TEXT NOT NULL,
explanation TEXT NOT NULL,
audio_path TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
return db;
}
export function saveFragment(
db: Database,
input: { source: string; kind: "text" | "voice"; content: string },
): number {
const stmt = db.prepare(
"INSERT INTO fragments (source, kind, content) VALUES (?, ?, ?)",
);
return Number(stmt.run(input.source, input.kind, input.content).lastInsertRowid);
}
export function saveProgression(
db: Database,
input: {
intent: string;
key: string;
mode: string;
symbols: string[];
explanation: string;
audioPath: string | null;
},
): number {
const stmt = db.prepare(
`INSERT INTO progressions (intent, key, mode, symbols, explanation, audio_path)
VALUES (?, ?, ?, ?, ?, ?)`,
);
return Number(
stmt.run(
input.intent,
input.key,
input.mode,
JSON.stringify(input.symbols),
input.explanation,
input.audioPath,
).lastInsertRowid,
);
}
export function listFragments(db: Database) {
return db
.prepare("SELECT id, source, kind, content, created_at FROM fragments ORDER BY id")
.all() as Array<{
id: number;
source: string;
kind: string;
content: string;
created_at: string;
}>;
}
- [ ] Step 4: Run the test to verify it passes
Run: cd muse && npx vitest run src/library.test.ts
Expected: PASS (2 tests).
- [ ] Step 5: Commit
git add muse/src/library.ts muse/src/library.test.ts
git commit -m "feat(muse): SQLite library for fragments and progressions"
Task 8: Pipeline — handleIntent (wires Brain → Theory → Card → MIDI → Render → Library)
Files:
- Create: muse/src/pipeline.ts
- Test: muse/src/pipeline.test.ts
Interfaces:
- Consumes: generateProgression (Task 4), spellChords (Task 2), formatCard (Task 3), progressionToMidi (Task 5), renderMidiToAudio (Task 6), saveFragment/saveProgression (Task 7).
- Produces:
- MuseReply = { text: string; audioPath: string | null }
- PipelineDeps — an injectable bag of the six collaborators plus db, soundfontPath, audioDir, so the pipeline is testable with fakes.
- handleIntent(intent: string, source: string, deps: PipelineDeps): Promise<MuseReply> — runs the full loop, saves the fragment and progression, returns the card text + audio path. Audio failures degrade gracefully (text card still returned, audioPath: null).
- [ ] Step 1: Write the failing test
muse/src/pipeline.test.ts
import { describe, it, expect, vi } from "vitest";
import { handleIntent, type PipelineDeps } from "./pipeline";
import { openLibrary, listFragments } from "./library";
function makeDeps(overrides: Partial<PipelineDeps> = {}): PipelineDeps {
return {
db: openLibrary(":memory:"),
soundfontPath: "/sf/piano.sf2",
audioDir: "/scratch",
generate: vi.fn().mockResolvedValue({
key: "F",
mode: "Ionian",
chords: [
{ roman: "I", symbol: "Fmaj7" },
{ roman: "bVI", symbol: "Abmaj7" },
],
explanation: "Abmaj7 is borrowed from F minor.",
}),
render: vi.fn().mockResolvedValue("/scratch/clip.wav"),
...overrides,
};
}
describe("handleIntent", () => {
it("returns a card with spelled chords + explanation and an audio path", async () => {
const deps = makeDeps();
const reply = await handleIntent("dark but hopeful", "discord:1", deps);
expect(reply.text).toContain("Fmaj7 (F A C E)");
expect(reply.text).toContain("Abmaj7 (Ab C Eb G)");
expect(reply.text).toContain("borrowed from F minor");
expect(reply.audioPath).toBe("/scratch/clip.wav");
});
it("saves the intent as a fragment", async () => {
const deps = makeDeps();
await handleIntent("dark but hopeful", "discord:1", deps);
const frags = listFragments(deps.db);
expect(frags).toHaveLength(1);
expect(frags[0].content).toBe("dark but hopeful");
});
it("degrades to text-only when rendering fails", async () => {
const deps = makeDeps({ render: vi.fn().mockRejectedValue(new Error("no fluidsynth")) });
const reply = await handleIntent("dark but hopeful", "discord:1", deps);
expect(reply.text).toContain("Fmaj7 (F A C E)");
expect(reply.audioPath).toBeNull();
});
});
- [ ] Step 2: Run the test to verify it fails
Run: cd muse && npx vitest run src/pipeline.test.ts
Expected: FAIL — cannot find module ./pipeline.
- [ ] Step 3: Write
muse/src/pipeline.ts
import { spellChords } from "./theory";
import { formatCard } from "./card";
import { progressionToMidi } from "./midi";
import { saveFragment, saveProgression, type Database } from "./library";
import type { BrainResult } from "./brain";
export type MuseReply = { text: string; audioPath: string | null };
export type PipelineDeps = {
db: Database;
soundfontPath: string;
audioDir: string;
generate: (intent: string) => Promise<BrainResult>;
render: (
midiBytes: Uint8Array,
opts: { soundfontPath: string; outDir: string; basename: string },
) => Promise<string>;
};
export async function handleIntent(
intent: string,
source: string,
deps: PipelineDeps,
): Promise<MuseReply> {
// Capture (#1): save every incoming fragment before anything can fail downstream.
saveFragment(deps.db, { source, kind: "text", content: intent });
const result = await deps.generate(intent);
const spelled = spellChords(result.chords);
const text = formatCard({ chords: spelled, explanation: result.explanation });
const midi = progressionToMidi(spelled);
let audioPath: string | null = null;
const basename = `muse-${Date.now()}`;
try {
audioPath = await deps.render(midi, {
soundfontPath: deps.soundfontPath,
outDir: deps.audioDir,
basename,
});
} catch {
audioPath = null; // graceful degradation — text card still goes out
}
saveProgression(deps.db, {
intent,
key: result.key,
mode: result.mode,
symbols: spelled.map((c) => c.symbol),
explanation: result.explanation,
audioPath,
});
return { text, audioPath };
}
- [ ] Step 4: Run the test to verify it passes
Run: cd muse && npx vitest run src/pipeline.test.ts
Expected: PASS (3 tests).
- [ ] Step 5: Commit
git add muse/src/pipeline.ts muse/src/pipeline.test.ts
git commit -m "feat(muse): handleIntent pipeline wiring Brain through Library"
Task 9: Config, Discord bot, entry point, README
Files:
- Create: muse/src/config.ts
- Create: muse/src/bot.ts
- Create: muse/src/index.ts
- Create: muse/README.md
- Test: muse/src/config.test.ts
Interfaces:
- Consumes: everything above, discord.js, @anthropic-ai/sdk.
- Produces:
- loadConfig(env: NodeJS.ProcessEnv): Config — reads + validates env vars, throws a clear error listing any that are missing.
- startBot(config: Config): void — wires the Discord client to handleIntent.
- index.ts — entry point that loads config and starts the bot.
- [ ] Step 1: Write the failing test
muse/src/config.test.ts
import { describe, it, expect } from "vitest";
import { loadConfig } from "./config";
const full = {
DISCORD_TOKEN: "t",
DISCORD_CHANNEL_ID: "c",
ANTHROPIC_API_KEY: "k",
MUSE_SOUNDFONT_PATH: "/sf/piano.sf2",
};
describe("loadConfig", () => {
it("reads a complete environment with defaults", () => {
const cfg = loadConfig(full);
expect(cfg.discordToken).toBe("t");
expect(cfg.channelId).toBe("c");
expect(cfg.dbPath).toBe("./muse.db"); // default
expect(cfg.audioDir).toBe("./scratch"); // default
});
it("throws listing every missing required var", () => {
expect(() => loadConfig({})).toThrow(/DISCORD_TOKEN/);
expect(() => loadConfig({})).toThrow(/ANTHROPIC_API_KEY/);
});
});
- [ ] Step 2: Run the test to verify it fails
Run: cd muse && npx vitest run src/config.test.ts
Expected: FAIL — cannot find module ./config.
- [ ] Step 3: Write
muse/src/config.ts
export type Config = {
discordToken: string;
channelId: string;
anthropicApiKey: string;
soundfontPath: string;
dbPath: string;
audioDir: string;
};
export function loadConfig(env: NodeJS.ProcessEnv): Config {
const required = {
DISCORD_TOKEN: env.DISCORD_TOKEN,
DISCORD_CHANNEL_ID: env.DISCORD_CHANNEL_ID,
ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY,
MUSE_SOUNDFONT_PATH: env.MUSE_SOUNDFONT_PATH,
};
const missing = Object.entries(required)
.filter(([, v]) => !v)
.map(([k]) => k);
if (missing.length > 0) {
throw new Error(`Missing required environment variables: ${missing.join(", ")}`);
}
return {
discordToken: required.DISCORD_TOKEN!,
channelId: required.DISCORD_CHANNEL_ID!,
anthropicApiKey: required.ANTHROPIC_API_KEY!,
soundfontPath: required.MUSE_SOUNDFONT_PATH!,
dbPath: env.MUSE_DB_PATH ?? "./muse.db",
audioDir: env.MUSE_AUDIO_DIR ?? "./scratch",
};
}
- [ ] Step 4: Run the test to verify it passes
Run: cd muse && npx vitest run src/config.test.ts
Expected: PASS (2 tests).
- [ ] Step 5: Write
muse/src/bot.ts(not unit-tested — thin Discord shell)
import { Client, Events, GatewayIntentBits, AttachmentBuilder } from "discord.js";
import Anthropic from "@anthropic-ai/sdk";
import { mkdirSync } from "node:fs";
import type { Config } from "./config";
import { openLibrary } from "./library";
import { generateProgression } from "./brain";
import { renderMidiToAudio } from "./render";
import { handleIntent, type PipelineDeps } from "./pipeline";
export function startBot(config: Config): void {
mkdirSync(config.audioDir, { recursive: true });
const anthropic = new Anthropic({ apiKey: config.anthropicApiKey });
const db = openLibrary(config.dbPath);
const deps: PipelineDeps = {
db,
soundfontPath: config.soundfontPath,
audioDir: config.audioDir,
generate: (intent) => generateProgression(intent, anthropic),
render: renderMidiToAudio,
};
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
client.once(Events.ClientReady, (c) => {
console.log(`Muse is listening as ${c.user.tag}`);
});
client.on(Events.MessageCreate, async (message) => {
if (message.author.bot) return;
if (message.channelId !== config.channelId) return;
const intent = message.content.trim();
if (!intent) return;
try {
const reply = await handleIntent(intent, `discord:${message.author.id}`, deps);
const files = reply.audioPath ? [new AttachmentBuilder(reply.audioPath)] : [];
await message.reply({ content: reply.text, files });
} catch (err) {
console.error("Muse failed to handle a message:", err);
await message.reply("Something went wrong reaching for that one — try again?");
}
});
client.login(config.discordToken);
}
- [ ] Step 6: Write
muse/src/index.ts
import { loadConfig } from "./config";
import { startBot } from "./bot";
const config = loadConfig(process.env);
startBot(config);
- [ ] Step 7: Write
muse/README.md
# Muse — Phase 1
A personal songwriting companion. Send a feeling to the #muse Discord channel
and Muse replies with an interesting chord progression — each chord spelled out,
a one-line "why it feels that way", and a piano audio clip.
## Prerequisites
- Node.js 20+
- FluidSynth + a free piano soundfont:
```bash
brew install fluidsynth
# download a soundfont, e.g. FluidR3_GM.sf2 (MIT) or a Salamander SF2,
# and point MUSE_SOUNDFONT_PATH at it
```
- A Discord bot application (Muse's own), with the **MESSAGE CONTENT** privileged
intent enabled, invited to your server. Add a `#muse` channel and copy its ID.
## Setup
```bash
cd muse
npm install
cp .env.example .env # fill in DISCORD_TOKEN, DISCORD_CHANNEL_ID, ANTHROPIC_API_KEY, MUSE_SOUNDFONT_PATH
npm test # run the unit tests
npm start # start the bot (keep it running on the always-on Mac)
```
## How it works
`handleIntent` runs the pipeline: Brain (Claude → Roman numerals + chord symbols +
explanation) → Theory (`tonal` spells each chord) → Card (text) → MIDI
(`midi-writer-js`) → Render (FluidSynth → WAV) → Library (SQLite). Every fragment
and progression is saved to `muse.db`.
- [ ] Step 8: Typecheck the whole project
Run: cd muse && npm run typecheck
Expected: no errors.
- [ ] Step 9: Manual smoke test (requires real env)
With FluidSynth installed, a soundfont at MUSE_SOUNDFONT_PATH, and a valid ANTHROPIC_API_KEY, run a one-off script that calls generateProgression + renderMidiToAudio on "dark but hopeful, floating underwater" and confirm: a progression with maj7/borrowed chords comes back, and a playable .wav is produced in MUSE_AUDIO_DIR. (Then the full Discord path once the bot token + channel are set up.) Record the result; do not claim the loop works until you have heard a clip.
- [ ] Step 10: Commit
git add muse/src/config.ts muse/src/config.test.ts muse/src/bot.ts muse/src/index.ts muse/README.md
git commit -m "feat(muse): config, Discord bot shell, entry point, README"
Self-Review Notes
- Spec coverage: Capture (#1) —
saveFragmentruns first inhandleIntent(Task 8) and every message is saved (Task 9 bot). Reply layers — chords + spelled notes + “why” (Task 3 card), audio clip (Tasks 5–6, attached in Task 9). Audio from day one — Tasks 5/6/8. Memory Stage A (save everything) — Task 7. Brain = Claude + tonal guardrail — Tasks 4 (Claude, Roman + symbol) + 2 (tonal spells/validates). Runs on the Mac — plain Node process (Task 9). Hum-harmonization, development, learns-you, dashboard are explicitly later phases and out of scope here. - Graceful degradation: render failure (no FluidSynth yet) still returns the text card (Task 8) — so the loop is usable before the soundfont is installed.
- Testability: the LLM call and the FluidSynth call are the two non-deterministic boundaries; both are injected into
handleIntentand faked in tests, and exercised by the manual smoke test (Task 9, Step 9).