🦄 Unicorn.Land ▸ docs/superpowers/plans/2026-06-27-muse-phase-1.md
updated 2026-06-27

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


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[] }

{
  "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"
  }
}
{
  "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"]
}
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "node",
    include: ["src/**/*.test.ts"],
  },
});
node_modules/
dist/
.env
*.sf2
*.wav
*.mid
muse.db
muse.db-*
scratch/
# 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
/** 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[];
};
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);
  });
});

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.)

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).

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/);
  });
});

Run: cd muse && npx vitest run src/theory.test.ts Expected: FAIL — cannot find module ./theory.

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 };
  });
}

Run: cd muse && npx vitest run src/theory.test.ts Expected: PASS (4 tests).

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).

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");
  });
});

Run: cd muse && npx vitest run src/card.test.ts Expected: FAIL — cannot find module ./card.

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}_`;
}

Run: cd muse && npx vitest run src/card.test.ts Expected: PASS (2 tests).

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.

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();
  });
});

Run: cd muse && npx vitest run src/brain.test.ts Expected: FAIL — cannot find module ./brain.

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;
}

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.)

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.

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]);
  });
});

Run: cd muse && npx vitest run src/midi.test.ts Expected: FAIL — cannot find module ./midi.

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();
}

Run: cd muse && npx vitest run src/midi.test.ts Expected: PASS (1 test).

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.

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"));
  });
});

Run: cd muse && npx vitest run src/render.test.ts Expected: FAIL — cannot find module ./render.

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;
}

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.)

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.

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);
  });
});

Run: cd muse && npx vitest run src/library.test.ts Expected: FAIL — cannot find module ./library.

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;
  }>;
}

Run: cd muse && npx vitest run src/library.test.ts Expected: PASS (2 tests).

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).

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();
  });
});

Run: cd muse && npx vitest run src/pipeline.test.ts Expected: FAIL — cannot find module ./pipeline.

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 };
}

Run: cd muse && npx vitest run src/pipeline.test.ts Expected: PASS (3 tests).

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.

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/);
  });
});

Run: cd muse && npx vitest run src/config.test.ts Expected: FAIL — cannot find module ./config.

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",
  };
}

Run: cd muse && npx vitest run src/config.test.ts Expected: PASS (2 tests).

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);
}
import { loadConfig } from "./config";
import { startBot } from "./bot";

const config = loadConfig(process.env);
startBot(config);
# 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`.

Run: cd muse && npm run typecheck Expected: no errors.

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.

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