salgadev commited on
Commit
b2e4883
·
verified ·
1 Parent(s): ba9bf33

Sync from GitHub 6feaf31d

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +96 -0
  2. Dockerfile +48 -0
  3. MIDI_TO_GP5.md +76 -0
  4. README.md +88 -10
  5. apps/web/.eslintrc.json +8 -0
  6. apps/web/__tests__/conversation.test.ts +118 -0
  7. apps/web/__tests__/engine.test.ts +166 -0
  8. apps/web/__tests__/player.test.ts +43 -0
  9. apps/web/__tests__/prompt.test.ts +81 -0
  10. apps/web/app/api/jambuddy/detect/route.ts +98 -0
  11. apps/web/app/api/jambuddy/route.ts +248 -0
  12. apps/web/app/globals.css +320 -0
  13. apps/web/app/layout.tsx +31 -0
  14. apps/web/app/page.tsx +628 -0
  15. apps/web/data/onomatopoeia.json +39 -0
  16. apps/web/data/patterns/d-beat.json +28 -0
  17. apps/web/data/patterns/skank.json +20 -0
  18. apps/web/lib/jambuddy/player.ts +209 -0
  19. apps/web/lib/jambuddy/prompt.ts +287 -0
  20. apps/web/lib/jambuddy/recorder.ts +156 -0
  21. apps/web/lib/jambuddy/visualizer.tsx +195 -0
  22. apps/web/lib/midi/generator.ts +106 -0
  23. apps/web/lib/patterns/engine.ts +273 -0
  24. apps/web/lib/voice/conversation.ts +219 -0
  25. apps/web/next-env.d.ts +5 -0
  26. apps/web/next.config.mjs +16 -0
  27. apps/web/package.json +36 -0
  28. apps/web/postcss.config.mjs +6 -0
  29. apps/web/scripts/_debug_jambuddy.cjs +28 -0
  30. apps/web/tailwind.config.ts +29 -0
  31. apps/web/tsconfig.json +19 -0
  32. apps/web/vitest.config.ts +17 -0
  33. docs/01-vision.md +90 -0
  34. docs/02-architecture.md +271 -0
  35. docs/03-data-model.md +400 -0
  36. docs/04-ux-voice-first.md +388 -0
  37. docs/05-accessibility.md +294 -0
  38. docs/06-stable-audio-integration.md +466 -0
  39. docs/07-reaper-integration.md +305 -0
  40. docs/08-build-plan.md +244 -0
  41. docs/09-risks.md +314 -0
  42. docs/10-team-pitch.md +239 -0
  43. docs/CONTRIBUTING.md +160 -0
  44. docs/HANDOFF.md +144 -0
  45. docs/HARNESS.md +55 -0
  46. gp5_to_keyswitched_mid.py +1123 -0
  47. gp_to_keyswitched_mid.js +284 -0
  48. midi_to_gp5.py +413 -0
  49. midjson_to_mid.py +93 -0
  50. package.json +29 -0
.gitignore ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+ .pnpm-store/
4
+
5
+ # Build outputs
6
+ .next/
7
+ out/
8
+ dist/
9
+ build/
10
+ *.tsbuildinfo
11
+
12
+ # Logs
13
+ npm-debug.log*
14
+ pnpm-debug.log*
15
+ yarn-debug.log*
16
+ yarn-error.log*
17
+ *.log
18
+
19
+ # Environment
20
+ .env
21
+ .env.local
22
+ .env.development.local
23
+ .env.test.local
24
+ .env.production.local
25
+
26
+ # Editor
27
+ .vscode/
28
+ .idea/
29
+ *.swp
30
+ *.swo
31
+ .DS_Store
32
+
33
+ # Test coverage
34
+ coverage/
35
+ .nyc_output/
36
+
37
+ # Python
38
+ __pycache__/
39
+ *.py[cod]
40
+ *$py.class
41
+ *.egg-info/
42
+ .venv/
43
+ venv/
44
+ .pytest_cache/
45
+ .ruff_cache/
46
+ .mypy_cache/
47
+
48
+ # Audio service
49
+ services/audio/models/
50
+ services/audio/loras/
51
+ services/audio/cache/
52
+ services/audio/__pycache__/
53
+
54
+ # Training data (large, keep manifest only)
55
+ data/training/*.wav
56
+ data/training/*.mp3
57
+ data/training/*.flac
58
+
59
+ # openlore
60
+ .openlore/
61
+ .openlore/analysis/
62
+ .openlore/serve.log
63
+ .openlore/serve.pid
64
+
65
+ # OS
66
+ Thumbs.db
67
+
68
+ # text2midi (cloned model repo, not part of this project)
69
+ text2midi/
70
+
71
+ # Scratch + source song data (user's GP files, not in git per conftest)
72
+ # Source song folders / individual song files
73
+ gp5_songs/
74
+ *.gp
75
+ *.gp5
76
+
77
+ # Conversion-corpus fixtures (GP files stay out of git; only baseline.yaml is tracked)
78
+ tests/fixtures/
79
+
80
+ # Event-trace scratch dumps
81
+ fallen_events.json
82
+ summoning_js_events.json
83
+
84
+ # Generated keyswitched MIDI output (regenerable via gp5_to_keyswitched_mid.py)
85
+ # Per-track output lands in song-named dirs at repo root (e.g. "Altars rev Yes Breakdown/")
86
+ Altars rev Yes Breakdown/
87
+ Sacrifice ReDrum Full v2 gracenotes/
88
+ The Summoning Aug 2026/
89
+ *.keyswitched.mid
90
+
91
+ # Jam Buddy generated WAVs (regenerable via the webapp / tools/jam_buddy.py)
92
+ generations/
93
+
94
+ # SA3 engine — large venv + license-gated weights; regenerable, keep out of git
95
+ stable-audio-3/
96
+ toggle-switch.webp
Dockerfile ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Jam Buddy — HF Space (Docker)
2
+ #
3
+ # Builds the Next.js webapp + a lightweight Python env for the SA3 API adapter.
4
+ # The Space runs in API mode only (no local torch/SA3 — generation goes to the
5
+ # Stability REST API via STABILITY_API_KEY). The route resolves the Python
6
+ # interpreter from JAM_BUDDY_PYTHON, which we set to the container python.
7
+
8
+ # ---- Build stage: Next.js ----
9
+ FROM node:20-slim AS web-build
10
+ WORKDIR /app
11
+ # pnpm workspace: copy manifests first for layer caching
12
+ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
13
+ COPY apps/web/package.json apps/web/package.json
14
+ COPY packages/ packages/
15
+ RUN corepack enable && pnpm install --frozen-lockfile
16
+ # Copy source + build
17
+ COPY apps/web apps/web
18
+ COPY tsconfig.base.json .
19
+ RUN cd apps/web && pnpm build
20
+
21
+ # ---- Runtime stage ----
22
+ FROM node:20-slim
23
+ WORKDIR /app
24
+
25
+ # Python for the SA3 API adapter (lightweight: no torch).
26
+ RUN apt-get update && apt-get install -y --no-install-recommends python3 python3-pip \
27
+ && rm -rf /var/lib/apt/lists/*
28
+ ENV JAM_BUDDY_PYTHON=/usr/bin/python3
29
+
30
+ # Copy the built web app + the tools the API route shells to.
31
+ COPY --from=web-build /app/apps/web/.next apps/web/.next
32
+ COPY --from=web-build /app/apps/web/package.json apps/web/package.json
33
+ COPY --from=web-build /app/apps/web/node_modules apps/web/node_modules
34
+ COPY --from=web-build /app/node_modules node_modules
35
+ COPY --from=web-build /app/packages packages
36
+ COPY apps/web/next.config.mjs apps/web/next.config.mjs
37
+ COPY apps/web/public apps/web/public 2>/dev/null || true
38
+ COPY tools tools
39
+ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
40
+
41
+ # Python deps for tools/jam_buddy_api.py (requests, mido, librosa, scipy, soundfile, numpy).
42
+ RUN python3 -m pip install --no-cache-dir requests mido librosa scipy soundfile numpy
43
+
44
+ # HF Spaces expects the app on port 7860.
45
+ ENV PORT=7860
46
+ WORKDIR /app/apps/web
47
+ EXPOSE 7860
48
+ CMD ["node", "node_modules/next/dist/bin/next", "start", "-p", "7860"]
MIDI_TO_GP5.md ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MIDI → GP5/6 converter — design
2
+
3
+ ## Goal
4
+ Convert a keyswitched MIDI file (one track at a time) back to a Guitar Pro 5/6 file.
5
+
6
+ ## Direction
7
+ Mirror of `gp5_to_keyswitched_mid.py`. Where the forward script uses
8
+ `detect_techniques` to read articulations from `note.effect.*`, the reverse
9
+ script needs to infer articulations from the keyswitch note that
10
+ precedes each pitched note.
11
+
12
+ ## Architecture
13
+
14
+ ```
15
+ midi_to_gp5.py
16
+ ├── parse MIDI → flat list of (track_name, channel, notes, keyswitches)
17
+ ├── classify tracks (guitar / bass / drums)
18
+ ├── for each pitched note, look at the preceding keyswitch note and
19
+ │ in the same tick → infer the technique
20
+ ├── build a Song with one Track per MIDI track
21
+ └── write via guitarpro.write()
22
+ ```
23
+
24
+ ## Inference rules (keyswitch → technique)
25
+
26
+ Same dictionary as the forward script, inverted. Driven by `KEYSWITCH_NOTES`
27
+ which maps MIDI note number → technique name:
28
+
29
+ | Note | Technique | GP5 effect |
30
+ |------|-----------|------------|
31
+ | 17 / 18 | sustain | (no effect — sustain is the default) |
32
+ | 20 | palm_mute | `effect.palmMute = True` |
33
+ | 9 | harmonic | `effect.harmonic = HarmonicEffect(type=Natural)` |
34
+ | 23 | slide_down | `effect.slides = [SlideType.shiftSlideTo]` (with next-note direction) |
35
+ | 24 | slide_up | same |
36
+ | 26 | hammer | `effect.hammer = True` |
37
+ | 27 | slide_in | `effect.grace = GraceEffect(transition=slide)` |
38
+ | 91 | bend | `effect.bend = BendEffect(points=[...])` |
39
+
40
+ Drum notes use standard GM mappings (MIDI 36 = kick, 38 = snare, 42 = hi-hat closed).
41
+
42
+ ## Open questions
43
+
44
+ 1. **Tie detection**: notes whose `note_on` happens before the previous
45
+ `note_off` for the same pitch → ties. Inferred from MIDI timing.
46
+ 2. **Bend reconstruction**: reading pitch wheel events from MIDI is
47
+ possible but fragile. Most keyswitch-instrumented MIDIs don't include
48
+ pitch bend. Initially: skip bend (write straight notes).
49
+ 3. **Slide direction (up/down)**: target pitch is the next note on the
50
+ same channel. We don't know the target string, so we use the pitch
51
+ comparison and let the bar position fill in.
52
+ 4. **String/fret selection**: pick the lowest playable fret on the
53
+ appropriate string. Not unique; heuristic only.
54
+ 5. **Tuning**: read from existing GP5 file or default to standard EADGBE.
55
+ 6. **Track routing**: user passes `--track-name "Distortion Guitar"` and
56
+ `--instrument guitar|bass|drums` to control the new GP track.
57
+
58
+ ## File layout
59
+
60
+ ```
61
+ midi_to_gp5.py # the converter
62
+ tests/
63
+ test_midi_to_gp5.py # round-trip tests
64
+ expected/
65
+ *.mid # input fixtures (committed)
66
+ *.gp5 # expected outputs (committed)
67
+ ```
68
+
69
+ ## Status
70
+
71
+ - [x] Design above
72
+ - [ ] MIDI parser
73
+ - [ ] Track classifier
74
+ - [ ] Articulation inferer
75
+ - [ ] Song builder
76
+ - [ ] Tests
README.md CHANGED
@@ -1,10 +1,88 @@
1
- ---
2
- title: Jam Buddy
3
- emoji: 🌍
4
- colorFrom: purple
5
- colorTo: gray
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Jam Buddy
2
+
3
+ > An AI music companion that listens to what you play and joins in — at your
4
+ > tempo, in the instrument you pick. Built for the Stability AI Challenge at
5
+ > Music Hackspace Montreal (August 22–23, 2026).
6
+
7
+ ## What this is
8
+
9
+ **Jam Buddy** is a call-and-response music practice partner. You start playing,
10
+ it joins in.
11
+
12
+ - Load a **MIDI take** (from a controller) — the buddy detects your tempo and
13
+ matches its length, then responds with a complementary part in your chosen
14
+ instrument at the same tempo.
15
+ - Load an **audio take** (mic/interface) — the buddy uses Stable Audio 3's
16
+ audio-to-audio path to actually *hear your groove* and respond to it
17
+ rhythmically.
18
+ - Pick an **instrument** (bass / lead / rhythm / synth / drums), a **genre**,
19
+ and a **mood** — the buddy generates the response with SA3.
20
+ - **PLAY BOTH** plays your take and the buddy's response together, in tempo.
21
+ - Every response is saved to `generations/` so you can keep and inspect what it
22
+ produced.
23
+
24
+ The whole thing is **screenreader-compatible by design**: every knob is a
25
+ labelled `<input type=range>`, buttons have accessible names, and the status is
26
+ announced via `aria-live`. Voice-first *is* accessibility.
27
+
28
+ The SA3 generation runs **locally on the open Stable Audio 3 weights** —
29
+ `small-music` for melodic instruments, `small-sfx` for clean isolated drum hits.
30
+
31
+ Built for the **Stability AI Challenge** at Music Hackspace Montreal
32
+ (August 22–23, 2026, in partnership with MUTEK).
33
+
34
+ ## Quick start (web app)
35
+
36
+ ```bash
37
+ cd apps/web
38
+ pnpm install
39
+ pnpm dev
40
+ # → http://localhost:3000
41
+ ```
42
+
43
+ The web app shells out to `tools/jam_buddy.py` (the SA3 pipeline). That needs
44
+ the `stable-audio-3` venv + a one-time model download. See
45
+ [`docs/06-stable-audio-integration.md`](docs/06-stable-audio-integration.md).
46
+
47
+ ### CLI (the pipeline directly)
48
+
49
+ ```bash
50
+ # MIDI take → tempo + length matched, then respond with bass
51
+ python3 tools/jam_buddy.py --midi take.mid --instrument bass --out out.wav
52
+
53
+ # Audio take → audio-to-audio, responds to the groove
54
+ python3 tools/jam_buddy.py --wav take.wav --genre metal --instrument lead --out out.wav
55
+
56
+ # Manual knob only
57
+ python3 tools/jam_buddy.py --bpm 120 --instrument drums --out out.wav
58
+ ```
59
+
60
+ ## Documentation
61
+
62
+ | Doc | Purpose |
63
+ |---|---|
64
+ | [`docs/01-vision.md`](docs/01-vision.md) | What we're building and why |
65
+ | [`docs/02-architecture.md`](docs/02-architecture.md) | System architecture, services, data flow |
66
+ | [`docs/03-data-model.md`](docs/03-data-model.md) | Prompt builder, knobs, generation metadata |
67
+ | [`docs/05-accessibility.md`](docs/05-accessibility.md) | Screenreader/keyboard accessibility strategy |
68
+ | [`docs/06-stable-audio-integration.md`](docs/06-stable-audio-integration.md) | SA3 setup, audio-to-audio, Vega notes, LoRA |
69
+ | [`docs/07-reaper-integration.md`](docs/07-reaper-integration.md) | DAW integration via Reaper |
70
+ | [`docs/08-build-plan.md`](docs/08-build-plan.md) | Hackathon build plan |
71
+ | [`docs/09-risks.md`](docs/09-risks.md) | Ranked risks + mitigations |
72
+ | [`docs/10-team-pitch.md`](docs/10-team-pitch.md) | Team pitch + skills |
73
+
74
+ ## Conventions
75
+
76
+ - **Language**: TypeScript (frontend), Python 3.10+ (audio pipeline)
77
+ - **Linting**: ESLint + Prettier (TS), ruff + black (Python)
78
+ - **Accessibility**: axe-core in CI, NVDA + VoiceOver tested before each demo
79
+ - **Commits**: Conventional Commits (`feat:`, `fix:`, `docs:`, etc.)
80
+ - **Branches**: `feat/*`, `fix/*`, `docs/*`, `chore/*`
81
+
82
+ For the agent harness — commands, test contract, and what's deliberately not in
83
+ git — see [`docs/HARNESS.md`](docs/HARNESS.md).
84
+
85
+ ## License
86
+
87
+ MIT (code). Generated audio stays local / your own material; no third-party
88
+ samples ship in this repo.
apps/web/.eslintrc.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": ["next/core-web-vitals", "next/typescript"],
3
+ "rules": {
4
+ "@typescript-eslint/no-explicit-any": "error",
5
+ "@typescript-eslint/consistent-type-imports": "warn"
6
+ },
7
+ "ignorePatterns": ["node_modules/", ".next/", "dist/", "__tests__/"]
8
+ }
apps/web/__tests__/conversation.test.ts ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Voice conversation FSM tests.
3
+ *
4
+ * Pure transition tests — no DOM, no audio. Verify each state transition
5
+ * produces the expected next state and side-effects.
6
+ */
7
+
8
+ import { describe, it, expect } from "vitest";
9
+ import { transition } from "../lib/voice/conversation";
10
+ import type { ParseResult } from "@patterntalk/shared-types";
11
+
12
+ describe("voice FSM — happy path", () => {
13
+ it("idle → listening → parsing → generating → ready on a clean prompt", () => {
14
+ let s = transition("idle", {}, { type: "START_LISTENING" });
15
+ expect(s.next).toBe("listening");
16
+ expect(s.sideEffects.map((e) => e.type)).toContain("START_MIC");
17
+
18
+ s = transition(s.next, s.context, { type: "TRANSCRIPT_FINAL", transcript: "d-beat at 180" });
19
+ expect(s.next).toBe("parsing");
20
+
21
+ const parseOk: ParseResult = {
22
+ ok: true,
23
+ confidence: 0.95,
24
+ request: {
25
+ patternId: "d-beat",
26
+ patternName: "D-Beat",
27
+ bars: 4,
28
+ tempoSource: "prompt",
29
+ tempoDefault: 120,
30
+ timeSignature: { numerator: 4, denominator: 4 },
31
+ tempo: 180,
32
+ },
33
+ };
34
+ s = transition(s.next, s.context, { type: "PARSE_OK", result: parseOk });
35
+ expect(s.next).toBe("generating");
36
+
37
+ s = transition(s.next, s.context, {
38
+ type: "GENERATION_OK",
39
+ sampleUrl: "http://localhost:8001/cache/abc.wav",
40
+ });
41
+ expect(s.next).toBe("ready");
42
+ expect(s.context.parsed?.patternId).toBe("d-beat");
43
+ expect(s.context.sampleUrl).toBe("http://localhost:8001/cache/abc.wav");
44
+ });
45
+ });
46
+
47
+ describe("voice FSM — low confidence → confirmation", () => {
48
+ it("parsing → awaiting-confirmation when PARSE_LOW_CONFIDENCE fires", () => {
49
+ const lowConf: ParseResult = {
50
+ ok: false,
51
+ confidence: 0.4,
52
+ heardAs: "tupatupa",
53
+ candidates: [
54
+ {
55
+ request: {
56
+ patternId: "skank",
57
+ patternName: "Skank Beat",
58
+ bars: 4,
59
+ tempoSource: "default",
60
+ tempoDefault: 120,
61
+ timeSignature: { numerator: 4, denominator: 4 },
62
+ },
63
+ confidence: 0.4,
64
+ reason: "phonetic match",
65
+ },
66
+ ],
67
+ };
68
+ const s = transition("parsing", {}, { type: "PARSE_LOW_CONFIDENCE", result: lowConf });
69
+ expect(s.next).toBe("awaiting-confirmation");
70
+ const speak = s.sideEffects.find((e) => e.type === "SPEAK");
71
+ expect(speak).toBeDefined();
72
+ if (speak && speak.type === "SPEAK") {
73
+ expect(speak.text).toContain("Skank Beat");
74
+ }
75
+ });
76
+ });
77
+
78
+ describe("voice FSM — error states", () => {
79
+ it("any state → error on ERROR event", () => {
80
+ const s = transition("listening", {}, {
81
+ type: "ERROR",
82
+ kind: "mic-denied",
83
+ message: "Microphone access denied. Enable it in browser settings.",
84
+ });
85
+ expect(s.next).toBe("error");
86
+ expect(s.context.error?.kind).toBe("mic-denied");
87
+ });
88
+
89
+ it("GENERATION_FAIL routes to error state", () => {
90
+ const s = transition("generating", {}, {
91
+ type: "GENERATION_FAIL",
92
+ message: "Sample generation timed out. MIDI is still ready.",
93
+ });
94
+ expect(s.next).toBe("error");
95
+ expect(s.context.error?.message).toContain("timed out");
96
+ });
97
+ });
98
+
99
+ describe("voice FSM — RESET", () => {
100
+ it("RESET from any state returns to idle", () => {
101
+ const s1 = transition("ready", { parsed: { patternId: "x", bars: 4, tempoSource: "default", tempoDefault: 120, timeSignature: { numerator: 4, denominator: 4 } } }, { type: "RESET" });
102
+ expect(s1.next).toBe("idle");
103
+ expect(s1.context).toEqual({});
104
+ });
105
+ });
106
+
107
+ describe("voice FSM — ignores stray events", () => {
108
+ it("TRANSCRIPT_FINAL while idle is ignored", () => {
109
+ const s = transition("idle", {}, { type: "TRANSCRIPT_FINAL", transcript: "hi" });
110
+ expect(s.next).toBe("idle");
111
+ expect(s.sideEffects).toHaveLength(0);
112
+ });
113
+
114
+ it("PLAY_START while idle is ignored", () => {
115
+ const s = transition("idle", {}, { type: "PLAY_START" });
116
+ expect(s.next).toBe("idle");
117
+ });
118
+ });
apps/web/__tests__/engine.test.ts ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Pattern engine tests.
3
+ *
4
+ * These are the foundation's correctness contract. They run on every PR.
5
+ * If they break, the demo breaks — fix the engine, not the test.
6
+ */
7
+
8
+ import { describe, it, expect } from "vitest";
9
+ import {
10
+ expandPattern,
11
+ bpmToMicrosecondsPerQuarter,
12
+ ticksPerBar,
13
+ TICKS_PER_QUARTER,
14
+ } from "../lib/patterns/engine";
15
+ import dbeat from "../data/patterns/d-beat.json";
16
+ import skank from "../data/patterns/skank.json";
17
+ import type { PatternTemplate } from "@patterntalk/shared-types";
18
+
19
+ const DB = dbeat as PatternTemplate;
20
+ const SK = skank as PatternTemplate;
21
+
22
+ describe("engine math primitives", () => {
23
+ it("computes ticks-per-bar correctly for 4/4", () => {
24
+ expect(ticksPerBar({ numerator: 4, denominator: 4 })).toBe(1920);
25
+ });
26
+
27
+ it("converts BPM to microseconds-per-quarter-note", () => {
28
+ expect(bpmToMicrosecondsPerQuarter(120)).toBe(500000);
29
+ expect(bpmToMicrosecondsPerQuarter(180)).toBe(333333);
30
+ expect(bpmToMicrosecondsPerQuarter(60)).toBe(1000000);
31
+ });
32
+ });
33
+
34
+ describe("expandPattern — d-beat", () => {
35
+ const events = expandPattern({ template: DB, bars: 1, bpm: 180 });
36
+
37
+ it("preserves the template's hit count for 1 bar", () => {
38
+ expect(events).toHaveLength(DB.hits.length);
39
+ });
40
+
41
+ it("produces 4x hits for 4 bars", () => {
42
+ const fourBars = expandPattern({ template: DB, bars: 4, bpm: 180 });
43
+ expect(fourBars).toHaveLength(DB.hits.length * 4);
44
+ });
45
+
46
+ it("starts at tick 0 and respects the 1920 ticks-per-bar window", () => {
47
+ const tickValues = events.map((e) => e.tick);
48
+ expect(tickValues[0]).toBe(0);
49
+ expect(Math.max(...tickValues)).toBeLessThan(1920);
50
+ });
51
+
52
+ it("sorts events by tick ascending", () => {
53
+ const sorted = [...events].sort((a, b) => a.tick - b.tick);
54
+ expect(events.map((e) => e.tick)).toEqual(sorted.map((e) => e.tick));
55
+ });
56
+
57
+ it("keeps all velocities in 1–127 range (humanize clamp)", () => {
58
+ for (const ev of events) {
59
+ expect(ev.velocity).toBeGreaterThanOrEqual(1);
60
+ expect(ev.velocity).toBeLessThanOrEqual(127);
61
+ }
62
+ });
63
+
64
+ it("emits a mix of kick, snare, and ride-bell hits", () => {
65
+ const limbs = new Set(events.map((e) => e.limb));
66
+ expect(limbs.has("kick")).toBe(true);
67
+ expect(limbs.has("snare")).toBe(true);
68
+ expect(limbs.has("ride-bell")).toBe(true);
69
+ });
70
+ });
71
+
72
+ describe("expandPattern — cymbal override", () => {
73
+ it("replaces ride-bell hits with crash when cymbal override is { type: 'crash' }", () => {
74
+ const events = expandPattern({
75
+ template: DB,
76
+ bars: 1,
77
+ bpm: 180,
78
+ overrides: { cymbal: { type: "crash", pattern: "8ths" } },
79
+ });
80
+ const rideBellCount = events.filter((e) => e.limb === "ride-bell").length;
81
+ const crashCount = events.filter((e) => e.limb === "crash").length;
82
+ expect(rideBellCount).toBe(0);
83
+ expect(crashCount).toBeGreaterThan(0);
84
+ });
85
+
86
+ it("replaces hihat with hihat-open", () => {
87
+ const events = expandPattern({
88
+ template: SK,
89
+ bars: 1,
90
+ bpm: 120,
91
+ overrides: { cymbal: { type: "hihat-open", pattern: "upstrokes" } },
92
+ });
93
+ expect(events.filter((e) => e.limb === "hihat-open").length).toBeGreaterThan(0);
94
+ expect(events.filter((e) => e.limb === "hihat").length).toBe(0);
95
+ });
96
+ });
97
+
98
+ describe("expandPattern — accents", () => {
99
+ it("boosts velocity by +20 on beat 1", () => {
100
+ const baseline = expandPattern({ template: DB, bars: 4, bpm: 180 });
101
+ const accented = expandPattern({
102
+ template: DB,
103
+ bars: 4,
104
+ bpm: 180,
105
+ overrides: { accents: ["1"] },
106
+ });
107
+
108
+ // The first kick in each bar is at tick = barIndex * 1920.
109
+ // Its velocity in DB.hits is 110; humanize can shift ±2.
110
+ // With accents, raw velocity = 110 + 20 + humanize (range 128–132),
111
+ // clamped to 127. So expected >= 127.
112
+ const firstKickPerBar = [0, 1920, 3840, 5760].map(
113
+ (tick) => accented.find((e) => e.tick === tick && e.limb === "kick")!,
114
+ );
115
+ for (const ev of firstKickPerBar) {
116
+ expect(ev.velocity).toBeGreaterThanOrEqual(127);
117
+ // Clamped to 127
118
+ expect(ev.velocity).toBeLessThanOrEqual(127);
119
+ }
120
+ // Sanity: without accents, same hit should be lower.
121
+ const firstUnaccented = baseline
122
+ .filter((e) => e.limb === "kick" && [0, 1920, 3840, 5760].includes(e.tick))
123
+ .map((e) => e.velocity);
124
+ expect(firstUnaccented.every((v) => v < 127)).toBe(true);
125
+ });
126
+
127
+ it("regression: accents apply to ALL bars, not just bar 0", () => {
128
+ // Catches the bug where barTicks was being miscomputed from
129
+ // bars[1]?.tick (which is undefined for ExpandedBar), making
130
+ // accents only match for bar 0.
131
+ const accented = expandPattern({
132
+ template: DB,
133
+ bars: 4,
134
+ bpm: 180,
135
+ overrides: { accents: ["1"] },
136
+ });
137
+ const beatOneKicks = [0, 1920, 3840, 5760]
138
+ .map((tick) => accented.find((e) => e.tick === tick && e.limb === "kick"))
139
+ .filter((e): e is NonNullable<typeof e> => e !== undefined);
140
+ expect(beatOneKicks).toHaveLength(4);
141
+ for (const ev of beatOneKicks) {
142
+ expect(ev.velocity).toBe(127); // 110 + humanize(±2) + 20 → clamped
143
+ }
144
+ });
145
+ });
146
+
147
+ describe("expandPattern — integration sanity", () => {
148
+ it("d-beat at 180 BPM for 4 bars produces a tick range equal to 4 × ticksPerBar", () => {
149
+ const events = expandPattern({ template: DB, bars: 4, bpm: 180 });
150
+ const maxTick = Math.max(...events.map((e) => e.tick));
151
+ expect(maxTick).toBeLessThan(4 * ticksPerBar({ numerator: 4, denominator: 4 }));
152
+ });
153
+
154
+ it("skank at 120 BPM has 8 hits in 1 bar", () => {
155
+ const events = expandPattern({ template: SK, bars: 1, bpm: 120 });
156
+ expect(events).toHaveLength(SK.hits.length);
157
+ // 2 kicks + 2 snares + 4 hihats
158
+ expect(events.filter((e) => e.limb === "kick")).toHaveLength(2);
159
+ expect(events.filter((e) => e.limb === "snare")).toHaveLength(2);
160
+ expect(events.filter((e) => e.limb === "hihat")).toHaveLength(4);
161
+ });
162
+
163
+ it("respects TICKS_PER_QUARTER constant", () => {
164
+ expect(TICKS_PER_QUARTER).toBe(480);
165
+ });
166
+ });
apps/web/__tests__/player.test.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import { midiToFreq, parseMidi, isPercussion, type ParsedNote } from "@/lib/jambuddy/player";
3
+
4
+ describe("midiToFreq", () => {
5
+ it("maps A4 (69) to 440 Hz", () => {
6
+ expect(midiToFreq(69)).toBeCloseTo(440, 3);
7
+ });
8
+ it("maps C4 (60) to ~261.6 Hz", () => {
9
+ expect(midiToFreq(60)).toBeCloseTo(261.63, 1);
10
+ });
11
+ it("is an octave up 12 semitones", () => {
12
+ expect(midiToFreq(81)).toBeCloseTo(midiToFreq(69) * 2, 3);
13
+ });
14
+ });
15
+
16
+ describe("parseMidi", () => {
17
+ it("throws a readable error for non-MIDI bytes", () => {
18
+ expect(() => parseMidi(new Uint8Array([1, 2, 3]).buffer)).toThrow();
19
+ });
20
+ });
21
+
22
+ describe("isPercussion", () => {
23
+ it("flags GM channel 9 as percussion", () => {
24
+ const drum: ParsedNote = {
25
+ time: 0,
26
+ midi: 36,
27
+ duration: 0.1,
28
+ velocity: 0.9,
29
+ channel: 9,
30
+ };
31
+ expect(isPercussion(drum)).toBe(true);
32
+ });
33
+ it("does not flag a normal channel", () => {
34
+ const bass: ParsedNote = {
35
+ time: 0,
36
+ midi: 40,
37
+ duration: 0.2,
38
+ velocity: 0.8,
39
+ channel: 0,
40
+ };
41
+ expect(isPercussion(bass)).toBe(false);
42
+ });
43
+ });
apps/web/__tests__/prompt.test.ts ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildPrompt,
4
+ type BuddyKnobs,
5
+ } from "@/lib/jambuddy/prompt";
6
+
7
+ const base: BuddyKnobs = {
8
+ instrument: "bass",
9
+ inputInstrument: "drums",
10
+ genre: "metal",
11
+ mood: "energetic",
12
+ bpm: 184,
13
+ };
14
+
15
+ describe("buildPrompt", () => {
16
+ it("follows the AudioSparx tag structure: TrackType, Genre, Moods, Instruments, BPM, studio", () => {
17
+ const { prompt } = buildPrompt(base);
18
+ expect(prompt).toContain("TrackType: Music, VocalType: Instrumental");
19
+ expect(prompt).toContain("Genre: Heavy Metal");
20
+ expect(prompt).toContain("Moods: Energetic");
21
+ expect(prompt).toContain("Instruments: Bass Guitar");
22
+ expect(prompt).toContain("184 BPM");
23
+ expect(prompt).toContain("studio recording");
24
+ });
25
+
26
+ it("omits the genre fragment when genre is 'any'", () => {
27
+ const { prompt } = buildPrompt({ ...base, genre: "any" });
28
+ expect(prompt).not.toContain("Genre:");
29
+ expect(prompt).toContain("TrackType: Music, VocalType: Instrumental");
30
+ expect(prompt).toContain("184 BPM");
31
+ });
32
+
33
+ it("clamps BPM to a sane range", () => {
34
+ const low = buildPrompt({ ...base, bpm: 5 });
35
+ expect(low.prompt).toContain("40 BPM");
36
+ const high = buildPrompt({ ...base, bpm: 9999 });
37
+ expect(high.prompt).toContain("240 BPM");
38
+ });
39
+
40
+ it("sets a negative prompt that steers away from a full mix", () => {
41
+ const { negativePrompt } = buildPrompt(base);
42
+ expect(negativePrompt).toContain("field recording");
43
+ expect(negativePrompt).toContain("full band");
44
+ expect(negativePrompt).toContain("vocals");
45
+ });
46
+
47
+ it("rounds fractional BPM", () => {
48
+ const { prompt } = buildPrompt({ ...base, bpm: 184.6 });
49
+ expect(prompt).toContain("185 BPM");
50
+ });
51
+
52
+ it("negates the user's input instrument in the negative prompt when it differs from the buddy", () => {
53
+ const { negativePrompt } = buildPrompt({ ...base, inputInstrument: "drums" });
54
+ expect(negativePrompt).toContain("drums");
55
+ });
56
+
57
+ it("does not negate the buddy's own instrument family in the negative prompt (lead guitar + input guitar)", () => {
58
+ const { negativePrompt } = buildPrompt({
59
+ ...base,
60
+ instrument: "lead",
61
+ inputInstrument: "guitar",
62
+ });
63
+ // buddy=lead is in the guitar family; adding "guitar" to the negative
64
+ // would steer SA3 away from the buddy itself, so we must NOT add it.
65
+ expect(negativePrompt).not.toContain("guitar");
66
+ });
67
+
68
+ it("drops 'percussion' from the negative when the buddy is drums", () => {
69
+ const { negativePrompt } = buildPrompt({
70
+ ...base,
71
+ instrument: "drums",
72
+ inputInstrument: "other",
73
+ });
74
+ expect(negativePrompt).not.toContain("percussion");
75
+ });
76
+
77
+ it("omits the complement clause when inputInstrument is 'other'", () => {
78
+ const { prompt } = buildPrompt({ ...base, inputInstrument: "other" });
79
+ expect(prompt).not.toContain("complement");
80
+ });
81
+ });
apps/web/app/api/jambuddy/detect/route.ts ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ import { writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import type { BuddyGenre } from "@/lib/jambuddy/prompt";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ /**
12
+ * Jam Buddy BPM/duration detection.
13
+ *
14
+ * POST /api/jambuddy/detect
15
+ * body: { midi?: string(base64), audio?: string(base64), genre?: BuddyGenre }
16
+ *
17
+ * Shells to tools/jam_buddy_api.py (or jam_buddy.py) with --detect-only and
18
+ * returns the detected BPM + duration so the client can PRE-FILL the tempo
19
+ * knob. The knob stays authoritative afterward.
20
+ */
21
+ export async function POST(req: NextRequest) {
22
+ let body: { midi?: string; audio?: string; genre?: BuddyGenre };
23
+ try {
24
+ body = await req.json();
25
+ } catch {
26
+ return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
27
+ }
28
+ if (!body.midi && !body.audio) {
29
+ return NextResponse.json(
30
+ { error: "need a midi or audio take to detect tempo from" },
31
+ { status: 400 },
32
+ );
33
+ }
34
+
35
+ // Resolve repo root + python like the main route.
36
+ const exists = (p: string) => require("node:fs").existsSync(p);
37
+ let repoRoot: string | null = process.env.JAM_BUDDY_ROOT ?? null;
38
+ if (!repoRoot) {
39
+ let cur = process.cwd();
40
+ while (cur && !exists(join(cur, "tools", "jam_buddy_api.py"))) {
41
+ const parent = join(cur, "..");
42
+ if (parent === cur) break;
43
+ cur = parent;
44
+ }
45
+ repoRoot = exists(join(cur, "tools", "jam_buddy_api.py")) ? cur : null;
46
+ }
47
+ if (!repoRoot) {
48
+ return NextResponse.json(
49
+ { error: "could not locate repo root" },
50
+ { status: 500 },
51
+ );
52
+ }
53
+ const python =
54
+ process.env.JAM_BUDDY_PYTHON ??
55
+ (exists(join(repoRoot, "stable-audio-3", ".venv", "Scripts", "python.exe"))
56
+ ? join(repoRoot, "stable-audio-3", ".venv", "Scripts", "python.exe")
57
+ : "python3");
58
+ const script = join(repoRoot, "tools", "jam_buddy_api.py");
59
+
60
+ try {
61
+ const args = ["--detect-only"];
62
+ if (body.midi) {
63
+ const midiPath = join(tmpdir(), `jambuddy-detect-${Date.now()}.mid`);
64
+ await writeFile(midiPath, Buffer.from(body.midi, "base64"));
65
+ args.push("--midi", midiPath);
66
+ } else if (body.audio) {
67
+ const audioPath = join(tmpdir(), `jambuddy-detect-${Date.now()}.wav`);
68
+ await writeFile(audioPath, Buffer.from(body.audio, "base64"));
69
+ args.push("--wav", audioPath);
70
+ // Genre prior for audio detection (helps octave disambiguation).
71
+ if (body.genre) args.push("--genre", body.genre);
72
+ }
73
+ const { stdout } = await execFileAsync(python, [script, ...args], {
74
+ timeout: 60_000,
75
+ maxBuffer: 2 * 1024 * 1024,
76
+ });
77
+ // stdout ends with "DETECT <bpm> <duration>"
78
+ const m = stdout.match(/DETECT\s+([\d.]+)\s+([\d.]+)/);
79
+ const bpmVal = m?.[1];
80
+ const durVal = m?.[2];
81
+ if (!bpmVal || !durVal) {
82
+ return NextResponse.json(
83
+ { error: "detection produced no result", detail: stdout.trim() },
84
+ { status: 500 },
85
+ );
86
+ }
87
+ return NextResponse.json({
88
+ bpm: Math.round(parseFloat(bpmVal)),
89
+ duration: parseFloat(durVal),
90
+ });
91
+ } catch (err) {
92
+ const detail =
93
+ err instanceof Error
94
+ ? (err as Error & { stderr?: string }).stderr?.trim() || err.message
95
+ : String(err);
96
+ return NextResponse.json({ error: "detection failed", detail }, { status: 500 });
97
+ }
98
+ }
apps/web/app/api/jambuddy/route.ts ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ import { writeFile, mkdir, readFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { buildPrompt, MODEL_FOR_INSTRUMENT, type BuddyKnobs } from "@/lib/jambuddy/prompt";
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ interface JambuddyBody {
12
+ knobs?: BuddyKnobs;
13
+ /** Override BPM. If a MIDI take is given, detection runs on it instead. */
14
+ bpm?: number;
15
+ /** Base64-encoded MIDI take (a controller recording). */
16
+ midi?: string;
17
+ /** Base64-encoded audio take (mic/interface/render) for audio-to-audio. */
18
+ audio?: string;
19
+ /** Response length in seconds. Default 30 (ignored when a take is provided). */
20
+ duration?: number;
21
+ /** Generation backend: "local" (CPU SA3) or "api" (Stable Audio 3.0 Large, 26 credits/gen). */
22
+ mode?: "local" | "api";
23
+ }
24
+
25
+ /**
26
+ * Jam Buddy API route.
27
+ *
28
+ * POST /api/jambuddy
29
+ * body: { knobs, bpm?, midi?, duration? }
30
+ *
31
+ * Shells to tools/jam_buddy.py (the SA3 pipeline) and returns the generated
32
+ * WAV as audio/wav. The Python side does the heavy lifting (SA3 generation +
33
+ * MIDI tempo detection); this route just wires the webapp to it.
34
+ *
35
+ * If a MIDI take is uploaded, the buddy detects the tempo from it (so it
36
+ * "joins in" at YOUR tempo) and the manual `bpm` knob is ignored.
37
+ *
38
+ * The SA3 venv python is resolved from env (JAM_BUDDY_PYTHON) or defaults to
39
+ * the stable-audio-3 venv. The script path is resolved from the repo root.
40
+ */
41
+ export async function POST(req: NextRequest) {
42
+ let body: JambuddyBody;
43
+ try {
44
+ body = await req.json();
45
+ } catch {
46
+ return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
47
+ }
48
+
49
+ if (!body.knobs) {
50
+ return NextResponse.json({ error: "missing knobs" }, { status: 400 });
51
+ }
52
+
53
+ const { prompt, negativePrompt } = buildPrompt(body.knobs);
54
+ const bpm = body.bpm ?? 120;
55
+ // Drums use small-sfx (clean isolated hits); everything else small-music.
56
+ const model = MODEL_FOR_INSTRUMENT[body.knobs.instrument];
57
+
58
+ // Resolve the Python interpreter + script by locating the repo root, which
59
+ // contains tools/jam_buddy.py. Walk up from the Next server cwd until we
60
+ // find it — the cwd differs between `pnpm dev` (apps/web) and a root-level
61
+ // launch, so don't assume a fixed number of levels up. Allow an env override.
62
+ const exists = (p: string) => require("node:fs").existsSync(p);
63
+ let repoRoot: string | null = process.env.JAM_BUDDY_ROOT ?? null;
64
+ if (!repoRoot) {
65
+ let cur = process.cwd();
66
+ while (cur && !exists(join(cur, "tools", "jam_buddy.py"))) {
67
+ const parent = join(cur, "..");
68
+ if (parent === cur) break;
69
+ cur = parent;
70
+ }
71
+ repoRoot = exists(join(cur, "tools", "jam_buddy.py")) ? cur : null;
72
+ }
73
+ if (!repoRoot) {
74
+ return NextResponse.json(
75
+ { error: "could not locate repo root (tools/jam_buddy.py)" },
76
+ { status: 500 },
77
+ );
78
+ }
79
+ // Thread the TS-built AudioSparx prompt through so Genre:/Moods:/Instruments
80
+ // tags actually reach SA3. The Python CLI also accepts --instrument/--genre
81
+ // for standalone use, but route.ts is authoritative here.
82
+ // Resolve the Python interpreter. Prefer JAM_BUDDY_PYTHON (set in the HF
83
+ // Space container to /usr/bin/python3); fall back to the local SA3 venv;
84
+ // finally to `python3` on PATH (container / CI). The venv path is Windows
85
+ // and won't exist in the Linux Space, so the fallback matters.
86
+ const venvPython = join(repoRoot, "stable-audio-3", ".venv", "Scripts", "python.exe");
87
+ const python =
88
+ process.env.JAM_BUDDY_PYTHON ??
89
+ (exists(venvPython) ? venvPython : "python3");
90
+
91
+ // Local = Stable Audio small models on CPU (supports negative prompt, free,
92
+ // slow). API = Stable Audio 3.0 Large via Stability REST (no negative prompt,
93
+ // fast, 26 credits/gen). Default to API — key is present, it's faster and
94
+ // isolates better; the user can flip to local (offline / free) in the GUI.
95
+ const mode: "local" | "api" =
96
+ body.mode === "local" ? "local" : "api";
97
+ const script = join(
98
+ repoRoot,
99
+ "tools",
100
+ mode === "api" ? "jam_buddy_api.py" : "jam_buddy.py",
101
+ );
102
+
103
+ // Always write to a PERSISTENT generations dir at the repo root so the
104
+ // output survives and is inspectable. Filenames are timestamped + tagged so
105
+ // you can tell them apart. The dir is gitignored (regenerable artifact).
106
+ const generationsDir = join(repoRoot, "generations");
107
+ await mkdir(generationsDir, { recursive: true });
108
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
109
+ const sourceTag = body.midi ? "midi" : body.audio ? "audio" : "manual";
110
+ // API returns MP3 (output_format mp3); local returns WAV.
111
+ const ext = mode === "api" ? "mp3" : "wav";
112
+ // Descriptive filename: the knob values + input type + engine, sanitized so
113
+ // you can tell generations apart without opening them.
114
+ const slug = (s: string) =>
115
+ s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
116
+ const knobBpmSlug = slug(String(body.knobs.bpm));
117
+ const outName =
118
+ [
119
+ stamp,
120
+ slug(body.knobs.instrument),
121
+ slug(body.knobs.genre),
122
+ slug(body.knobs.mood),
123
+ body.knobs.inputInstrument && body.knobs.inputInstrument !== "other"
124
+ ? `over-${slug(body.knobs.inputInstrument)}`
125
+ : null,
126
+ knobBpmSlug ? `${knobBpmSlug}bpm` : null,
127
+ sourceTag,
128
+ mode,
129
+ ]
130
+ .filter(Boolean)
131
+ .join("-") + `.${ext}`;
132
+ const outPath = join(generationsDir, outName);
133
+
134
+ try {
135
+ // Build args. Option A: the knob BPM is authoritative and ALWAYS sent. The
136
+ // take (if any) sets duration + drives audio-to-audio; it never overrides
137
+ // the tempo. Detection only pre-fills the knob on the client.
138
+ const knobBpm = Math.round(
139
+ Math.max(40, Math.min(240, body.knobs.bpm)),
140
+ );
141
+
142
+ const args = [
143
+ "--bpm",
144
+ String(knobBpm),
145
+ "--instrument",
146
+ body.knobs.instrument,
147
+ "--prompt",
148
+ prompt,
149
+ "--out",
150
+ outPath,
151
+ ];
152
+ // Local model + negative prompt are CPU-only concepts. The API fixes the
153
+ // model (stable-audio-3) and accepts NO negative prompt.
154
+ if (mode === "local") {
155
+ args.unshift("--model", model);
156
+ args.push("--negative-prompt", negativePrompt);
157
+ }
158
+ if (body.midi) {
159
+ // MIDI: the take only provides response DURATION (tempo comes from the
160
+ // knob). Pass --midi so the adapter reads its length for duration.
161
+ const midiPath = join(tmpdir(), `jambuddy-take-${Date.now()}.mid`);
162
+ await writeFile(midiPath, Buffer.from(body.midi, "base64"));
163
+ args.push("--midi", midiPath);
164
+ console.log(`[jambuddy] MIDI take: duration from it; tempo = knob ${knobBpm}`);
165
+ } else if (body.audio) {
166
+ // Audio: pass to SA3 via init_audio so it responds to the groove. Tempo
167
+ // is still the knob; the take sets duration + drives audio-to-audio.
168
+ const audioPath = join(tmpdir(), `jambuddy-take-${Date.now()}.wav`);
169
+ await writeFile(audioPath, Buffer.from(body.audio, "base64"));
170
+ args.push("--wav", audioPath);
171
+ args.push("--genre", body.knobs.genre);
172
+ console.log(`[jambuddy] audio take: audio-to-audio; tempo = ${knobBpm}`);
173
+ } else {
174
+ // No take: 4 bars in 4/4 = 16 beats at the knob tempo.
175
+ // seconds = beats * (60 / bpm) = 16 * 60 / bpm = 960 / bpm.
176
+ const bars4 = 960 / knobBpm;
177
+ args.push("--duration", String(Math.max(1, bars4)));
178
+ console.log(`[jambuddy] no take: 4 bars = ${bars4.toFixed(2)}s @ ${knobBpm} BPM`);
179
+ }
180
+
181
+ const t0 = Date.now();
182
+ // The Python child needs STABILITY_API_KEY in its env for API mode. Read it
183
+ // from the repo-root .env (the child's cwd is apps/web, so it can't find it
184
+ // itself) and pass it explicitly. Local mode ignores it.
185
+ let childEnv = process.env;
186
+ if (mode === "api") {
187
+ const dotenvPath = join(repoRoot, ".env");
188
+ let apiKey = process.env.STABILITY_API_KEY ?? null;
189
+ if (!apiKey) {
190
+ try {
191
+ const dotenvText = await readFile(dotenvPath, "utf8");
192
+ const m = dotenvText.match(/^STABILITY_API_KEY=(.+)$/m);
193
+ const val = m?.[1];
194
+ if (val) apiKey = val.trim().replace(/^["']|["']$/g, "");
195
+ } catch {
196
+ /* .env missing — the child will error clearly */
197
+ }
198
+ }
199
+ if (!apiKey) {
200
+ throw new Error(
201
+ "STABILITY_API_KEY not found in repo .env or process env (required for api mode)",
202
+ );
203
+ }
204
+ childEnv = { ...process.env, STABILITY_API_KEY: apiKey };
205
+ }
206
+ const { stdout } = await execFileAsync(python, [script, ...args], {
207
+ timeout: 300_000, // 5 min — SA3 generation on CPU is slow
208
+ maxBuffer: 10 * 1024 * 1024,
209
+ env: childEnv,
210
+ });
211
+ const generateMs = Date.now() - t0;
212
+ console.log("[jambuddy] stdout:", stdout);
213
+ console.log("[jambuddy] generate time:", generateMs, "ms");
214
+
215
+ // The script prints the tempo it used, e.g.
216
+ // "Detected BPM (MIDI): 117 from ..." (with a take)
217
+ // "Using explicit BPM: 120" (manual knob)
218
+ // "Wrote ...: 30.0s @ 117 BPM"
219
+ // Parse the final @ <n> BPM so the GUI can show the tempo the buddy locked
220
+ // onto. Default to the manual bpm knob.
221
+ let usedBpm = bpm;
222
+ const atMatch = stdout.match(/@\s*([\d.]+)\s*BPM/i);
223
+ if (atMatch && atMatch[1]) usedBpm = Math.round(parseFloat(atMatch[1]));
224
+ console.log("[jambuddy] used BPM:", usedBpm);
225
+
226
+ const wav = await readFile(outPath);
227
+ // API returns MP3, local returns WAV — serve the right content-type.
228
+ const contentType = mode === "api" ? "audio/mpeg" : "audio/wav";
229
+ const filename = mode === "api" ? "buddy_response.mp3" : "buddy_response.wav";
230
+ return new NextResponse(wav, {
231
+ headers: {
232
+ "Content-Type": contentType,
233
+ "Content-Disposition": `attachment; filename="${filename}"`,
234
+ "X-Jam-Buddy-BPM": String(usedBpm),
235
+ "X-Jam-Buddy-Time": String(generateMs),
236
+ },
237
+ });
238
+ } catch (err) {
239
+ console.error("[jambuddy] error:", err);
240
+ // Surface the underlying stderr so a broken pipeline is diagnosable from
241
+ // the browser console, not a blank "generation failed".
242
+ const detail =
243
+ err instanceof Error
244
+ ? (err as Error & { stderr?: string }).stderr?.trim() || err.message
245
+ : String(err);
246
+ return NextResponse.json({ error: "generation failed", detail }, { status: 500 });
247
+ }
248
+ }
apps/web/app/globals.css ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ /*
6
+ * Jam Buddy hardware-sampler rack.
7
+ * Self-contained dark panel with a rotary-knob look and trigger pads.
8
+ * Kept as plain CSS so it doesn't fight the Tailwind theme tokens, and
9
+ * accessibility is preserved (range inputs, focus rings, aria-live).
10
+ */
11
+ .jambuddy-rack {
12
+ background: linear-gradient(180deg, #1c1e2b 0%, #12131b 100%);
13
+ border: 1px solid #2a2d3d;
14
+ }
15
+
16
+ .jambuddy-pad {
17
+ border-radius: 0.5rem;
18
+ border: 1px solid #2a2d3d;
19
+ background: #1f2130;
20
+ color: #7f829c;
21
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
22
+ font-size: 0.7rem;
23
+ text-transform: uppercase;
24
+ letter-spacing: 0.08em;
25
+ padding: 0.6rem 0;
26
+ transition: background 0.15s, color 0.15s, box-shadow 0.15s;
27
+ }
28
+ .jambuddy-pad:hover {
29
+ background: #2a2d3d;
30
+ }
31
+ .jambuddy-pad--on {
32
+ background: #f4a261;
33
+ color: #12131b;
34
+ box-shadow: 0 0 12px rgba(244, 162, 97, 0.4);
35
+ font-weight: bold;
36
+ }
37
+
38
+ /* Rotary knob = a styled <input type=range>. A metallic potentiometer: a
39
+ * brushed-aluminum body with a knurled edge, a gloss highlight, and a real
40
+ * pointer line that rotates with the value (read off --knob-pct). */
41
+ .jambuddy-knob {
42
+ -webkit-appearance: none;
43
+ appearance: none;
44
+ width: 3.5rem;
45
+ height: 3.5rem;
46
+ border-radius: 9999px;
47
+ background:
48
+ radial-gradient(circle at 50% 50%, #dfe3ea 0%, #aeb4c0 34%, #5b6270 68%, #3a3f4a 100%);
49
+ border: 2px solid #2a2d3d;
50
+ cursor: pointer;
51
+ position: relative;
52
+ box-shadow:
53
+ inset 0 2px 3px rgba(255, 255, 255, 0.5),
54
+ inset 0 -4px 8px rgba(0, 0, 0, 0.45),
55
+ 0 3px 6px rgba(0, 0, 0, 0.6);
56
+ /* Pointer line + knurl tick marks drawn on top of the metal body. */
57
+ }
58
+ /* The arc: a conic-gradient slice rotated to the knob's angle. --knob-rot is
59
+ * passed as an angle (e.g. 150deg), not a percentage, so the sweep is valid. */
60
+ .jambuddy-knob::before {
61
+ content: "";
62
+ position: absolute;
63
+ inset: 6px;
64
+ border-radius: 9999px;
65
+ background:
66
+ conic-gradient(
67
+ from -135deg,
68
+ transparent 0deg,
69
+ transparent calc(var(--knob-rot, 0deg) - 6deg),
70
+ #12131b calc(var(--knob-rot, 0deg) - 6deg) var(--knob-rot, 0deg),
71
+ transparent var(--knob-rot, 0deg) 360deg
72
+ );
73
+ opacity: 0.9;
74
+ }
75
+ /* The pointer line itself (the "needle"). */
76
+ .jambuddy-knob::after {
77
+ content: "";
78
+ position: absolute;
79
+ top: 50%;
80
+ left: 50%;
81
+ width: 6px;
82
+ height: 26px;
83
+ border-radius: 2px;
84
+ background: linear-gradient(180deg, #14161d, #3a3f4a 45%, #14161d);
85
+ box-shadow: 0 0 2px rgba(0, 0, 0, 0.8);
86
+ transform: translate(-50%, -100%) rotate(var(--knob-rot, 0deg));
87
+ transform-origin: 50% 100%;
88
+ }
89
+ /* Range thumb hidden — the pointer is drawn by ::after so the native thumb is
90
+ invisible; keep it transparent to preserve drag interaction. */
91
+ .jambuddy-knob::-webkit-slider-thumb {
92
+ -webkit-appearance: none;
93
+ appearance: none;
94
+ width: 0;
95
+ height: 0;
96
+ }
97
+ .jambuddy-knob::-moz-range-thumb {
98
+ width: 0;
99
+ height: 0;
100
+ }
101
+
102
+ /* The big trigger button */
103
+ .jambuddy-trigger {
104
+ border-radius: 0.5rem;
105
+ border: none;
106
+ background: linear-gradient(180deg, #f4a261 0%, #e07b3a 100%);
107
+ color: #12131b;
108
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
109
+ font-size: 1rem;
110
+ font-weight: 700;
111
+ letter-spacing: 0.12em;
112
+ padding: 0.9rem 0;
113
+ cursor: pointer;
114
+ box-shadow: 0 2px 0 #a85a24;
115
+ }
116
+ .jambuddy-trigger:hover {
117
+ filter: brightness(1.05);
118
+ }
119
+ .jambuddy-trigger:disabled {
120
+ opacity: 0.5;
121
+ cursor: wait;
122
+ }
123
+
124
+ /* Big RED record button — a physical record arm with a glowing tip. */
125
+ .jambuddy-record {
126
+ border-radius: 0.5rem;
127
+ border: 1px solid #7a1c1c;
128
+ background: linear-gradient(180deg, #d64545 0%, #a11f1f 55%, #7a1414 100%);
129
+ color: #ffe9e9;
130
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
131
+ font-size: 1rem;
132
+ font-weight: 800;
133
+ letter-spacing: 0.12em;
134
+ padding: 0.9rem 0;
135
+ cursor: pointer;
136
+ box-shadow:
137
+ inset 0 1px 0 rgba(255, 255, 255, 0.35),
138
+ inset 0 -3px 6px rgba(0, 0, 0, 0.4),
139
+ 0 2px 0 #5c0f0f;
140
+ position: relative;
141
+ }
142
+ .jambuddy-record:hover {
143
+ filter: brightness(1.08);
144
+ }
145
+ .jambuddy-record[aria-pressed="true"] {
146
+ background: linear-gradient(180deg, #e05252 0%, #b52424 55%, #8a1616 100%);
147
+ box-shadow:
148
+ inset 0 2px 4px rgba(0, 0, 0, 0.55),
149
+ 0 0 14px rgba(224, 60, 60, 0.6),
150
+ 0 1px 0 #5c0f0f;
151
+ transform: translateY(1px);
152
+ }
153
+ .jambuddy-record:disabled {
154
+ opacity: 0.5;
155
+ cursor: wait;
156
+ }
157
+
158
+ /* Physical hardware toggle for the engine (API / Local). A labelled checkbox
159
+ * hidden inside; the visual is a metal slide switch with a chunky lever that
160
+ * travels between two detent positions (API left, Local right), matching the
161
+ * hardware-sampler aesthetic. Keyboard/screenreader usable via the input. */
162
+ .engine-toggle {
163
+ position: relative;
164
+ display: inline-flex;
165
+ align-items: center;
166
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
167
+ font-size: 0.68rem;
168
+ text-transform: uppercase;
169
+ letter-spacing: 0.06em;
170
+ }
171
+ .engine-toggle input {
172
+ position: absolute;
173
+ opacity: 0;
174
+ width: 0;
175
+ height: 0;
176
+ }
177
+ /* The switch body — a recessed metal channel with a center detent notch. */
178
+ .engine-toggle__switch {
179
+ position: relative;
180
+ display: inline-flex;
181
+ align-items: center;
182
+ width: 6.5rem;
183
+ height: 2rem;
184
+ border-radius: 0.4rem;
185
+ background: linear-gradient(180deg, #4a4f5a 0%, #2c3038 60%, #1e2127 100%);
186
+ border: 1px solid #17191e;
187
+ box-shadow:
188
+ inset 0 2px 5px rgba(0, 0, 0, 0.7),
189
+ 0 1px 1px rgba(255, 255, 255, 0.12);
190
+ padding: 0 0.35rem;
191
+ }
192
+ /* Labels pinned to each side of the lever travel. */
193
+ .engine-toggle__label {
194
+ position: absolute;
195
+ top: 50%;
196
+ transform: translateY(-50%);
197
+ color: #8b92a0;
198
+ font-weight: 700;
199
+ z-index: 1;
200
+ pointer-events: none;
201
+ }
202
+ .engine-toggle__label--api {
203
+ left: 0.45rem;
204
+ }
205
+ .engine-toggle__label--local {
206
+ right: 0.3rem;
207
+ }
208
+ /* The chunky metal lever — slides to whichever side is active. */
209
+ .engine-toggle__lever {
210
+ position: absolute;
211
+ top: 50%;
212
+ left: 0.3rem;
213
+ width: 2.6rem;
214
+ height: 1.5rem;
215
+ border-radius: 0.25rem;
216
+ background: linear-gradient(180deg, #f0e9dc 0%, #cfc7b8 40%, #a89f8e 100%);
217
+ border: 1px solid #6b6352;
218
+ box-shadow:
219
+ inset 0 1px 1px rgba(255, 255, 255, 0.7),
220
+ inset 0 -3px 5px rgba(0, 0, 0, 0.35),
221
+ 0 2px 3px rgba(0, 0, 0, 0.5);
222
+ transform: translateY(-50%);
223
+ transition: left 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
224
+ cursor: pointer;
225
+ }
226
+ /* A grip groove on the lever. */
227
+ .engine-toggle__lever::after {
228
+ content: "";
229
+ position: absolute;
230
+ top: 50%;
231
+ left: 50%;
232
+ transform: translate(-50%, -50%);
233
+ width: 1rem;
234
+ height: 3px;
235
+ border-radius: 2px;
236
+ background: rgba(0, 0, 0, 0.25);
237
+ box-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
238
+ }
239
+ /* Checked (API) -> lever slides left under the API label; active label glows. */
240
+ .engine-toggle input:checked + .engine-toggle__switch .engine-toggle__lever {
241
+ left: 0.3rem;
242
+ }
243
+ .engine-toggle input:not(:checked) + .engine-toggle__switch .engine-toggle__lever {
244
+ left: calc(100% - 2.9rem);
245
+ }
246
+ .engine-toggle input:checked + .engine-toggle__switch .engine-toggle__label--api {
247
+ color: #f4a261;
248
+ text-shadow: 0 0 6px rgba(244, 162, 97, 0.6);
249
+ }
250
+ .engine-toggle input:not(:checked) + .engine-toggle__switch .engine-toggle__label--local {
251
+ color: #5fd38a;
252
+ text-shadow: 0 0 6px rgba(95, 211, 138, 0.6);
253
+ }
254
+ .engine-toggle input:focus-visible + .engine-toggle__switch {
255
+ outline: 3px solid var(--focus);
256
+ outline-offset: 2px;
257
+ }
258
+ .engine-toggle input:disabled + .engine-toggle__switch {
259
+ opacity: 0.5;
260
+ cursor: not-allowed;
261
+ }
262
+ :root {
263
+ --bg: #ffffff;
264
+ --fg: #1a1a1a;
265
+ --accent: #0066cc;
266
+ --focus: #ffd700;
267
+ --limb-kick: #d62828;
268
+ --limb-snare: #1d3557;
269
+ --limb-hihat: #f4a261;
270
+ --limb-ride: #2a9d8f;
271
+ --limb-crash: #e76f51;
272
+ --limb-china: #8338ec;
273
+ }
274
+
275
+ @media (prefers-color-scheme: dark) {
276
+ :root {
277
+ --bg: #1a1a1a;
278
+ --fg: #f0f0f0;
279
+ --accent: #66b2ff;
280
+ }
281
+ }
282
+
283
+ @media (prefers-contrast: more) {
284
+ :root {
285
+ --fg: #000000;
286
+ --bg: #ffffff;
287
+ --accent: #0000ee;
288
+ --focus: #ff00ff;
289
+ }
290
+ }
291
+
292
+ /* Always-visible focus outline — WCAG 2.4.7 */
293
+ :focus-visible {
294
+ outline: 3px solid var(--focus);
295
+ outline-offset: 2px;
296
+ }
297
+
298
+ /* Skip links — pattern from docs/05-accessibility.md §Keyboard navigation */
299
+ .skip-link {
300
+ position: absolute;
301
+ top: -40px;
302
+ left: 0;
303
+ background: var(--accent);
304
+ color: var(--bg);
305
+ padding: 0.5rem 1rem;
306
+ z-index: 100;
307
+ }
308
+ .skip-link:focus {
309
+ top: 0;
310
+ }
311
+
312
+ /* Reduced motion — docs/05-accessibility.md §Reduced motion */
313
+ @media (prefers-reduced-motion: reduce) {
314
+ *,
315
+ *::before,
316
+ *::after {
317
+ animation-duration: 0.01ms !important;
318
+ transition-duration: 0.01ms !important;
319
+ }
320
+ }
apps/web/app/layout.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata, Viewport } from "next";
2
+ import "./globals.css";
3
+
4
+ export const metadata: Metadata = {
5
+ title: "Jam Buddy",
6
+ description:
7
+ "Jam Buddy — an AI music companion that listens to what you play and joins in, at your tempo, in the instrument you pick. Built for the Stability AI Challenge at Music Hackspace Montreal.",
8
+ };
9
+
10
+ export const viewport: Viewport = {
11
+ width: "device-width",
12
+ initialScale: 1,
13
+ themeColor: "#1a1a1a",
14
+ };
15
+
16
+ export default function RootLayout({
17
+ children,
18
+ }: {
19
+ children: React.ReactNode;
20
+ }) {
21
+ return (
22
+ <html lang="en">
23
+ <body className="min-h-screen bg-bg text-fg antialiased">
24
+ <a href="#main-content" className="skip-link">
25
+ Skip to main content
26
+ </a>
27
+ {children}
28
+ </body>
29
+ </html>
30
+ );
31
+ }
apps/web/app/page.tsx ADDED
@@ -0,0 +1,628 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useRef, useState } from "react";
4
+ import {
5
+ buildPrompt,
6
+ GENRE_LABELS,
7
+ GENRES,
8
+ INPUT_INSTRUMENTS,
9
+ INPUT_INSTRUMENT_LABELS,
10
+ INSTRUMENTS,
11
+ INSTRUMENT_LABELS,
12
+ MOOD_LABELS,
13
+ MOODS,
14
+ type BuddyGenre,
15
+ type BuddyInstrument,
16
+ type BuddyMood,
17
+ type InputInstrument,
18
+ } from "@/lib/jambuddy/prompt";
19
+ import { parseMidi, isPercussion, midiDuration } from "@/lib/jambuddy/player";
20
+ import { createMidiRecorder, recordedNotesAsFile, type MidiRecorder } from "@/lib/jambuddy/recorder";
21
+ import { Visualizer } from "@/lib/jambuddy/visualizer";
22
+
23
+ /**
24
+ * Jam Buddy — "you start playing, it joins in."
25
+ *
26
+ * A hardware-sampler-styled panel. The user sets the instrument (a knob), the
27
+ * style, and the tempo, optionally loads a MIDI take from a controller, then
28
+ * hits "JOIN IN" — the buddy responds at their tempo. The response plays back
29
+ * in the rack.
30
+ *
31
+ * Accessibility preserved: every knob is a labelled <input type=range> (keyboard
32
+ * operable + screenreader-readable), buttons have accessible names, and the
33
+ * status is announced via aria-live. Focus outline is the global :focus-visible.
34
+ */
35
+
36
+ /* A rotary-style knob. Under the hood a labelled <input type=range> so it works
37
+ * with a keyboard and a screen reader, wrapped in a dark hardware look. */
38
+ function Knob({
39
+ label,
40
+ value,
41
+ min,
42
+ max,
43
+ step,
44
+ onChange,
45
+ format = (v: number) => String(v),
46
+ disabled = false,
47
+ editable = false,
48
+ }: {
49
+ label: string;
50
+ value: number;
51
+ min: number;
52
+ max: number;
53
+ step?: number;
54
+ onChange: (v: number) => void;
55
+ format?: (v: number) => string;
56
+ disabled?: boolean;
57
+ /** Render the readout as a typeable number input (e.g. exact tempo). */
58
+ editable?: boolean;
59
+ }) {
60
+ const pct = ((value - min) / (max - min)) * 100;
61
+ // --knob-rot is an actual angle (degrees), NOT a percentage. CSS can't do
62
+ // calc(<percentage> * <angle>) — passing the degrees directly keeps the
63
+ // needle + arc in sync with the value.
64
+ const rot = `${pct * 3}deg`;
65
+ return (
66
+ <label className={`flex flex-col items-center gap-1 ${disabled ? "opacity-40" : ""}`}>
67
+ <span className="text-[10px] uppercase tracking-widest text-[#7f8c9b]">
68
+ {label}
69
+ </span>
70
+ <input
71
+ type="range"
72
+ min={min}
73
+ max={max}
74
+ step={step ?? 1}
75
+ value={value}
76
+ aria-label={label}
77
+ disabled={disabled}
78
+ onChange={(e) => onChange(Number(e.target.value))}
79
+ className="jambuddy-knob"
80
+ style={{ ["--knob-rot" as string]: rot }}
81
+ />
82
+ {editable ? (
83
+ <input
84
+ type="number"
85
+ min={min}
86
+ max={max}
87
+ step={step ?? 1}
88
+ value={value}
89
+ aria-label={`${label} value`}
90
+ disabled={disabled}
91
+ onChange={(e) => {
92
+ const v = e.target.value === "" ? min : Number(e.target.value);
93
+ onChange(Math.min(max, Math.max(min, v)));
94
+ }}
95
+ className="w-16 rounded border border-[#2a2d3d] bg-[#12131b] px-1 text-center font-mono text-sm font-bold text-[#e8e8f0]"
96
+ />
97
+ ) : (
98
+ <span className="font-mono text-sm font-bold text-[#e8e8f0]">
99
+ {disabled ? "from take" : format(value)}
100
+ </span>
101
+ )}
102
+ </label>
103
+ );
104
+ }
105
+
106
+ /* A trigger pad that selects an option and flashes as a visual affordance. */
107
+ function Pad({
108
+ label,
109
+ selected,
110
+ onSelect,
111
+ }: {
112
+ label: string;
113
+ selected: boolean;
114
+ onSelect: () => void;
115
+ }) {
116
+ return (
117
+ <button
118
+ type="button"
119
+ onClick={onSelect}
120
+ aria-pressed={selected}
121
+ className={`jambuddy-pad ${selected ? "jambuddy-pad--on" : ""}`}
122
+ >
123
+ {label}
124
+ </button>
125
+ );
126
+ }
127
+
128
+ export default function HomePage() {
129
+ const [instrument, setInstrument] = useState<BuddyInstrument>("bass");
130
+ const [inputInstrument, setInputInstrument] = useState<InputInstrument>("other");
131
+ const [genre, setGenre] = useState<BuddyGenre>("metal");
132
+ const [mood, setMood] = useState<BuddyMood>("energetic");
133
+ const [bpm, setBpm] = useState(184);
134
+ const [midiFile, setMidiFile] = useState<File | null>(null);
135
+ const [midiBytes, setMidiBytes] = useState<ArrayBuffer | null>(null);
136
+ const [audioFile, setAudioFile] = useState<File | null>(null);
137
+ const [status, setStatus] = useState<string>("Ready.");
138
+ const [audioUrl, setAudioUrl] = useState<string | null>(null);
139
+ const [usedBpm, setUsedBpm] = useState<number | null>(null);
140
+ const [usedSeconds, setUsedSeconds] = useState<number | null>(null);
141
+ const [busy, setBusy] = useState(false);
142
+ // Generation backend. API (Stable Audio 3.0 Large) is default when the key is
143
+ // present — fast + better isolation, 26 credits/gen. Local = CPU small model,
144
+ // free, supports the negative prompt, slower.
145
+ const [mode, setMode] = useState<"api" | "local">("api");
146
+ // Live MIDI capture. recRef holds the active recorder; recording is a state
147
+ // so the big RED button reflects it.
148
+ const recRef = useRef<MidiRecorder | null>(null);
149
+ const [recording, setRecording] = useState(false);
150
+ // Object URL of the last recorded MIDI take (for save + piano-roll).
151
+ const [recordedMidiUrl, setRecordedMidiUrl] = useState<string | null>(null);
152
+
153
+ const { prompt, negativePrompt } = buildPrompt({
154
+ instrument,
155
+ inputInstrument,
156
+ genre,
157
+ mood,
158
+ bpm,
159
+ });
160
+
161
+ function fileToBase64(file: File): Promise<string> {
162
+ return new Promise((resolve, reject) => {
163
+ const reader = new FileReader();
164
+ reader.onload = () => {
165
+ const result = reader.result as string;
166
+ const comma = result.indexOf(",");
167
+ resolve(comma >= 0 ? result.slice(comma + 1) : result);
168
+ };
169
+ reader.onerror = reject;
170
+ reader.readAsDataURL(file);
171
+ });
172
+ }
173
+
174
+ function fileToArrayBuffer(file: File): Promise<ArrayBuffer> {
175
+ return file.arrayBuffer();
176
+ }
177
+
178
+ /** Detect whether a take file is MIDI or audio from its name/MIME type. */
179
+ function isMidiFile(f: File): boolean {
180
+ return (
181
+ /\.mid$/i.test(f.name) ||
182
+ /\.midi$/i.test(f.name) ||
183
+ f.type === "audio/midi" ||
184
+ f.type === "audio/x-midi"
185
+ );
186
+ }
187
+
188
+ /** Handle a single uploaded take (MIDI or audio), auto-detecting the type. */
189
+ async function handleTakeFile(f: File | null) {
190
+ // One upload slot for either kind; picking a new file clears the old take.
191
+ setMidiFile(null);
192
+ setMidiBytes(null);
193
+ setAudioFile(null);
194
+ if (!f) {
195
+ setStatus("Ready.");
196
+ return;
197
+ }
198
+ if (isMidiFile(f)) {
199
+ setMidiFile(f);
200
+ const bytes = await fileToArrayBuffer(f);
201
+ setMidiBytes(bytes);
202
+ // Soft-default: if the MIDI has GM channel 9 notes, pre-select the
203
+ // "Drums" input-instrument pad. parseMidi throws on corrupt bytes; fall
204
+ // back to "other".
205
+ try {
206
+ setInputInstrument(parseMidi(bytes).some(isPercussion) ? "drums" : "other");
207
+ } catch {
208
+ setInputInstrument("other");
209
+ }
210
+ // Pre-fill the tempo knob from the take's tempo map (Option A: detect
211
+ // first, knob stays authoritative + editable).
212
+ await prefillTempo(f, "midi");
213
+ setStatus(
214
+ `Loaded ${f.name}. Tempo auto-detected (${bpm} BPM); adjust the knob if needed.`,
215
+ );
216
+ } else {
217
+ setAudioFile(f);
218
+ // Audio is heard by the buddy (audio-to-audio). Clear the MIDI-driven
219
+ // input-instrument default so the user's declaration reflects the audio.
220
+ setInputInstrument("other");
221
+ await prefillTempo(f, "audio");
222
+ setStatus(
223
+ `Loaded ${f.name}. Tempo auto-detected (${bpm} BPM); adjust the knob if needed.`,
224
+ );
225
+ }
226
+ }
227
+
228
+ /** Ask the server to detect BPM+duration and pre-fill the tempo knob. */
229
+ async function prefillTempo(f: File, kind: "midi" | "audio") {
230
+ try {
231
+ const base64 = await fileToBase64(f);
232
+ const res = await fetch("/api/jambuddy/detect", {
233
+ method: "POST",
234
+ headers: { "Content-Type": "application/json" },
235
+ body: JSON.stringify(
236
+ kind === "midi" ? { midi: base64 } : { audio: base64, genre },
237
+ ),
238
+ });
239
+ if (res.ok) {
240
+ const data = (await res.json()) as { bpm?: number; duration?: number };
241
+ if (typeof data.bpm === "number" && data.bpm > 0) {
242
+ setBpm(Math.round(Math.max(40, Math.min(240, data.bpm))));
243
+ }
244
+ return data;
245
+ }
246
+ } catch {
247
+ /* detection is best-effort; knob keeps its current value */
248
+ }
249
+ return null;
250
+ }
251
+
252
+ /** Toggle live MIDI capture. On stop, the take is written as a .mid file and
253
+ * fed through the normal take pipeline (tempo detect + duration + input). */
254
+ async function toggleRecord() {
255
+ if (recording) {
256
+ // Stop: convert recorded notes to a .mid File and treat as a MIDI take.
257
+ const rec = recRef.current;
258
+ if (rec) {
259
+ const { notes, durationSec } = rec.stop();
260
+ rec.dispose();
261
+ recRef.current = null;
262
+ setRecording(false);
263
+ if (notes.length === 0) {
264
+ setStatus("Recording stopped — no notes captured.");
265
+ return;
266
+ }
267
+ // Use the current knob BPM (or 120) for the tempo map of the .mid.
268
+ const file = recordedNotesAsFile(notes, bpm || 120);
269
+ // Keep an object URL so the user can save the .mid and see its roll.
270
+ if (recordedMidiUrl) URL.revokeObjectURL(recordedMidiUrl);
271
+ setRecordedMidiUrl(URL.createObjectURL(file));
272
+ setStatus(
273
+ `Captured ${notes.length} notes (${durationSec.toFixed(1)}s). Detecting tempo…`,
274
+ );
275
+ await handleTakeFile(file);
276
+ } else {
277
+ setRecording(false);
278
+ }
279
+ return;
280
+ }
281
+ // Start: create the recorder + begin listening.
282
+ setStatus("Connecting to MIDI controller…");
283
+ try {
284
+ const rec = await createMidiRecorder();
285
+ recRef.current = rec;
286
+ rec.start();
287
+ setRecording(true);
288
+ setStatus("● RECORDING — play your take, then press stop.");
289
+ } catch (e) {
290
+ setStatus(`MIDI record unavailable: ${String(e)}`);
291
+ }
292
+ }
293
+
294
+ /** Download the last recorded MIDI take as a .mid file. */
295
+ function saveRecordedMidi() {
296
+ if (!recordedMidiUrl) {
297
+ setStatus("Record a take first, then save it.");
298
+ return;
299
+ }
300
+ const a = document.createElement("a");
301
+ a.href = recordedMidiUrl;
302
+ a.download = `jambuddy-live-capture-${new Date()
303
+ .toISOString()
304
+ .replace(/[-:]/g, "")
305
+ .replace(/\.\d+Z$/, "Z")}.mid`;
306
+ a.rel = "noopener";
307
+ document.body.appendChild(a);
308
+ a.click();
309
+ document.body.removeChild(a);
310
+ setStatus("Saved your recorded MIDI take.");
311
+ }
312
+
313
+ async function joinIn() {
314
+ setBusy(true);
315
+ setStatus(
316
+ mode === "api"
317
+ ? "Producing… (Stable Audio API, ~20s)."
318
+ : "Producing… this can take a minute on CPU.",
319
+ );
320
+ setAudioUrl(null);
321
+ setUsedBpm(null);
322
+ setUsedSeconds(null);
323
+ try {
324
+ const payload: {
325
+ knobs: {
326
+ instrument: BuddyInstrument;
327
+ inputInstrument: InputInstrument;
328
+ genre: BuddyGenre;
329
+ mood: BuddyMood;
330
+ bpm: number;
331
+ };
332
+ bpm?: number;
333
+ midi?: string;
334
+ audio?: string;
335
+ duration?: number;
336
+ mode: "api" | "local";
337
+ } = { knobs: { instrument, inputInstrument, genre, mood, bpm }, mode };
338
+
339
+ if (midiFile) {
340
+ setStatus("Reading your MIDI take…");
341
+ const base64 = await fileToBase64(midiFile);
342
+ payload.midi = base64;
343
+ // Detect tempo from the take, and match its length so the response
344
+ // starts and ends together with it (in tempo).
345
+ delete payload.bpm;
346
+ if (midiBytes) payload.duration = Math.max(1, midiDuration(midiBytes));
347
+ } else if (audioFile) {
348
+ setStatus("Reading your audio take…");
349
+ const base64 = await fileToBase64(audioFile);
350
+ payload.audio = base64;
351
+ // Audio-to-audio: the buddy responds to the groove.
352
+ delete payload.bpm;
353
+ }
354
+
355
+ const res = await fetch("/api/jambuddy", {
356
+ method: "POST",
357
+ headers: { "Content-Type": "application/json" },
358
+ body: JSON.stringify(payload),
359
+ });
360
+ if (!res.ok) {
361
+ const err = await res.json().catch(() => null);
362
+ setStatus(`Production failed: ${err?.detail ?? err?.error ?? res.status}`);
363
+ return;
364
+ }
365
+ // The route returns the tempo it locked onto + the wall-clock generate time.
366
+ const headerBpm = res.headers.get("X-Jam-Buddy-BPM");
367
+ if (headerBpm) setUsedBpm(Math.round(Number(headerBpm)));
368
+ const headerTime = res.headers.get("X-Jam-Buddy-Time");
369
+ if (headerTime) setUsedSeconds(Number(headerTime) / 1000);
370
+ const blob = await res.blob();
371
+ setAudioUrl(URL.createObjectURL(blob));
372
+ setStatus("Done — your buddy produced a response.");
373
+ } catch (e) {
374
+ setStatus(`Error: ${String(e)}`);
375
+ } finally {
376
+ setBusy(false);
377
+ }
378
+ }
379
+
380
+ return (
381
+ <main
382
+ id="main-content"
383
+ className="mx-auto max-w-3xl px-4 py-8"
384
+ aria-labelledby="page-title"
385
+ >
386
+ <div className="jambuddy-rack rounded-2xl p-6 shadow-2xl">
387
+ <header className="mb-6 flex items-center justify-between border-b border-[#2a2d3d] pb-3">
388
+ <h1
389
+ id="page-title"
390
+ className="font-mono text-2xl font-bold tracking-tight text-[#e8e8f0]"
391
+ >
392
+ JAM <span className="text-[#f4a261]">BUDDY</span>
393
+ </h1>
394
+ <span className="rounded bg-[#2a2d3d] px-2 py-1 font-mono text-[10px] uppercase tracking-widest text-[#7f829c]">
395
+ rack • v0.1
396
+ </span>
397
+ </header>
398
+
399
+ {/* Instrument / trigger pads */}
400
+ <section aria-labelledby="pads-label" className="mb-6">
401
+ <h2
402
+ id="pads-label"
403
+ className="mb-2 font-mono text-[10px] uppercase tracking-widest text-[#7f829c]"
404
+ >
405
+ Instrument
406
+ </h2>
407
+ <div className="grid grid-cols-4 gap-2 sm:grid-cols-8">
408
+ {INSTRUMENTS.map((inst) => (
409
+ <Pad
410
+ key={inst}
411
+ label={INSTRUMENT_LABELS[inst]}
412
+ selected={instrument === inst}
413
+ onSelect={() => setInstrument(inst)}
414
+ />
415
+ ))}
416
+ </div>
417
+ </section>
418
+
419
+ {/* Knob row */}
420
+ <section
421
+ aria-label="Style controls"
422
+ className="mb-6 grid grid-cols-3 gap-4 rounded-xl bg-[#15161f] p-4"
423
+ >
424
+ <Knob
425
+ label="Genre"
426
+ value={GENRES.indexOf(genre)}
427
+ min={0}
428
+ max={GENRES.length - 1}
429
+ onChange={(v) => setGenre(GENRES[v] ?? "metal")}
430
+ format={(v) => GENRE_LABELS[GENRES[v] ?? "metal"] ?? ""}
431
+ />
432
+ <Knob
433
+ label="Mood"
434
+ value={MOODS.indexOf(mood)}
435
+ min={0}
436
+ max={MOODS.length - 1}
437
+ onChange={(v) => setMood(MOODS[v] ?? "energetic")}
438
+ format={(v) => MOOD_LABELS[MOODS[v] ?? "energetic"] ?? ""}
439
+ />
440
+ <Knob
441
+ label="Tempo"
442
+ value={bpm}
443
+ min={60}
444
+ max={220}
445
+ step={1}
446
+ onChange={setBpm}
447
+ format={(v) => `${v} BPM`}
448
+ editable
449
+ />
450
+ </section>
451
+
452
+ {/* Your-take-is knob — declares what instrument the user is playing
453
+ so the buddy can complement it (no MIR on input). */}
454
+ <section
455
+ aria-label="Your take"
456
+ className="mb-6 rounded bg-[#1f2130] p-4"
457
+ >
458
+ <h2 className="mb-2 font-mono text-[10px] uppercase tracking-widest text-[#7f829c]">
459
+ Your take is
460
+ </h2>
461
+ <p className="mb-2 text-[11px] text-[#7f829c]">
462
+ For MIDI we can&apos;t read the notes, so tell us what you&apos;re
463
+ playing — it helps the buddy complement (not duplicate) it. Audio is
464
+ heard directly (audio-to-audio).
465
+ </p>
466
+ <div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
467
+ {INPUT_INSTRUMENTS.map((i) => (
468
+ <Pad
469
+ key={i}
470
+ label={INPUT_INSTRUMENT_LABELS[i]}
471
+ selected={inputInstrument === i}
472
+ onSelect={() => setInputInstrument(i)}
473
+ />
474
+ ))}
475
+ </div>
476
+ </section>
477
+
478
+ {/* Take input — MIDI or audio */}
479
+ <section
480
+ aria-labelledby="take-label"
481
+ className="mb-6 rounded bg-[#1f2130] p-4"
482
+ >
483
+ <h2
484
+ id="take-label"
485
+ className="mb-2 font-mono text-[10px] uppercase tracking-widest text-[#7f829c]"
486
+ >
487
+ Your take (optional)
488
+ </h2>
489
+ <p className="mb-2 text-[11px] text-[#7f829c]">
490
+ MIDI sets tempo + length; audio makes the buddy respond to your groove.
491
+ Drop either in the slot below.
492
+ </p>
493
+ <label className="flex flex-col gap-1">
494
+ <input
495
+ type="file"
496
+ accept=".mid,.midi,.wav,.mp3,.aiff,.flac,audio/midi,audio/x-midi,audio/*"
497
+ aria-label="Upload your take — MIDI or audio"
498
+ onChange={(e) => handleTakeFile(e.target.files?.[0] ?? null)}
499
+ className="block w-full rounded border border-[#2a2d3d] bg-[#12131b] px-3 py-2 text-sm text-[#c9c9d6] file:mr-3 file:rounded file:border-0 file:bg-[#f4a261] file:px-3 file:py-1 file:font-bold file:text-[#12131b]"
500
+ />
501
+ </label>
502
+ {midiFile && (
503
+ <p className="mt-2 text-xs text-[#7f829c]">
504
+ {midiFile.name} — MIDI: tempo + length detected from it.
505
+ </p>
506
+ )}
507
+ {audioFile && (
508
+ <p className="mt-2 text-xs text-[#7f829c]">
509
+ {audioFile.name} — audio: buddy responds to its groove (audio-to-audio).
510
+ </p>
511
+ )}
512
+ </section>
513
+
514
+ {/* Transport */}
515
+ <div className="mb-3 flex items-center gap-3">
516
+ <span className="font-mono text-[10px] uppercase tracking-widest text-[#7f829c]">
517
+ Engine
518
+ </span>
519
+ <label className="engine-toggle">
520
+ <input
521
+ type="checkbox"
522
+ checked={mode === "api"}
523
+ onChange={(e) => setMode(e.target.checked ? "api" : "local")}
524
+ />
525
+ <span className="engine-toggle__switch">
526
+ <span className="engine-toggle__label engine-toggle__label--api">API</span>
527
+ <span className="engine-toggle__lever" aria-hidden="true" />
528
+ <span className="engine-toggle__label engine-toggle__label--local">Local</span>
529
+ </span>
530
+ </label>
531
+ <span className="font-mono text-[10px] text-[#7f829c]">
532
+ {mode === "api"
533
+ ? "Stable Audio 3.0 Large · 26 credits/gen · ~20s"
534
+ : "Local CPU · free · supports negative prompt · slower"}
535
+ </span>
536
+ </div>
537
+ <div className="mb-2 flex items-center gap-4">
538
+ <button
539
+ type="button"
540
+ onClick={toggleRecord}
541
+ disabled={busy}
542
+ aria-pressed={recording}
543
+ aria-label={recording ? "Stop recording" : "Record from MIDI controller"}
544
+ className="jambuddy-record flex-1"
545
+ >
546
+ {recording ? "■ STOP" : "● RECORD"}
547
+ </button>
548
+ <button
549
+ type="button"
550
+ onClick={saveRecordedMidi}
551
+ disabled={!recordedMidiUrl || busy}
552
+ className="jambuddy-trigger flex-1"
553
+ style={{
554
+ background: "linear-gradient(180deg,#5fd38a 0%,#3aa55f 100%)",
555
+ boxShadow: "0 2px 0 #256b3f",
556
+ }}
557
+ >
558
+ SAVE MIDI
559
+ </button>
560
+ <button
561
+ type="button"
562
+ onClick={joinIn}
563
+ disabled={busy}
564
+ className="jambuddy-trigger flex-1"
565
+ >
566
+ {busy ? "PRODUCING…" : "JOIN IN"}
567
+ </button>
568
+ <div className="font-mono text-xs text-[#7f829c]">
569
+ <div className="uppercase tracking-widest">Prompt</div>
570
+ <div className="mt-1 max-w-[16rem] truncate text-[#e8e8f0]">
571
+ {prompt}
572
+ </div>
573
+ <div className="mt-1 opacity-60">neg: {negativePrompt}</div>
574
+ </div>
575
+ </div>
576
+
577
+ {/* Status + playback */}
578
+ <section
579
+ aria-live="polite"
580
+ aria-label="Status"
581
+ className="rounded bg-[#12131b] p-4"
582
+ >
583
+ <div className="flex items-center justify-between">
584
+ <span className="font-mono text-xs text-[#7f829c]">STATUS</span>
585
+ <span
586
+ className={`h-2 w-2 rounded-full ${
587
+ busy ? "bg-[#f4a261]" : audioUrl ? "bg-[#5fd38a]" : "bg-[#4a4d5e]"
588
+ }`}
589
+ />
590
+ </div>
591
+ <p className="mt-1 text-sm text-[#e8e8f0]">{status}</p>
592
+ {usedBpm !== null && (
593
+ <p className="mt-2 font-mono text-sm font-bold text-[#f4a261]">
594
+ Buddy tempo: {usedBpm} BPM
595
+ </p>
596
+ )}
597
+ {usedSeconds !== null && (
598
+ <p className="mt-1 font-mono text-xs text-[#7f829c]">
599
+ Generated in {usedSeconds.toFixed(1)}s
600
+ </p>
601
+ )}
602
+ {audioUrl && (
603
+ <audio controls src={audioUrl} className="mt-3 w-full">
604
+ Your browser does not support audio playback.
605
+ </audio>
606
+ )}
607
+ </section>
608
+
609
+ {/* Take + response visualizers */}
610
+ {(midiBytes || audioUrl) && (
611
+ <section
612
+ aria-label="Take and response"
613
+ className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2"
614
+ >
615
+ <Visualizer
616
+ midiBytes={midiBytes}
617
+ label="Your take (MIDI piano-roll)"
618
+ />
619
+ <Visualizer
620
+ audioUrl={audioUrl}
621
+ label="Buddy response (waveform)"
622
+ />
623
+ </section>
624
+ )}
625
+ </div>
626
+ </main>
627
+ );
628
+ }
apps/web/data/onomatopoeia.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": 1,
3
+ "entries": [
4
+ {
5
+ "id": "skank-beat",
6
+ "patterns": ["tupatupatupa", "tupa-tupa-tupa", "chka-chka-chka", "tupa tupa tupa"],
7
+ "patternId": "skank",
8
+ "defaultCymbal": { "type": "hihat-open", "pattern": "upstrokes" },
9
+ "confidence": 0.9,
10
+ "notes": "Ska-style offbeat hi-hat upstrokes. The canonical demo phrase."
11
+ },
12
+ {
13
+ "id": "blast-beat",
14
+ "patterns": ["krrk-krrk-krrk", "krrk krrk krrk", "BLAM-BLAM-BLAM", "krkrkrkrk"],
15
+ "patternId": "blast-traditional",
16
+ "defaultIntensity": "brutal",
17
+ "confidence": 0.85,
18
+ "notes": "Traditional blast beat. Kick-snare alternation."
19
+ },
20
+ {
21
+ "id": "boom-bap",
22
+ "patterns": ["boom-bap", "boom bap", "boom-bap-boom-bap"],
23
+ "patternId": "hiphop-basic",
24
+ "confidence": 0.95
25
+ },
26
+ {
27
+ "id": "china-accent",
28
+ "patterns": ["tss", "chka"],
29
+ "patternId": "china-accent",
30
+ "defaultCymbal": { "type": "china", "pattern": "wash" }
31
+ },
32
+ {
33
+ "id": "ride-bell",
34
+ "patterns": ["ding-ding-ding", "ding ding ding", "ding-ding-ding-ding"],
35
+ "patternId": "ride-bell-8ths",
36
+ "defaultCymbal": { "type": "ride", "pattern": "bell" }
37
+ }
38
+ ]
39
+ }
apps/web/data/patterns/d-beat.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "d-beat",
3
+ "name": "D-Beat",
4
+ "description": "D-beat pattern at 180 BPM. Kick plays on every beat with 8th-note doubles, snare hits on 2 and 4, ride bell plays steady 8ths throughout. Common in Discharge, Entombed, and Scandinavian crust punk.",
5
+ "tags": ["metal", "punk", "discharge", "entombed", "crust", "fast"],
6
+ "genre": "metal",
7
+ "defaultTempo": 180,
8
+ "defaultBars": 1,
9
+ "swingRatio": 0,
10
+ "hits": [
11
+ { "position": 0.0, "limb": "kick", "velocity": 110 },
12
+ { "position": 0.125, "limb": "kick", "velocity": 80 },
13
+ { "position": 0.25, "limb": "snare", "velocity": 100 },
14
+ { "position": 0.375, "limb": "kick", "velocity": 80 },
15
+ { "position": 0.5, "limb": "kick", "velocity": 110 },
16
+ { "position": 0.625, "limb": "kick", "velocity": 80 },
17
+ { "position": 0.75, "limb": "snare", "velocity": 100 },
18
+ { "position": 0.875, "limb": "kick", "velocity": 80 },
19
+ { "position": 0.0, "limb": "ride-bell", "velocity": 70 },
20
+ { "position": 0.125, "limb": "ride-bell", "velocity": 70 },
21
+ { "position": 0.25, "limb": "ride-bell", "velocity": 70 },
22
+ { "position": 0.375, "limb": "ride-bell", "velocity": 70 },
23
+ { "position": 0.5, "limb": "ride-bell", "velocity": 70 },
24
+ { "position": 0.625, "limb": "ride-bell", "velocity": 70 },
25
+ { "position": 0.75, "limb": "ride-bell", "velocity": 70 },
26
+ { "position": 0.875, "limb": "ride-bell", "velocity": 70 }
27
+ ]
28
+ }
apps/web/data/patterns/skank.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "skank",
3
+ "name": "Skank Beat",
4
+ "description": "Skank beat at 120 BPM. Kick on 1 and 3, snare on 2 and 4, hi-hat plays on every upbeat — the 'and' of each beat. Ska upstroke feel.",
5
+ "tags": ["ska", "punk", "upstrokes", "offbeat"],
6
+ "genre": "punk",
7
+ "defaultTempo": 120,
8
+ "defaultBars": 1,
9
+ "swingRatio": 0,
10
+ "hits": [
11
+ { "position": 0.0, "limb": "kick", "velocity": 100 },
12
+ { "position": 0.5, "limb": "kick", "velocity": 100 },
13
+ { "position": 0.25, "limb": "snare", "velocity": 100 },
14
+ { "position": 0.75, "limb": "snare", "velocity": 100 },
15
+ { "position": 0.125, "limb": "hihat", "velocity": 80 },
16
+ { "position": 0.375, "limb": "hihat", "velocity": 80 },
17
+ { "position": 0.625, "limb": "hihat", "velocity": 80 },
18
+ { "position": 0.875, "limb": "hihat", "velocity": 80 }
19
+ ]
20
+ }
apps/web/lib/jambuddy/player.ts ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Jam Buddy playback.
3
+ *
4
+ * Plays the user's MIDI take and the buddy's generated response TOGETHER, in
5
+ * tempo, via the Web Audio API.
6
+ *
7
+ * - The MIDI take is rendered to audio with a lightweight synth (an oscillator
8
+ * per note) so it's audible as "your" part, distinct from the buddy.
9
+ * - The generated response is a decoded AudioBuffer (the buddy's WAV).
10
+ * - Both are scheduled on the SAME AudioContext clock starting together, so
11
+ * they stay in sync.
12
+ *
13
+ * This module is browser-only (window.AudioContext). It does not run under
14
+ * Node — keep all Web Audio in here and call from the page.
15
+ */
16
+
17
+ import { Midi } from "@tonejs/midi";
18
+
19
+ /** A parsed note: absolute start time (sec), midi note, velocity 0-1. */
20
+ export interface ParsedNote {
21
+ time: number;
22
+ midi: number;
23
+ duration: number;
24
+ velocity: number;
25
+ /** GM channel 0-15; 9 = percussion. Used to route drums to a click. */
26
+ channel: number;
27
+ }
28
+
29
+ /** Parse a MIDI file's bytes into playable notes (+ duration). */
30
+ export function parseMidi(bytes: ArrayBuffer): ParsedNote[] {
31
+ const midi = new Midi(bytes);
32
+ const notes: ParsedNote[] = [];
33
+ for (const track of midi.tracks) {
34
+ // tonejs exposes the channel on the Track (a plain number), not each Note.
35
+ const channel = track.channel ?? 0;
36
+ for (const note of track.notes) {
37
+ notes.push({
38
+ time: note.time,
39
+ midi: note.midi,
40
+ duration: note.duration,
41
+ velocity: note.velocity,
42
+ channel,
43
+ });
44
+ }
45
+ }
46
+ notes.sort((a, b) => a.time - b.time);
47
+ return notes;
48
+ }
49
+
50
+ /**
51
+ * Is this a percussion note (GM channel 9)? Drums render as a percussive
52
+ * click/noise burst so they're audible alongside the produced WAV, instead
53
+ * of a low thin oscillator that gets buried.
54
+ */
55
+ export function isPercussion(note: ParsedNote): boolean {
56
+ return note.channel === 9;
57
+ }
58
+
59
+ /** midi note -> frequency in Hz. */
60
+ export function midiToFreq(n: number): number {
61
+ return 440 * Math.pow(2, (n - 69) / 12);
62
+ }
63
+
64
+ /**
65
+ * Schedule a MIDI note as an audible source through a gain envelope.
66
+ * Percussion (GM channel 9) renders as a short noise-burst click so drum hits
67
+ * cut through the produced WAV; pitched notes render as a short oscillator.
68
+ */
69
+ function scheduleNote(
70
+ ctx: AudioContext,
71
+ note: ParsedNote,
72
+ dest: AudioNode,
73
+ when: number,
74
+ ) {
75
+ const gain = ctx.createGain();
76
+ const dur = Math.max(0.05, note.duration);
77
+ const vel = Math.max(0.2, note.velocity); // floor so quiet notes stay audible
78
+
79
+ if (isPercussion(note)) {
80
+ // Short noise burst (kick/snare-ish) so drum hits are clearly heard.
81
+ const len = Math.max(0.05, Math.min(0.15, dur));
82
+ const buf = ctx.createBuffer(1, Math.ceil(len * ctx.sampleRate), ctx.sampleRate);
83
+ const data = buf.getChannelData(0);
84
+ for (let i = 0; i < data.length; i++) {
85
+ data[i] = (Math.random() * 2 - 1) * Math.exp(-(i / data.length) * 6);
86
+ }
87
+ const src = ctx.createBufferSource();
88
+ src.buffer = buf;
89
+ gain.gain.setValueAtTime(0, when);
90
+ gain.gain.linearRampToValueAtTime(vel * 0.7, when + 0.005);
91
+ gain.gain.exponentialRampToValueAtTime(0.0001, when + len);
92
+ src.connect(gain);
93
+ gain.connect(dest);
94
+ src.start(when);
95
+ src.stop(when + len + 0.02);
96
+ return;
97
+ }
98
+
99
+ const osc = ctx.createOscillator();
100
+ osc.type = "triangle";
101
+ osc.frequency.value = midiToFreq(note.midi);
102
+
103
+ gain.gain.setValueAtTime(0, when);
104
+ gain.gain.linearRampToValueAtTime(vel * 0.5, when + 0.01);
105
+ gain.gain.exponentialRampToValueAtTime(0.0001, when + dur);
106
+
107
+ osc.connect(gain);
108
+ gain.connect(dest);
109
+ osc.start(when);
110
+ osc.stop(when + dur + 0.02);
111
+ }
112
+
113
+ /**
114
+ * Play the MIDI take and the buddy WAV together.
115
+ *
116
+ * @param midiBytes the user's MIDI take (raw bytes)
117
+ * @param buddyAudioUrl the generated response WAV (object URL)
118
+ * @param when optional start offset in sec (default: immediately)
119
+ * @returns an object with stop(), and promise resolving when both finish.
120
+ */
121
+ export async function playTogether(
122
+ midiBytes: ArrayBuffer,
123
+ buddyAudioUrl: string,
124
+ ): Promise<{ stop: () => void; done: Promise<void> }> {
125
+ const ctx = new AudioContext();
126
+ await ctx.resume();
127
+
128
+ // Decode the buddy WAV into a buffer.
129
+ const resp = await fetch(buddyAudioUrl);
130
+ const wav = await resp.arrayBuffer();
131
+ const buddyBuffer = await ctx.decodeAudioData(wav);
132
+
133
+ const master = ctx.createGain();
134
+ master.gain.value = 0.8;
135
+ master.connect(ctx.destination);
136
+
137
+ const notes = parseMidi(midiBytes);
138
+ const startAt = ctx.currentTime + 0.1;
139
+
140
+ // Schedule the buddy at the same clock time.
141
+ const buddySrc = ctx.createBufferSource();
142
+ buddySrc.buffer = buddyBuffer;
143
+ buddySrc.connect(master);
144
+ buddySrc.start(startAt);
145
+
146
+ // Schedule the MIDI notes.
147
+ for (const note of notes) {
148
+ scheduleNote(ctx, note, master, startAt + note.time);
149
+ }
150
+
151
+ const buddyEnd = startAt + buddyBuffer.duration;
152
+ const lastMidiTime = notes.length ? (notes[notes.length - 1]?.time ?? 0) : 0;
153
+ const midiEnd = startAt + lastMidiTime + 1;
154
+ const end = Math.max(buddyEnd, midiEnd);
155
+
156
+ const done = new Promise<void>((resolve) => {
157
+ setTimeout(() => {
158
+ try {
159
+ ctx.close();
160
+ } catch {
161
+ /* already closed */
162
+ }
163
+ resolve();
164
+ }, Math.max(0, (end - ctx.currentTime) * 1000) + 200);
165
+ });
166
+
167
+ return {
168
+ stop: () => {
169
+ try {
170
+ buddySrc.stop();
171
+ ctx.close();
172
+ } catch {
173
+ /* ignore */
174
+ }
175
+ },
176
+ done,
177
+ };
178
+ }
179
+
180
+ /** Get the detected tempo from a MIDI file (for display). */
181
+ export function midiTempo(buf: ArrayBuffer): number {
182
+ try {
183
+ const midi = new Midi(buf);
184
+ const t = midi.header.tempos[0]?.bpm;
185
+ return t ? Math.round(t) : 120;
186
+ } catch {
187
+ return 120;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Get the total duration (seconds) of a MIDI file — the time of the last note
193
+ * end. This is what the generated response must match so the take and the
194
+ * buddy are the same length and stay in tempo together.
195
+ */
196
+ export function midiDuration(buf: ArrayBuffer): number {
197
+ try {
198
+ const midi = new Midi(buf);
199
+ let end = 0;
200
+ for (const track of midi.tracks) {
201
+ for (const note of track.notes) {
202
+ end = Math.max(end, note.time + note.duration);
203
+ }
204
+ }
205
+ return end;
206
+ } catch {
207
+ return 30;
208
+ }
209
+ }
apps/web/lib/jambuddy/prompt.ts ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Jam Buddy prompt builder.
3
+ *
4
+ * Builds the SA3 text prompt from the user's "knobs", following the official
5
+ * Stable Audio 3 prompt guide (docs/guides/prompting.md → "Stem & Solo
6
+ * Instrument Prompting"):
7
+ *
8
+ * Key elements: TrackType: Instrument, Instrument/Stem, Genre, Mood & energy,
9
+ * BPM.
10
+ *
11
+ * The guide's recommended structure for an isolated instrument is:
12
+ * TrackType: Instrument, {instrument}, {genre}, {mood}, {BPM} BPM
13
+ *
14
+ * We also append "studio recording" (positive) and use "field recording" as the
15
+ * negative prompt — the training data is Freesound/AudioSparx (full of field
16
+ * recordings), so steering away from that improves quality.
17
+ *
18
+ * Pure function — no DOM, no I/O. Test this thoroughly.
19
+ */
20
+
21
+ export type BuddyInstrument =
22
+ | "bass"
23
+ | "lead"
24
+ | "rhythm"
25
+ | "synth"
26
+ | "drums"
27
+ | "sax"
28
+ | "cleanguitar"
29
+ | "overdrivenguitar";
30
+
31
+ /**
32
+ * What the user IS playing on their take. SA3 has no MIR — it cannot read the
33
+ * input MIDI/audio to know what's already there. The user declares it, which
34
+ * (a) gives the buddy context about what to complement, and (b) honestly
35
+ * acknowledges the model limit. "other" = default, no specific declaration.
36
+ */
37
+ export type InputInstrument =
38
+ | "drums"
39
+ | "bass"
40
+ | "guitar"
41
+ | "keys"
42
+ | "vocals"
43
+ | "other";
44
+
45
+ export type BuddyGenre =
46
+ | "metal"
47
+ | "rock"
48
+ | "punk"
49
+ | "hiphop"
50
+ | "edm"
51
+ | "jazz"
52
+ | "pop"
53
+ | "any";
54
+
55
+ export type BuddyMood =
56
+ | "energetic"
57
+ | "chill"
58
+ | "dark"
59
+ | "bright"
60
+ | "aggressive"
61
+ | "melodic";
62
+
63
+ /** Instrument → AudioSparx `Instruments:` tag fragment (the "knob" options). */
64
+ export const INSTRUMENT_PROMPTS: Readonly<Record<BuddyInstrument, string>> = {
65
+ bass: "Bass Guitar, a grooving bass line, tight and in the pocket",
66
+ lead: "Lead Guitar, a soaring melodic lead guitar riff",
67
+ rhythm: "Rhythm Guitar, tight palm-muted power chords",
68
+ synth: "Synth, a warm atmospheric pad",
69
+ drums: "Drums, a punchy drum groove, kick and snare locked in",
70
+ sax: "Saxophone, a warm breathy saxophone line with a rich tone",
71
+ cleanguitar: "Clean Guitar, bright chimey clean electric guitar arpeggios",
72
+ overdrivenguitar: "Overdriven Guitar, a gritty overdriven guitar riff with crunch",
73
+ };
74
+
75
+ /** Genre → AudioSparx `Genre:` tag. Matches the model's training vocab. */
76
+ export const GENRE_PROMPTS: Readonly<Record<BuddyGenre, string>> = {
77
+ metal: "Heavy Metal",
78
+ rock: "Rock",
79
+ punk: "Punk",
80
+ hiphop: "Hip Hop",
81
+ edm: "Electronic Dance Music",
82
+ jazz: "Jazz",
83
+ pop: "Pop",
84
+ any: "",
85
+ };
86
+
87
+ /** Mood → AudioSparx `Moods:` tag. */
88
+ export const MOOD_PROMPTS: Readonly<Record<BuddyMood, string>> = {
89
+ energetic: "Energetic",
90
+ chill: "Relaxed",
91
+ dark: "Dark",
92
+ bright: "Uplifting",
93
+ aggressive: "Aggressive",
94
+ melodic: "Melodic",
95
+ };
96
+
97
+ /** Negative-prompt tokens always applied (full-mix steer). */
98
+ const BASE_NEGATIVES: readonly string[] = [
99
+ "other instruments", "full band", "mixed ensemble", "vocals",
100
+ "singing", "chords", "crowd", "noise", "field recording",
101
+ ];
102
+
103
+ /**
104
+ * SA3 model per instrument. Drums force `small-sfx` — it yields clean isolated
105
+ * drum hits, not full-mix texture (verified: sfx is sparse, ~1.2 hits/sec,
106
+ * clean single hits vs music's dense smear). Everything else uses `small-music`
107
+ * for musical phrases.
108
+ */
109
+ export const MODEL_FOR_INSTRUMENT: Readonly<Record<BuddyInstrument, string>> = {
110
+ bass: "small-music",
111
+ lead: "small-music",
112
+ rhythm: "small-music",
113
+ synth: "small-music",
114
+ drums: "small-sfx",
115
+ sax: "small-music",
116
+ cleanguitar: "small-music",
117
+ overdrivenguitar: "small-music",
118
+ };
119
+
120
+ export interface BuddyKnobs {
121
+ instrument: BuddyInstrument;
122
+ /** What the user is playing on the take. Helps SA3 complement, not duplicate. */
123
+ inputInstrument: InputInstrument;
124
+ genre: BuddyGenre;
125
+ mood: BuddyMood;
126
+ bpm: number;
127
+ }
128
+
129
+ export interface BuddyPrompt {
130
+ /** Positive prompt for SA3. */
131
+ prompt: string;
132
+ /** Negative prompt for SA3. */
133
+ negativePrompt: string;
134
+ }
135
+
136
+ /**
137
+ * Build the SA3 prompt from the knobs.
138
+ *
139
+ * Structure follows the official Stable Audio 3 prompt guide — the model is
140
+ * trained on Freesound/AudioSparx metadata, so prompts that use the AudioSparx
141
+ * tag vocabulary (`Genre:`, `Moods:`, `Instruments:`) adhere best:
142
+ *
143
+ * TrackType: Music, VocalType: Instrumental, Genre: {genre},
144
+ * Moods: {mood}, Instruments: {instrument}, {BPM} BPM, studio recording
145
+ *
146
+ * `TrackType: Music, VocalType: Instrumental` is the documented prefix for
147
+ * music generation (it's what the training metadata prepends) and materially
148
+ * improves quality/adherence vs. the old `TrackType: Instrument`.
149
+ */
150
+ export function buildPrompt(knobs: BuddyKnobs): BuddyPrompt {
151
+ const parts: string[] = ["TrackType: Music, VocalType: Instrumental"];
152
+
153
+ const genre = GENRE_PROMPTS[knobs.genre];
154
+ if (genre) parts.push(`Genre: ${genre}`);
155
+
156
+ const mood = MOOD_PROMPTS[knobs.mood];
157
+ parts.push(`Moods: ${mood}`);
158
+
159
+ const instrument = INSTRUMENT_PROMPTS[knobs.instrument];
160
+ parts.push(`Instruments: ${instrument}`);
161
+
162
+ const bpm = Math.max(40, Math.min(240, Math.round(knobs.bpm)));
163
+ parts.push(`${bpm} BPM`, "studio recording");
164
+
165
+ // Build the negative prompt only — DO NOT add "avoid ..." to the positive
166
+ // prompt. SA3 is trained on AudioSparx tag vocab; free-form "avoid drums"
167
+ // would be out-of-vocab noise that dilutes the metadata header. The
168
+ // negative prompt is the correct place to steer away from the user's input
169
+ // instrument, and SA3 reads it as a hard constraint.
170
+ const negatives = [...BASE_NEGATIVES];
171
+ if (knobs.instrument !== "drums") negatives.push("percussion");
172
+ if (knobs.inputInstrument && knobs.inputInstrument !== "other") {
173
+ const ctx = INPUT_INSTRUMENT_PROMPTS[knobs.inputInstrument];
174
+ // Only negate the user's instrument when it's a DIFFERENT family from
175
+ // the buddy. e.g. buddy=lead (lead guitar) + input=guitar would steer
176
+ // SA3 away from ALL guitars including the buddy's. Compare nouns, not
177
+ // strings: drop the negation when the input noun overlaps the buddy's
178
+ // noun family.
179
+ if (ctx && !buddyInstrumentFamilyMatches(knobs.instrument, ctx)) {
180
+ negatives.push(ctx);
181
+ }
182
+ }
183
+ const negativePrompt = negatives.join(", ");
184
+
185
+ return {
186
+ prompt: parts.join(", "),
187
+ negativePrompt,
188
+ };
189
+ }
190
+
191
+ /**
192
+ * Does the buddy instrument's noun family overlap with the user's input
193
+ * noun? Used to avoid negating an instrument family the buddy is part of.
194
+ *
195
+ * e.g. buddy=lead ("lead guitar"), input=guitar ("guitar") — overlap, so we
196
+ * do NOT add "guitar" to the negative, since it would steer SA3 away from
197
+ * the buddy itself.
198
+ */
199
+ function buddyInstrumentFamilyMatches(
200
+ instrument: BuddyInstrument,
201
+ inputNoun: string,
202
+ ): boolean {
203
+ const family: Readonly<Record<BuddyInstrument, string>> = {
204
+ bass: "bass",
205
+ lead: "guitar",
206
+ rhythm: "guitar",
207
+ synth: "synth",
208
+ drums: "drums",
209
+ sax: "sax",
210
+ cleanguitar: "guitar",
211
+ overdrivenguitar: "guitar",
212
+ };
213
+ return family[instrument] === inputNoun;
214
+ }
215
+
216
+ /** The knob options, for rendering dropdowns. */
217
+ export const INSTRUMENTS: readonly BuddyInstrument[] = [
218
+ "bass",
219
+ "lead",
220
+ "rhythm",
221
+ "synth",
222
+ "drums",
223
+ "sax",
224
+ "cleanguitar",
225
+ "overdrivenguitar",
226
+ ];
227
+ /** Input-instrument vocab (what the user is playing on the take). */
228
+ export const INPUT_INSTRUMENTS: readonly InputInstrument[] = [
229
+ "drums",
230
+ "bass",
231
+ "guitar",
232
+ "keys",
233
+ "vocals",
234
+ "other",
235
+ ];
236
+ /** Display labels for the input-instrument knob (AudioSparx vocab where possible). */
237
+ export const INPUT_INSTRUMENT_LABELS: Readonly<Record<InputInstrument, string>> = {
238
+ drums: "Drums",
239
+ bass: "Bass",
240
+ guitar: "Guitar",
241
+ keys: "Keys / Synth",
242
+ vocals: "Vocals",
243
+ other: "Other / mixed",
244
+ };
245
+ /** Input-instrument → short AudioSparx-style noun for the prompt. */
246
+ export const INPUT_INSTRUMENT_PROMPTS: Readonly<Record<InputInstrument, string>> = {
247
+ drums: "drums",
248
+ bass: "bass",
249
+ guitar: "guitar",
250
+ keys: "keys",
251
+ vocals: "vocals",
252
+ other: "",
253
+ };
254
+ export const GENRES: readonly BuddyGenre[] = [
255
+ "metal",
256
+ "rock",
257
+ "punk",
258
+ "hiphop",
259
+ "edm",
260
+ "jazz",
261
+ "pop",
262
+ "any",
263
+ ];
264
+ export const MOODS: readonly BuddyMood[] = [
265
+ "energetic",
266
+ "chill",
267
+ "dark",
268
+ "bright",
269
+ "aggressive",
270
+ "melodic",
271
+ ];
272
+ /** Human-friendly knob labels. The enum stays lowercase for wire-format
273
+ * stability; the UI reads these for display. AudioSparx vocab where possible. */
274
+ export const GENRE_LABELS: Readonly<Record<BuddyGenre, string>> = {
275
+ metal: "Heavy Metal", rock: "Rock", punk: "Punk Rock", hiphop: "Hip Hop",
276
+ edm: "Electronic", jazz: "Jazz", pop: "Pop", any: "Any",
277
+ };
278
+ export const MOOD_LABELS: Readonly<Record<BuddyMood, string>> = {
279
+ energetic: "Energetic", chill: "Chill", dark: "Dark", bright: "Bright",
280
+ aggressive: "Aggressive", melodic: "Melodic",
281
+ };
282
+ /** Human-friendly labels for the buddy-instrument pads. */
283
+ export const INSTRUMENT_LABELS: Readonly<Record<BuddyInstrument, string>> = {
284
+ bass: "Bass", lead: "Lead Guitar", rhythm: "Rhythm Guitar", synth: "Synth",
285
+ drums: "Drums", sax: "Sax", cleanguitar: "Clean Guitar",
286
+ overdrivenguitar: "Overdriven Guitar",
287
+ };
apps/web/lib/jambuddy/recorder.ts ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Web MIDI capture for Jam Buddy.
3
+ *
4
+ * Listens to a connected MIDI input (a controller the user is playing),
5
+ * records note-on/note-off events with timestamps, and converts them into a
6
+ * Standard MIDI File (.mid) so the take feeds the existing upload pipeline
7
+ * (tempo auto-detect + duration + generation).
8
+ *
9
+ * Browser-only (Web MIDI API). Keep all MIDI access here, call from the page.
10
+ */
11
+
12
+ import { Midi } from "@tonejs/midi";
13
+
14
+ export interface RecordedNote {
15
+ /** Absolute time (seconds) from the recording start. */
16
+ time: number;
17
+ midi: number;
18
+ velocity: number;
19
+ /** GM channel the message arrived on (0-15; 9 = percussion). */
20
+ channel: number;
21
+ }
22
+
23
+ export interface MidiRecorder {
24
+ start(): void;
25
+ stop(): { notes: RecordedNote[]; durationSec: number };
26
+ isActive(): boolean;
27
+ /** Detach the MIDI listener (call when leaving the page / no longer needed). */
28
+ dispose(): void;
29
+ }
30
+
31
+ /**
32
+ * Build a Standard MIDI File byte buffer from recorded notes + a detected BPM.
33
+ * Single track, tempo meta at t=0, one note-on/note-off pair per event in
34
+ * PPQ-relative ticks. Reuses @tonejs/midi so we don't hand-roll SMF bytes.
35
+ */
36
+ export function recordedNotesToMidi(
37
+ notes: RecordedNote[],
38
+ bpm: number,
39
+ ): Uint8Array {
40
+ const midi = new Midi();
41
+ midi.header.setTempo(60_000_000 / bpm);
42
+ midi.header.timeSignatures = [{ ticks: 0, timeSignature: [4, 4] }];
43
+
44
+ // Group by channel: a separate track per GM channel so percussion (9) and
45
+ // pitched notes don't collide.
46
+ const channels = [...new Set(notes.map((n) => n.channel))];
47
+ const byChannel = new Map<number, RecordedNote[]>();
48
+ for (const ch of channels) byChannel.set(ch, []);
49
+ for (const n of notes) byChannel.get(n.channel)!.push(n);
50
+
51
+ const ppq = 480;
52
+ for (const [ch, chNotes] of byChannel) {
53
+ const track = midi.addTrack();
54
+ track.channel = ch;
55
+ track.name = `Capture ch${ch}`;
56
+ for (const n of [...chNotes].sort((a, b) => a.time - b.time)) {
57
+ const ticks = Math.max(0, Math.round((n.time / 60) * bpm * ppq));
58
+ const durTicks = Math.max(1, Math.round((0.1 / 60) * bpm * ppq));
59
+ track.addNote({
60
+ midi: n.midi,
61
+ ticks,
62
+ durationTicks: durTicks,
63
+ velocity: Math.max(0.01, Math.min(1, n.velocity / 127)),
64
+ });
65
+ }
66
+ }
67
+ return midi.toArray();
68
+ }
69
+
70
+ /** Build a .mid File (for the take upload slot). */
71
+ export function recordedNotesAsFile(
72
+ notes: RecordedNote[],
73
+ bpm: number,
74
+ ): File {
75
+ const bytes = recordedNotesToMidi(notes, bpm);
76
+ const ts = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
77
+ return new File(
78
+ [new Uint8Array(bytes)],
79
+ `jambuddy-live-capture-${ts}.mid`,
80
+ { type: "audio/midi" },
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Create a recorder bound to a MIDI input. Requests MIDI access (user gesture
86
+ * required in some browsers). Returns a started recorder + the chosen input.
87
+ *
88
+ * If `preferredDeviceName` is given, use that input; otherwise the first
89
+ * available. Throws if Web MIDI is unsupported or no input is available.
90
+ */
91
+ export async function createMidiRecorder(
92
+ preferredDeviceName?: string,
93
+ ): Promise<MidiRecorder> {
94
+ if (typeof navigator === "undefined" || !("requestMIDIAccess" in navigator)) {
95
+ throw new Error("Web MIDI is not supported in this browser (try Chrome/Edge).");
96
+ }
97
+
98
+ const access = await navigator.requestMIDIAccess();
99
+ const inputs = [...access.inputs.values()];
100
+ if (inputs.length === 0) {
101
+ throw new Error("No MIDI input device found. Connect a controller.");
102
+ }
103
+ const input =
104
+ inputs.find((i) => i.name === preferredDeviceName) ??
105
+ inputs[0] ??
106
+ null;
107
+ if (!input) {
108
+ throw new Error("No MIDI input device found. Connect a controller.");
109
+ }
110
+
111
+ let active = false;
112
+ let startTime = 0;
113
+ const notes: RecordedNote[] = [];
114
+
115
+ const onMessage = (e: MIDIMessageEvent) => {
116
+ if (!active || !e.data) return;
117
+ const status = e.data[0];
118
+ const midi = e.data[1];
119
+ const vel = e.data[2];
120
+ if (status === undefined || midi === undefined || vel === undefined) return;
121
+ const cmd = status & 0xf0;
122
+ const channel = status & 0x0f;
123
+ if (cmd === 0x90 && vel > 0) {
124
+ notes.push({
125
+ time: (performance.now() - startTime) / 1000,
126
+ midi,
127
+ velocity: vel,
128
+ channel,
129
+ });
130
+ }
131
+ };
132
+ input.addEventListener("midimessage", onMessage);
133
+
134
+ return {
135
+ start() {
136
+ startTime = performance.now();
137
+ notes.length = 0;
138
+ active = true;
139
+ },
140
+ stop() {
141
+ active = false;
142
+ return {
143
+ notes: [...notes],
144
+ durationSec:
145
+ notes.length > 0
146
+ ? (performance.now() - startTime) / 1000
147
+ : 0,
148
+ };
149
+ },
150
+ isActive: () => active,
151
+ dispose() {
152
+ active = false;
153
+ input.removeEventListener("midimessage", onMessage);
154
+ },
155
+ };
156
+ }
apps/web/lib/jambuddy/visualizer.tsx ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ /**
4
+ * Jam Buddy take visualizer.
5
+ *
6
+ * Renders the user's take and the buddy's response on a canvas:
7
+ * - MIDI -> a piano-roll (notes as bars over time, pitch on the y-axis).
8
+ * - audio -> a real waveform (min/max peaks over time).
9
+ *
10
+ * MIDI has no waveform (it's note data, not audio), so the honest visual for a
11
+ * MIDI take is a piano-roll; audio gets the waveform. Both share a time axis so
12
+ * you can compare the take and the response side by side.
13
+ */
14
+
15
+ import { useEffect, useRef } from "react";
16
+ import { parseMidi, type ParsedNote } from "./player";
17
+
18
+ interface VisualizerProps {
19
+ /** MIDI bytes -> piano-roll. Mutually exclusive with audioUrl. */
20
+ midiBytes?: ArrayBuffer | null;
21
+ /** Audio object URL -> waveform. Mutually exclusive with midiBytes. */
22
+ audioUrl?: string | null;
23
+ /** Optional label shown above the canvas. */
24
+ label?: string;
25
+ /** Height of the canvas in px. */
26
+ height?: number;
27
+ }
28
+
29
+ const NOTE_MIN = 21; // A0
30
+ const NOTE_MAX = 108; // C8
31
+
32
+ /** Draw a piano-roll of MIDI notes onto a canvas. */
33
+ function drawPianoRoll(
34
+ canvas: HTMLCanvasElement,
35
+ notes: ParsedNote[],
36
+ duration: number,
37
+ ) {
38
+ const ctx = canvas.getContext("2d");
39
+ if (!ctx) return;
40
+ const dpr = window.devicePixelRatio || 1;
41
+ const w = canvas.clientWidth;
42
+ const h = canvas.clientHeight;
43
+ canvas.width = w * dpr;
44
+ canvas.height = h * dpr;
45
+ ctx.scale(dpr, dpr);
46
+
47
+ ctx.fillStyle = "#12131b";
48
+ ctx.fillRect(0, 0, w, h);
49
+
50
+ if (notes.length === 0 || duration <= 0) {
51
+ ctx.fillStyle = "#7f829c";
52
+ ctx.font = "12px ui-monospace, monospace";
53
+ ctx.textAlign = "center";
54
+ ctx.fillText("no notes", w / 2, h / 2);
55
+ return;
56
+ }
57
+
58
+ const pad = 8;
59
+ const plotW = w - pad * 2;
60
+ const plotH = h - pad * 2;
61
+ const span = NOTE_MAX - NOTE_MIN;
62
+
63
+ // Grid lines every octave.
64
+ ctx.strokeStyle = "#2a2d3d";
65
+ ctx.lineWidth = 1;
66
+ for (let n = NOTE_MIN; n <= NOTE_MAX; n += 12) {
67
+ const y = pad + (1 - (n - NOTE_MIN) / span) * plotH;
68
+ ctx.beginPath();
69
+ ctx.moveTo(pad, y);
70
+ ctx.lineTo(w - pad, y);
71
+ ctx.stroke();
72
+ }
73
+
74
+ // Notes as bars.
75
+ const maxTime = Math.max(duration, ...notes.map((n) => n.time + n.duration));
76
+ for (const n of notes) {
77
+ const x = pad + (n.time / maxTime) * plotW;
78
+ const bw = Math.max(2, (n.duration / maxTime) * plotW);
79
+ const y = pad + (1 - (n.midi - NOTE_MIN) / span) * plotH;
80
+ const bh = Math.max(2, plotH / span);
81
+ ctx.fillStyle = n.channel === 9 ? "#f4a261" : "#5fd38a";
82
+ ctx.fillRect(x, y - bh, bw, bh);
83
+ }
84
+ }
85
+
86
+ /** Draw a waveform (min/max peaks) of an AudioBuffer onto a canvas. */
87
+ function drawWaveform(
88
+ canvas: HTMLCanvasElement,
89
+ buffer: AudioBuffer,
90
+ ) {
91
+ const ctx = canvas.getContext("2d");
92
+ if (!ctx) return;
93
+ const dpr = window.devicePixelRatio || 1;
94
+ const w = canvas.clientWidth;
95
+ const h = canvas.clientHeight;
96
+ canvas.width = w * dpr;
97
+ canvas.height = h * dpr;
98
+ ctx.scale(dpr, dpr);
99
+
100
+ ctx.fillStyle = "#12131b";
101
+ ctx.fillRect(0, 0, w, h);
102
+
103
+ const data = buffer.getChannelData(0);
104
+ const step = Math.ceil(data.length / w);
105
+ const amp = h / 2;
106
+ ctx.fillStyle = "#5fd38a";
107
+ for (let x = 0; x < w; x++) {
108
+ let min = 1;
109
+ let max = -1;
110
+ for (let i = 0; i < step; i++) {
111
+ const v = data[x * step + i];
112
+ if (v === undefined) continue;
113
+ if (v < min) min = v;
114
+ if (v > max) max = v;
115
+ }
116
+ const y1 = amp + min * amp;
117
+ const y2 = amp + max * amp;
118
+ ctx.fillRect(x, y1, 1, Math.max(1, y2 - y1));
119
+ }
120
+ }
121
+
122
+ export function Visualizer({
123
+ midiBytes,
124
+ audioUrl,
125
+ label,
126
+ height = 120,
127
+ }: VisualizerProps) {
128
+ const canvasRef = useRef<HTMLCanvasElement>(null);
129
+
130
+ useEffect(() => {
131
+ const canvas = canvasRef.current;
132
+ if (!canvas) return;
133
+
134
+ if (midiBytes) {
135
+ let notes: ParsedNote[] = [];
136
+ let duration = 0;
137
+ try {
138
+ notes = parseMidi(midiBytes);
139
+ duration = notes.reduce(
140
+ (m, n) => Math.max(m, n.time + n.duration),
141
+ 0,
142
+ );
143
+ } catch {
144
+ /* corrupt bytes -> empty roll */
145
+ }
146
+ drawPianoRoll(canvas, notes, duration);
147
+ return;
148
+ }
149
+
150
+ if (audioUrl) {
151
+ const ctx = new AudioContext();
152
+ fetch(audioUrl)
153
+ .then((r) => r.arrayBuffer())
154
+ .then((buf) => ctx.decodeAudioData(buf))
155
+ .then((audio) => {
156
+ drawWaveform(canvas, audio);
157
+ ctx.close();
158
+ })
159
+ .catch(() => {
160
+ const c = canvas.getContext("2d");
161
+ if (c) {
162
+ c.fillStyle = "#12131b";
163
+ c.fillRect(0, 0, canvas.clientWidth, canvas.clientHeight);
164
+ c.fillStyle = "#7f829c";
165
+ c.font = "12px ui-monospace, monospace";
166
+ c.textAlign = "center";
167
+ c.fillText("no audio", canvas.clientWidth / 2, canvas.clientHeight / 2);
168
+ }
169
+ });
170
+ return;
171
+ }
172
+
173
+ // Nothing to draw.
174
+ const c = canvas.getContext("2d");
175
+ if (c) {
176
+ c.fillStyle = "#12131b";
177
+ c.fillRect(0, 0, canvas.clientWidth, canvas.clientHeight);
178
+ }
179
+ }, [midiBytes, audioUrl]);
180
+
181
+ return (
182
+ <div className="w-full">
183
+ {label && (
184
+ <div className="mb-1 font-mono text-[10px] uppercase tracking-widest text-[#7f829c]">
185
+ {label}
186
+ </div>
187
+ )}
188
+ <canvas
189
+ ref={canvasRef}
190
+ className="w-full rounded border border-[#2a2d3d] bg-[#12131b]"
191
+ style={{ height }}
192
+ />
193
+ </div>
194
+ );
195
+ }
apps/web/lib/midi/generator.ts ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * MIDI file generator.
3
+ *
4
+ * Wraps @tonejs/midi to convert MidiEvent[] into a Uint8Array (.mid file bytes)
5
+ * suitable for download or for the drag-and-drop-into-Reaper flow.
6
+ *
7
+ * Source: docs/03-data-model.md §MIDI generation.
8
+ */
9
+
10
+ import { Midi } from "@tonejs/midi";
11
+ import {
12
+ LIMB_TO_GM,
13
+ type Limb,
14
+ type MidiEvent,
15
+ } from "@patterntalk/shared-types";
16
+ import { bpmToMicrosecondsPerQuarter } from "../patterns/engine";
17
+
18
+ export interface MidiMeta {
19
+ patternId: string;
20
+ patternName: string;
21
+ bars: number;
22
+ bpm: number;
23
+ }
24
+
25
+ /**
26
+ * Convert MidiEvent[] to a SMF (Standard MIDI File) byte buffer.
27
+ * One track, channel 10 (the GM drum channel).
28
+ */
29
+ export function eventsToMidi(events: MidiEvent[], meta: MidiMeta): Uint8Array {
30
+ const midi = new Midi();
31
+
32
+ // Tempo + time signature on the master track header.
33
+ midi.header.setTempo(bpmToMicrosecondsPerQuarter(meta.bpm));
34
+ midi.header.timeSignatures = [{ ticks: 0, timeSignature: [4, 4] }];
35
+
36
+ const drums = midi.addTrack();
37
+ drums.name = `${meta.patternName} (Jam Buddy)`;
38
+ drums.channel = 9; // 0-indexed; SMF channel 10 is the GM drum channel.
39
+
40
+ for (const ev of events) {
41
+ const midiNote = LIMB_TO_GM[ev.limb];
42
+ drums.addNote({
43
+ midi: midiNote,
44
+ ticks: ev.tick,
45
+ durationTicks: ev.duration,
46
+ velocity: ev.velocity / 127,
47
+ });
48
+ }
49
+
50
+ return midi.toArray();
51
+ }
52
+
53
+ /**
54
+ * Build a filename per docs/07-reaper-integration.md §File naming.
55
+ * Example: jambuddy-d-beat-4bars-180bpm-2026-08-22T1430Z.mid
56
+ */
57
+ export function midiFilename(meta: MidiMeta, when: Date = new Date()): string {
58
+ const ts = when.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
59
+ return `jambuddy-${meta.patternId}-${meta.bars}bars-${meta.bpm}bpm-${ts}.mid`;
60
+ }
61
+
62
+ /**
63
+ * Trigger a browser download of the MIDI file. Returns the filename used.
64
+ *
65
+ * The caller is responsible for the Reaper drag-and-drop UX (see
66
+ * docs/07-reaper-integration.md §Drag-from-browser directly into Reaper)
67
+ * — this is the simpler click-to-download path, kept as the reliable fallback.
68
+ */
69
+ export function downloadMidi(events: MidiEvent[], meta: MidiMeta): string {
70
+ const bytes = eventsToMidi(events, meta);
71
+ // `new Uint8Array(...)` re-wraps as a plain (non-SharedArrayBuffer-backed)
72
+ // ArrayBuffer — required by lib.dom.d.ts in TS 5.7+.
73
+ const blob = new Blob([new Uint8Array(bytes)], { type: "audio/midi" });
74
+ const url = URL.createObjectURL(blob);
75
+ const filename = midiFilename(meta);
76
+
77
+ const a = document.createElement("a");
78
+ a.href = url;
79
+ a.download = filename;
80
+ a.rel = "noopener";
81
+ document.body.appendChild(a);
82
+ a.click();
83
+ document.body.removeChild(a);
84
+
85
+ // Revoke after a tick so the download has time to start.
86
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
87
+
88
+ return filename;
89
+ }
90
+
91
+ /**
92
+ * Inverse of downloadMidi: build a File object suitable for the HTML5
93
+ * drag-and-drop API (DataTransfer.files). Used for drag-from-browser
94
+ * directly into a Reaper track.
95
+ */
96
+ export function midiAsFile(events: MidiEvent[], meta: MidiMeta): File {
97
+ const bytes = eventsToMidi(events, meta);
98
+ return new File(
99
+ [new Uint8Array(bytes)],
100
+ midiFilename(meta),
101
+ { type: "audio/midi" },
102
+ );
103
+ }
104
+
105
+ // Re-export Limb for convenience — keeps consumers from digging into shared-types.
106
+ export type { Limb };
apps/web/lib/patterns/engine.ts ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Pattern engine.
3
+ *
4
+ * Expands a PatternTemplate into MidiEvent[] given the user's request.
5
+ * Pure function — no DOM, no audio, no I/O. Test this thoroughly.
6
+ *
7
+ * Process (per docs/03-data-model.md §Pattern engine):
8
+ * 1. Load template
9
+ * 2. Expand to requested bars (repeat template.defaultBars pattern N times)
10
+ * 3. Apply cymbal override (replace ride/hihat hits with cymbal type)
11
+ * 4. Apply accents (+20 velocity on matching positions)
12
+ * 5. Apply feel modifier (swing / half-time / double-time)
13
+ * 6. Convert fractional positions (0.0–1.0) to absolute ticks at tempoBpm
14
+ */
15
+
16
+ import type {
17
+ Hit,
18
+ Limb,
19
+ MidiEvent,
20
+ ParsedRequest,
21
+ PatternTemplate,
22
+ } from "@patterntalk/shared-types";
23
+
24
+ /** MIDI ticks per quarter note. The de facto standard (also SMTE/QT). */
25
+ export const TICKS_PER_QUARTER = 480;
26
+
27
+ /** Compute ticks-per-bar for a given time signature. */
28
+ export function ticksPerBar(
29
+ timeSignature: { numerator: number; denominator: number },
30
+ ): number {
31
+ // A bar = numerator quarter notes, regardless of denominator.
32
+ return TICKS_PER_QUARTER * timeSignature.numerator;
33
+ }
34
+
35
+ export interface EngineOverrides {
36
+ cymbal?: ParsedRequest["cymbal"];
37
+ accents?: string[];
38
+ feel?: ParsedRequest["feel"];
39
+ }
40
+
41
+ export interface EngineInput {
42
+ template: PatternTemplate;
43
+ bars: number;
44
+ bpm: number;
45
+ timeSignature?: { numerator: number; denominator: number };
46
+ overrides?: EngineOverrides;
47
+ }
48
+
49
+ /** Limb set considered "cymbal-shaped" — replaced by a cymbal override. */
50
+ const CYMBAL_LIMBS: ReadonlySet<Limb> = new Set([
51
+ "hihat",
52
+ "hihat-open",
53
+ "ride",
54
+ "ride-bell",
55
+ "crash",
56
+ "china",
57
+ "splash",
58
+ ]);
59
+
60
+ /** Map cymbal hint to a Limb. */
61
+ function cymbalToLimb(type: NonNullable<ParsedRequest["cymbal"]>["type"]): Limb {
62
+ switch (type) {
63
+ case "crash":
64
+ return "crash";
65
+ case "ride":
66
+ return "ride-bell"; // default ride → ride-bell (8ths on the bell)
67
+ case "china":
68
+ return "china";
69
+ case "hihat-open":
70
+ return "hihat-open";
71
+ case "hihat-closed":
72
+ return "hihat";
73
+ case "splash":
74
+ return "splash";
75
+ }
76
+ }
77
+
78
+ interface ExpandedBar {
79
+ /** 0-based bar index. */
80
+ barIndex: number;
81
+ hits: Array<{ hit: Hit; tick: number }>;
82
+ }
83
+
84
+ /**
85
+ * Expand a template to N bars. Repeats the template's `defaultBars` pattern
86
+ * until N bars are filled. Velocity humanization: tiny per-bar nudge so
87
+ * repeated bars don't sound robotic.
88
+ */
89
+ function expandBars(input: EngineInput): ExpandedBar[] {
90
+ const ts = input.timeSignature ?? { numerator: 4, denominator: 4 };
91
+ const barTicks = ticksPerBar(ts);
92
+ const template = input.template;
93
+ const repeatLength = Math.max(1, template.defaultBars);
94
+ const totalBars = Math.max(1, Math.floor(input.bars));
95
+
96
+ const result: ExpandedBar[] = [];
97
+ for (let barIndex = 0; barIndex < totalBars; barIndex++) {
98
+ // Index into the template pattern (handles templates that cover > 1 bar)
99
+ const templateBarIndex = barIndex % repeatLength;
100
+
101
+ // Humanization: ±2 velocity, deterministic per bar index.
102
+ const humanize = (barIndex * 17 + 11) % 5 - 2;
103
+
104
+ const hits: Array<{ hit: Hit; tick: number }> = [];
105
+ for (const hit of template.hits) {
106
+ // Filter hits to only those in the current template bar.
107
+ // Hits can have positions across multiple bars; we only want the ones
108
+ // for the current templateBarIndex. Since positions are 0.0–1.0 within
109
+ // a single bar, all hits in a single-bar template belong to bar 0;
110
+ // for multi-bar templates the position encoding is per-bar.
111
+ if (template.defaultBars === 1 || templateBarIndex === 0) {
112
+ // Clamp position to [0, 1) — guards against authoring errors.
113
+ const pos = Math.max(0, Math.min(0.999_999, hit.position));
114
+ const tick = Math.round(barIndex * barTicks + pos * barTicks);
115
+ const velocity = Math.max(
116
+ 1,
117
+ Math.min(127, hit.velocity + humanize),
118
+ );
119
+ hits.push({ hit: { ...hit, velocity }, tick });
120
+ }
121
+ }
122
+ result.push({ barIndex, hits });
123
+ }
124
+ return result;
125
+ }
126
+
127
+ /**
128
+ * Apply a cymbal override. For each expanded hit whose limb is a cymbal,
129
+ * replace the limb with the override's target.
130
+ *
131
+ * For multi-bar templates we only override cymbals in the bars that exist.
132
+ */
133
+ function applyCymbalOverride(
134
+ bars: ExpandedBar[],
135
+ cymbal: NonNullable<ParsedRequest["cymbal"]>,
136
+ ): ExpandedBar[] {
137
+ const target: Limb = cymbalToLimb(cymbal.type);
138
+ return bars.map((bar) => ({
139
+ ...bar,
140
+ hits: bar.hits.map(({ hit, tick }) =>
141
+ CYMBAL_LIMBS.has(hit.limb)
142
+ ? { hit: { ...hit, limb: target }, tick }
143
+ : { hit, tick },
144
+ ),
145
+ }));
146
+ }
147
+
148
+ /**
149
+ * Apply accent boosts. For each accent descriptor, find hits whose position
150
+ * matches and boost velocity by +20.
151
+ *
152
+ * GOTCHA: `barTicks` must come from `ticksPerBar(timeSignature)`, NOT from
153
+ * any property of `bars[N]`. `ExpandedBar` has `{ barIndex, hits }` — no
154
+ * `.tick` field. Reading `bars[1]?.tick` returns undefined, which silently
155
+ * collapses `barTicks` to 1, and accents then only match bar 0. Regression
156
+ * test: "__tests__/engine.test.ts > regression: accents apply to ALL bars".
157
+ *
158
+ * Supported descriptors (start of v1):
159
+ * "1", "2", "3", "4" — beats within a 4/4 bar
160
+ * "and-of-2", "and-of-4" — classic backbeat accents
161
+ */
162
+ function applyAccents(
163
+ bars: ExpandedBar[],
164
+ accents: string[],
165
+ timeSignature: { numerator: number; denominator: number } = { numerator: 4, denominator: 4 },
166
+ ): ExpandedBar[] {
167
+ const barTicks = ticksPerBar(timeSignature);
168
+ const quarter = TICKS_PER_QUARTER;
169
+
170
+ return bars.map((bar) => {
171
+ const hits = bar.hits.map(({ hit, tick }) => {
172
+ const localTick = tick - bar.barIndex * barTicks;
173
+ // Position within the bar in quarter notes
174
+ const localQuarter = localTick / quarter;
175
+ const beat = Math.floor(localQuarter) + 1; // 1-based
176
+ const isAnd = (localQuarter - Math.floor(localQuarter)) > 0.4;
177
+ const localPos = `${beat}${isAnd ? "-and" : ""}`;
178
+
179
+ const matched = accents.some((a) => {
180
+ if (a === localPos) return true;
181
+ if (a === "1" && beat === 1 && !isAnd) return true;
182
+ if (a === "3" && beat === 3 && !isAnd) return true;
183
+ if (a === "and-of-2" && beat === 2 && isAnd) return true;
184
+ if (a === "and-of-4" && beat === 4 && isAnd) return true;
185
+ return false;
186
+ });
187
+
188
+ return matched
189
+ ? {
190
+ hit: { ...hit, velocity: Math.min(127, hit.velocity + 20), accent: true },
191
+ tick,
192
+ }
193
+ : { hit, tick };
194
+ });
195
+ return { ...bar, hits };
196
+ });
197
+ }
198
+
199
+ /**
200
+ * Apply feel modifier. v1 supports swing (delays offbeat 8ths by swingRatio).
201
+ * Half-time and double-time are recognized but not yet implemented —
202
+ * TODO for the engine-extension PR.
203
+ */
204
+ function applyFeel(
205
+ bars: ExpandedBar[],
206
+ feel: NonNullable<ParsedRequest["feel"]>,
207
+ swingRatio: number,
208
+ timeSignature: { numerator: number; denominator: number } = { numerator: 4, denominator: 4 },
209
+ ): ExpandedBar[] {
210
+ if (feel !== "swing") return bars; // half-time / double-time deferred
211
+
212
+ const barTicks = ticksPerBar(timeSignature);
213
+ const quarter = TICKS_PER_QUARTER;
214
+ const swingOffset = Math.round(swingRatio * quarter);
215
+
216
+ return bars.map((bar) => ({
217
+ ...bar,
218
+ hits: bar.hits.map(({ hit, tick }) => {
219
+ const localTick = tick - bar.barIndex * barTicks;
220
+ const localQuarter = localTick / quarter;
221
+ const isAnd = (localQuarter - Math.floor(localQuarter)) > 0.4;
222
+ return isAnd
223
+ ? { hit, tick: tick + swingOffset }
224
+ : { hit, tick };
225
+ }),
226
+ }));
227
+ }
228
+
229
+ /**
230
+ * Expand a PatternTemplate into MidiEvent[] for the requested bars and tempo.
231
+ *
232
+ * This is the single entry point used by the MIDI generator and by tests.
233
+ */
234
+ export function expandPattern(input: EngineInput): MidiEvent[] {
235
+ const ts = input.timeSignature ?? { numerator: 4, denominator: 4 };
236
+ const overrides = input.overrides ?? {};
237
+
238
+ let bars = expandBars(input);
239
+
240
+ if (overrides.cymbal) {
241
+ bars = applyCymbalOverride(bars, overrides.cymbal);
242
+ }
243
+ if (overrides.accents && overrides.accents.length > 0) {
244
+ bars = applyAccents(bars, overrides.accents, ts);
245
+ }
246
+ if (overrides.feel) {
247
+ bars = applyFeel(bars, overrides.feel, input.template.swingRatio ?? 0, ts);
248
+ }
249
+
250
+ const events: MidiEvent[] = [];
251
+ for (const bar of bars) {
252
+ for (const { hit, tick } of bar.hits) {
253
+ events.push({
254
+ tick,
255
+ limb: hit.limb,
256
+ velocity: hit.velocity,
257
+ duration: 8, // ~8 ticks — short drum hit
258
+ });
259
+ }
260
+ }
261
+
262
+ // Sort by tick for clean MIDI output.
263
+ events.sort((a, b) => a.tick - b.tick);
264
+ return events;
265
+ }
266
+
267
+ /**
268
+ * Compute the bar-tempo in microseconds-per-quarter-note for the MIDI header.
269
+ * Standard MIDI tempo meta-event.
270
+ */
271
+ export function bpmToMicrosecondsPerQuarter(bpm: number): number {
272
+ return Math.round(60_000_000 / bpm);
273
+ }
apps/web/lib/voice/conversation.ts ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Voice conversation state machine.
3
+ *
4
+ * The single source of truth for "what state is the voice UI in."
5
+ * Consumed by the React layer to decide which UI is shown and which
6
+ * speech synthesis phrase to play.
7
+ *
8
+ * States (docs/04-ux-voice-first.md + two additions for error handling
9
+ * and confirmation):
10
+ *
11
+ * idle — nothing happening
12
+ * listening — mic active, capturing
13
+ * parsing — got final transcript, running the parser
14
+ * awaiting-confirmation — parser confidence < 0.7, user must pick
15
+ * generating — pattern engine + audio service running
16
+ * ready — MIDI + sample available, awaiting next command
17
+ * playing — audio preview playing
18
+ * error — recoverable failure (mic denied, parse error)
19
+ *
20
+ * Transitions are encoded as a pure function — given (state, event) return
21
+ * (nextState, side-effects). The React layer (or a test) executes side-effects.
22
+ */
23
+
24
+ import type {
25
+ ConversationContext,
26
+ ConversationState,
27
+ ParseResult,
28
+ } from "@patterntalk/shared-types";
29
+
30
+ /** Events that drive state transitions. */
31
+ export type ConversationEvent =
32
+ | { type: "START_LISTENING" }
33
+ | { type: "STOP_LISTENING" }
34
+ | { type: "TRANSCRIPT_FINAL"; transcript: string }
35
+ | { type: "PARSE_OK"; result: Extract<ParseResult, { ok: true }> }
36
+ | { type: "PARSE_LOW_CONFIDENCE"; result: Extract<ParseResult, { ok: false }> }
37
+ | { type: "CONFIRM_CANDIDATE"; index: number }
38
+ | { type: "GENERATION_OK"; sampleUrl?: string; variations?: number }
39
+ | { type: "GENERATION_FAIL"; message: string }
40
+ | { type: "PLAY_START" }
41
+ | { type: "PLAY_STOP" }
42
+ | { type: "RESET" }
43
+ | { type: "ERROR"; kind: NonNullable<ConversationContext["error"]>["kind"]; message: string };
44
+
45
+ export interface Transition {
46
+ next: ConversationState;
47
+ context: ConversationContext;
48
+ /** Side effects the host should run (TTS, API calls, etc.). */
49
+ sideEffects: ConversationSideEffect[];
50
+ }
51
+
52
+ export type ConversationSideEffect =
53
+ | { type: "SPEAK"; text: string; interrupt: boolean }
54
+ | { type: "START_MIC" }
55
+ | { type: "STOP_MIC" }
56
+ | { type: "TRIGGER_GENERATION" }
57
+ | { type: "TRIGGER_PLAYBACK" }
58
+ | { type: "TRIGGER_STOP" };
59
+
60
+ /**
61
+ * Pure transition function. Given a state + event, returns the next state,
62
+ * updated context, and side-effects to run.
63
+ *
64
+ * Unknown events for the current state are ignored (returns same state).
65
+ * This is intentional — voice UIs receive many stray events; the FSM
66
+ * should be quiet when not in the right state.
67
+ */
68
+ export function transition(
69
+ state: ConversationState,
70
+ context: ConversationContext,
71
+ event: ConversationEvent,
72
+ ): Transition {
73
+ switch (event.type) {
74
+ case "RESET":
75
+ return {
76
+ next: "idle",
77
+ context: {},
78
+ sideEffects: [{ type: "SPEAK", text: "Starting over.", interrupt: true }],
79
+ };
80
+
81
+ case "START_LISTENING": {
82
+ if (state !== "idle" && state !== "ready" && state !== "error") {
83
+ return { next: state, context, sideEffects: [] };
84
+ }
85
+ return {
86
+ next: "listening",
87
+ context: {},
88
+ sideEffects: [{ type: "START_MIC" }],
89
+ };
90
+ }
91
+
92
+ case "STOP_LISTENING": {
93
+ if (state !== "listening") {
94
+ return { next: state, context, sideEffects: [] };
95
+ }
96
+ return {
97
+ next: state,
98
+ context,
99
+ sideEffects: [{ type: "STOP_MIC" }],
100
+ };
101
+ }
102
+
103
+ case "TRANSCRIPT_FINAL": {
104
+ if (state !== "listening") {
105
+ return { next: state, context, sideEffects: [] };
106
+ }
107
+ return {
108
+ next: "parsing",
109
+ context: {},
110
+ sideEffects: [
111
+ { type: "STOP_MIC" },
112
+ { type: "SPEAK", text: "Parsing.", interrupt: false },
113
+ ],
114
+ };
115
+ }
116
+
117
+ case "PARSE_OK": {
118
+ if (state !== "parsing") return { next: state, context, sideEffects: [] };
119
+ return {
120
+ next: "generating",
121
+ context: { parsed: event.result.request },
122
+ sideEffects: [
123
+ {
124
+ type: "SPEAK",
125
+ text: `Generating ${event.result.request.patternName ?? event.result.request.patternId ?? "pattern"}, ${event.result.request.bars} bars at ${event.result.request.tempo ?? event.result.request.tempoDefault} BPM.`,
126
+ interrupt: true,
127
+ },
128
+ { type: "TRIGGER_GENERATION" },
129
+ ],
130
+ };
131
+ }
132
+
133
+ case "PARSE_LOW_CONFIDENCE": {
134
+ if (state !== "parsing") return { next: state, context, sideEffects: [] };
135
+ const top = event.result.candidates[0];
136
+ const message = top
137
+ ? `I heard ${event.result.heardAs}. Did you mean ${top.request.patternName ?? top.request.patternId}?`
138
+ : `I heard ${event.result.heardAs}, but I'm not sure.`;
139
+ return {
140
+ next: "awaiting-confirmation",
141
+ context: {},
142
+ sideEffects: [{ type: "SPEAK", text: message, interrupt: true }],
143
+ };
144
+ }
145
+
146
+ case "CONFIRM_CANDIDATE": {
147
+ if (state !== "awaiting-confirmation") {
148
+ return { next: state, context, sideEffects: [] };
149
+ }
150
+ // The host should have stored the candidates in context.candidates.
151
+ // We can't store ParseResult directly because it lives in shared-types;
152
+ // for now, callers pass the index and the host resolves it externally.
153
+ // This is intentional — keeps the FSM serializable.
154
+ return {
155
+ next: "generating",
156
+ context,
157
+ sideEffects: [{ type: "TRIGGER_GENERATION" }],
158
+ };
159
+ }
160
+
161
+ case "GENERATION_OK": {
162
+ if (state !== "generating") {
163
+ return { next: state, context, sideEffects: [] };
164
+ }
165
+ return {
166
+ next: "ready",
167
+ context: {
168
+ ...context,
169
+ sampleUrl: event.sampleUrl,
170
+ variations: context.variations ?? [],
171
+ },
172
+ sideEffects: [
173
+ {
174
+ type: "SPEAK",
175
+ text: "Ready. Say play to preview, regenerate, download MIDI, or new pattern.",
176
+ interrupt: true,
177
+ },
178
+ ],
179
+ };
180
+ }
181
+
182
+ case "GENERATION_FAIL": {
183
+ return {
184
+ next: "error",
185
+ context: {
186
+ ...context,
187
+ error: { kind: "audio-error", message: event.message },
188
+ },
189
+ sideEffects: [{ type: "SPEAK", text: event.message, interrupt: true }],
190
+ };
191
+ }
192
+
193
+ case "PLAY_START": {
194
+ if (state !== "ready") return { next: state, context, sideEffects: [] };
195
+ return {
196
+ next: "playing",
197
+ context,
198
+ sideEffects: [{ type: "TRIGGER_PLAYBACK" }],
199
+ };
200
+ }
201
+
202
+ case "PLAY_STOP": {
203
+ if (state !== "playing") return { next: state, context, sideEffects: [] };
204
+ return {
205
+ next: "ready",
206
+ context,
207
+ sideEffects: [{ type: "TRIGGER_STOP" }],
208
+ };
209
+ }
210
+
211
+ case "ERROR": {
212
+ return {
213
+ next: "error",
214
+ context: { ...context, error: { kind: event.kind, message: event.message } },
215
+ sideEffects: [{ type: "SPEAK", text: event.message, interrupt: true }],
216
+ };
217
+ }
218
+ }
219
+ }
apps/web/next-env.d.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+
4
+ // NOTE: This file should not be edited
5
+ // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
apps/web/next.config.mjs ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {
3
+ reactStrictMode: true,
4
+ // PatternTalk's audio service runs on a different port (8001); in dev we
5
+ // proxy /api/audio/* there to avoid CORS. See feat/audio-service PR.
6
+ async rewrites() {
7
+ return [
8
+ {
9
+ source: "/api/audio/:path*",
10
+ destination: "http://localhost:8001/:path*",
11
+ },
12
+ ];
13
+ },
14
+ };
15
+
16
+ export default nextConfig;
apps/web/package.json ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "@patterntalk/web",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "next dev -p 3000",
8
+ "build": "next build",
9
+ "start": "next start -p 3000",
10
+ "lint": "next lint",
11
+ "typecheck": "tsc --noEmit",
12
+ "test": "vitest run",
13
+ "test:watch": "vitest"
14
+ },
15
+ "dependencies": {
16
+ "@patterntalk/shared-types": "workspace:*",
17
+ "@tonejs/midi": "^2.0.28",
18
+ "next": "^14.2.18",
19
+ "react": "^18.3.1",
20
+ "react-dom": "^18.3.1"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^22.9.3",
24
+ "@types/react": "^18.3.12",
25
+ "@types/react-dom": "^18.3.1",
26
+ "@vitejs/plugin-react": "^4.3.4",
27
+ "autoprefixer": "^10.4.20",
28
+ "eslint": "^8.57.1",
29
+ "eslint-config-next": "^14.2.18",
30
+ "happy-dom": "^15.11.7",
31
+ "postcss": "^8.4.49",
32
+ "tailwindcss": "^3.4.15",
33
+ "typescript": "^5.6.3",
34
+ "vitest": "^2.1.8"
35
+ }
36
+ }
apps/web/postcss.config.mjs ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
apps/web/scripts/_debug_jambuddy.cjs ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { execFile } = require("node:child_process");
2
+ const { promisify } = require("node:util");
3
+ const execFileAsync = promisify(execFile);
4
+ const { join } = require("node:path");
5
+
6
+ const repoRoot = join(__dirname, "..", "..", "..");
7
+ const python = join(repoRoot, "stable-audio-3", ".venv", "Scripts", "python.exe");
8
+ const script = join(repoRoot, "tools", "jam_buddy.py");
9
+ const out = join(require("node:os").tmpdir(), "out.wav");
10
+
11
+ execFileAsync(
12
+ python,
13
+ [script, "--bpm", "120", "--out", out],
14
+ { timeout: 300000, maxBuffer: 10 * 1024 * 1024 },
15
+ )
16
+ .then(({ stdout }) => {
17
+ console.log("OK. stdout bytes:", stdout.length);
18
+ console.log("tail:", stdout.slice(-200));
19
+ })
20
+ .catch((err) => {
21
+ console.log("ERROR name:", err.name);
22
+ console.log("ERROR code:", err.code);
23
+ console.log("ERROR killed:", err.killed);
24
+ console.log("ERROR signal:", err.signal);
25
+ console.log("ERROR message:", err.message);
26
+ console.log("ERROR stderr:", JSON.stringify(err.stderr));
27
+ console.log("ERROR stdout:", JSON.stringify(err.stdout?.slice(-300)));
28
+ });
apps/web/tailwind.config.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Config } from "tailwindcss";
2
+
3
+ const config: Config = {
4
+ content: [
5
+ "./app/**/*.{ts,tsx}",
6
+ "./components/**/*.{ts,tsx}",
7
+ "./lib/**/*.{ts,tsx}",
8
+ ],
9
+ theme: {
10
+ extend: {
11
+ colors: {
12
+ // Per docs/05-accessibility.md §High contrast and theming
13
+ bg: "var(--bg)",
14
+ fg: "var(--fg)",
15
+ accent: "var(--accent)",
16
+ focus: "var(--focus)",
17
+ "limb-kick": "var(--limb-kick)",
18
+ "limb-snare": "var(--limb-snare)",
19
+ "limb-hihat": "var(--limb-hihat)",
20
+ "limb-ride": "var(--limb-ride)",
21
+ "limb-crash": "var(--limb-crash)",
22
+ "limb-china": "var(--limb-china)",
23
+ },
24
+ },
25
+ },
26
+ plugins: [],
27
+ };
28
+
29
+ export default config;
apps/web/tsconfig.json ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "jsx": "preserve",
5
+ "allowJs": false,
6
+ "noEmit": true,
7
+ "plugins": [{ "name": "next" }],
8
+ "paths": {
9
+ "@/*": ["./*"]
10
+ }
11
+ },
12
+ "include": [
13
+ "next-env.d.ts",
14
+ "**/*.ts",
15
+ "**/*.tsx",
16
+ ".next/types/**/*.ts"
17
+ ],
18
+ "exclude": ["node_modules", ".next", "dist", "__tests__/**/*"]
19
+ }
apps/web/vitest.config.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from "vitest/config";
2
+ import react from "@vitejs/plugin-react";
3
+ import path from "node:path";
4
+
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ test: {
8
+ environment: "happy-dom",
9
+ globals: false,
10
+ include: ["__tests__/**/*.test.ts", "__tests__/**/*.test.tsx"],
11
+ },
12
+ resolve: {
13
+ alias: {
14
+ "@": path.resolve(__dirname, "."),
15
+ },
16
+ },
17
+ });
docs/01-vision.md ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 01 — Vision
2
+
3
+ ## The problem
4
+
5
+ Music generators and practice tools don't act like a bandmate. You sit down,
6
+ start playing, and the software asks you to describe what you want in its terms
7
+ — or it produces a finished "song" you have no part in. There's no tool that
8
+ just **listens to what you're playing and joins in**.
9
+
10
+ AI music tools compound the problem:
11
+ - They generate full mixes at a fixed tempo, not parts that lock to *your* tempo.
12
+ - They're visual toy apps or opaque audio generators with no semantic structure,
13
+ so blind and visually impaired producers can't use them at all.
14
+
15
+ ## The product
16
+
17
+ **Jam Buddy** is a call-and-response practice companion. The interaction is the
18
+ product: *you start playing, it joins in at your tempo, in the instrument you
19
+ pick.*
20
+
21
+ 1. You load a **MIDI take** (controller) or **audio take** (mic/interface).
22
+ 2. The buddy detects your tempo (exact from MIDI note times; tempo-range prior
23
+ on audio) and matches its response length to your take.
24
+ 3. You pick the **instrument** the buddy should play (bass / lead / rhythm /
25
+ synth / drums) plus a genre and mood.
26
+ 4. SA3 generates the response — `small-music` for melodic parts, `small-sfx`
27
+ for clean isolated drums — at your tempo.
28
+ 5. **PLAY BOTH** plays your take and the buddy's response together, in tempo.
29
+ 6. Every response is saved to `generations/` so you can keep and A/B it.
30
+
31
+ The UI is a hardware-sampler-styled rack, and it's **screenreader-compatible by
32
+ design**: labelled range-input knobs, accessible button names, `aria-live`
33
+ status. Voice-first *is* accessibility — we build the accessible version first,
34
+ not as an afterthought.
35
+
36
+ ## Why this fits the Stability AI challenge
37
+
38
+ The brief asks for *"a publicly available and accessible tool for music
39
+ producers using the power of the Stable Audio 3 models, encouraging open
40
+ development and showing the strengths of local open models."*
41
+
42
+ - **SA3 powers the response** — generated locally from the open `small-music` /
43
+ `small-sfx` weights, no black-box API.
44
+ - **Two real input modes**: MIDI (tempo + length lock) and audio
45
+ (audio-to-audio — the buddy genuinely hears and responds to your groove).
46
+ - **Accessibility as load-bearing**, not bolted on.
47
+ - **Open + local**: the whole pipeline runs on consumer hardware.
48
+
49
+ ## Who this is for
50
+
51
+ **Primary:**
52
+ - Musicians who want a practice partner that locks to *their* tempo.
53
+ - Producers exploring complementary parts without leaving their flow.
54
+ - Blind / visually impaired producers — the rack is fully keyboard + screenreader
55
+ operable.
56
+
57
+ **Secondary:**
58
+ - Any jam context — one player's take, a generated counterpart in the room.
59
+
60
+ ## What success looks like at the hackathon
61
+
62
+ **Demo (3–4 minutes):**
63
+ 1. Elevator pitch: *"a practice partner that hears you start playing and joins
64
+ in at your tempo, in your instrument."*
65
+ 2. Load a MIDI take from a real controller.
66
+ 3. Pick an instrument, hit JOIN IN — the buddy responds at your tempo.
67
+ 4. PLAY BOTH — you + the buddy together, the co-play moment.
68
+ 5. Toggle the keyboard/screenreader path to show accessibility.
69
+ 6. Point at the `generations/` files — everything it produced is kept.
70
+
71
+ **Deliverables:**
72
+ - Working web app (`apps/web`) + the `tools/jam_buddy.py` SA3 pipeline.
73
+ - Open-source MIT repo.
74
+ - Screenreader-tested UI (NVDA + VoiceOver).
75
+
76
+ **Stretch / not claimed:**
77
+ - A LoRA fine-tune for "your style" is a real path (via `underfit` on a GPU)
78
+ but is NOT shipped — we demo the open local model, honestly.
79
+
80
+ ## What we explicitly are not
81
+
82
+ - Not a DAW. Reaper / the DAW does that.
83
+ - Not a full song generator. We produce one complementary part at your tempo.
84
+ - Not a VST first. The web rack is the primary surface; plugin wrap is stretch.
85
+
86
+ ## North star
87
+
88
+ A musician plays a 20-second idea, picks "lead guitar," and within a minute the
89
+ buddy has responded with a lead part at the same tempo — and both play together.
90
+ That's the co-play moment we're building toward.
docs/02-architecture.md ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 02 — Architecture
2
+
3
+ ## System overview
4
+
5
+ PatternTalk is two services plus a shared data layer.
6
+
7
+ ```
8
+ ┌─────────────────────────────────────────────────────────────┐
9
+ │ Browser │
10
+ │ ┌──────────────────────────────────────────────────────┐ │
11
+ │ │ Next.js Web App (TypeScript) │ │
12
+ │ │ ├─ Voice UI (Web Speech API: STT + TTS) │ │
13
+ │ │ ├─ Prompt parser │ │
14
+ │ │ ├─ Pattern engine (loads templates) │ │
15
+ │ │ ├─ MIDI generator (@tonejs/midi) │ │
16
+ │ │ ├─ Audio context analyzer (Meyda/Essentia.js) │ │
17
+ │ │ ├─ Reaper Web Control client │ │
18
+ │ │ └─ Visual grid (optional, layered on top) │ │
19
+ │ └──────────────────────────────────────────────────────┘ │
20
+ └──────────┬──────────────────────────────────┬───────────────┘
21
+ │ HTTP │ WebSocket
22
+ ▼ ▼
23
+ ┌──────────────────────┐ ┌────────────────────────────┐
24
+ │ Audio Service │ │ Reaper (user's machine) │
25
+ │ (Python, FastAPI) │ │ Web Control surface │
26
+ │ ├─ SA3 inference │ │ + ReaScript bridge │
27
+ │ ├─ LoRA loader │ └────────────────────────────┘
28
+ │ └─ Sample generator │
29
+ └──────────────────────┘
30
+
31
+
32
+ ┌──────────────────────────────────────┐
33
+ │ Stable Audio 3 weights (local FS) │
34
+ │ + Brutal-drum LoRA adapter │
35
+ └──────────────────────────────────────┘
36
+ ```
37
+
38
+ ## Services
39
+
40
+ ### 1. Web app (`apps/web`)
41
+
42
+ **Stack:** Next.js 14+ (App Router), TypeScript, Tailwind CSS, Radix UI primitives, @tonejs/midi, Meyda.
43
+
44
+ **Responsibilities:**
45
+ - Render the voice-first UI
46
+ - Capture microphone input → text via Web Speech API (`SpeechRecognition`)
47
+ - Speak responses via `SpeechSynthesis`
48
+ - Parse prompts → structured requests (see [`docs/03-data-model.md`](03-data-model.md))
49
+ - Load pattern templates, generate MIDI in-browser
50
+ - Optional: analyze uploaded audio for BPM (Meyda/Essentia.js)
51
+ - Talk to Reaper via Web Control (when available)
52
+ - Talk to audio service for sample generation
53
+
54
+ **State management:** Zustand or React state. Avoid Redux. State is small.
55
+
56
+ **Why Next.js:**
57
+ - Server components let us split render work from interactive shell
58
+ - Easy deploy to Vercel
59
+ - TS support is first-class
60
+ - Accessibility ecosystem (react-aria, Radix) is mature
61
+
62
+ ### 2. Audio service (`services/audio`)
63
+
64
+ **Stack:** Python 3.10+, FastAPI, PyTorch 2.x, diffusers/transformers for SA3.
65
+
66
+ **Responsibilities:**
67
+ - Load Stable Audio 3 weights (base model)
68
+ - Load LoRA adapter (brutal-drums)
69
+ - Run inference on prompts → audio buffers
70
+ - Stream or return audio as WAV
71
+ - Cache generated samples (LRU + disk-backed)
72
+
73
+ **Why separate service:**
74
+ - Python ML ecosystem is non-negotiable for SA3
75
+ - Decoupling lets us run on different machines (Vega for dev, cloud GPU for demo)
76
+ - Browser can't run SA3 inference efficiently
77
+ - Service can be scaled or replaced without touching the web app
78
+
79
+ ### 3. Reaper integration (in user's DAW)
80
+
81
+ **Stack:** Reaper Web Control surface (built-in HTTP server) + optional ReaScript.
82
+
83
+ **Responsibilities:**
84
+ - Expose project tempo (BPM), time signature, play state
85
+ - Accept MIDI files dropped onto tracks
86
+
87
+ **Why this is not a "service":**
88
+ - Reaper runs on the user's machine
89
+ - PatternTalk is a client to its Web Control API
90
+ - No persistent server-side integration needed
91
+
92
+ ## Data flow
93
+
94
+ ### Happy path: voice prompt → MIDI + sample
95
+
96
+ ```
97
+ User voice: "tupatupatupa on the hihat, 4 bars"
98
+
99
+
100
+ [Web Speech API] ──text──▶ "tupatupatupa on the hihat, 4 bars"
101
+
102
+
103
+ [Onomatopoeia matcher] ──▶ { onomatopoeia: "tupatupatupa", mappedTo: "skank-beat" }
104
+
105
+
106
+ [Prompt parser] ──▶ {
107
+ │ pattern: "skank-beat",
108
+ │ bars: 4,
109
+ │ tempo: null, // not specified
110
+ │ timeSignature: "4/4", // default
111
+ │ cymbalHint: "hi-hat upstrokes"
112
+ │ }
113
+
114
+ [Tempo resolver] ──▶ tempo: 174
115
+ │ (priority: explicit in prompt > Reaper project BPM > uploaded audio BPM > 120 default)
116
+
117
+ [Pattern engine] ──▶ MIDI events[] (loaded from skank-beat template, expanded to 4 bars at 174 BPM)
118
+
119
+
120
+ [MIDI generator] ──▶ .mid file (Blob in browser)
121
+
122
+ ├─▶ [Download to user]
123
+
124
+ └─▶ [Audio service request]
125
+ POST /generate-sample
126
+ { prompt: "skank beat, hi-hat upstrokes, brutal drums", duration: 8 }
127
+
128
+
129
+ [SA3 + LoRA inference] ──▶ audio buffer
130
+
131
+
132
+ [Response] ──▶ .wav file (Blob in browser)
133
+
134
+ └─▶ [Download to user]
135
+ ```
136
+
137
+ ### Voice response flow
138
+
139
+ After generation, PatternTalk speaks back:
140
+
141
+ ```
142
+ "Skank beat, hi-hat upstrokes on the upbeats, 4 bars at 174 BPM.
143
+ MIDI ready. Sample ready. Say 'play' to preview, 'regenerate' to try again,
144
+ or 'download' to save the MIDI."
145
+ ```
146
+
147
+ ### Reaper sync flow
148
+
149
+ ```
150
+ Web app mounts → checks for Reaper Web Control at localhost:8080
151
+
152
+ ├─ present → fetch /_/project/tempo → use as tempo default
153
+
154
+ └─ absent → use uploaded audio BPM or 120 default
155
+ ```
156
+
157
+ ## Folder structure
158
+
159
+ ```
160
+ patterntalk/
161
+ ├── apps/
162
+ │ └── web/ # Next.js app
163
+ │ ├── app/ # App Router pages
164
+ │ │ ├── page.tsx # Main voice UI
165
+ │ │ ├── library/ # Pattern library
166
+ │ │ └── layout.tsx
167
+ │ ├── components/
168
+ │ │ ├── voice/ # Voice UI primitives
169
+ │ │ ├── grid/ # Visual grid (optional)
170
+ │ │ └── ui/ # Radix wrappers
171
+ │ ├── lib/
172
+ │ │ ├── parser/ # Prompt + onomatopoeia parser
173
+ │ │ ├── patterns/ # Pattern engine + template loader
174
+ │ │ ├── midi/ # MIDI generation (@tonejs/midi)
175
+ │ │ ├── audio/ # Web Audio, Meyda analyzer
176
+ │ │ └── reaper/ # Reaper Web Control client
177
+ │ ├── data/
178
+ │ │ ├── patterns/ # JSON pattern templates
179
+ │ │ └── onomatopoeia.json # Onomatopoeia mapping table
180
+ │ ├── public/
181
+ │ └── package.json
182
+ ├── services/
183
+ │ └── audio/ # FastAPI service
184
+ │ ├── sa3/ # SA3 wrapper
185
+ │ │ ├── inference.py
186
+ │ │ ├── lora.py
187
+ │ │ └── server.py
188
+ │ ├── training/ # LoRA fine-tuning scripts
189
+ │ │ └── train_lora.py
190
+ │ ├── models/ # SA3 base weights (gitignored)
191
+ │ ├── loras/ # Trained LoRA adapters
192
+ │ ├── cache/ # Generated sample cache
193
+ │ └── requirements.txt
194
+ ├── data/
195
+ │ └── training/ # Brutal drum samples for LoRA
196
+ │ ├── oneshots/ # Kick, snare, china, etc.
197
+ │ ├── loops/ # Short brutal loops
198
+ │ └── manifest.yaml # Training data manifest
199
+ ├── docs/ # This directory
200
+ ├── scripts/
201
+ │ ├── reaper/ # ReaScript helpers
202
+ │ └── verify/ # Accessibility + smoke tests
203
+ ├── .github/
204
+ │ └── workflows/ # CI (axe-core, lint, build)
205
+ ├── package.json # Workspace root
206
+ └── README.md
207
+ ```
208
+
209
+ ## Why monorepo
210
+
211
+ - Single repo for web + audio service + training data
212
+ - Shared types between TS and Python (via JSON Schema + codegen, or just hand-written TS interfaces mirrored in Pydantic)
213
+ - Single CI pipeline
214
+ - Easier to ship as one artifact at the demo
215
+
216
+ **Tooling:** pnpm workspaces + a simple Python venv per service. Avoid Turborepo/Nx overhead for a 2-day project.
217
+
218
+ ## Deployment
219
+
220
+ | Component | Target |
221
+ |---|---|
222
+ | Web app | Vercel (free tier) |
223
+ | Audio service | RunPod / Vast.ai during hackathon, optional Fly.io / Modal for inference |
224
+ | Models + LoRA weights | HuggingFace Hub (public, for the LoRA at least) |
225
+ | Training data | Small dataset, commit directly to repo or HuggingFace dataset |
226
+
227
+ ## Key technical decisions
228
+
229
+ ### Decision 1: Voice-first, not visual-first
230
+
231
+ The voice UI is the primary surface. The visual grid is optional and layered.
232
+
233
+ **Rationale:** Differentiates from every other drum plugin, hits the accessibility theme head-on, and matches how drummers actually think.
234
+
235
+ ### Decision 2: Pattern engine decoupled from audio engine
236
+
237
+ The pattern (MIDI events) is hand-coded from templates. The audio (samples) is generated by SA3.
238
+
239
+ **Rationale:** Your domain expertise lives in the patterns. SA3's strength is sample quality. Don't conflate them. Each can be evaluated independently.
240
+
241
+ ### Decision 3: Cloud GPU for inference during demo
242
+
243
+ Vega 56 can run SA3 small but slowly. Use cloud for demo-day inference to guarantee snappy response.
244
+
245
+ **Rationale:** Live inference during a 3-minute demo is high-risk if hardware is slow. A $20 cloud spend buys reliability.
246
+
247
+ ### Decision 4: Open weights and open code
248
+
249
+ MIT code, public LoRA weights, public training manifest.
250
+
251
+ **Rationale:** Stability challenge explicitly rewards "open development." Showing the weights and training data is itself part of the demo.
252
+
253
+ ### Decision 5: Reaper-first DAW integration
254
+
255
+ Web Control surface + MIDI export, not a full VST/CLAP.
256
+
257
+ **Rationale:** Web Control + drag-MIDI is 80% of the value at 20% of the work. CLAP wrap is stretch.
258
+
259
+ ## What this architecture doesn't do
260
+
261
+ - No multi-user real-time collaboration (out of scope for 2 days)
262
+ - No pattern saving to cloud accounts (localStorage only for now)
263
+ - No mobile-first UI (desktop browser is the target)
264
+ - No offline mode (Vega inference can run offline, but the demo assumes network for cloud inference)
265
+ - No AU plugin format (Mac pain, not worth it for the demo)
266
+
267
+ ## Open architectural questions
268
+
269
+ 1. **Where does the pattern library live?** Browser-only (static JSON) vs. served from the audio service? *Lean: browser-only, simpler.*
270
+ 2. **Should variations be pre-generated or on-demand?** Pre-generated is faster demo, on-demand is more impressive. *Lean: pre-generate for safety, on-demand as a "show your work" feature.*
271
+ 3. **Real-time WebSocket for inference progress, or just polling?** WebSocket is nicer UX, polling is simpler. *Lean: WebSocket if time, polling if not.*
docs/03-data-model.md ADDED
@@ -0,0 +1,400 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 03 — Data Model
2
+
3
+ ## Overview
4
+
5
+ PatternTalk's core data is small and human-editable. Most of it lives in JSON files under `apps/web/data/` and `data/training/`.
6
+
7
+ ```
8
+ Prompt (user voice/text)
9
+
10
+
11
+ ParsedRequest ◄── onomatopoeia.json + prompt grammar
12
+
13
+
14
+ Pattern (loaded from patterns/*.json, expanded to bars + tempo)
15
+
16
+
17
+ MIDI events
18
+
19
+
20
+ .mid file
21
+ ```
22
+
23
+ ## Prompt grammar
24
+
25
+ User input is freeform text, but parses into a structured `ParsedRequest`.
26
+
27
+ ```typescript
28
+ interface ParsedRequest {
29
+ // Source pattern
30
+ onomatopoeia?: string; // e.g. "tupatupatupa"
31
+ patternId?: string; // e.g. "d-beat", "blast-traditional"
32
+ patternName?: string; // e.g. "D-Beat", user-friendly
33
+
34
+ // Length
35
+ bars: number; // default 4
36
+ beats?: number; // override pattern beats per bar
37
+
38
+ // Tempo
39
+ tempo?: number; // explicit BPM
40
+ tempoSource: "prompt" | "reaper" | "audio" | "default";
41
+ tempoDefault: number; // 120 if nothing else
42
+
43
+ // Time signature
44
+ timeSignature: {
45
+ numerator: number; // default 4
46
+ denominator: number; // default 4
47
+ };
48
+
49
+ // Cymbal/voice hints
50
+ cymbal?: {
51
+ type: "crash" | "ride" | "china" | "hihat-open" | "hihat-closed" | "splash";
52
+ pattern?: "8ths" | "quarters" | "bell" | "wash" | "upstrokes";
53
+ };
54
+
55
+ // Accent overrides
56
+ accents?: ("1" | "and-of-2" | "3" | "and-of-4" | string)[];
57
+
58
+ // Style modifiers
59
+ feel?: "straight" | "swing" | "half-time" | "double-time";
60
+ intensity?: "soft" | "medium" | "brutal" | "brutal-max";
61
+ }
62
+ ```
63
+
64
+ ## Onomatopoeia mapping
65
+
66
+ `apps/web/data/onomatopoeia.json` is the lookup table the parser checks first.
67
+
68
+ ```json
69
+ {
70
+ "version": 1,
71
+ "entries": [
72
+ {
73
+ "id": "skank-beat",
74
+ "patterns": ["tupatupatupa", "tupa-tupa-tupa", "chka-chka-chka"],
75
+ "patternId": "skank",
76
+ "defaultCymbal": { "type": "hihat-open", "pattern": "upstrokes" },
77
+ "confidence": 0.9,
78
+ "notes": "Ska-style offbeat hi-hat upstrokes."
79
+ },
80
+ {
81
+ "id": "blast-beat",
82
+ "patterns": ["krrk-krrk-krrk", "BLAM-BLAM-BLAM", "krkrkrkrk"],
83
+ "patternId": "blast-traditional",
84
+ "defaultIntensity": "brutal",
85
+ "confidence": 0.85,
86
+ "notes": "Traditional blast beat. Kick-snare alternation."
87
+ },
88
+ {
89
+ "id": "boom-bap",
90
+ "patterns": ["boom-bap", "boom-bap-boom-bap"],
91
+ "patternId": "hiphop-basic",
92
+ "confidence": 0.95
93
+ },
94
+ {
95
+ "id": "china-accent",
96
+ "patterns": ["tss", "chka", "BLAM"],
97
+ "patternId": "china-accent",
98
+ "defaultCymbal": { "type": "china", "pattern": "wash" }
99
+ },
100
+ {
101
+ "id": "ride-bell",
102
+ "patterns": ["ding-ding-ding", "ding-ding-ding-ding"],
103
+ "patternId": "ride-bell-8ths",
104
+ "defaultCymbal": { "type": "ride", "pattern": "bell" }
105
+ }
106
+ ]
107
+ }
108
+ ```
109
+
110
+ ### Matching logic
111
+
112
+ 1. Normalize input: lowercase, strip punctuation, collapse whitespace
113
+ 2. Phonetic match: Soundex or simple Levenshtein against entries
114
+ 3. Syllable count: "tupatupatupa" = 4 syllables → likely 8th-note hi-hat
115
+ 4. Confidence threshold: 0.7 to auto-map, lower to ask user
116
+
117
+ ### User-defined onomatopoeias
118
+
119
+ Users can add their own mappings via the UI. Stored in `localStorage` per device.
120
+
121
+ ```typescript
122
+ interface UserOnomatopoeia {
123
+ phrase: string;
124
+ patternId: string;
125
+ cymbal?: CymbalHint;
126
+ createdAt: number;
127
+ }
128
+ ```
129
+
130
+ ## Pattern templates
131
+
132
+ `apps/web/data/patterns/*.json` — one file per pattern. Hand-authored. **This is your moat.**
133
+
134
+ ```typescript
135
+ interface PatternTemplate {
136
+ id: string; // unique slug
137
+ name: string; // "D-Beat"
138
+ description: string; // screenreader-friendly text
139
+ tags: string[]; // searchable
140
+ genre: "metal" | "rock" | "punk" | "pop" | "jazz" | "funk" | "latin" | "hiphop";
141
+ defaultTempo: number; // BPM
142
+ defaultBars: number; // how many bars this template covers
143
+ swingRatio?: number; // 0.0 = straight, 0.33 = shuffle
144
+ hits: Hit[];
145
+ }
146
+
147
+ interface Hit {
148
+ position: number; // 0.0 to 1.0, fraction of one bar
149
+ limb: Limb;
150
+ velocity: number; // 0-127 MIDI velocity
151
+ accent?: boolean; // visual + audio emphasis
152
+ }
153
+
154
+ type Limb =
155
+ | "kick" // right foot
156
+ | "snare" // left hand (default) or right hand (cross-handed)
157
+ | "hihat" // right hand (default) or left hand (cross-handed)
158
+ | "hihat-open" // same as hihat but with open hi-hat sample
159
+ | "ride"
160
+ | "ride-bell"
161
+ | "crash"
162
+ | "china"
163
+ | "splash"
164
+ | "tom-1" // highest
165
+ | "tom-2"
166
+ | "tom-3" // lowest floor tom
167
+ | "cross-stick"; // rim click
168
+ ```
169
+
170
+ ### Example: D-Beat
171
+
172
+ `apps/web/data/patterns/d-beat.json`:
173
+
174
+ ```json
175
+ {
176
+ "id": "d-beat",
177
+ "name": "D-Beat",
178
+ "description": "D-beat pattern at 180 BPM. Kick plays on every beat with 8th-note doubles, snare hits on 2 and 4, ride bell plays steady 8ths throughout. Common in Discharge, Entombed, and Scandinavian crust punk.",
179
+ "tags": ["metal", "punk", "discharge", "entombed", "crust", "fast"],
180
+ "genre": "metal",
181
+ "defaultTempo": 180,
182
+ "defaultBars": 1,
183
+ "swingRatio": 0,
184
+ "hits": [
185
+ { "position": 0.0, "limb": "kick", "velocity": 110 },
186
+ { "position": 0.125, "limb": "kick", "velocity": 80 },
187
+ { "position": 0.25, "limb": "snare", "velocity": 100 },
188
+ { "position": 0.375, "limb": "kick", "velocity": 80 },
189
+ { "position": 0.5, "limb": "kick", "velocity": 110 },
190
+ { "position": 0.625, "limb": "kick", "velocity": 80 },
191
+ { "position": 0.75, "limb": "snare", "velocity": 100 },
192
+ { "position": 0.875, "limb": "kick", "velocity": 80 },
193
+ { "position": 0.0, "limb": "ride-bell", "velocity": 70 },
194
+ { "position": 0.125, "limb": "ride-bell", "velocity": 70 },
195
+ { "position": 0.25, "limb": "ride-bell", "velocity": 70 },
196
+ { "position": 0.375, "limb": "ride-bell", "velocity": 70 },
197
+ { "position": 0.5, "limb": "ride-bell", "velocity": 70 },
198
+ { "position": 0.625, "limb": "ride-bell", "velocity": 70 },
199
+ { "position": 0.75, "limb": "ride-bell", "velocity": 70 },
200
+ { "position": 0.875, "limb": "ride-bell", "velocity": 70 }
201
+ ]
202
+ }
203
+ ```
204
+
205
+ ### Pattern library — starting list
206
+
207
+ **Metal (priority, 8 patterns):**
208
+ - `d-beat.json` — D-beat
209
+ - `blast-traditional.json` — traditional blast (kick-snare)
210
+ - `blast-hammer.json` — hammer-and-tongs blast
211
+ - `blast-hyper.json` — hyperblast (double kick 16ths)
212
+ - `half-time-metal.json` — Mastodon/Baroness half-time
213
+ - `djent-polyrhythm.json` — Meshuggah-style 4-over-3 kick
214
+ - `doom-slow.json` — Sleep/Electric Wizard doom
215
+ - `groove-metal.json` — mid-tempo groove metal
216
+
217
+ **Punk/Hardcore (4 patterns):**
218
+ - `punk-rock.json` — Ramones/Misfits 4-on-floor
219
+ - `hardcore.json` — Black Flag/Converge
220
+ - `skank.json` — ska skank beat (hi-hat upstrokes)
221
+ - `reggae-one-drop.json` — reggae
222
+
223
+ **Rock/Pop (4 patterns):**
224
+ - `rock-basic.json` — AC/DC/Beatles standard rock
225
+ - `rock-half-time.json` — half-time shuffle
226
+ - `pop-groove.json` — modern pop
227
+ - `country-train.json` — train beat
228
+
229
+ **Other genres (4 patterns):**
230
+ - `bossa-nova.json` — bossa nova
231
+ - `jazz-swing.json` — jazz ride swing
232
+ - `funk-new-orleans.json` — NOLA funk
233
+ - `hiphop-basic.json` — boom-bap
234
+
235
+ **Target: 18–20 patterns before hackathon, more as time permits.**
236
+
237
+ ## Pattern engine
238
+
239
+ Lives in `apps/web/lib/patterns/engine.ts`.
240
+
241
+ ### Inputs
242
+ - `PatternTemplate`
243
+ - `bars: number`
244
+ - `tempoBpm: number`
245
+ - `timeSignature: { numerator, denominator }`
246
+ - Optional `cymbal` override (replaces the default cymbal)
247
+ - Optional `accents` (boosts velocity on specified positions)
248
+ - Optional `feel` modifier
249
+
250
+ ### Process
251
+
252
+ ```
253
+ 1. Load template
254
+ 2. Expand to requested bars:
255
+ - If template.defaultBars === requested bars: use as-is
256
+ - Else: repeat the pattern N times, optionally vary velocity (humanize)
257
+ 3. Apply cymbal override:
258
+ - If cymbal specified: replace ride/hihat hits with cymbal type
259
+ - Velocity scaled: cymbal typically quieter than snare
260
+ 4. Apply accents:
261
+ - Find positions matching accent specs
262
+ - Boost velocity by +20
263
+ 5. Apply feel modifier:
264
+ - "swing": delay 8th notes on the "and" by swingRatio
265
+ - "half-time": halve the effective tempo of snare/hihat
266
+ - "double-time": double it
267
+ 6. Convert positions (0.0-1.0) to absolute ticks at tempoBpm
268
+ 7. Return array of MIDI events
269
+ ```
270
+
271
+ ### Output
272
+
273
+ ```typescript
274
+ interface MidiEvent {
275
+ tick: number; // absolute tick position
276
+ limb: Limb;
277
+ velocity: number; // 0-127
278
+ duration: number; // ticks (typically 1-10 for drums)
279
+ }
280
+ ```
281
+
282
+ ## MIDI generation
283
+
284
+ `apps/web/lib/midi/generator.ts` uses `@tonejs/midi`.
285
+
286
+ ```typescript
287
+ import { Midi } from "@tonejs/midi";
288
+
289
+ function eventsToMidi(events: MidiEvent[]): Uint8Array {
290
+ const midi = new Midi();
291
+ midi.header.setTempo(events.bpm); // bpm on master track
292
+ midi.header.timeSignature = [4, 4];
293
+
294
+ const drums = midi.addTrack();
295
+ drums.name = "PatternTalk Drums";
296
+
297
+ for (const ev of events) {
298
+ drums.addNote({
299
+ midi: limbToGeneralMidi(ev.limb),
300
+ ticks: ev.tick,
301
+ durationTicks: ev.duration,
302
+ velocity: ev.velocity / 127,
303
+ });
304
+ }
305
+
306
+ return midi.toArray();
307
+ }
308
+
309
+ function limbToGeneralMidi(limb: Limb): number {
310
+ // GM Drum Map
311
+ const map: Record<Limb, number> = {
312
+ "kick": 36,
313
+ "snare": 38,
314
+ "cross-stick": 37,
315
+ "hihat": 42,
316
+ "hihat-open": 46,
317
+ "ride": 51,
318
+ "ride-bell": 53,
319
+ "crash": 49,
320
+ "china": 52,
321
+ "splash": 55,
322
+ "tom-1": 50,
323
+ "tom-2": 47,
324
+ "tom-3": 45,
325
+ };
326
+ return map[limb];
327
+ }
328
+ ```
329
+
330
+ ## Audio sample prompts
331
+
332
+ When PatternTalk calls the audio service, the prompt includes both the pattern context and a style hint:
333
+
334
+ ```typescript
335
+ interface AudioPrompt {
336
+ pattern: string; // pattern name
337
+ limb?: Limb; // specific sample to generate
338
+ styleHints: string[]; // ["brutal drums", "18-inch china", "trashy"]
339
+ durationSeconds: number; // 1-5 for one-shots, 5-15 for loops
340
+ intensity: "soft" | "medium" | "brutal" | "brutal-max";
341
+ loraAdapter?: string; // "brutal-drums" by default
342
+ }
343
+ ```
344
+
345
+ Examples:
346
+ - Limb-specific one-shot: `{ pattern: "D-Beat", limb: "china", styleHints: ["trashy", "18-inch", "panic-attack"], durationSeconds: 3, intensity: "brutal-max" }`
347
+ - Loop preview: `{ pattern: "D-Beat", styleHints: ["brutal drums", "tight snare"], durationSeconds: 8, intensity: "brutal" }`
348
+
349
+ ## Training data manifest
350
+
351
+ `data/training/manifest.yaml` describes every audio file used to train the brutal-drum LoRA.
352
+
353
+ ```yaml
354
+ version: 1
355
+ lora:
356
+ name: patterntalk-brutal-drums
357
+ base_model: stable-audio-3-small
358
+ training_steps: 1500
359
+ learning_rate: 1e-4
360
+ rank: 32
361
+
362
+ samples:
363
+ - id: kick-001
364
+ path: oneshots/kick-tight.wav
365
+ category: kick
366
+ tags: [tight, clicky, triggered]
367
+ source: original-recording
368
+ license: CC-BY
369
+ duration_seconds: 1.2
370
+
371
+ - id: snare-discharge-001
372
+ path: oneshots/snare-trashy.wav
373
+ category: snare
374
+ tags: [trashy, snappy, crust]
375
+ source: freesound.org/user-x
376
+ license: CC-BY
377
+ duration_seconds: 0.8
378
+
379
+ - id: loop-dbeat-discharge-style
380
+ path: loops/dbeat-180.wav
381
+ category: loop
382
+ tags: [d-beat, 180bpm, discharge-style]
383
+ source: original-recording
384
+ license: CC-BY
385
+ duration_seconds: 4.0
386
+ bpm: 180
387
+ ```
388
+
389
+ **License discipline:** All samples must be CC0, CC-BY, or original recordings. No unlicensed material. Documented in the manifest, surfaced in the model card.
390
+
391
+ ## What we're explicitly not modeling (yet)
392
+
393
+ - User accounts (out of scope)
394
+ - Cloud-saved pattern libraries (localStorage only)
395
+ - Pattern remixing/chaining
396
+ - Time-signature patterns other than 4/4 (the parser supports it but templates are 4/4)
397
+ - Polymeter / metric modulation
398
+ - Genre mixing (one pattern per request, not hybrids)
399
+
400
+ These are all reasonable post-hackathon extensions.
docs/04-ux-voice-first.md ADDED
@@ -0,0 +1,388 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 04 — UX: Voice-First Design
2
+
3
+ ## Core principle
4
+
5
+ **Voice is the primary surface. The visual grid is optional and layered.**
6
+
7
+ A drummer with their hands full should be able to use PatternTalk without putting down sticks. A blind drummer using NVDA should be able to use it without sighted help. Sighted users get a parallel visual experience, but it never blocks the voice flow.
8
+
9
+ ## Voice technology
10
+
11
+ ### Speech-to-Text (STT)
12
+
13
+ **Web Speech API: `SpeechRecognition`**
14
+
15
+ ```typescript
16
+ const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
17
+ recognition.continuous = true;
18
+ recognition.interimResults = true;
19
+ recognition.lang = "en-US";
20
+
21
+ recognition.onresult = (event) => {
22
+ const last = event.results[event.results.length - 1];
23
+ if (last.isFinal) {
24
+ onFinalTranscript(last[0].transcript.trim());
25
+ } else {
26
+ onInterimTranscript(last[0].transcript);
27
+ }
28
+ };
29
+ ```
30
+
31
+ **Compatibility:** Chrome, Edge, Safari. Firefox has it but disabled by default. For the hackathon demo, Chrome is the assumption.
32
+
33
+ **Accessibility note:** Screenreaders (NVDA, VoiceOver) do NOT consume Web Speech API STT output — they have their own voice input path. So STT is for users *without* a screenreader or for users who want hands-free. Screenreader users will type or use their screenreader's voice control. Both paths converge on the same prompt parser.
34
+
35
+ ### Text-to-Speech (TTS)
36
+
37
+ **Web Speech API: `SpeechSynthesis`**
38
+
39
+ ```typescript
40
+ function speak(text: string, opts: { interrupt?: boolean } = {}) {
41
+ if (opts.interrupt) speechSynthesis.cancel();
42
+ const utterance = new SpeechSynthesisUtterance(text);
43
+ utterance.rate = 1.1; // slightly faster than default for snappy feel
44
+ utterance.pitch = 1.0;
45
+ speechSynthesis.speak(utterance);
46
+ }
47
+ ```
48
+
49
+ **Voice selection:** Use the system default voice. No fancy custom voices — keeps the demo reliable across machines.
50
+
51
+ **Accessibility note:** When a screenreader is active, `SpeechSynthesis` is muted by the screenreader (it doesn't double-speak). The text is read by the screenreader using the same DOM. This is exactly what we want.
52
+
53
+ ## The conversation loop
54
+
55
+ PatternTalk maintains a state machine for voice interactions:
56
+
57
+ ```
58
+ states: idle → listening → parsing → generating → ready → (next action)
59
+ ```
60
+
61
+ ### State diagram
62
+
63
+ ```
64
+ [IDLE]
65
+ │ user clicks "Start" or presses Space
66
+
67
+ [LISTENING]
68
+ │ mic captures audio
69
+ │ interim transcripts shown
70
+ │ user pauses or says a wake word
71
+
72
+ [PARSING]
73
+ │ onomatopoeia matcher → pattern
74
+ │ prompt parser → ParsedRequest
75
+ │ tempo resolver → final tempo
76
+
77
+ [GENERATING]
78
+ │ pattern engine → MIDI events
79
+ │ audio service → sample (async)
80
+ │ speak: "Generating skank beat, 4 bars..."
81
+
82
+ [READY]
83
+ │ speak: "Skank beat ready. MIDI and sample generated.
84
+ │ Say 'play' to preview, 'regenerate' to try again,
85
+ │ 'download MIDI', 'download sample',
86
+ │ or 'new pattern' to start over."
87
+
88
+ ├─ user: "play" → [PLAYING]
89
+ ├─ user: "regenerate" → [GENERATING] (4 variations possible)
90
+ ├─ user: "download MIDI" → trigger download
91
+ ├─ user: "download sample" → trigger download
92
+ └─ user: "new pattern" → [LISTENING]
93
+ ```
94
+
95
+ ### Wake word
96
+
97
+ A simple wake word avoids the "always listening" creep:
98
+
99
+ - **Wake word:** "PatternTalk" (or user-configurable)
100
+ - After wake word, the system listens for one command
101
+ - Optional: continuous listening mode for power users
102
+
103
+ For the demo, "always listening with a push-to-talk toggle" is simpler. Use Spacebar or click to start/stop listening.
104
+
105
+ ## Voice commands
106
+
107
+ ### Generation commands
108
+
109
+ | User says | System does |
110
+ |---|---|
111
+ | "Tupatupatupa on the hihat, 4 bars" | Generates skank beat (4 bars) |
112
+ | "4 bars of d-beat riding the crash at 180" | Generates d-beat (4 bars, 180 BPM, ride-bell → crash override) |
113
+ | "8 bars of skank beat" | Generates skank (8 bars, default tempo) |
114
+ | "Triplet-driven double bass with accents on the one and the three" | Generates djent-polyrhythm (4 bars, default tempo, accents on 1 and 3) |
115
+ | "Match my project tempo" | Sets `tempoSource = "reaper"` |
116
+ | "Set tempo to 160" | Overrides tempo |
117
+ | "Half-time feel" | Applies half-time modifier |
118
+ | "Swing it" | Applies swing feel |
119
+
120
+ ### Action commands
121
+
122
+ | User says | System does |
123
+ |---|---|
124
+ | "Play" / "Preview" | Plays the generated sample |
125
+ | "Stop" | Stops playback |
126
+ | "Regenerate" / "Try again" | Generates new variation |
127
+ | "Variations" | Generates 4 variations |
128
+ | "Pick number 2" | Selects variation 2 |
129
+ | "Download MIDI" | Downloads .mid |
130
+ | "Download sample" | Downloads .wav |
131
+ | "New pattern" / "Start over" | Resets to listening |
132
+ | "Help" | Reads command list |
133
+ | "Repeat" | Re-speaks the last response |
134
+
135
+ ### Sample-generation commands
136
+
137
+ | User says | System does |
138
+ |---|---|
139
+ | "Give me a trashy china" | Generates china one-shot |
140
+ | "Snare sample, brutal" | Generates snare one-shot (intensity=brutal) |
141
+ | "Loop preview" | Generates 8-second loop of current pattern |
142
+ | "Tighter snare" | Generates snare variant |
143
+
144
+ ## Visual language — hardware / metallic reference
145
+
146
+ The rack is styled as a hardware sampler. Source-of-truth visual reference:
147
+
148
+ - **`toggle-switch.webp`** (repo root) — the metallic toggle-switch aesthetic to
149
+ match. Classic industrial 2-position toggle:
150
+ - **Chrome ball knob** on a **hexagonal metal housing** (reads as a polished
151
+ steel nut), seated in a **brushed-aluminum plate**.
152
+ - **Blue ON / red OFF** labels (colored inserts in the metal plate).
153
+ - Depth cues that sell it: specular highlight on the knob, drop shadow of the
154
+ knob onto the housing, inner shading on the hex recess, soft outer shadow on
155
+ the plate, clean bold sans-serif labels.
156
+ - The existing engine rocker (`apps/web/app/globals.css` `.engine-toggle`) is the
157
+ in-app analog — brushed-steel two-position rocker. Keep any new toggle/switch
158
+ in the same metallic language (ball-on-hex for a literal toggle, brushed steel
159
+ for a rocker), not flat web styling.
160
+
161
+ ## Screen layout (visual fallback)
162
+
163
+ When the user is sighted, the visual layout has these regions:
164
+
165
+ ```
166
+ ┌─────────────────────────────────────────────────────┐
167
+ │ PatternTalk [help] [×] │
168
+ ├─────────────────────────────────────────────────────┤
169
+ │ │
170
+ │ 🎤 [Listening...] │
171
+ │ │
172
+ │ You said: "tupatupatupa on the hihat, 4 bars" │
173
+ │ │
174
+ │ ┌───────────────────────────────────────────────┐ │
175
+ │ │ Pattern: Skank Beat │ │
176
+ │ │ Tempo: 120 BPM (say "match project") │ │
177
+ │ │ Bars: 4 │ │
178
+ │ │ Status: Ready │ │
179
+ │ └───────────────────────────────────────────────┘ │
180
+ │ │
181
+ │ ┌───────────────────────────────────────────────┐ │
182
+ │ │ [Visual grid — bars of colored dots] │ │
183
+ │ │ ●K ●K ●S ●K ●K ●K ●S ●K │ │
184
+ │ │ ●H ●H ●H ●H ●H ●H ●H ●H │ │
185
+ │ └───────────────────────────────────────────────┘ │
186
+ │ │
187
+ │ [Play] [Download MIDI] [Download Sample] │
188
+ │ [Regenerate] [Variations (4)] [New Pattern] │
189
+ │ │
190
+ └─────────────────────────────────────────────────────┘
191
+ ```
192
+
193
+ **The visual grid is hidden by default** in voice-first mode. Sighted users can toggle it on. It's also hidden when a screenreader is detected (to reduce clutter, since the screenreader will read the description anyway).
194
+
195
+ ## Visual grid component
196
+
197
+ For each bar in the pattern, render a row of dots:
198
+
199
+ ```
200
+ Bar 1 of 4 — Skank Beat at 120 BPM
201
+ ● · ● · ● · ● · ← kick (red, larger)
202
+ · ● · ● · ● · ● ← snare (blue, smaller)
203
+ ● ● ● ● ● ● ● ● ← hi-hat (yellow, medium)
204
+ ```
205
+
206
+ Each limb has:
207
+ - A color (configurable)
208
+ - A size (kick = large, snare = medium, hi-hat = small)
209
+ - A label tooltip on hover
210
+
211
+ **Accessibility:** The grid has an `aria-label` summarizing the pattern:
212
+
213
+ ```html
214
+ <div role="img" aria-label="Skank beat, 4 bars at 120 BPM. Kick on every beat with 8th note doubles. Snare on 2 and 4. Hi-hat on every 8th note.">
215
+ <!-- visual dots -->
216
+ </div>
217
+ ```
218
+
219
+ ## Keyboard navigation
220
+
221
+ Every action has a keyboard equivalent. No mouse required.
222
+
223
+ | Key | Action |
224
+ |---|---|
225
+ | `Space` | Start/stop listening |
226
+ | `Enter` | Confirm current selection / play preview |
227
+ | `Tab` | Move focus through controls |
228
+ | `1`-`4` | Pick variation N |
229
+ | `M` | Download MIDI |
230
+ | `S` | Download sample |
231
+ | `R` | Regenerate |
232
+ | `V` | Show variations |
233
+ | `N` | New pattern |
234
+ | `?` | Help (read command list) |
235
+ | `Esc` | Cancel current action |
236
+
237
+ Focus is always visible (high-contrast outline). When the screen announces something, focus moves to it so the screenreader reads it.
238
+
239
+ ## Pattern library page (`/library`)
240
+
241
+ A separate page listing all patterns, with search and filter:
242
+
243
+ - Search by name, tag, or genre
244
+ - Filter by genre, tempo range
245
+ - Click to load into the main UI
246
+ - Share URL for any pattern (`/library?pattern=d-beat`)
247
+ - "Fork" button: duplicate a pattern into the user's localStorage library
248
+
249
+ **Accessibility:** Search is keyboard-driven. Results announced via `aria-live="polite"`. Filter chips have visible labels and ARIA states.
250
+
251
+ ## Variations UI
252
+
253
+ When user asks for variations, show 4 mini-grids side by side:
254
+
255
+ ```
256
+ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
257
+ │ Var 1 │ │ Var 2 │ │ Var 3 │ │ Var 4 │ [press 1-4]
258
+ │ ●K ●K ●S │ │ ●K ●K ●K │ │ ●K ●K ●S │ │ ●K ●K ●S │
259
+ │ · ·S · │ │ ·S · ·S │ │ · ·S · │ │ · ·S · │
260
+ │ ●H ●H ●H │ │ ●H ●H ●H │ │ ●H ●H ●H │ │ ●H ●H ●H │
261
+ └──────────┘ └──────────┘ └──────────┘ └──────────┘
262
+ ```
263
+
264
+ Each variation has:
265
+ - A different velocity humanization
266
+ - A different micro-timing variation
267
+ - Sometimes a different fill at the end of the bar
268
+ - The patternId is the same; the variations are deterministic seeds of `engine.humanize()`
269
+
270
+ For the demo, pre-generate 4 variations and store them so the picker is instant.
271
+
272
+ ## Sample preview player
273
+
274
+ Audio playback uses the Web Audio API:
275
+
276
+ ```typescript
277
+ async function playPreview(audioBuffer: ArrayBuffer) {
278
+ const ctx = new AudioContext();
279
+ const decoded = await ctx.decodeAudioData(audioBuffer);
280
+ const source = ctx.createBufferSource();
281
+ source.buffer = decoded;
282
+ source.connect(ctx.destination);
283
+ source.start(0);
284
+ }
285
+ ```
286
+
287
+ The preview loops until the user says "stop" or presses Esc.
288
+
289
+ ## Error handling
290
+
291
+ Voice systems fail. The UX must handle it gracefully.
292
+
293
+ | Failure | UX response |
294
+ |---|---|
295
+ | Mic permission denied | Show "Enable microphone in browser settings" + manual text input |
296
+ | SpeechRecognition unavailable | Fall back to text input, show banner |
297
+ | No onomatopoeia match | "I heard 'xyz', but I'm not sure what you mean. Try saying it differently, or pick from the list." |
298
+ | Pattern not found | "Pattern 'xyz' not found. Did you mean 'd-beat'?" |
299
+ | Audio service timeout | "Sample generation is taking longer than expected. Using cached sample. Say 'regenerate' to retry." |
300
+ | Audio service error | "Couldn't generate a sample. MIDI is still ready. Say 'download MIDI' to save." |
301
+ | Reaper not detected | "I don't see Reaper running. Say 'set tempo' to specify BPM manually." |
302
+
303
+ In all cases, the user is told what's happening. No silent failures.
304
+
305
+ ## Performance budget
306
+
307
+ | Metric | Target |
308
+ |---|---|
309
+ | First meaningful paint | < 1.5s |
310
+ | Time to interactive (voice ready) | < 3s |
311
+ | Prompt → MIDI generation | < 500ms (in-browser, no I/O) |
312
+ | Prompt → sample ready | < 30s (cloud inference), < 5min acceptable (Vega inference) |
313
+ | Voice transcript latency | < 500ms |
314
+ | MIDI file download | < 200ms |
315
+
316
+ ## Demo flow (3–4 minutes)
317
+
318
+ This is the rehearsal script:
319
+
320
+ ```
321
+ [Open PatternTalk inside Reaper, mic icon visible]
322
+
323
+ DRUMMER: "PatternTalk, tupatupatupa on the hihat, 4 bars"
324
+
325
+ APP: "Skank beat, hi-hat upstrokes on the upbeats, 4 bars at 120 BPM.
326
+ MIDI ready. Sample ready."
327
+
328
+ [Sighted users see grid + buttons. Blind drummer presses Tab to navigate.]
329
+
330
+ DRUMMER: "Match my project tempo"
331
+
332
+ APP: "Tempo set to 174 from Reaper project. Regenerating."
333
+
334
+ APP: "Done. 4 bars at 174 BPM. Say 'play' to preview."
335
+
336
+ DRUMMER: "Play"
337
+
338
+ [Audio plays through speakers]
339
+
340
+ DRUMMER: "Download MIDI"
341
+
342
+ APP: "MIDI downloaded."
343
+
344
+ [Drummer drags MIDI from browser downloads into Reaper track]
345
+
346
+ DRUMMER: "Regenerate, brutal"
347
+
348
+ APP: "Brutal mode engaged. Generating 4 variations."
349
+
350
+ [4 mini-grids appear]
351
+
352
+ DRUMMER: "Pick number 3"
353
+
354
+ APP: "Variation 3 selected. Preview playing."
355
+
356
+ DRUMMER: "New pattern. 4 bars of d-beat riding the crash at 180."
357
+
358
+ APP: "D-beat with crash ride, 4 bars at 180. Generating."
359
+
360
+ [Generation completes]
361
+
362
+ DRUMMER: "Show me the grid."
363
+
364
+ [Visual grid appears]
365
+
366
+ [Switch on NVDA. Demo screenreader navigation.]
367
+
368
+ DRUMMER: "Tab."
369
+
370
+ NVDA: "Generate button. Tab. Regenerate button. Tab. Variations button..."
371
+
372
+ DRUMMER: "Read the pattern."
373
+
374
+ NVDA: "D-beat pattern. Kick plays on every beat with 8th-note doubles.
375
+ Snare hits on 2 and 4. Ride bell — no, crash — plays steady 8ths
376
+ throughout. 4 bars at 180 BPM."
377
+
378
+ DRUMMER: "Download MIDI."
379
+
380
+ [Done]
381
+ ```
382
+
383
+ ## What this UX is not
384
+
385
+ - Not a chat interface. No typing back-and-forth. Voice is push-to-talk with clear states.
386
+ - Not a visual-first app with voice features. Voice IS the app.
387
+ - Not accessible as a feature. Accessibility is the design constraint that shaped voice-first.
388
+ - Not a music theory tutor. We don't explain what a blast beat *means*, we just play it.
docs/05-accessibility.md ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 05 — Accessibility Strategy
2
+
3
+ ## Why this isn't an afterthought
4
+
5
+ PatternTalk's founder runs [Narwall.tech](https://narwall.tech), an accessibility testing tool that uses a real screenreader to find compliance gaps. The hackathon's Accessibility & Inclusion theme is listed as a first-class track. The target users include blind and visually impaired producers, a population currently locked out of almost every AI music tool.
6
+
7
+ So accessibility is **load-bearing**, not a checklist. The voice-first design (see [`04-ux-voice-first.md`](04-ux-voice-first.md)) was driven by accessibility, not the other way around.
8
+
9
+ ## Principles
10
+
11
+ 1. **Test with real assistive technology, not just linters.** axe-core catches ~30% of issues. NVDA and VoiceOver catch the rest.
12
+ 2. **Voice-first is screenreader-first.** Building the voice layer well means the screenreader layer works for free.
13
+ 3. **Every action has a keyboard equivalent.** No mouse-only paths.
14
+ 4. **Every pattern has a textual description.** Not an afterthought caption — the description is the data.
15
+ 5. **No information conveyed by color alone.** The visual grid uses size, position, and label too.
16
+ 6. **Works at 200% zoom and at high contrast.** Tested with Windows High Contrast Mode.
17
+
18
+ ## Standards we comply with
19
+
20
+ - **WCAG 2.2 AA** as the floor. AAA where feasible.
21
+ - **WAI-ARIA 1.2** for semantic structure.
22
+ - **Section 508** (US federal) — implicit via WCAG.
23
+ - **EN 301 549** (EU) — implicit via WCAG.
24
+ - **VPAT 2.4** — generated for the demo.
25
+
26
+ ## Screenreader support
27
+
28
+ ### Tested screenreaders
29
+
30
+ | Screenreader | OS | Browser | Status |
31
+ |---|---|---|---|
32
+ | NVDA 2024.x | Windows | Firefox + Chrome | Primary test target |
33
+ | VoiceOver | macOS | Safari | Primary test target |
34
+ | JAWS 2024 | Windows | Chrome | Stretch target |
35
+ | TalkBack | Android | Chrome | Post-hackathon |
36
+ | VoiceOver | iOS | Safari | Post-hackathon |
37
+
38
+ ### Test cadence
39
+
40
+ - **Before each commit to `main`** that touches the UI: axe-core in CI blocks on AA violations
41
+ - **Once per day during the hackathon**: manual NVDA run-through of the full flow
42
+ - **Before demo**: NVDA + VoiceOver run-through, both screenshare-able
43
+
44
+ ## Keyboard navigation
45
+
46
+ Every interactive element is reachable via Tab. Focus order matches visual order. Focus is always visible (high-contrast outline).
47
+
48
+ ```css
49
+ :focus-visible {
50
+ outline: 3px solid var(--focus-color, #FFD700);
51
+ outline-offset: 2px;
52
+ }
53
+ ```
54
+
55
+ Skip links for repeated UI:
56
+
57
+ ```html
58
+ <a href="#main-content" class="skip-link">Skip to main content</a>
59
+ <a href="#pattern-grid" class="skip-link">Skip to pattern grid</a>
60
+ <a href="#controls" class="skip-link">Skip to controls</a>
61
+ ```
62
+
63
+ ### Roving tabindex for the visual grid
64
+
65
+ The grid is a 2D structure. Arrow keys navigate within it; Tab enters/exits.
66
+
67
+ ```typescript
68
+ function onGridKeydown(e: KeyboardEvent, row: number, col: number) {
69
+ switch (e.key) {
70
+ case "ArrowRight": moveFocus(row, col + 1); break;
71
+ case "ArrowLeft": moveFocus(row, col - 1); break;
72
+ case "ArrowDown": moveFocus(row + 1, col); break;
73
+ case "ArrowUp": moveFocus(row - 1, col); break;
74
+ case "Home": moveFocus(row, 0); break;
75
+ case "End": moveFocus(row, lastCol); break;
76
+ case "Enter": announceCurrentCell(); break;
77
+ }
78
+ }
79
+ ```
80
+
81
+ ## Voice interaction as accessibility
82
+
83
+ ### How voice maps to screenreaders
84
+
85
+ When PatternTalk speaks via `SpeechSynthesis`:
86
+
87
+ - **No screenreader active:** audio plays through speakers, user hears it
88
+ - **NVDA active:** NVDA mutes the speech synthesis output and reads the same text via its own voice, using the live region's text content
89
+ - **VoiceOver active:** same behavior as NVDA
90
+
91
+ This is the magic: a single voice output layer works for everyone.
92
+
93
+ ### How STT maps to screenreaders
94
+
95
+ Screenreaders don't expose their STT input to web pages. So:
96
+
97
+ - **Sighted users without screenreader:** use Web Speech API STT
98
+ - **Screenreader users:** type into a regular text input, or use their screenreader's voice control (e.g., Dragon, Voice Access on Android, Voice Control on macOS)
99
+
100
+ Both paths converge on the same prompt parser. The UI offers both visibly:
101
+
102
+ ```
103
+ [🎤 Hold Space to talk] or [Type a prompt: ____________]
104
+ ```
105
+
106
+ ### ARIA live regions
107
+
108
+ When PatternTalk wants to announce something (a new pattern generated, an error, a status change), it uses ARIA live regions:
109
+
110
+ ```html
111
+ <div aria-live="polite" aria-atomic="true" id="status">
112
+ <!-- PatternTalk writes here when state changes -->
113
+ Skank beat, 4 bars at 174 BPM. MIDI ready.
114
+ </div>
115
+
116
+ <div aria-live="assertive" role="alert" id="errors">
117
+ <!-- Errors go here, interrupt speechreader -->
118
+ </div>
119
+ ```
120
+
121
+ **Polite** for routine status (don't interrupt). **Assertive** for errors (interrupt).
122
+
123
+ ## Pattern description format
124
+
125
+ Every pattern has a screenreader-friendly text description. The format:
126
+
127
+ ```
128
+ {Pattern name}. {Genre} at {tempo} BPM.
129
+ {Limb-by-limb description}.
130
+ {Notable characteristics}.
131
+ {Tags/context}.
132
+ ```
133
+
134
+ Examples:
135
+
136
+ **D-Beat:**
137
+ ```
138
+ D-Beat. Metal at 180 BPM. Kick plays on every beat with
139
+ 8th-note doubles. Snare hits on 2 and 4. Ride bell plays
140
+ steady 8th notes throughout. Common in Discharge, Entombed,
141
+ and Scandinavian crust punk.
142
+ ```
143
+
144
+ **Skank Beat:**
145
+ ```
146
+ Skank Beat. Ska at 120 BPM. Kick on 1 and 3. Snare on
147
+ 2 and 4. Hi-hat plays on every upbeat — the "and" of each
148
+ beat. Ska upstroke feel.
149
+ ```
150
+
151
+ **Blast Traditional:**
152
+ ```
153
+ Traditional Blast Beat. Metal at 200 BPM. Kick and snare
154
+ alternate in 8th notes throughout. Hi-hat plays continuous
155
+ 8th notes. High intensity.
156
+ ```
157
+
158
+ **Djent Polyrhythm:**
159
+ ```
160
+ Djent Polyrhythm. Metal at 140 BPM. Kick plays a 4-over-3
161
+ polyrhythm against the 4/4 pulse. Snare on 2 and 4.
162
+ Ride cymbal plays 8th notes. Polyphonic, Meshuggah-style.
163
+ ```
164
+
165
+ The description is generated from the template at runtime. Sighted users see it in a panel; screenreaders read it on demand.
166
+
167
+ ## High contrast and theming
168
+
169
+ ```css
170
+ :root {
171
+ --bg: #ffffff;
172
+ --fg: #1a1a1a;
173
+ --accent: #0066cc;
174
+ --focus: #ffd700;
175
+ --limb-kick: #d62828;
176
+ --limb-snare: #1d3557;
177
+ --limb-hihat: #f4a261;
178
+ --limb-ride: #2a9d8f;
179
+ --limb-crash: #e76f51;
180
+ --limb-china: #8338ec;
181
+ }
182
+
183
+ @media (prefers-color-scheme: dark) {
184
+ :root {
185
+ --bg: #1a1a1a;
186
+ --fg: #f0f0f0;
187
+ --accent: #66b2ff;
188
+ }
189
+ }
190
+
191
+ @media (prefers-contrast: more) {
192
+ :root {
193
+ --fg: #000000;
194
+ --bg: #ffffff;
195
+ --accent: #0000ee;
196
+ --focus: #ff00ff;
197
+ }
198
+ }
199
+ ```
200
+
201
+ ## Reduced motion
202
+
203
+ Some users get sick from animation. Respect `prefers-reduced-motion`:
204
+
205
+ ```css
206
+ @media (prefers-reduced-motion: reduce) {
207
+ *, *::before, *::after {
208
+ animation-duration: 0.01ms !important;
209
+ transition-duration: 0.01ms !important;
210
+ }
211
+ }
212
+ ```
213
+
214
+ The visual grid has subtle "pulse" animations when patterns play back. With reduced motion, those are disabled — only color changes indicate playback position.
215
+
216
+ ## Captions and transcripts
217
+
218
+ Every audio sample generated by SA3 gets a textual caption (derived from the prompt + pattern context):
219
+
220
+ ```
221
+ Sample: brutal china crash, 18-inch, trashy.
222
+ Duration: 3.0 seconds. Intensity: brutal-max.
223
+ ```
224
+
225
+ Captions are:
226
+ - Visible by default in the UI
227
+ - Read by screenreaders when focus moves to the sample player
228
+ - Exportable as a sidecar text file with the .wav download
229
+
230
+ ## CI accessibility checks
231
+
232
+ ```yaml
233
+ # .github/workflows/ci.yml
234
+ name: CI
235
+ on: [push, pull_request]
236
+ jobs:
237
+ a11y:
238
+ runs-on: ubuntu-latest
239
+ steps:
240
+ - uses: actions/checkout@v4
241
+ - uses: actions/setup-node@v4
242
+ - run: npm ci
243
+ - run: npm run build
244
+ - run: npm run test:a11y # axe-core via @axe-core/playwright
245
+ ```
246
+
247
+ `npm run test:a11y` runs Playwright with axe-core against every page. Fails on any WCAG AA violation.
248
+
249
+ ## Manual screenreader test protocol
250
+
251
+ Before each demo:
252
+
253
+ 1. **Cold start:** Close browser, open PatternTalk, immediately start NVDA
254
+ 2. **Full flow via keyboard only:** Generate a pattern, audition it, download MIDI, generate variations, pick one, start over
255
+ 3. **Verify announcements:** Every state change should be announced within 1 second
256
+ 4. **Verify focus:** Focus should never get stuck or disappear
257
+ 5. **Verify grid:** Tab to grid, arrow-navigate, every cell announces its limb and velocity
258
+ 6. **Verify download:** Download MIDI and sample, verify file names announced
259
+ 7. **Verify errors:** Trigger an error path (e.g., Reaper not running), verify error is announced politely
260
+ 8. **VoiceOver pass:** Repeat steps 1–7 with VoiceOver on macOS
261
+
262
+ A short screen recording of this is part of the demo deliverables.
263
+
264
+ ## Reduced-physical-load mode (stretch)
265
+
266
+ For drummers with RSI, chronic pain, or temporary injury. The plugin generates the physically demanding parts (blast beats, double bass) and the drummer plays the parts they can play.
267
+
268
+ Implementation: split each pattern into "AI plays" vs "you play" subsets. Export two MIDI tracks. Drummer mutes the AI track they want to play themselves.
269
+
270
+ Not in scope for the 2-day hackathon, but the data model supports it (each `Hit` has a `playableBy: "ai" | "human"` flag).
271
+
272
+ ## Haptic metronome (stretch)
273
+
274
+ A paired PWA on the drummer's phone vibrates on the beat. Different vibration patterns for downbeat, backbeat, fills.
275
+
276
+ Out of scope for the 2-day hackathon. Post-hackathon product.
277
+
278
+ ## Real-time audio captioning (stretch)
279
+
280
+ Capture audio from the interface, run lightweight audio classification, output live captions: "kick, snare, kick-snare fill, china crash."
281
+
282
+ Out of scope for the 2-day hackathon. This is what would make the tool usable for deaf producers collaborating with hearing producers.
283
+
284
+ ## Why this matters for the hackathon
285
+
286
+ Jury includes Andrew Huang, who has explicitly designed for accessibility in past work. Zack Zukowski (Stability AI) is known for caring about inclusive design. The "Accessibility & Inclusion" theme is a first-class track. The MUTEK Festival, where winners present, has an audience that includes accessibility advocates.
287
+
288
+ A demo that opens with "let me show you the screenreader view" is memorable. A demo that says "and we tested this with NVDA daily" is credible.
289
+
290
+ ## Narwall integration (post-hackathon)
291
+
292
+ Long-term, PatternTalk could be tested *by* Narwall as part of its workflow — using Narwall's real-screenreader-based testing to continuously verify that no UI regression breaks the screenreader experience. That's a natural fit for the founder's other product and would make PatternTalk the only music tool with continuous accessibility testing baked in.
293
+
294
+ For the hackathon: mention this as the long-term plan in the demo, don't build the integration.
docs/06-stable-audio-integration.md ADDED
@@ -0,0 +1,466 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 06 — Stable Audio 3 Integration
2
+
3
+ ## What we're using Stable Audio 3 for
4
+
5
+ PatternTalk uses SA3 for two things, both explicitly listed in the Stability AI challenge brief:
6
+
7
+ 1. **One-shot sample generation** — "give me a trashy 18-inch china"
8
+ 2. **Loop preview generation** — 5–15 second previews of patterns with brutal drum styling
9
+
10
+ Plus one bonus use case:
11
+
12
+ 3. **LoRA fine-tuning** — train a small adapter on brutal drum samples so the model actually understands the genre
13
+
14
+ The MIDI patterns themselves are NOT generated by SA3. They're hand-coded templates. This is a deliberate split: SA3's strength is sample quality, not structured MIDI generation.
15
+
16
+ ## Why local inference, not API
17
+
18
+ The Stability AI challenge explicitly rewards *"showing the strengths of local open models."* Using the open weights locally:
19
+
20
+ - Proves we actually use the open model, not a black-box API
21
+ - Eliminates rate limits and surprise outages during the demo
22
+ - Lets us fine-tune and ship the LoRA weights as a deliverable
23
+ - Makes the inference reproducible and inspectable
24
+
25
+ We pay for this with slower inference on consumer hardware. Mitigation: cloud GPU for the heavy work.
26
+
27
+ ## Hardware reality
28
+
29
+ ### Vega 56 (your machine) — honest assessment
30
+
31
+ **Specs:** 8GB HBM2, GCN 5th gen, ROCm-supported but old.
32
+
33
+ **Inference (SA3 small):**
34
+ - ✅ Fits in 8GB VRAM in fp16
35
+ - ⚠️ Tight memory headroom — no concurrent inference, no batching
36
+ - ⚠️ ROCm + PyTorch + audio models = some setup friction
37
+ - ⚠️ Throughput roughly **1/4 to 1/6 of an RTX 4090** on diffusion models
38
+ - ⚠️ A 30-second sample at 100 denoising steps: expect 2–10 minutes wall time
39
+
40
+ **Fine-tuning (LoRA):**
41
+ - ❌ Not practical. Gradient buffers + optimizer state blow past 8GB.
42
+ - ⏱️ If forced: 10–50× slower than cloud. Full LoRA training: hours to days, not feasible in a hackathon.
43
+
44
+ **Verdict:** Use Vega for development and testing the inference path. Use cloud GPU for fine-tuning and demo-day inference.
45
+
46
+ ### Cloud GPU (RunPod / Vast.ai / Modal)
47
+
48
+ **Recommended for the hackathon:**
49
+
50
+ | Provider | GPU | Cost/hr | Notes |
51
+ |---|---|---|---|
52
+ | RunPod | RTX 4090 | $0.40 | Easiest UX, instant deploy |
53
+ | RunPod | A4000 | $0.30 | Slightly slower than 4090 |
54
+ | Vast.ai | RTX 3090 | $0.20 | Cheaper, more setup |
55
+ | Modal | A10G | $0.50 | Serverless, pay per second |
56
+ | Lambda Labs | A100 | $1.10 | Overkill but fast |
57
+
58
+ **Recommendation:** RunPod with a 4090 for ~6 hours total = **~$3**. Fine-tune during one session, run demo inference during another.
59
+
60
+ ## Setup
61
+
62
+ ### Step 1: Get the model weights
63
+
64
+ ```bash
65
+ # Clone the repo
66
+ git clone https://github.com/Stability-AI/stable-audio-3.git
67
+ cd stable-audio-3
68
+
69
+ # Request access on HuggingFace (gated)
70
+ # https://huggingface.co/stabilityai/stable-audio-3-small
71
+ # https://huggingface.co/stabilityai/stable-audio-3-medium
72
+
73
+ # Login
74
+ huggingface-cli login
75
+
76
+ # Download weights (use small on Vega, medium on cloud)
77
+ python scripts/download_weights.py --model small
78
+ ```
79
+
80
+ ### Step 2: Install dependencies (local, Vega)
81
+
82
+ ROCm is finicky. Use a pre-built PyTorch container if possible.
83
+
84
+ ```bash
85
+ # Option A: Use the official Stability AI Docker image
86
+ docker pull stabilityai/stable-audio-tools:latest
87
+
88
+ # Option B: Manual install (if Docker fails)
89
+ pip install torch==2.4.0 --index-url https://download.pytorch.org/whl/rocm5.7
90
+ pip install stable-audio-tools
91
+ pip install fastapi uvicorn
92
+ ```
93
+
94
+ **Reality check:** On Vega, expect 1–3 hours of yak-shaving to get inference running the first time. Budget for this.
95
+
96
+ ### Step 3: Verify inference works
97
+
98
+ ```python
99
+ # scripts/smoke_test.py
100
+ from stable_audio_tools import get_pretrained_model
101
+ from stable_audio_tools.inference.generation import generate_diffusion_cond
102
+
103
+ model, config = get_pretrained_model("stabilityai/stable-audio-3-small")
104
+
105
+ # Time this — establishes your baseline
106
+ import time
107
+ start = time.time()
108
+ output = generate_diffusion_cond(
109
+ model,
110
+ steps=100,
111
+ cfg_scale=7,
112
+ conditioning=[{"prompt": "d-beat drum loop, 180 BPM, brutal", "seconds_start": 0, "seconds_total": 8}],
113
+ batch_size=1,
114
+ sample_size=44100 * 8,
115
+ device="cuda",
116
+ )
117
+ elapsed = time.time() - start
118
+ print(f"Generated in {elapsed:.1f}s")
119
+ # Save output
120
+ import torchaudio
121
+ torchaudio.save("smoke_test.wav", output.squeeze().cpu(), 44100)
122
+ ```
123
+
124
+ If this takes < 2 minutes on Vega, you're fine. If it takes > 10 minutes, commit to cloud GPU for the demo.
125
+
126
+ ## Fine-tuning: brutal-drum LoRA
127
+
128
+ ### Why fine-tune
129
+
130
+ Off-the-shelf SA3 doesn't know "brutal" drums. Your testing confirmed it. Fine-tuning on a curated dataset makes the model actually understand the genre vocabulary.
131
+
132
+ ### Training data curation
133
+
134
+ **Minimum viable dataset (start with this):**
135
+
136
+ - 20–40 one-shots:
137
+ - 5–10 kicks (tight, clicky, triggered)
138
+ - 5–10 snares (snappy, trashy, mid-range)
139
+ - 3–5 chinas (trashy, dark, bright variants)
140
+ - 3–5 crashes (various sizes/washes)
141
+ - 2–3 hi-hats (closed, open, stack)
142
+ - 2–3 rides (dry, washy, bell-forward)
143
+ - 10–20 loops (3–10 seconds each):
144
+ - 3–5 d-beat loops at various tempos
145
+ - 3–5 blast beat loops (traditional, hammer, hyperblast)
146
+ - 2–3 half-time grooves
147
+ - 2–3 punk rock / hardcore loops
148
+ - 2–3 djent polyrhythm loops
149
+
150
+ **Sources (in order of preference):**
151
+
152
+ 1. **Your own recordings** — best, no licensing issues, your taste
153
+ 2. **Freesound.org** — filter to CC0 or CC-BY, document the user
154
+ 3. **Splice / Loopcloud** — if you have a subscription, export with licensing documented
155
+ 4. **Bandcamp / label sample packs** — check license terms, some are CC-BY
156
+ 5. **Your band's existing recordings** — if you produced them, you own them
157
+
158
+ **Discipline:** Every file in `data/training/` has a corresponding entry in `data/training/manifest.yaml` with source, license, duration, BPM (for loops), and tags.
159
+
160
+ ### Pre-processing
161
+
162
+ All samples normalized to:
163
+ - WAV format
164
+ - 44100 Hz sample rate (or 48000 — match SA3's expected rate)
165
+ - Mono (drums don't need stereo for LoRA training; stereo adds noise)
166
+ - Loudness normalized to ~ -14 LUFS
167
+ - Trimmed silence at start/end
168
+ - Loops: aligned to bar boundaries, ideally with a single downbeat
169
+
170
+ ```python
171
+ # services/audio/training/preprocess.py
172
+ import torchaudio
173
+ import pyloudnorm as pyln
174
+
175
+ def preprocess(input_path: str, output_path: str):
176
+ waveform, sr = torchaudio.load(input_path)
177
+ # Resample
178
+ if sr != 44100:
179
+ waveform = torchaudio.functional.resample(waveform, sr, 44100)
180
+ # To mono
181
+ waveform = waveform.mean(dim=0, keepdim=True)
182
+ # Loudness normalize
183
+ meter = pyln.Meter(44100)
184
+ loudness = meter.integrated_loudness(waveform.numpy().T)
185
+ normalized = pyln.normalize.loudness(waveform.numpy().T, loudness, -14.0)
186
+ waveform = torch.from_numpy(normalized.T).unsqueeze(0)
187
+ torchaudio.save(output_path, waveform, 44100)
188
+ ```
189
+
190
+ ### LoRA training script
191
+
192
+ Stability's repo supports LoRA via `stable_audio_tools.training.lora`. Use their example as a base.
193
+
194
+ ```python
195
+ # services/audio/training/train_lora.py
196
+ import torch
197
+ from stable_audio_tools.models import create_model_from_config
198
+ from stable_audio_tools.training.lora import LoRADataset, LoRATrainer
199
+ from stable_audio_tools.data.utils import load_training_manifest
200
+
201
+ # Load base model
202
+ model, config = create_model_from_config("model_config.json")
203
+ model.load_state_dict(torch.load("stable_audio_3_small.safetensors"))
204
+
205
+ # Load training manifest
206
+ dataset = LoRADataset(
207
+ manifest_path="data/training/manifest.yaml",
208
+ audio_dir="data/training/",
209
+ )
210
+
211
+ # LoRA config
212
+ lora_config = {
213
+ "rank": 32,
214
+ "alpha": 32,
215
+ "dropout": 0.05,
216
+ "target_modules": ["to_q", "to_k", "to_v", "to_out.0"], # SA3 specifics
217
+ }
218
+
219
+ # Train
220
+ trainer = LoRATrainer(
221
+ model=model,
222
+ dataset=dataset,
223
+ lora_config=lora_config,
224
+ learning_rate=1e-4,
225
+ batch_size=2,
226
+ gradient_accumulation=4,
227
+ max_steps=1500,
228
+ save_every=500,
229
+ output_dir="loras/brutal-drums",
230
+ device="cuda",
231
+ )
232
+
233
+ trainer.train()
234
+ ```
235
+
236
+ **Expected training time on RTX 4090:** 30–90 minutes for 1500 steps with rank 32.
237
+
238
+ **Output:**
239
+ - `loras/brutal-drums/adapter.safetensors` — the LoRA weights (~50MB)
240
+ - `loras/brutal-drums/checkpoints/` — intermediate checkpoints
241
+ - `loras/brutal-drums/training_log.json` — loss curve
242
+
243
+ ### Publishing the LoRA
244
+
245
+ After training, push to HuggingFace:
246
+
247
+ ```python
248
+ # services/audio/training/publish.py
249
+ from huggingface_hub import HfApi
250
+
251
+ api = HfApi()
252
+ api.create_repo("your-username/patterntalk-brutal-drums", repo_type="model")
253
+
254
+ api.upload_folder(
255
+ folder_path="loras/brutal-drums/",
256
+ repo_id="your-username/patterntalk-brutal-drums",
257
+ commit_message="Initial LoRA trained on brutal drum samples",
258
+ )
259
+
260
+ # Upload model card
261
+ api.upload_file(
262
+ path_or_fileobj="loras/brutal-drums/README.md",
263
+ path_in_repo="README.md",
264
+ repo_id="your-username/patterntalk-brutal-drums",
265
+ )
266
+ ```
267
+
268
+ **Model card must include:**
269
+ - Base model reference
270
+ - Training data summary (with manifest link)
271
+ - License
272
+ - Intended use
273
+ - Limitations (it's a LoRA, not general-purpose)
274
+ - Citation to Stability AI and PatternTalk
275
+
276
+ ## Inference service
277
+
278
+ ### Architecture
279
+
280
+ ```python
281
+ # services/audio/sa3/server.py
282
+ from fastapi import FastAPI, HTTPException
283
+ from pydantic import BaseModel
284
+ import torch
285
+ from stable_audio_tools import get_pretrained_model
286
+ from stable_audio_tools.inference.generation import generate_diffusion_cond
287
+ import torchaudio
288
+ import io
289
+ import hashlib
290
+
291
+ app = FastAPI()
292
+
293
+ # Load model once at startup
294
+ model, config = get_pretrained_model("stabilityai/stable-audio-3-small")
295
+ model = model.to("cuda")
296
+ model.eval()
297
+
298
+ # Load LoRA adapter
299
+ model.load_adapter("loras/brutal-drums/adapter.safetensors", adapter_name="brutal")
300
+
301
+ # Sample cache (LRU + disk)
302
+ cache_dir = Path("cache/")
303
+ cache_dir.mkdir(exist_ok=True)
304
+
305
+ class GenerateRequest(BaseModel):
306
+ prompt: str
307
+ duration_seconds: float = 8.0
308
+ intensity: str = "medium" # soft | medium | brutal | brutal-max
309
+ use_lora: bool = True
310
+ cfg_scale: float = 7.0
311
+ steps: int = 100
312
+
313
+ class GenerateResponse(BaseModel):
314
+ audio_url: str # URL to download the WAV
315
+ prompt: str
316
+ duration: float
317
+ cached: bool
318
+
319
+ @app.post("/generate", response_model=GenerateResponse)
320
+ async def generate(req: GenerateRequest):
321
+ # Cache key
322
+ cache_key = hashlib.sha256(
323
+ f"{req.prompt}|{req.duration_seconds}|{req.intensity}|{req.use_lora}".encode()
324
+ ).hexdigest()[:16]
325
+
326
+ cache_path = cache_dir / f"{cache_key}.wav"
327
+
328
+ if cache_path.exists():
329
+ return GenerateResponse(
330
+ audio_url=f"/cache/{cache_key}.wav",
331
+ prompt=req.prompt,
332
+ duration=req.duration_seconds,
333
+ cached=True,
334
+ )
335
+
336
+ # Build conditioning
337
+ conditioning = [{
338
+ "prompt": req.prompt,
339
+ "seconds_start": 0,
340
+ "seconds_total": req.duration_seconds,
341
+ }]
342
+
343
+ # Apply LoRA
344
+ if req.use_lora:
345
+ model.set_adapter("brutal")
346
+ else:
347
+ model.disable_adapters()
348
+
349
+ # Generate
350
+ try:
351
+ output = generate_diffusion_cond(
352
+ model,
353
+ steps=req.steps,
354
+ cfg_scale=req.cfg_scale,
355
+ conditioning=conditioning,
356
+ sample_size=int(44100 * req.duration_seconds),
357
+ device="cuda",
358
+ )
359
+ except Exception as e:
360
+ raise HTTPException(500, f"Generation failed: {e}")
361
+
362
+ # Save
363
+ torchaudio.save(str(cache_path), output.squeeze().cpu(), 44100)
364
+
365
+ return GenerateResponse(
366
+ audio_url=f"/cache/{cache_key}.wav",
367
+ prompt=req.prompt,
368
+ duration=req.duration_seconds,
369
+ cached=False,
370
+ )
371
+
372
+ @app.get("/cache/{key}.wav")
373
+ async def get_cached(key: str):
374
+ path = cache_dir / f"{key}.wav"
375
+ if not path.exists():
376
+ raise HTTPException(404)
377
+ return FileResponse(path)
378
+ ```
379
+
380
+ ### Intensity → prompt engineering
381
+
382
+ Map user-friendly intensity to model-friendly prompts:
383
+
384
+ ```python
385
+ INTENSITY_PROMPTS = {
386
+ "soft": "clean studio drums, polished, tight, controlled",
387
+ "medium": "live room drums, punchy, present",
388
+ "brutal": "brutal drums, trashy, aggressive, raw, distorted",
389
+ "brutal-max": "ultra-brutal drums, completely destroyed, blown-out, panic-attack intensity",
390
+ }
391
+
392
+ def build_audio_prompt(req: AudioPrompt) -> str:
393
+ parts = [
394
+ req.pattern,
395
+ req.styleHints.join(", "),
396
+ INTENSITY_PROMPTS[req.intensity],
397
+ "drum recording, close-mic'd",
398
+ ]
399
+ if req.limb:
400
+ parts.append(f"single {req.limb} hit")
401
+ return ", ".join(filter(None, parts))
402
+ ```
403
+
404
+ ## Prompting strategy for SA3
405
+
406
+ Stable Audio 3 responds well to:
407
+ - Genre terms ("d-beat", "black metal", "brutal")
408
+ - Tempo ("180 BPM")
409
+ - Recording style ("close-mic'd", "room mic", "triggered")
410
+ - Specific instrument descriptors ("tight snare", "trashy china")
411
+ - Intensity adjectives ("brutal", "raw", "polished")
412
+
413
+ SA3 responds poorly to:
414
+ - Vague aesthetic terms ("cool", "interesting")
415
+ - Mixing many genres ("jazz-black-metal-funk")
416
+ - Trying to specify timing ("snare on the and of 2")
417
+
418
+ Our brutal-drum LoRA biases the model toward:
419
+ - Recognizing metal subgenres
420
+ - Generating physically realistic drum sounds
421
+ - Avoiding pop/EDM patterns
422
+
423
+ ## Demo strategy
424
+
425
+ For the live demo, we want generation to be fast and reliable. Pre-generate a few showcase samples:
426
+
427
+ ```bash
428
+ # Pre-generate demo cache
429
+ python scripts/pregenerate_demo.py
430
+ # Generates:
431
+ # cache/dbeat-180.wav
432
+ # cache/blast-traditional-200.wav
433
+ # cache/china-trashy.wav
434
+ # cache/kick-brutal.wav
435
+ # cache/skank-120.wav
436
+ # cache/variation-1.wav ... variation-4.wav
437
+ ```
438
+
439
+ During the demo, hit the live API for the "regenerate" command (to show inference happening), but rely on cache for the initial generation to keep the demo snappy.
440
+
441
+ ## What can go wrong
442
+
443
+ | Failure | Mitigation |
444
+ |---|---|
445
+ | Cloud GPU instance dies | Pre-generated cache + fallback to Vega inference |
446
+ | SA3 produces generic output | LoRA adapter handles this; fallback is to label output "demo variation" |
447
+ | Generation exceeds 30s | Show progress indicator + have pre-generated fallback |
448
+ | Audio quality is bad | Iterate on LoRA training data; have multiple variations to pick from |
449
+ | ROCm issues on Vega | Skip local dev, use cloud-only |
450
+
451
+ ## Cost summary
452
+
453
+ | Item | Cost |
454
+ |---|---|
455
+ | Cloud GPU (RunPod 4090, 6 hours total) | ~$3 |
456
+ | HuggingFace Pro (if needed for gated weights) | $0 (free tier sufficient) |
457
+ | Total cloud spend | **~$3** |
458
+
459
+ If your budget is truly $0, do everything on Vega and accept 2–10 minute inference times. The product still works, the demo just needs more buffer time.
460
+
461
+ ## Post-hackathon
462
+
463
+ - Quantize the model (int8) for faster inference
464
+ - Train additional LoRAs: rock, jazz, funk (one per genre)
465
+ - Host inference on Modal/Replicate for public use
466
+ - Build a "train your own LoRA" UI — the "personal LoRA trainer" use case from the brief
docs/07-reaper-integration.md ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 07 — Reaper Integration
2
+
3
+ ## Goal
4
+
5
+ PatternTalk lives alongside Reaper, not inside it (for the 2-day hackathon). Integration happens at three levels:
6
+
7
+ 1. **Auto-detect project tempo** via Reaper's Web Control surface
8
+ 2. **MIDI export** that's drag-and-droppable into Reaper
9
+ 3. **Optional ReaScript bridge** for richer control (stretch)
10
+
11
+ The CLAP plugin wrap is a stretch goal — see [`02-architecture.md`](02-architecture.md#decision-5-reaper-first-daw-integration).
12
+
13
+ ## Reaper Web Control surface
14
+
15
+ Reaper has a built-in HTTP server you can enable in Preferences → Control Surfaces → Web Browser Interface.
16
+
17
+ **Default URL:** `http://localhost:8080`
18
+
19
+ **Default endpoints we care about:**
20
+
21
+ | Endpoint | Returns |
22
+ |---|---|
23
+ | `GET /_/` | HTML control panel (we ignore this) |
24
+ | `GET /_/action?name=...` | Run a named action |
25
+ | `GET /_/set?param=value` | Set a parameter |
26
+ | `WS ws://localhost:8080/_/` | WebSocket for live state |
27
+
28
+ **Project tempo** is exposed via the WebSocket. On connect:
29
+
30
+ ```json
31
+ {
32
+ "type": "state",
33
+ "data": {
34
+ "tempo": 174,
35
+ "timesig": [4, 4],
36
+ "playstate": 0,
37
+ "position": 0
38
+ }
39
+ }
40
+ ```
41
+
42
+ We subscribe to `state` updates and use the current tempo as the default for new patterns.
43
+
44
+ ## Client-side Reaper detection
45
+
46
+ ```typescript
47
+ // apps/web/lib/reaper/client.ts
48
+
49
+ class ReaperClient {
50
+ private socket: WebSocket | null = null;
51
+ private listeners: Set<(state: ReaperState) => void> = new Set();
52
+ private state: ReaperState = {
53
+ connected: false,
54
+ tempo: null,
55
+ timeSignature: null,
56
+ };
57
+
58
+ async connect(): Promise<boolean> {
59
+ try {
60
+ this.socket = new WebSocket("ws://localhost:8080/_/");
61
+ this.socket.onopen = () => {
62
+ this.state.connected = true;
63
+ this.notify();
64
+ };
65
+ this.socket.onmessage = (event) => {
66
+ const msg = JSON.parse(event.data);
67
+ if (msg.type === "state") {
68
+ this.state.tempo = msg.data.tempo;
69
+ this.state.timeSignature = msg.data.timesig;
70
+ this.notify();
71
+ }
72
+ };
73
+ this.socket.onerror = () => {
74
+ this.state.connected = false;
75
+ this.notify();
76
+ };
77
+ return true;
78
+ } catch (e) {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ getTempo(): number | null {
84
+ return this.state.tempo;
85
+ }
86
+
87
+ isConnected(): boolean {
88
+ return this.state.connected;
89
+ }
90
+
91
+ subscribe(listener: (state: ReaperState) => void): () => void {
92
+ this.listeners.add(listener);
93
+ return () => this.listeners.delete(listener);
94
+ }
95
+
96
+ private notify() {
97
+ for (const listener of this.listeners) {
98
+ listener(this.state);
99
+ }
100
+ }
101
+ }
102
+
103
+ interface ReaperState {
104
+ connected: boolean;
105
+ tempo: number | null;
106
+ timeSignature: [number, number] | null;
107
+ }
108
+
109
+ export const reaper = new ReaperClient();
110
+ ```
111
+
112
+ ## Tempo resolution priority
113
+
114
+ When the user generates a pattern, PatternTalk picks the tempo in this order:
115
+
116
+ 1. **Explicit in prompt:** "set tempo to 160" or "180 BPM" → 160 or 180
117
+ 2. **From Reaper project:** If connected → use project tempo
118
+ 3. **From uploaded audio:** If user uploaded an audio file → detected BPM
119
+ 4. **Default:** 120 BPM
120
+
121
+ ```typescript
122
+ // apps/web/lib/parser/tempo.ts
123
+
124
+ export async function resolveTempo(
125
+ parsed: ParsedRequest,
126
+ reaper: ReaperClient,
127
+ uploadedAudio: AudioBuffer | null
128
+ ): Promise<number> {
129
+ if (parsed.tempo) {
130
+ return { tempo: parsed.tempo, source: "prompt" };
131
+ }
132
+
133
+ if (reaper.isConnected() && reaper.getTempo()) {
134
+ return { tempo: reaper.getTempo()!, source: "reaper" };
135
+ }
136
+
137
+ if (uploadedAudio) {
138
+ const bpm = await detectBpm(uploadedAudio);
139
+ if (bpm) {
140
+ return { tempo: bpm, source: "audio" };
141
+ }
142
+ }
143
+
144
+ return { tempo: 120, source: "default" };
145
+ }
146
+ ```
147
+
148
+ ## MIDI export
149
+
150
+ The MIDI file is generated in-browser. The user downloads it as a `.mid` file, then drags it onto a Reaper track.
151
+
152
+ ### File naming
153
+
154
+ ```
155
+ patterntalk-{pattern-id}-{bars}bars-{bpm}bpm-{timestamp}.mid
156
+ ```
157
+
158
+ Examples:
159
+ - `patterntalk-d-beat-4bars-180bpm-2026-08-22T1430Z.mid`
160
+ - `patterntalk-skank-4bars-120bpm-2026-08-22T1435Z.mid`
161
+
162
+ ### Drag-and-drop into Reaper
163
+
164
+ Reaper accepts MIDI files dropped from the file system onto a track. We make this explicit:
165
+
166
+ ```typescript
167
+ // apps/web/components/midi/DownloadButton.tsx
168
+
169
+ function downloadMidi(events: MidiEvent[], meta: PatternMeta) {
170
+ const midi = eventsToMidi(events, meta);
171
+ const blob = new Blob([midi], { type: "audio/midi" });
172
+ const url = URL.createObjectURL(blob);
173
+
174
+ const filename = `patterntalk-${meta.patternId}-${meta.bars}bars-${meta.bpm}bpm-${new Date().toISOString()}.mid`;
175
+
176
+ // Trigger download
177
+ const a = document.createElement("a");
178
+ a.href = url;
179
+ a.download = filename;
180
+ a.click();
181
+
182
+ // Cleanup
183
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
184
+
185
+ return filename;
186
+ }
187
+ ```
188
+
189
+ ### Drag-from-browser directly into Reaper
190
+
191
+ For the smoothest demo, the MIDI file should be draggable from the browser window directly onto a Reaper track. Reaper accepts this if the file is exposed as a real file (not a Blob URL), which means we need to keep the file in memory and reference it via the DataTransfer API.
192
+
193
+ ```typescript
194
+ function makeMidiDraggable(
195
+ events: MidiEvent[],
196
+ meta: PatternMeta,
197
+ element: HTMLElement
198
+ ) {
199
+ const midi = eventsToMidi(events, meta);
200
+ const filename = `patterntalk-${meta.patternId}-${meta.bars}bars-${meta.bpm}bpm.mid`;
201
+
202
+ element.draggable = true;
203
+ element.ondragstart = (e) => {
204
+ const file = new File([midi], filename, { type: "audio/midi" });
205
+ e.dataTransfer!.files = [file];
206
+ // Some browsers need this
207
+ e.dataTransfer!.setData("DownloadURL", `audio/midi:${filename}:${e.dataTransfer!.getData("DownloadURL")}`);
208
+ };
209
+ }
210
+ ```
211
+
212
+ **Caveat:** Browser support for dragging real files (not blob URLs) into native apps varies. Test in Chrome on the demo machine before relying on this.
213
+
214
+ If drag-from-browser is flaky, the fallback is "click to download, then drag from Downloads folder to Reaper." Annoying but reliable.
215
+
216
+ ## Verifying MIDI works in Reaper
217
+
218
+ Before the demo, smoke-test the MIDI export:
219
+
220
+ 1. Open Reaper, create a new project at 180 BPM, 4/4
221
+ 2. Add a track, load any drum sampler VST (free options: Drumgizmo, MT Power Drum Kit, or Reaper's built-in ReaDrumCrafter)
222
+ 3. Download a PatternTalk MIDI file
223
+ 4. Drag onto the track
224
+ 5. Hit play — confirm the pattern plays correctly
225
+
226
+ Common bugs to watch for:
227
+ - Tempo mismatch (Reaper plays at project tempo; MIDI file tempo should match)
228
+ - Wrong GM drum map notes (kick = 36, snare = 38, etc.)
229
+ - Notes too short or too long (drum hits should be very short, ~1-10 ticks)
230
+ - MIDI file doesn't import at all (file format corruption)
231
+
232
+ ## ReaScript bridge (stretch)
233
+
234
+ For richer integration, a small ReaScript can:
235
+
236
+ - Auto-create a new track and load the MIDI when PatternTalk generates
237
+ - Set the project tempo to match the pattern
238
+ - Start playback automatically
239
+
240
+ ```lua
241
+ -- scripts/reaper/patterntalk_bridge.lua
242
+
243
+ -- Receives HTTP requests from PatternTalk
244
+ -- Endpoint: http://localhost:8081/...
245
+
246
+ -- For each request, execute a Reaper action
247
+
248
+ function onRequest(method, path, body)
249
+ if path == "/import-midi" then
250
+ local filepath = body.filepath
251
+ local trackIndex = body.trackIndex or 0
252
+ Reaper.MIDI_InsertMedia(filepath, trackIndex)
253
+ return { ok = true }
254
+ end
255
+
256
+ if path == "/set-tempo" then
257
+ local tempo = body.tempo
258
+ reaper.SetProjectTimeSignature(0, tempo, ...)
259
+ return { ok = true }
260
+ end
261
+ end
262
+ ```
263
+
264
+ This is a stretch goal. If we have a teammate who's comfortable with ReaScript, we ship it. If not, the Web Control surface + MIDI drag-and-drop is the integration story.
265
+
266
+ ## Web Control endpoint reference
267
+
268
+ The full Reaper Web Control API is documented at:
269
+ https://www.reaper.fm/developers/webcontrol.php
270
+
271
+ Key endpoints for PatternTalk:
272
+
273
+ | Action | Endpoint |
274
+ |---|---|
275
+ | Get full state | `WS /_/` |
276
+ | Run named action | `GET /_/action?name=<action_id>` |
277
+ | Set project tempo | `GET /_/set?project_tempo=<bpm>` |
278
+ | Get current tempo | (via WebSocket state) |
279
+ | Transport play | `GET /_/action?name=40044` (transport: play) |
280
+ | Transport stop | `GET /_/action?name=40044` (toggle, state-dependent) |
281
+
282
+ **Note:** Reaper's Web Control API is reverse-engineered more than documented. The state format isn't formally specced. Expect some brittleness; verify on the actual Reaper version (currently 7.x).
283
+
284
+ ## Demo integration flow
285
+
286
+ For the live demo, the integration sequence is:
287
+
288
+ 1. **Reaper is already open** with a project at ~174 BPM (a typical d-beat tempo)
289
+ 2. **PatternTalk opens** in a browser window next to Reaper
290
+ 3. **PatternTalk detects Reaper**, announces "Reaper connected at 174 BPM"
291
+ 4. **User generates a pattern** — PatternTalk uses 174 from Reaper
292
+ 5. **User downloads or drags MIDI** — drops into a Reaper track with a drum VST
293
+ 6. **Reaper plays** — pattern sounds correct
294
+ 7. **Optional:** Change Reaper project tempo to 160, regenerate in PatternTalk, drag new MIDI, plays at new tempo
295
+
296
+ The whole sequence, with audio, takes about 30 seconds. Memorable.
297
+
298
+ ## What this integration is NOT
299
+
300
+ - Not a VST/AU/CLAP plugin (stretch goal only)
301
+ - Not bidirectional — PatternTalk reads from Reaper, doesn't write project state
302
+ - Not automatic — user still has to drag the MIDI file in
303
+ - Not a replacement for Reaper's built-in features
304
+
305
+ This is intentional. The 80/20 here is "auto-detect tempo + clean MIDI export." Everything else is stretch.
docs/08-build-plan.md ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 08 — Build Plan
2
+
3
+ ## Timeline overview
4
+
5
+ ```
6
+ NOW (3 weeks before) Pre-hack prep
7
+ HACK DAY 1 (Sat Aug 22) Build core features
8
+ HACK DAY 2 (Sun Aug 23) Polish, integrate, demo
9
+ POST-HACK Publish, ship, follow up
10
+ ```
11
+
12
+ ## Pre-hackathon (you have ~3 weeks)
13
+
14
+ Goal: arrive at the event with a working skeleton, training data ready, and most patterns written. This compresses day 1 to "polish core, add stretch features" instead of "build from zero."
15
+
16
+ ### Week 1: foundations
17
+
18
+ **By end of week 1:**
19
+
20
+ - [ ] Get Stable Audio 3 API access / model weights downloaded
21
+ - [ ] HuggingFace account created
22
+ - [ ] Request gated access to `stabilityai/stable-audio-3-small`
23
+ - [ ] Get inference running locally on Vega (or document why cloud-only)
24
+ - [ ] Time a generation, establish baseline performance
25
+ - [ ] Set up RunPod account (or chosen cloud GPU provider)
26
+ - [ ] Test deploying a simple inference container
27
+ - [ ] Verify you can ssh/exec into it
28
+ - [ ] Stand up the repo
29
+ - [ ] Next.js + TypeScript + Tailwind initialized
30
+ - [ ] Folder structure per [`02-architecture.md`](02-architecture.md#folder-structure)
31
+ - [ ] CI with axe-core for accessibility (even on empty pages, it's a foundation)
32
+ - [ ] Deploy "coming soon" placeholder to Vercel
33
+ - [ ] Set up audio service skeleton
34
+ - [ ] FastAPI + SA3 inference wrapper
35
+ - [ ] Single `/generate` endpoint, no LoRA yet
36
+ - [ ] Install NVDA on Windows, run through Reaper once to baseline familiarity
37
+ - [ ] Set up Reaper with a drum sampler VST for testing MIDI export
38
+
39
+ ### Week 2: data and templates
40
+
41
+ **By end of week 2:**
42
+
43
+ - [ ] Curate training data for brutal-drum LoRA
44
+ - [ ] 20–40 one-shots (recordings or sourced, all licensed)
45
+ - [ ] 10–20 short loops
46
+ - [ ] Manifest in `data/training/manifest.yaml`
47
+ - [ ] All preprocessed to 44100 Hz mono
48
+ - [ ] Write the 6 demo-critical pattern templates
49
+ - [ ] `d-beat.json`
50
+ - [ ] `blast-traditional.json`
51
+ - [ ] `skank.json`
52
+ - [ ] `half-time-metal.json`
53
+ - [ ] `djent-polyrhythm.json`
54
+ - [ ] `punk-rock.json`
55
+ - [ ] Write the onomatopoeia mapping table with at least 10 entries
56
+ - [ ] Implement prompt parser MVP (handles the 6 patterns + 10 onomatopoeias)
57
+ - [ ] Implement pattern engine MVP (loads templates, expands to bars, applies tempo)
58
+ - [ ] Implement MIDI generation (downloads work, drags into Reaper)
59
+
60
+ ### Week 3: integration and rehearsal
61
+
62
+ **By end of week 3:**
63
+
64
+ - [ ] Train brutal-drum LoRA on cloud GPU
65
+ - [ ] First attempt: 500 steps, see how it shapes up
66
+ - [ ] Iterate: adjust learning rate, dataset, prompt format
67
+ - [ ] Final: ship-ready LoRA + publish to HuggingFace (draft, don't publish yet)
68
+ - [ ] Implement Reaper Web Control client
69
+ - [ ] Connect, parse state, extract tempo
70
+ - [ ] Tempo resolution priority implemented
71
+ - [ ] Implement Web Speech API integration
72
+ - [ ] STT works in Chrome
73
+ - [ ] TTS works
74
+ - [ ] Voice state machine (idle → listening → parsing → generating → ready)
75
+ - [ ] Build basic UI shell
76
+ - [ ] Voice button, transcript display, status panel
77
+ - [ ] Action buttons (play, download, regenerate)
78
+ - [ ] Visual grid component (basic version)
79
+ - [ ] Accessibility pass 1
80
+ - [ ] Tab through every element
81
+ - [ ] NVDA run-through of the voice flow
82
+ - [ ] ARIA labels on every interactive element
83
+ - [ ] Skip links
84
+ - [ ] High contrast check
85
+ - [ ] End-to-end rehearsal
86
+ - [ ] Generate a pattern in PatternTalk
87
+ - [ ] Drag MIDI into Reaper
88
+ - [ ] Play it
89
+ - [ ] Generate sample
90
+ - [ ] Play sample
91
+ - [ ] Switch on NVDA, redo the flow
92
+ - [ ] Write the team-pitch and post to Music Hackspace Discord
93
+
94
+ ## Hackathon Day 1 (Saturday Aug 22)
95
+
96
+ ### Morning (9:00 AM – 12:30 PM): setup + team formation
97
+
98
+ - [ ] Arrive at PHI Centre, get settled
99
+ - [ ] Finalize team composition (if recruiting on-site)
100
+ - [ ] Confirm Reaper + PatternTalk working environment
101
+ - [ ] Connect to venue WiFi, verify cloud GPU access
102
+
103
+ ### Early afternoon (12:30 PM – 3:00 PM): core demo path
104
+
105
+ **Goal:** End-to-end voice → pattern → MIDI → Reaper working.
106
+
107
+ - [ ] Finalize pattern engine: tempo, bars, time signature, cymbal overrides
108
+ - [ ] Finalize MIDI export, verify it sounds right in Reaper
109
+ - [ ] Wire up Web Speech API end-to-end
110
+ - [ ] Wire up sample generation from cloud GPU
111
+ - [ ] First smoke test: voice prompt → MIDI + sample → download → Reaper
112
+
113
+ ### Late afternoon (3:00 PM – 6:00 PM): variations and audio context
114
+
115
+ - [ ] Implement 4-variation engine (humanize velocities, micro-timing, fill variants)
116
+ - [ ] Implement uploaded audio BPM detection (Meyda)
117
+ - [ ] Wire up Reaper tempo sync (Web Control client)
118
+ - [ ] Pre-generate demo variations for the showcase patterns
119
+
120
+ ### Evening (6:00 PM – 9:00 PM): polish + accessibility
121
+
122
+ - [ ] Visual grid component (sighted users)
123
+ - [ ] Pattern library page (skeleton)
124
+ - [ ] Accessibility pass with NVDA
125
+ - [ ] Tab through everything
126
+ - [ ] Voice announcements work
127
+ - [ ] Error states announced
128
+ - [ ] Demo rehearsal (round 1)
129
+ - [ ] Evening check-in / informal demos at the venue
130
+
131
+ **Day 1 deliverable:** A working PatternTalk that takes voice prompts, generates MIDI, downloads it, and plays in Reaper. Plus 4-variation picker. Plus screenreader-tested.
132
+
133
+ ## Hackathon Day 2 (Sunday Aug 23)
134
+
135
+ ### Morning (9:00 AM – 12:00 PM): stretch features
136
+
137
+ Priority order (cut from the bottom if time runs short):
138
+
139
+ 1. [ ] **Sample preview player** with progress indicator
140
+ 2. [ ] **Pattern library** with search/filter and shareable URLs
141
+ 3. [ ] **User-defined onomatopoeias** (localStorage)
142
+ 4. [ ] **CLAP plugin wrap** of the web UI (if a teammate can take it)
143
+ 5. [ ] **Real-time audio captioning** (stretch, probably cut)
144
+
145
+ ### Midday (12:00 PM – 2:00 PM): polish
146
+
147
+ - [ ] Final visual grid polish (animations, color tuning)
148
+ - [ ] Voice response tuning (speak rate, prompts)
149
+ - [ ] Pre-generate all demo cache samples
150
+ - [ ] Pre-stage the LoRA weights download for live demo
151
+ - [ ] Test with cloud GPU off (Vega-only path) as fallback
152
+
153
+ ### Afternoon (2:00 PM – 4:00 PM): demo rehearsal
154
+
155
+ - [ ] Full demo run-through, timed (target: 3–4 minutes)
156
+ - [ ] NVDA demo run-through, timed
157
+ - [ ] VoiceOver demo run-through, timed
158
+ - [ ] Rehearse the pitch (3 sentences max)
159
+ - [ ] Backup plan: video recording of working demo, in case of network issues
160
+
161
+ ### Late afternoon (4:00 PM – 6:00 PM): present
162
+
163
+ - [ ] Public demos
164
+ - [ ] Jury presentation
165
+ - [ ] Feedback session
166
+
167
+ ### Evening (6:00 PM onwards): wind down
168
+
169
+ - [ ] Final cleanup
170
+ - [ ] Publish LoRA to HuggingFace (if not already)
171
+ - [ ] Push code to GitHub, write a proper README
172
+ - [ ] Write blog post / social announcement
173
+
174
+ ## Checkpoints
175
+
176
+ Use these to catch problems early. If a checkpoint fails, stop and address it before moving on.
177
+
178
+ | Checkpoint | When | What "passing" means |
179
+ |---|---|---|
180
+ | **C1: Inference works** | Pre-hack week 1 | SA3 small generates a sample on Vega in < 10 min OR cloud GPU works |
181
+ | **C2: Training data ready** | Pre-hack week 2 | 30+ samples in `data/training/` with manifest, all licensed |
182
+ | **C3: 6 patterns authored** | Pre-hack week 2 | All 6 demo patterns load, expand, generate MIDI |
183
+ | **C4: End-to-end MIDI** | Pre-hack week 3 | Voice prompt → MIDI → Reaper plays correctly |
184
+ | **C5: NVDA reads the UI** | Pre-hack week 3 | Screenreader announces every state change |
185
+ | **C6: Demo path works** | Hack day 1 | Full voice → MIDI → sample → Reaper in < 60 seconds |
186
+ | **C7: LoRA published** | Hack day 1 evening | Weights on HuggingFace with model card |
187
+ | **C8: Variations work** | Hack day 2 morning | 4 variations show in UI, picker works |
188
+ | **C9: Demo polished** | Hack day 2 afternoon | Rehearsal runs in 3–4 minutes without mistakes |
189
+
190
+ ## What to cut if time runs out
191
+
192
+ In priority order (drop from the bottom):
193
+
194
+ 1. ❌ Real-time audio captioning
195
+ 2. ❌ Haptic metronome PWA
196
+ 3. ❌ Reduced-physical-load mode
197
+ 4. ❌ CLAP plugin wrap
198
+ 5. ❌ User-defined onomatopoeias
199
+ 6. ❌ Pattern library page (if a minimal in-app picker works)
200
+ 7. ❌ Audio context BPM detection (fall back to prompt/Reaper-only)
201
+ 8. ❌ Visual grid polish (fall back to functional ugly grid)
202
+ 9. ❌ Dark mode (fall back to light only)
203
+
204
+ The must-haves are: voice prompt, pattern generation, MIDI export, sample generation, Reaper tempo sync. If we have those five, we have a demo that lands.
205
+
206
+ ## What to add if time is plentiful
207
+
208
+ In priority order:
209
+
210
+ 1. ✅ CLAP plugin wrap
211
+ 2. ✅ Audio context BPM detection
212
+ 3. ✅ Pattern library page with shareable URLs
213
+ 4. ✅ User-defined onomatopoeias
214
+ 5. ✅ Visual grid polish with animations
215
+ 6. ✅ Dark mode + theme support
216
+ 7. ✅ Multiple LoRA adapters (one per genre: rock, jazz, funk)
217
+ 8. ✅ Real-time audio captioning
218
+
219
+ ## Communication cadence
220
+
221
+ - **Standup** (15 min) at start of each day
222
+ - **Mid-day check-in** (5 min, async) — what's blocked, what's next
223
+ - **End-of-day retro** (15 min) — what shipped, what's deferred
224
+ - **Demo** (3–4 min) at end of each day for informal feedback
225
+
226
+ ## Post-hackathon
227
+
228
+ **Within 1 week:**
229
+ - [ ] Publish LoRA weights to HuggingFace
230
+ - [ ] Publish repo on GitHub with full README
231
+ - [ ] Blog post / dev.to article about the project
232
+ - [ ] Submit to Music Hackspace showcase if invited
233
+
234
+ **Within 1 month:**
235
+ - [ ] Add more pattern templates (target: 30+)
236
+ - [ ] Train LoRAs for additional genres
237
+ - [ ] Add CLAP plugin wrap (if not done at hackathon)
238
+ - [ ] Integrate with Narwall for continuous accessibility testing
239
+
240
+ **Within 3 months:**
241
+ - [ ] Public launch of the web app
242
+ - [ ] Community pattern library with moderation
243
+ - [ ] "Train your own LoRA" UI
244
+ - [ ] Mobile companion app for haptic metronome
docs/09-risks.md ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 09 — Risks & Mitigations
2
+
3
+ Risks ranked by impact × likelihood. Each has an owner, a mitigation, and a contingency.
4
+
5
+ ## Risk matrix
6
+
7
+ | # | Risk | Likelihood | Impact | Severity | Status |
8
+ |---|---|---|---|---|---|
9
+ | 1 | Cloud GPU unavailable during demo | Low | Critical | **HIGH** | Mitigated |
10
+ | 2 | SA3 inference too slow on Vega | High | High | **HIGH** | Mitigated |
11
+ | 3 | LoRA training fails / produces bad output | Medium | High | **HIGH** | Mitigated |
12
+ | 4 | Voice recognition unreliable in noisy venue | High | Medium | **MEDIUM** | Mitigated |
13
+ | 5 | Reaper Web Control API changes / doesn't work | Medium | Medium | **MEDIUM** | Mitigated |
14
+ | 6 | Pattern templates too few / too low quality | Medium | Medium | **MEDIUM** | Mitigated |
15
+ | 7 | Onomatopoeia parser doesn't understand users | Medium | Low | **MEDIUM** | Mitigated |
16
+ | 8 | Screenreader breaks during demo | Low | Medium | **MEDIUM** | Mitigated |
17
+ | 9 | Network drops during demo | Medium | High | **MEDIUM** | Mitigated |
18
+ | 10 | Solo bottleneck — running out of time | High | High | **HIGH** | Mitigated |
19
+ | 11 | Team conflict / teammate leaves | Medium | High | **MEDIUM** | Monitor |
20
+ | 12 | Licensing issues with training data | Low | High | **MEDIUM** | Mitigated |
21
+ | 13 | Browser doesn't support Web Speech API | Low | High | **MEDIUM** | Mitigated |
22
+ | 14 | MIDI export has bugs that only show in Reaper | Medium | Medium | **MEDIUM** | Mitigated |
23
+ | 15 | Audio sample quality is generic | Medium | Medium | **MEDIUM** | Mitigated |
24
+
25
+ ---
26
+
27
+ ## High-severity risks
28
+
29
+ ### Risk 1: Cloud GPU unavailable during demo
30
+
31
+ **Scenario:** RunPod has an outage, you can't reach your instance, the venue WiFi blocks the connection, or your account gets suspended.
32
+
33
+ **Mitigation:**
34
+ - Pre-generate ALL demo samples before the event, store in `cache/` directory
35
+ - Have a backup account on a different provider (Vast.ai + RunPod)
36
+ - Test cloud GPU connection from the venue WiFi before the demo
37
+
38
+ **Contingency:**
39
+ - Run inference on Vega locally (slow but works)
40
+ - Use pre-generated samples only and label them as "preset variations"
41
+ - Record a video of the working demo as ultimate fallback
42
+
43
+ **Owner:** You. **Verified:** Test cloud GPU from venue network on day 1 morning.
44
+
45
+ ---
46
+
47
+ ### Risk 2: SA3 inference too slow on Vega
48
+
49
+ **Scenario:** A 30-second sample takes 15 minutes on your Vega. The demo times out.
50
+
51
+ **Mitigation:**
52
+ - Establish baseline performance in pre-hack week 1 (see C1 in [`08-build-plan.md`](08-build-plan.md))
53
+ - If > 5 min per sample on Vega, commit to cloud-only
54
+ - Pre-generate demo samples on cloud GPU
55
+ - Cache aggressively (LRU + disk)
56
+
57
+ **Contingency:**
58
+ - Demo with cached samples only, narrate "this was generated earlier by the model"
59
+ - Show inference happening live as a "by the way, look, it's running" secondary moment, not the main demo
60
+
61
+ **Owner:** You. **Verified:** Run `scripts/smoke_test.py` on Vega before the event.
62
+
63
+ ---
64
+
65
+ ### Risk 3: LoRA training fails / produces bad output
66
+
67
+ **Scenario:** Training crashes, or the resulting LoRA makes samples worse than the base model.
68
+
69
+ **Mitigation:**
70
+ - Run a small training (200 steps) first to verify the pipeline
71
+ - Train the real LoRA at least 3 days before the event, so you have time to iterate
72
+ - Keep the base model as a fallback (can disable LoRA at inference time)
73
+ - Document the training hyperparameters so you can reproduce if needed
74
+
75
+ **Contingency:**
76
+ - Ship with the base SA3 model only, label output "Stable Audio 3, no fine-tuning"
77
+ - Do a quick second training attempt during the hackathon if you have cloud GPU time
78
+ - Be honest with the jury: "we attempted fine-tuning but the base model worked better for the time we had"
79
+
80
+ **Owner:** You. **Verified:** Complete first training run before pre-hack week 3.
81
+
82
+ ---
83
+
84
+ ### Risk 10: Solo bottleneck — running out of time
85
+
86
+ **Scenario:** You try to do everything alone, run out of time, ship a half-finished product.
87
+
88
+ **Mitigation:**
89
+ - Aggressive scope cut (see [`08-build-plan.md`](08-build-plan.md#what-to-cut-if-time-runs-out))
90
+ - Pre-build as much as possible in the 3 weeks before
91
+ - Define the minimum viable demo path and protect it ruthlessly
92
+ - Daily checkpoint reviews to catch overruns early
93
+
94
+ **Contingency:**
95
+ - Ship a narrower demo: voice prompt → MIDI export only, no samples
96
+ - Have a video walkthrough of the missing features
97
+ - Focus the live demo on the 2–3 things that work perfectly
98
+
99
+ **Owner:** You. **Verified:** C6 (demo path works) at end of day 1.
100
+
101
+ ---
102
+
103
+ ## Medium-severity risks
104
+
105
+ ### Risk 4: Voice recognition unreliable in noisy venue
106
+
107
+ **Scenario:** The hackathon venue is loud. Web Speech API gets garbage transcripts. Demo fails.
108
+
109
+ **Mitigation:**
110
+ - Use a headset mic / close-mic'd setup, not the laptop mic
111
+ - Test voice recognition in a noisy environment before the event
112
+ - Have a fallback path: type the prompt instead of speaking
113
+ - Push-to-talk (Spacebar to start/stop) avoids picking up ambient noise
114
+
115
+ **Contingency:**
116
+ - Switch to typed prompts for the demo if voice fails
117
+ - Use a quieter corner of the venue for the live demo
118
+ - Have a backup video of working voice mode recorded in a quiet space
119
+
120
+ **Owner:** You. **Verified:** Test voice in actual venue conditions.
121
+
122
+ ---
123
+
124
+ ### Risk 5: Reaper Web Control API changes / doesn't work
125
+
126
+ **Scenario:** Reaper updates, the WebSocket format changes, your client breaks.
127
+
128
+ **Mitigation:**
129
+ - Test against your installed Reaper version (currently 7.x) before the event
130
+ - Hard-pin the WebSocket protocol version in your client
131
+ - Have the fallback path: "type the BPM manually"
132
+
133
+ **Contingency:**
134
+ - Demo without Reaper integration, just type the BPM
135
+ - Show Reaper integration in a video if live fails
136
+
137
+ **Owner:** You. **Verified:** Test on your Reaper install before the event.
138
+
139
+ ---
140
+
141
+ ### Risk 6: Pattern templates too few / too low quality
142
+
143
+ **Scenario:** You write 5 patterns, all of them are slightly wrong. Jury asks for a beat you don't have.
144
+
145
+ **Mitigation:**
146
+ - Aim for 18–20 patterns before hackathon
147
+ - Write the 6 demo-critical ones first
148
+ - Each pattern tested in Reaper before the event
149
+ - Have a "blank bar" fallback that lets the user define their own
150
+
151
+ **Contingency:**
152
+ - Generate variations from existing patterns (different bars, different cymbals, half-time)
153
+ - Be honest: "we focused on metal first, here's what we have"
154
+
155
+ **Owner:** You. **Verified:** C3 (6 patterns authored) at end of week 2.
156
+
157
+ ---
158
+
159
+ ### Risk 7: Onomatopoeia parser doesn't understand users
160
+
161
+ **Scenario:** User says "tupatupatupa" but the parser doesn't recognize it. Falls back to genre vocabulary, picks the wrong pattern.
162
+
163
+ **Mitigation:**
164
+ - Curate the onomatopoeia table with multiple variants per pattern
165
+ - Phonetic matching with Levenshtein distance
166
+ - Confidence threshold (0.7) below which the parser asks for confirmation
167
+ - "I heard X, did you mean Y?" response
168
+
169
+ **Contingency:**
170
+ - The user can always type the pattern name explicitly
171
+ - "Try one of these patterns: [list]" response
172
+
173
+ **Owner:** You. **Verified:** Test the top 20 onomatopoeia phrases manually.
174
+
175
+ ---
176
+
177
+ ### Risk 8: Screenreader breaks during demo
178
+
179
+ **Scenario:** NVDA crashes, VoiceOver doesn't pick up the UI, the screenreader demo fails.
180
+
181
+ **Mitigation:**
182
+ - Test NVDA + Chrome on the demo machine before the event
183
+ - Have a backup screenreader (VoiceOver on Mac, JAWS if you have access)
184
+ - Record the screenreader demo as a backup video
185
+ - Rehearse the keyboard navigation by feel, not by sight
186
+
187
+ **Contingency:**
188
+ - Show the recorded video of the working screenreader demo
189
+ - Skip the live screenreader demo, focus on the voice mode
190
+ - Describe the accessibility features narratively
191
+
192
+ **Owner:** You. **Verified:** Test on the demo machine, not your dev machine.
193
+
194
+ ---
195
+
196
+ ### Risk 9: Network drops during demo
197
+
198
+ **Scenario:** Venue WiFi dies, you can't reach the cloud GPU for inference.
199
+
200
+ **Mitigation:**
201
+ - Pre-generate all demo samples, store locally
202
+ - Run a local copy of the audio service on the demo machine as backup
203
+ - Have a mobile hotspot as tertiary fallback
204
+
205
+ **Contingency:**
206
+ - Demo with cached samples only
207
+ - Acknowledge the network issue honestly if it comes up
208
+
209
+ **Owner:** You. **Verified:** Confirm cloud GPU + local backup both reachable from venue.
210
+
211
+ ---
212
+
213
+ ### Risk 11: Team conflict / teammate leaves
214
+
215
+ **Scenario:** A teammate you recruited flakes, or there's conflict about direction.
216
+
217
+ **Mitigation:**
218
+ - Have all critical code paths owned by you as backup
219
+ - Set clear expectations early (what's the demo, what's the deadline)
220
+ - Communicate scope cuts in real time
221
+
222
+ **Contingency:**
223
+ - Cut features the flaky teammate was working on
224
+ - Lean on the must-haves list
225
+
226
+ **Owner:** You. **Status:** Monitor.
227
+
228
+ ---
229
+
230
+ ### Risk 12: Licensing issues with training data
231
+
232
+ **Scenario:** A sample you used is actually copyrighted, you get called out, model card needs to be pulled.
233
+
234
+ **Mitigation:**
235
+ - Every sample has a documented source and license in the manifest
236
+ - Prefer CC0 / CC-BY / original recordings
237
+ - For Freesound samples, filter to CC0 or CC-BY only, document the user
238
+ - For your own recordings, document date and context
239
+
240
+ **Contingency:**
241
+ - If a license is questioned, replace that sample and retrain (time permitting)
242
+ - Be transparent in the model card about licensing discipline
243
+
244
+ **Owner:** You. **Verified:** Manifest reviewed before publishing.
245
+
246
+ ---
247
+
248
+ ### Risk 13: Browser doesn't support Web Speech API
249
+
250
+ **Scenario:** Demo machine has Firefox, Web Speech API doesn't work.
251
+
252
+ **Mitigation:**
253
+ - Test on the demo browser before the event
254
+ - Chrome is the assumption — install it on the demo machine
255
+
256
+ **Contingency:**
257
+ - Switch to typed prompts only
258
+ - Use a different laptop with Chrome
259
+
260
+ **Owner:** You. **Verified:** Test on demo browser.
261
+
262
+ ---
263
+
264
+ ### Risk 14: MIDI export has bugs that only show in Reaper
265
+
266
+ **Scenario:** MIDI plays wrong in Reaper, or doesn't import at all.
267
+
268
+ **Mitigation:**
269
+ - Test every pattern in Reaper before the event
270
+ - Use a known-good GM drum map
271
+ - Set tempo in the MIDI file header to match project tempo
272
+ - Test on the actual Reaper version you'll use at the event
273
+
274
+ **Contingency:**
275
+ - Fix the bug live (if quick)
276
+ - Show the MIDI in a different DAW if Reaper is broken
277
+ - Generate a Reaper project file (.rpp) that includes the MIDI in a known-good arrangement
278
+
279
+ **Owner:** You. **Verified:** Full end-to-end test in Reaper.
280
+
281
+ ---
282
+
283
+ ### Risk 15: Audio sample quality is generic
284
+
285
+ **Scenario:** Even with the LoRA, the samples sound like generic AI drums, not brutal.
286
+
287
+ **Mitigation:**
288
+ - Iterate on the training dataset (more samples, better curation)
289
+ - Iterate on prompt engineering (try different intensity descriptors)
290
+ - Generate multiple variations and pick the best
291
+
292
+ **Contingency:**
293
+ - Show the variations, frame as "exploring the space"
294
+ - Be honest: "SA3 with our LoRA is good but not perfect yet — here's what it produces"
295
+
296
+ **Owner:** You. **Verified:** Listen to samples before the event.
297
+
298
+ ---
299
+
300
+ ## Risk ownership summary
301
+
302
+ | Owner | Risks |
303
+ |---|---|
304
+ | You (all) | All risks by default |
305
+ | Future teammate (frontend) | R10 (solo bottleneck) — if recruited |
306
+ | Future teammate (ML) | R3 (LoRA), R2 (Vega speed) — if recruited |
307
+
308
+ ## What we're explicitly NOT risking
309
+
310
+ - Building features that won't be demoed
311
+ - Custom DAW plugins (too risky for the timeframe)
312
+ - Mobile apps
313
+ - Cloud accounts / user authentication
314
+ - Anything that requires a third-party API key beyond SA3
docs/10-team-pitch.md ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 10 — Team Pitch & Skills Needed
2
+
3
+ ## The pitch (post this to Discord)
4
+
5
+ > Hey — I'm building **PatternTalk** for the Stability AI challenge at the Montreal Music Hackathon (Aug 22–23). The pitch: every existing DAW plugin is a black box to blind and visually impaired producers, and AI music tools completely lack genre expertise — try getting Stable Audio 3 to make a brutal china crash or a d-beat loop and you'll see what I mean. So I'm building two things in one product: (1) a voice-first, screenreader-native interface ("tupatupatupa on the hihat, 4 bars") that produces downloadable MIDI, generated samples via Stable Audio 3, and a screenreader-friendly description of what's being played; (2) a brutal-drum LoRA fine-tune of Stable Audio 3 so the model actually understands the genre. The whole thing is screenreader-native because I'm using a real screenreader throughout development — same approach I use at my day job running [Narwall.tech](https://narwall.tech), an accessibility testing tool. Looking for 1–2 teammates to make this a real demo, not a slideshow. DMs open.
6
+
7
+ ## Skills we're looking for
8
+
9
+ ### Priority 1: Frontend engineer (1 person, ideal)
10
+
11
+ **What they'd own:**
12
+ - The voice-first UI (Web Speech API, voice state machine)
13
+ - The visual grid component
14
+ - Pattern library page
15
+ - Accessibility implementation (ARIA, keyboard nav, screenreader testing)
16
+ - Deploy to Vercel
17
+
18
+ **Must-have:**
19
+ - Strong React/Next.js + TypeScript
20
+ - Comfortable with TailwindCSS or similar
21
+ - Cares about accessibility (ideally has shipped accessible UIs)
22
+ - Available for the full hackathon weekend (Aug 22–23 in Montreal)
23
+
24
+ **Nice-to-have:**
25
+ - Has used Web Speech API before
26
+ - Familiar with @tonejs/midi or Web Audio API
27
+ - Has worked with a screenreader (NVDA, VoiceOver, JAWS)
28
+ - Drummer or musician (genuine interest in the domain, not required)
29
+
30
+ **Time commitment:**
31
+ - 3 weeks of light prep (a few hours/week to align on architecture)
32
+ - Full weekend in Montreal, Aug 22–23
33
+
34
+ ---
35
+
36
+ ### Priority 2: ML / audio engineer (1 person, *this is the harder one to fill*)
37
+
38
+ **What they'd own:**
39
+ - Stable Audio 3 inference server (FastAPI + Python)
40
+ - LoRA fine-tuning pipeline
41
+ - Cloud GPU orchestration (RunPod / Vast.ai)
42
+ - Publishing the LoRA weights to HuggingFace
43
+
44
+ **Must-have:**
45
+ - Comfortable with PyTorch
46
+ - Has fine-tuned diffusion models before (audio models a strong plus)
47
+ - Can deploy Python services (FastAPI, Docker)
48
+ - Available for the full hackathon weekend
49
+
50
+ **Nice-to-have:**
51
+ - Has specifically worked with Stable Audio, AudioLDM, MusicGen, or Riffusion
52
+ - Has trained LoRAs before
53
+ - Familiar with ROCm or cloud GPU providers
54
+ - Has worked with audio data (sample rates, normalization, etc.)
55
+
56
+ **Time commitment:**
57
+ - 3 weeks of prep (more than frontend — needs to establish the SA3 pipeline)
58
+ - Full weekend in Montreal, Aug 22–23
59
+
60
+ ---
61
+
62
+ ### Priority 3: Musician / sound designer (optional but valuable)
63
+
64
+ **What they'd own:**
65
+ - Curate the brutal-drum training dataset
66
+ - Review generated samples for "does this actually sound brutal"
67
+ - Help write pattern templates if you want to delegate some
68
+ - Provide creative direction during the demo
69
+
70
+ **Must-have:**
71
+ - Plays drums (or has deep familiarity with drumming)
72
+ - Has strong taste in metal/rock/punk
73
+ - Available for the full hackathon weekend
74
+
75
+ **Nice-to-have:**
76
+ - Produces records or has production experience
77
+ - Has sample library curation experience
78
+ - Has experience with AI music tools (knows what works and what doesn't)
79
+
80
+ **Time commitment:**
81
+ - Light prep (curate some samples, share references)
82
+ - Full weekend in Montreal, Aug 22–23
83
+ - *Could be remote for prep, in-person for the hackathon*
84
+
85
+ ---
86
+
87
+ ### Priority 4: Designer (optional)
88
+
89
+ **What they'd own:**
90
+ - Visual grid polish (animations, color tuning)
91
+ - Logo, demo assets, brand if there's time
92
+ - Possibly the marketing visuals for the post-hack blog post
93
+
94
+ **Must-have:**
95
+ - Visual design sense, especially for accessibility-conscious design
96
+ - Available for at least hackathon day 2
97
+
98
+ **Nice-to-have:**
99
+ - Has designed for music applications before
100
+ - Has designed with WCAG AA+ compliance in mind
101
+
102
+ **Time commitment:**
103
+ - Hackathon weekend only is fine
104
+ - Lower priority than engineers
105
+
106
+ ---
107
+
108
+ ## What you (the founder) bring
109
+
110
+ To attract good teammates, you need to offer something back. Here's what you bring:
111
+
112
+ **Credibility:**
113
+ - Active startup ([Narwall.tech](https://narwall.tech)) — this isn't a fantasy project
114
+ - Real screenreader testing practice — unique in the music-tech space
115
+ - Drummer + producer with metal bands — domain expertise, no faking needed
116
+ - Working Stable Audio 3 setup planned (or in progress)
117
+
118
+ **Preparation:**
119
+ - Complete design docs before the event (this directory!)
120
+ - Working code skeleton before the event
121
+ - Pre-curated training dataset
122
+ - Pre-trained LoRA (or first attempt done)
123
+
124
+ **Project clarity:**
125
+ - A real plan with checkpoints (see [`08-build-plan.md`](08-build-plan.md))
126
+ - A real architecture (see [`02-architecture.md`](02-architecture.md))
127
+ - Honest risk assessment (see [`09-risks.md`](09-risks.md))
128
+ - Scope discipline (clear must-haves vs stretch)
129
+
130
+ **The pitch itself** — voice-first AI drummer for metal that works with a screenreader — is distinctive enough that good engineers will be interested.
131
+
132
+ ---
133
+
134
+ ## Where to find teammates
135
+
136
+ ### Music Hackspace channels (primary)
137
+
138
+ 1. **Music Hackspace Discord** — main hub, post the pitch there
139
+ - Invite link on musichackspace.org
140
+ - Active community of music tech builders
141
+ 2. **Prep calls** (Wed evenings, July 29 – Aug 19)
142
+ - Show up to all of them
143
+ - Mention you're looking for teammates
144
+ - This is literally what the prep calls are for
145
+ 3. **Montreal music tech community**
146
+ - If you can attend any in person before the event, do it
147
+
148
+ ### Other channels (secondary)
149
+
150
+ 4. **MUTEK Discord / community** — overlap with hackathon attendees
151
+ 5. **Reddit:**
152
+ - r/WeAreTheMusicMakers
153
+ - r/musicproduction
154
+ - r/Drumming
155
+ - r/musictech
156
+ 6. **HuggingFace Discord** — for the ML engineer specifically
157
+ 7. **Reaper forums** — for Reaper integration expertise
158
+ 8. **Local university CS / music tech programs** (McGill, Concordia in Montreal)
159
+
160
+ ### What to avoid
161
+
162
+ - Cold-DM-ing strangers without context
163
+ - Promising ownership or revenue (this is a hackathon, set expectations clearly)
164
+ - Recruiting people who don't have the full weekend available
165
+ - Recruiting people who can't be in Montreal in person (remote-only is hard for a hackathon)
166
+
167
+ ---
168
+
169
+ ## How to evaluate candidates
170
+
171
+ When someone responds to the pitch, ask:
172
+
173
+ 1. **"Can you be in Montreal August 22–23?"** (eliminates 50% of replies)
174
+ 2. **"What's the most interesting thing you've built recently?"** (gauge depth)
175
+ 3. **"Have you worked with screenreaders before?"** (for frontend; "have you fine-tuned audio models?" for ML)
176
+ 4. **"How do you feel about working from a detailed plan rather than improvising?"** (sets expectations)
177
+ 5. **"What would you want to own in this project?"** (alignment check)
178
+
179
+ If they pass those, share the docs (this directory), give them 24 hours to read, then have a 30-minute call to align.
180
+
181
+ ---
182
+
183
+ ## Team formation timeline
184
+
185
+ | Date | Action |
186
+ |---|---|
187
+ | Now (3 weeks out) | Post pitch to Discord + Reddit |
188
+ | July 29 | Attend prep call #1, mention looking for teammates |
189
+ | Aug 1 | Follow up with interested candidates |
190
+ | Aug 5 | Attend prep call #2, share progress, recruit more |
191
+ | Aug 8 | Finalize team composition |
192
+ | Aug 12 | Attend prep call #3 (with team if formed) |
193
+ | Aug 19 | Attend prep call #4 (final pre-hack alignment) |
194
+ | Aug 22 | Hackathon day 1 |
195
+
196
+ ---
197
+
198
+ ## What if you can't find teammates
199
+
200
+ Solo is a valid path. The plan is designed to be solo-shippable, with cuts clearly defined.
201
+
202
+ **If solo:**
203
+ - Focus ruthlessly on the must-haves (voice prompt, MIDI export, sample generation, Reaper sync)
204
+ - Pre-build as much as possible in the 3 weeks
205
+ - Use pre-generated samples and pre-cached variations for the demo
206
+ - Be honest with the jury about the scope
207
+
208
+ **If you find one teammate (frontend):**
209
+ - You own audio service + training data + LoRA + Reaper integration
210
+ - They own web app + voice UI + visual grid + accessibility
211
+ - This is the ideal 2-person team
212
+
213
+ **If you find two teammates (frontend + ML):**
214
+ - You own: prompt parser, pattern engine, training data curation, demo, project lead
215
+ - Frontend: web app, voice UI, accessibility
216
+ - ML: audio service, LoRA training, cloud GPU
217
+ - This is the dream team — you have time for stretch features
218
+
219
+ **If you find a musician-only team:**
220
+ - Less ideal — you'd be doing all the code
221
+ - But the musician adds value on the training data curation and the demo
222
+
223
+ ---
224
+
225
+ ## Post-hackathon: what happens to the team?
226
+
227
+ This is a hackathon, not a startup. After Aug 23, the team disbands by default. If people want to keep building:
228
+
229
+ - **You** (the founder) own the IP per the hackathon rules
230
+ - Contributors retain credit in the README and commit history
231
+ - Open-source repo means anyone can fork and continue
232
+ - If the project gains traction, consider:
233
+ - Adding collaborators as maintainers
234
+ - Forming a small LLC or collective if commercializing
235
+ - Or just letting it be an open-source project
236
+
237
+ Set these expectations at the start: "we own what we build, you get credit, and we can decide post-event whether to continue together."
238
+
239
+ The Music Hackspace IP rules are explicit: *"Your team keeps 100% ownership of what you create during the hackathon."*
docs/CONTRIBUTING.md ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing
2
+
3
+ ## Code of conduct
4
+
5
+ Be kind. Accessibility-first means inclusive by default. See [the hackathon's CoC](https://musichackspace.org) for the baseline.
6
+
7
+ ## Repo conventions
8
+
9
+ ### Languages
10
+
11
+ - **TypeScript** for the web app (`apps/web/`)
12
+ - **Python 3.10+** for the audio service (`services/audio/`)
13
+ - **JSON** for pattern templates and onomatopoeia tables
14
+ - **YAML** for training data manifests
15
+
16
+ ### Style
17
+
18
+ **TypeScript:**
19
+ - ESLint with `@typescript-eslint/recommended`
20
+ - Prettier for formatting
21
+ - No `any` unless absolutely necessary (and then comment why)
22
+ - Functional components, hooks, no class components
23
+ - Avoid `useEffect` for derived state — use `useMemo` or compute inline
24
+
25
+ **Python:**
26
+ - `ruff` for linting (replaces flake8, isort, etc.)
27
+ - `black` for formatting
28
+ - Type hints everywhere
29
+ - Pydantic for data models
30
+
31
+ ### Naming
32
+
33
+ - Components: `PascalCase.tsx`
34
+ - Hooks: `useCamelCase.ts`
35
+ - Utilities: `camelCase.ts`
36
+ - Constants: `UPPER_SNAKE_CASE`
37
+ - Files match exports (one default export per file preferred)
38
+
39
+ ### Git
40
+
41
+ **Commit format** (Conventional Commits):
42
+
43
+ ```
44
+ <type>(<scope>): <description>
45
+
46
+ [optional body]
47
+
48
+ [optional footer]
49
+ ```
50
+
51
+ Types:
52
+ - `feat` — new feature
53
+ - `fix` — bug fix
54
+ - `docs` — documentation only
55
+ - `style` — formatting, no code change
56
+ - `refactor` — code change that neither fixes a bug nor adds a feature
57
+ - `perf` — performance improvement
58
+ - `test` — adding or fixing tests
59
+ - `chore` — build, CI, tooling
60
+
61
+ Examples:
62
+ ```
63
+ feat(parser): add onomatopoeia matcher with confidence scoring
64
+ fix(midi): correct GM drum map for china (should be 52, was 49)
65
+ docs(architecture): clarify Reaper integration is not a plugin
66
+ chore(ci): add axe-core to GitHub Actions
67
+ ```
68
+
69
+ **Branch naming:**
70
+ - `feat/<short-description>`
71
+ - `fix/<short-description>`
72
+ - `docs/<short-description>`
73
+ - `chore/<short-description>`
74
+
75
+ Examples:
76
+ - `feat/voice-stt`
77
+ - `fix/midi-tempo-header`
78
+ - `docs/architecture-update`
79
+
80
+ ### Pull requests
81
+
82
+ - One feature per PR
83
+ - PR description explains *what* and *why*
84
+ - Screenshots / screen recordings for UI changes
85
+ - Accessibility check included (NVDA test notes, axe-core results)
86
+ - All CI checks passing
87
+
88
+ ### Accessibility requirements for every PR
89
+
90
+ If your PR touches the UI:
91
+
92
+ - [ ] Run axe-core locally (`npm run test:a11y`) — no AA violations
93
+ - [ ] Tab through the changed flow, verify focus order
94
+ - [ ] Test with NVDA (or document why you couldn't)
95
+ - [ ] All interactive elements have ARIA labels
96
+ - [ ] Color contrast meets WCAG AA (4.5:1 for normal text, 3:1 for large)
97
+ - [ ] No information conveyed by color alone
98
+
99
+ If your PR is docs-only or backend-only, skip these.
100
+
101
+ ## Adding a new pattern template
102
+
103
+ 1. Create `apps/web/data/patterns/<pattern-id>.json`
104
+ 2. Follow the schema in [`03-data-model.md`](03-data-model.md#pattern-templates)
105
+ 3. Test in the web app: `npm run dev`, load the pattern, generate MIDI, drag into Reaper
106
+ 4. Verify the description reads naturally with a screenreader
107
+ 5. Update the pattern list in [`03-data-model.md`](03-data-model.md#pattern-library--starting-list) if adding to the starting list
108
+
109
+ ## Adding a new onomatopoeia
110
+
111
+ 1. Edit `apps/web/data/onomatopoeia.json`
112
+ 2. Add the entry with `patterns` (array of variants), `patternId`, and any defaults
113
+ 3. Test the phonetic matcher: `npm run test:onomatopoeia`
114
+ 4. Manually speak the variants, verify they map correctly
115
+
116
+ ## Adding training data
117
+
118
+ 1. Place audio in `data/training/oneshots/` or `data/training/loops/`
119
+ 2. Preprocess: 44100 Hz mono, normalized to -14 LUFS
120
+ 3. Add an entry to `data/training/manifest.yaml` with:
121
+ - `id`, `path`, `category`, `tags`
122
+ - `source`, `license`, `duration_seconds`
123
+ - `bpm` (for loops only)
124
+ 4. Run `python services/audio/training/preprocess.py` to regenerate the preprocessed versions
125
+ 5. Verify the file plays correctly and the license is documented
126
+
127
+ ## Accessibility testing protocol
128
+
129
+ Before opening a PR that touches UI:
130
+
131
+ 1. **axe-core:** `npm run test:a11y` — must pass with no violations
132
+ 2. **NVDA (Windows + Chrome/Firefox):**
133
+ - Tab through the entire flow
134
+ - Verify every state change is announced
135
+ - Verify focus never disappears
136
+ - Verify error states are announced
137
+ 3. **VoiceOver (macOS + Safari):**
138
+ - Repeat the NVDA flow
139
+ - Especially test the grid navigation with VO + arrow keys
140
+ 4. **Keyboard-only:**
141
+ - Complete the entire demo flow without using the mouse
142
+ - Verify every action has a keyboard equivalent
143
+ 5. **High contrast:**
144
+ - Enable Windows High Contrast Mode
145
+ - Verify the UI is still usable
146
+ 6. **200% zoom:**
147
+ - Zoom the browser to 200%
148
+ - Verify no content is cut off or unreachable
149
+
150
+ If any of these fail, the PR is not ready.
151
+
152
+ ## Communication
153
+
154
+ - **Issues** — use GitHub Issues for bugs, feature requests, design questions
155
+ - **Discussions** — use GitHub Discussions for broader questions
156
+ - **Discord** — for real-time chat during the hackathon
157
+
158
+ ## License
159
+
160
+ By contributing, you agree that your contributions will be licensed under the project's MIT license (code) or CC-BY (sample data where applicable).
docs/HANDOFF.md ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HANDOFF — stable-jam resume guide
2
+
3
+ > This file exists so a fresh session can `cd D:/CODE/stable-jam` and pick up
4
+ > exactly where the previous one left off — no loss of context. Read this first,
5
+ > then the linked docs.
6
+
7
+ **Product:** Jam Buddy (consolidated here as **stable-jam**). "You start playing,
8
+ it joins in." A call-and-response AI music companion using Stable Audio 3,
9
+ built for the Stability AI Challenge at Music Hackspace Montreal (Aug 22–23 2026).
10
+
11
+ ---
12
+
13
+ ## What this repo is (and is not)
14
+
15
+ `stable-jam/` is the **source + docs + tests** for the Jam Buddy product. It does
16
+ NOT contain the heavy vendored model repos — those stay in the previous
17
+ location and are referenced by env vars (below). Don't expect to `git clone` and
18
+ get a GPU model; the SA3 weights are a separate download.
19
+
20
+ **Copied from** `D:/CODE/unstable-drums/` (the previous working dir, still intact
21
+ if you need to recover anything). This dir was created to give the hackathon a
22
+ clean, product-accurate home.
23
+
24
+ ## Product state (what actually works, verified this session)
25
+
26
+ - **Web app** (`apps/web`): hardware-sampler rack UI. Instrument pads (bass/lead/
27
+ rhythm/synth/drums), Genre/Mood/Tempo knobs, a "Your take" section with BOTH a
28
+ MIDI file input and an audio file input, JOIN IN + PLAY BOTH buttons, status
29
+ panel (aria-live) showing "Buddy tempo: N BPM", and an audio player. Every
30
+ response is saved to `generations/` at the repo root.
31
+ - **SA3 pipeline** (`tools/jam_buddy.py`): the CLI that does the generation.
32
+ Two input modes:
33
+ - `--midi take.mid` — detects tempo (exact) + response length from the take,
34
+ generates a complementary melodic part at that tempo. **Ignores the groove**
35
+ (SA3 can't parse MIDI notes).
36
+ - `--wav take.wav` — TRUE audio-to-audio via `init_audio` + `init_noise_level`
37
+ (default 0.4). The buddy actually HEARS the groove and responds rhythmically.
38
+ - `--bpm N` / `--model small-music|small-sfx` / `--genre` / `--duration`.
39
+ - Drums force `small-sfx` (clean isolated hits); everything else `small-music`.
40
+ - **API route** (`apps/web/app/api/jambuddy/route.ts`): POST /api/jambuddy with
41
+ `{knobs, bpm?, midi?, audio?, duration?}`, shells to the python, returns the
42
+ WAV + `X-Jam-Buddy-BPM` header. Writes output to `<repo>/generations/`.
43
+ - **Prompt builder** (`apps/web/lib/jambuddy/prompt.ts`): pure function building
44
+ the SA3 prompt from the knobs per the official SA3 prompting guide
45
+ (TrackType: Instrument, instrument, genre, mood, BPM, studio recording).
46
+ Negative prompt steers away from a full mix. `MODEL_FOR_INSTRUMENT` maps
47
+ drums→small-sfx.
48
+ - **Player** (`apps/web/lib/jambuddy/player.ts`): browser Web Audio. `playTogether`
49
+ renders the MIDI take as oscillators and plays it with the buddy WAV on the
50
+ same clock. **Note: this was "banked" — the user reported PLAY BOTH only
51
+ played the generated part, not the MIDI synth. It's parked, not debugged.**
52
+
53
+ ## Verified this session (proof)
54
+
55
+ - Web: `pnpm run test` -> 31/31 pass (prompt, player, engine, conversation);
56
+ `tsc --noEmit` clean; page serves the rack.
57
+ - CLI: `jam_buddy.py --midi <file>` -> detected 158 BPM, 24.3s response matching
58
+ the take. Audio-to-audio on a 5s take -> ~10s wall, valid WAV (peak 0.43,
59
+ rms 0.08). A ~378s take times out on CPU — keep audio takes short.
60
+ - The generations dir fills with timestamped WAVs.
61
+
62
+ ## Key environment / paths
63
+
64
+ SA3 (the engine) now lives IN this repo at `stable-audio-3/`. The venv's
65
+ editable-install `.pth` was repointed to this location (was `D:/CODE/unstable-drums/...`).
66
+ The `/api/jambuddy` route auto-resolves it via `join(repoRoot, "stable-audio-3", ...)`.
67
+
68
+ | Var | Value |
69
+ |---|---|
70
+ | `JAM_BUDDY_ROOT` | `D:/CODE/stable-jam` (repo root; route walks up to find `tools/jam_buddy.py`) |
71
+ | `JAM_BUDDY_PYTHON` | `D:/CODE/stable-jam/stable-audio-3/.venv/Scripts/python.exe` (the SA3 venv) |
72
+ | SA3 weights | cached in `stable-audio-3/.venv` + HF cache on G:/AI/models/huggingface |
73
+ | `HF_TOKEN` | in old repo's `.env` (needed for gated SA3 model access) — copy if regenerating weights |
74
+
75
+ The SA3 venv was extended with `mido` and `librosa`
76
+ (`uv pip install --python .../stable-audio-3/.venv/Scripts/python.exe mido librosa`).
77
+ A fresh session must use that venv or recreate it.
78
+
79
+ ## How to run (fresh session)
80
+
81
+ ```bash
82
+ cd D:/CODE/stable-jam
83
+ # 1. install web deps (node_modules was NOT copied)
84
+ cd apps/web && pnpm install && cd ../..
85
+
86
+ # 2. run tests + typecheck
87
+ cd apps/web && npx vitest run && npx tsc --noEmit
88
+
89
+ # 3. dev server
90
+ cd apps/web && pnpm dev # -> http://localhost:3000
91
+
92
+ # 4. python pipeline (SA3 venv is in THIS repo)
93
+ ./stable-audio-3/.venv/Scripts/python.exe tools/jam_buddy.py --midi take.mid --instrument bass --out out.wav
94
+ ```
95
+
96
+ ## Docs (all in `docs/`)
97
+
98
+ - `01-vision.md` — Jam Buddy vision (rewritten from the old PatternTalk framing).
99
+ - `02-architecture.md` — note: may still describe the old service layout; the
100
+ real arch is: Next.js web + `tools/jam_buddy.py` + SA3 venv.
101
+ - `05-accessibility.md` — the a11y strategy (still relevant; the product is
102
+ screenreader-compatible by design).
103
+ - `06-stable-audio-integration.md` — SA3 setup, audio-to-audio, Vega notes.
104
+ - `HARNESS.md` — agent harness commands + test contract (may still say
105
+ "PatternTalk" in places; the real product is Jam Buddy).
106
+
107
+ ## What's deliberately NOT in this repo (and why)
108
+
109
+ - `text2midi/` (2.8G vendored model) — not part of the Jam Buddy product; it
110
+ stays in the previous working dir if needed.
111
+ - The SA3 weights / `.venv` are inside `stable-audio-3/` here but **gitignored**
112
+ (large, regenerable, license-gated). See `.gitignore`.
113
+ - `node_modules/`, `.next/` — regenerable, not committed.
114
+ - `generations/*.wav` — gitignored (regenerable output).
115
+ - Source song/GP files + scratch WAVs in the old `tools/` — not product source.
116
+
117
+ ## Pending / next steps (from the last session)
118
+
119
+ 1. **PLAY BOTH** — unbank / fix: the MIDI synth part wasn't audible. Likely the
120
+ object URL or the oscillator gain. That's the "co-play" wow moment.
121
+ 2. **Metadata sidecar** — add a `<file>.json` next to each `generations/*.wav`
122
+ with prompt / negative / model / BPM / duration / source. Answers "what did it
123
+ go off on."
124
+ 3. **Re-render the docs** `02-architecture.md`, `03-data-model.md`,
125
+ `04-ux-voice-first.md`, `07-reaper-integration.md` to match Jam Buddy (they
126
+ still describe the old PatternTalk drum-machine in places).
127
+ 4. **LoRA / underfit** (style trainer) is a real path but needs a GPU — not shipped.
128
+ 5. **Demo script** — the judge's-eye review (session bg_161745) recommended a
129
+ 3-min demo script; see `docs/01-vision.md` "What success looks like".
130
+
131
+ ## The judge's review (session `bg_161745` — hackathon judge design review)
132
+
133
+ Key finding: **docs and product were telling different stories** (PatternTalk vs
134
+ Jam Buddy). That's why this repo was consolidated to `stable-jam`. Scoring
135
+ rubric + recs live in that session. The #1 fix was "kill the PatternTalk framing"
136
+ — largely done in README + 01-vision; the deeper docs still need the same pass.
137
+
138
+ ## The SA3 tempo/time-signature reality (don't re-learn it)
139
+
140
+ Stable Audio 3 has NO tempo or time-signature conditioning channel. "BPM" is a
141
+ weak semantic hint in the prompt. So "make SA3 follow a time signature" is not
142
+ possible with the stock model. The answer is MIDI-first (timing from MIDI/audio
143
+ detection) + SA3 as the timbre generator. Full reasoning is in the skill ref
144
+ `sa3-no-tempo-control.md`. The LoRA style path is separate.
docs/HARNESS.md ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # stable-jam — Agent Harness
2
+
3
+ **Jam Buddy** — "you start playing, it joins in." An AI music companion for the
4
+ **Music Hackspace Montreal** hackathon (Aug 22–23, 2026, Stability AI challenge).
5
+ A Next.js web rack + a Python SA3 pipeline that listens to a MIDI or audio take
6
+ and responds at your tempo in the instrument you pick.
7
+
8
+ ## Roles
9
+
10
+ - **Web app** — `apps/web/` (Next.js + TS + Tailwind + Vitest). The hardware
11
+ sampler rack: instrument pads, genre/mood/tempo knobs, MIDI/audio take input,
12
+ JOIN IN + PLAY BOTH, status panel, and `generations/` output.
13
+ - **SA3 pipeline** — `tools/jam_buddy.py` (Python). Detects tempo + duration
14
+ from a MIDI take (or runs audio-to-audio from a WAV), builds the SA3 prompt
15
+ from the knobs, generates the response WAV via the `stable-audio-3` venv.
16
+ - **Prompt builder** — `apps/web/lib/jambuddy/prompt.ts` (pure, tested).
17
+ - **Player** — `apps/web/lib/jambuddy/player.ts` (Web Audio; play take + buddy
18
+ together). Note: PLAY BOTH is banked / not fully debugged.
19
+ - **Legacy converters** — `gp5_to_keyswitched_mid.py` / `midi_to_gp5.py` /
20
+ `midjson_to_mid.py` (GP5↔MIDI tooling from the earlier iteration, still present).
21
+
22
+ ## Commands
23
+
24
+ ```bash
25
+ # Web app (node_modules NOT committed — install first)
26
+ cd apps/web && pnpm install && npx vitest run # 31 tests
27
+ cd apps/web && npx tsc --noEmit # typecheck
28
+ pnpm dev # local Next.js server -> :3000
29
+
30
+ # Python pipeline (SA3 venv is in THIS repo)
31
+ ./stable-audio-3/.venv/Scripts/python.exe tools/jam_buddy.py --midi take.mid --instrument bass --out out.wav
32
+
33
+ # Conversion CLIs (if needed)
34
+ python gp5_to_keyswitched_mid.py path/to/song.gp5
35
+ ```
36
+
37
+ ## Test contract
38
+
39
+ - Web: `apps/web/__tests__/` — `prompt.test.ts` (prompt builder),
40
+ `player.test.ts` (midi parsing/player), `engine.test.ts`, `conversation.test.ts`.
41
+ - Python: `tests/` — GP5 conversion against `tests/expected/baseline.yaml`.
42
+
43
+ ## Conventions
44
+
45
+ - TS for web, Python 3.10+ for audio/conversion.
46
+ - Accessibility-first (screenreader-native): axe-core, NVDA + VoiceOver checks.
47
+ - Conventional Commits, `feat/*`/`fix/*`/`docs/*`/`chore/*` branches.
48
+
49
+ ## Not-in-git (deliberately)
50
+
51
+ - `stable-audio-3/` + `text2midi/` (vendored models) live in the previous dir;
52
+ referenced via `JAM_BUDDY_PYTHON`. Don't copy them into this repo.
53
+ - `node_modules/`, `.next/`, `generations/*.wav` — regenerable.
54
+
55
+ See `docs/HANDOFF.md` for the full resume guide and current product state.
gp5_to_keyswitched_mid.py ADDED
@@ -0,0 +1,1123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Guitar Pro 5 (.gp5) -> Sforzando Keyswitched MIDI Converter
4
+ ============================================================
5
+
6
+ Reads a .gp5 file, detects common guitar articulations (palm mute, staccato,
7
+ harmonic, slide, bend) on every note of every guitar track, and emits a Type 1
8
+ MIDI file with the corresponding Sforzando keyswitch notes inserted exactly one
9
+ tick before each articulated note.
10
+
11
+ Bass tracks are skipped by default. Pass --include-bass to also process them.
12
+
13
+ All guitar tracks of the same instrument kind (e.g. two rhythm guitars) are
14
+ merged into a single MIDI track on a single channel, so Sforzando receives a
15
+ clean instrument-shaped stream.
16
+
17
+ INSTALL
18
+ -------
19
+ pip install PyGuitarPro mido
20
+
21
+ USAGE
22
+ -----
23
+ python gp5_to_keyswitched_mid.py path/to/song.gp5
24
+ python gp5_to_keyswitched_mid.py path/to/song.gp5 output.mid
25
+ python gp5_to_keyswitched_mid.py song.gp5 -b # also process bass
26
+ python gp5_to_keyswitched_mid.py song.gp5 -V 100 -r # velocity 100, emit sustain reset
27
+
28
+ Edit the KEYSWITCH_MAP dictionary below to change the keyswitch note numbers
29
+ to match the keyswitches declared in your .sfz instrument.
30
+ """
31
+
32
+ import argparse
33
+ import os
34
+ import re
35
+ import sys
36
+
37
+ import guitarpro
38
+ import mido
39
+
40
+
41
+ __all__ = [
42
+ "KEYSWITCH_MAP",
43
+ "INCLUDE_BASS",
44
+ "MERGE_BY_INSTRUMENT",
45
+ "EMIT_SUSTAIN_RESET",
46
+ "SUSTAIN_KEYSWITCH",
47
+ "DEFAULT_VELOCITY",
48
+ "detect_techniques",
49
+ "convert",
50
+ "main",
51
+ ]
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # EDITABLE SETTINGS
56
+ # ---------------------------------------------------------------------------
57
+
58
+ # Map detected technique name -> Sforzando keyswitch MIDI note number.
59
+ # THIS IS A TEMPLATE. The actual keyswitch numbers depend on the SFZ
60
+ # instrument you load in Sforzando. Verify each value by:
61
+ # 1. Open the .sfz file in a text editor.
62
+ # 2. Search for the technique name (e.g. 'sustain', 'palm_mute').
63
+ # 3. Find the sw_lokey/sw_hikey range that selects it; the keyswitch
64
+ # note is the value inside that range.
65
+ # 4. Or just play the note in Sforzando and listen.
66
+ # Below is one plausible mapping for Unreal Instruments METAL-GTX, based
67
+ # on the reabank metadata in the .sfz. Treat it as a starting point, not
68
+ # a guarantee. You can override at the CLI with --keyswitch-map FILE.
69
+ # sustain F1 / 17 — Sustain Down
70
+ # palm_mute G#1 / 20 — Palm Mute Down
71
+ # slide C1 / 24 — Slide Up
72
+ # harmonic A0 / 9 — Natural Harmonics
73
+ # bend G6 / 91 — Bending semi
74
+ # Staccato is intentionally NOT mapped: on a real guitar it is a
75
+ # duration/timing property, not a separate articulation. The original
76
+ # note's envelope is preserved by NOT emitting a staccato key.
77
+ KEYSWITCH_MAP = {
78
+ "sustain": 17,
79
+ "palm_mute": 20,
80
+ "harmonic": 9,
81
+ "slide_up": 24,
82
+ "slide_down": 23,
83
+ "slide_in": 27,
84
+ "hammer": 26,
85
+ "bend": 91,
86
+ }
87
+
88
+ # The keyswitch note number used to (re)assert the sustain/clean state.
89
+ # SusUp (F#0 = 18) is the cleaner-sounding of the two sustain keys in
90
+ # METAL-GTX — SusDown (F0 = 17) has a noticeable palm-mute-like quality.
91
+ # When a note in the GP file has no articulation, the script emits this
92
+ # key at the same tick so the sampler always knows what to play.
93
+ SUSTAIN_KEYSWITCH = 18
94
+
95
+ # Length of a keyswitch note (in ticks, at 960 PPQ). 960 ticks = one quarter
96
+ # note, which is large enough to be visible in any DAW's piano roll and
97
+ # long enough that no host will drop it. The actual delivered duration is
98
+ # further clamped to the gap until the next event on the same channel so
99
+ # two consecutive keyswitches never overlap.
100
+ KEYSWITCH_DURATION_TICKS = 960
101
+
102
+ # MIDI note numbers used as keyswitches. Used to truncate each keyswitch
103
+ # note_off to the gap before the next event on the same channel.
104
+
105
+ # General MIDI drum programs. A track is treated as drums if it uses one of
106
+ # these programs on a non-channel-9 channel, OR if its name says "drum".
107
+ _GM_DRUM_PROGRAMS = set(range(8, 17)) # 8..16 (drums 8..15 + reverse cymbal 16)
108
+
109
+ # General MIDI guitar programs (24..31 cover acoustic/electric/clean/distortion/
110
+ # overdriven/lead/etc., plus the muted guitar 28 and overdriven 29).
111
+ _GM_GUITAR_PROGRAMS = set(range(24, 32))
112
+
113
+ # General MIDI bass programs (32..39).
114
+ _GM_BASS_PROGRAMS = set(range(32, 40))
115
+
116
+ _GUITAR_NAME_TOKENS = (
117
+ "guitar", "rhythm", "lead", "clean", "harmony", "distort",
118
+ )
119
+ _BASS_NAME_TOKENS = (
120
+ "bass", "fretless", "slap",
121
+ )
122
+ _DRUM_NAME_TOKENS = (
123
+ "drum", "kit", "percussion",
124
+ )
125
+
126
+ # Program numbers that, when seen alone, are ambiguous (e.g. program 0 =
127
+ # "Acoustic Grand Piano" in GM, but GP5 also uses 0 for drums when the
128
+ # channel is 9). The classifier consults the track name in that case.
129
+
130
+ # Process bass tracks too. Override at the CLI with --include-bass.
131
+ INCLUDE_BASS = False
132
+
133
+ # When False (default), every included GP track becomes its own output MIDI
134
+ # track on its own channel, the channel the track had in the Guitar Pro file.
135
+ # This is the right mode for selecting a single track by name (--only) and
136
+ # for the common case of driving several Sforzando instances at once.
137
+ # Set to True to merge tracks of the same instrument kind into one MIDI
138
+ # track on a single deterministic channel.
139
+ MERGE_BY_INSTRUMENT = False
140
+
141
+ # When True (default), every non-articulated note in the GP file emits a
142
+ # sustain key (SUSTAIN_KEYSWITCH) at the same tick. This guarantees the
143
+ # sampler is always in a known state, regardless of what the previous
144
+ # sample held. Turn off with --no-sustain-reset on the CLI if you want
145
+ # explicit keyswitches only at articulated notes.
146
+ EMIT_SUSTAIN_RESET = True
147
+
148
+ # When True (default), trim leading silence so the first event lands at
149
+ # tick 0. Empty intro bars in the GP file are removed from the MIDI
150
+ # output. Useful when the GP arrangement has a long count-in or rests
151
+ # before the first note.
152
+ TRIM_SILENCE = True
153
+
154
+ # Velocity used when a note has no explicit velocity or its value is 0.
155
+ DEFAULT_VELOCITY = 100
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # TRACK CLASSIFICATION
160
+ # ---------------------------------------------------------------------------
161
+ # PyGuitarPro does not always populate `track.instrument.kind`. The reliable
162
+ # signals are: (1) the GM program number on `track.channel` (mute-proof, this
163
+ # is what the .gp5 file actually stores), and (2) the track name. We use
164
+ # both so that contrived tracks ("Julien Bass") are recognised even when the
165
+ # program number happens to be 0.
166
+
167
+ def _channel_1based(gp_track):
168
+ """Return the 1-based MIDI channel of a GP track, or None if unknown."""
169
+ ch = getattr(gp_track, "channel", None)
170
+ raw = getattr(ch, "channel", None) if ch is not None else None
171
+ if raw is None:
172
+ return None
173
+ return int(raw) + 1 # GP is 0-based, but here we want 1-based for the
174
+ # common "channel 9 = drums" convention.
175
+
176
+ def _program_number(gp_track):
177
+ """Return the GM program number 0-127 for a GP track, or None if unknown."""
178
+ ch = getattr(gp_track, "channel", None)
179
+ return getattr(ch, "instrument", None) if ch is not None else None
180
+
181
+ def _track_name(gp_track):
182
+ n = getattr(gp_track, "name", None) or ""
183
+ return n.strip().lower()
184
+
185
+ def _classify_track(gp_track):
186
+ """
187
+ Return (category, kind_label) for a GP track.
188
+
189
+ category is one of: 'guitar', 'bass', 'drums', 'other'.
190
+ kind_label is a short string used for channel hashing (e.g.
191
+ 'electricGuitar', 'fretlessBass').
192
+ """
193
+ name = _track_name(gp_track)
194
+ name_lower = name.lower()
195
+ chan_1b = _channel_1based(gp_track)
196
+ prog = _program_number(gp_track)
197
+
198
+ # Channel 9 in GP5 is always drums in the files we care about.
199
+ if chan_1b == 10:
200
+ return ("drums", "drums")
201
+
202
+ # Name-based overrides win early — GP5 files often use inconsistent
203
+ # program numbers (e.g. program 0 for "Julien Bass") and the name is
204
+ # the most reliable signal.
205
+ if any(tok in name_lower for tok in _DRUM_NAME_TOKENS):
206
+ return ("drums", "drums")
207
+ if any(tok in name_lower for tok in _BASS_NAME_TOKENS):
208
+ return ("bass", _classify_bass_label(name, prog))
209
+ if any(tok in name_lower for tok in _GUITAR_NAME_TOKENS):
210
+ return ("guitar", _classify_guitar_label(name, prog))
211
+
212
+ # Fall back to program number.
213
+ if prog is not None:
214
+ if prog in _GM_DRUM_PROGRAMS:
215
+ return ("drums", "drums")
216
+ if prog in _GM_BASS_PROGRAMS:
217
+ return ("bass", _classify_bass_label(name, prog))
218
+ if prog in _GM_GUITAR_PROGRAMS:
219
+ return ("guitar", _classify_guitar_label(name, prog))
220
+
221
+ return ("other", "other")
222
+
223
+
224
+ _GUITAR_PROGRAM_LABEL = {
225
+ 24: "nylonGuitar", 25: "steelGuitar", 26: "jazzGuitar",
226
+ 27: "cleanGuitar", 28: "mutedGuitar", 29: "overdrivenGuitar",
227
+ 30: "distortionGuitar", 31: "harmonicsGuitar",
228
+ }
229
+ _BASS_PROGRAM_LABEL = {
230
+ 32: "acousticBass", 33: "fingeredBass", 34: "pickedBass",
231
+ 35: "fretlessBass", 36: "slapBass1", 37: "slapBass2",
232
+ 38: "synthBass1", 39: "synthBass2",
233
+ }
234
+
235
+
236
+ def _classify_guitar_label(name, prog):
237
+ if prog is not None and prog in _GUITAR_PROGRAM_LABEL:
238
+ return _GUITAR_PROGRAM_LABEL[prog]
239
+ # Press fragile names: 'lead' / 'rhythm' / 'clean' / 'harmony' / 'distort'
240
+ nl = name.lower()
241
+ for tok in ("lead", "rhythm", "clean", "harmony", "distort", "acoustic"):
242
+ if tok in nl:
243
+ return f"{tok}Guitar"
244
+ return "electricGuitar"
245
+
246
+
247
+ def _classify_bass_label(name, prog):
248
+ if prog is not None and prog in _BASS_PROGRAM_LABEL:
249
+ return _BASS_PROGRAM_LABEL[prog]
250
+ return "bassGuitar"
251
+
252
+
253
+ # Reserve channel 9 (GM percussion) for drums. Sforzando expects the
254
+ # instrument on a pitched channel.
255
+ _RESERVED_CHANNELS = {9}
256
+
257
+
258
+ def _midi_channel_for(kind_name):
259
+ """
260
+ Return a deterministic MIDI channel 0-15 for a given instrument kind.
261
+ Channel 9 is skipped (GM percussion). Identical kind names always resolve
262
+ to the same channel, so merging tracks of the same kind keeps the
263
+ resulting overlapping notes on the same channel.
264
+ """
265
+ if not kind_name:
266
+ return 0
267
+ h = sum(ord(c) for c in kind_name)
268
+ ch = h % 16
269
+ if ch in _RESERVED_CHANNELS:
270
+ ch = (ch + 1) % 16
271
+ return ch
272
+
273
+
274
+ def _bucket_tracks_by_kind(guitar_tracks, bass_included):
275
+ """
276
+ Group included tracks into (kind_label, [gp_track, ...]) buckets.
277
+
278
+ Guitars are split by their kind label (e.g. 'cleanGuitar' becomes its
279
+ own bucket), so a file with rhythm + lead + clean guitars gets three
280
+ separate MIDI tracks. Bass tracks, when included, land in a single
281
+ 'Bass' bucket.
282
+
283
+ Returns a list of (bucket_label, kind_name, [gp_track...]) tuples in the
284
+ order they should appear in the output MIDI.
285
+ """
286
+ guitar_buckets = {}
287
+ for t in guitar_tracks:
288
+ kind = _classify_track(t)[1]
289
+ guitar_buckets.setdefault(kind, []).append(t)
290
+
291
+ buckets = []
292
+ for kind in sorted(guitar_buckets):
293
+ n = len(guitar_buckets[kind])
294
+ if len(guitar_buckets) == 1 and len(guitar_tracks) > 1:
295
+ label = "Guitar"
296
+ elif n == 1:
297
+ label = f"Guitar: {kind}"
298
+ else:
299
+ label = f"Guitar: {kind}"
300
+ buckets.append((label, kind, guitar_buckets[kind]))
301
+
302
+ if bass_included:
303
+ if len(bass_included) == 1:
304
+ label = "Bass"
305
+ kind = _classify_track(bass_included[0])[1]
306
+ else:
307
+ kind = _classify_track(bass_included[0])[1]
308
+ label = f"Bass: {kind}"
309
+ buckets.append((label, kind, bass_included))
310
+
311
+ return buckets
312
+
313
+
314
+ # ---------------------------------------------------------------------------
315
+ # FILENAME HELPERS
316
+ # ---------------------------------------------------------------------------
317
+ _FILENAME_FORBIDDEN = re.compile(r"[\\/:\*\?\"<>\|]+")
318
+ _FILENAME_WHITESPACE = re.compile(r"\s+")
319
+ _FILENAME_EDGEPUNCT = "\u2024\u2025\u2026\ufeff. "
320
+
321
+
322
+ def _sanitize_filename(name, default="output"):
323
+ """Make a track name safe for use as a filename on Windows / macOS / Linux."""
324
+ cleaned = _FILENAME_FORBIDDEN.sub(" ", name or "")
325
+ cleaned = _FILENAME_WHITESPACE.sub(" ", cleaned).strip()
326
+ cleaned = cleaned.rstrip(". ")
327
+ cleaned = cleaned.strip(_FILENAME_EDGEPUNCT)
328
+ if not cleaned:
329
+ cleaned = default
330
+ return cleaned
331
+
332
+
333
+ def _song_subfolder(input_path):
334
+ """
335
+ Derive a subfolder name from the .gp5 input file. The folder holds
336
+ per-track MIDI files so exports from different songs don't mix in
337
+ the same directory.
338
+ """
339
+ head, tail = os.path.split(input_path)
340
+ base, _ = os.path.splitext(tail)
341
+ return _sanitize_filename(base, default="song")
342
+
343
+
344
+ def _auto_output_path(only_filters, input_path, only_index=None):
345
+ """
346
+ Build a default output path. Files are organized as
347
+ <song_subfolder>/<track_name>_keyswitched.mid so exports from
348
+ different songs don't collide in the same directory.
349
+
350
+ Rules:
351
+ - No --only filter: fall back to <song_subfolder>/output_keyswitched.mid.
352
+ - --only matches exactly one track (or --only-index picks one):
353
+ <song_subfolder>/<sanitized_track_name>_keyswitched.mid.
354
+ - --only matches zero tracks: fall back to the default; the caller
355
+ will report 'no tracks found' anyway.
356
+ - --only matches multiple tracks and no --only-index: return None
357
+ to signal the caller to require an explicit output path.
358
+ """
359
+ subfolder = _song_subfolder(input_path)
360
+ if not only_filters:
361
+ return os.path.join(subfolder, "output_keyswitched.mid")
362
+ song = guitarpro.parse(input_path)
363
+ matched = [t for t in song.tracks
364
+ if any(f.lower() in _track_name(t) for f in only_filters)]
365
+ n_matched = len(matched)
366
+ if only_index is not None:
367
+ if 1 <= only_index <= n_matched:
368
+ matched = [matched[only_index - 1]]
369
+ else:
370
+ return None
371
+ if len(matched) == 1:
372
+ # When --only matched multiple tracks but --only-index picked one,
373
+ # include the index in the filename so the file is uniquely named.
374
+ suffix = f" ({only_index})" if only_index is not None and n_matched > 1 else ""
375
+ return os.path.join(
376
+ subfolder,
377
+ f"{_sanitize_filename(matched[0].name)}{suffix}_keyswitched.mid",
378
+ )
379
+ if len(matched) == 0:
380
+ return os.path.join(subfolder, "output_keyswitched.mid")
381
+ return None # ambiguous: caller must supply an output path
382
+
383
+
384
+ # ---------------------------------------------------------------------------
385
+ # ARTICULATION DETECTION
386
+ # ---------------------------------------------------------------------------
387
+ def detect_techniques(note):
388
+ """
389
+ Return a list of (technique_name, params_dict) tuples found on a note.
390
+
391
+ The technique name is the key into KEYSWITCH_MAP. A technique that
392
+ doesn't need to be split (e.g. palm_mute, harmonic) returns an empty
393
+ params dict. Slides return a 'direction' parameter ('up' or 'down')
394
+ based on the source pitch versus the next note on the same string.
395
+ """
396
+ techniques = []
397
+ effect = getattr(note, "effect", None)
398
+ if effect is None:
399
+ return techniques
400
+
401
+ # Palm mute: boolean flag.
402
+ if getattr(effect, "palmMute", False):
403
+ techniques.append(("palm_mute", {}))
404
+
405
+ # Staccato: boolean flag.
406
+ if getattr(effect, "staccato", False):
407
+ techniques.append(("staccato", {}))
408
+
409
+ # Harmonic: HarmonicEffect object when present (natural, artificial,
410
+ # pinch, tap, semi, feedback). We collapse all harmonic flavors to one
411
+ # 'harmonic' keyswitch; split by effect.harmonic.type if your SFZ needs
412
+ # finer resolution.
413
+ if getattr(effect, "harmonic", None) is not None:
414
+ techniques.append(("harmonic", {}))
415
+
416
+ # Hammer-on / pull-off: boolean flag. The METAL-GTX SFZ has a
417
+ # dedicated Hammer-On patch triggered by keyswitch D1 (MIDI 26).
418
+ if getattr(effect, "hammer", False):
419
+ techniques.append(("hammer", {}))
420
+
421
+ # Grace notes: stored as a GraceEffect on the note's effect. The
422
+ # transition field tells us whether the grace slides, hammers, or
423
+ # bends into the main note. In METAL-GTX, slide-in graces map to
424
+ # D#1 (MIDI 27) and bend-in graces map to the bend keyswitch.
425
+ grace = getattr(effect, "grace", None)
426
+ if grace is not None:
427
+ transition = getattr(grace, "transition", None)
428
+ if transition is not None:
429
+ t_name = getattr(transition, "name", str(transition))
430
+ if t_name == "slide":
431
+ techniques.append(("slide_in", {}))
432
+ elif t_name == "hammer":
433
+ techniques.append(("hammer", {}))
434
+ elif t_name == "bend":
435
+ techniques.append(("bend", {}))
436
+
437
+ # Slides: iterable of slide types. Direction is determined by the
438
+ # caller (see _resolve_slide_direction, which compares the slide
439
+ # note's pitch to the next note on the same string). If direction
440
+ # can't be determined, the slide defaults to 'slide_up'.
441
+ slides = getattr(effect, "slides", None)
442
+ if slides is not None:
443
+ try:
444
+ if len(slides) > 0:
445
+ techniques.append(("slide", {}))
446
+ except TypeError:
447
+ techniques.append(("slide", {}))
448
+
449
+ # Bend: BendEffect with a `points` list when present.
450
+ if getattr(effect, "bend", None) is not None:
451
+ techniques.append(("bend", {}))
452
+
453
+ return techniques
454
+
455
+
456
+ def _resolve_slide_direction(note, track_notes, note_index):
457
+ """
458
+ Determine whether a slide note goes up or down.
459
+
460
+ The PYGuitarPro note carries a slide flag but not the target pitch.
461
+ The target is the next note (in time) on the same string. The caller
462
+ passes a flat list of all notes in the track and the current note's
463
+ index in that list.
464
+
465
+ Returns 'up', 'down', or None (when direction cannot be determined).
466
+ """
467
+ if note.string is None:
468
+ return None
469
+ src_pitch = note.realValue
470
+ # Look ahead for the next note on the same string.
471
+ for later in track_notes[note_index + 1:]:
472
+ if getattr(later, "string", None) == note.string:
473
+ if later.realValue > src_pitch:
474
+ return "up"
475
+ if later.realValue < src_pitch:
476
+ return "down"
477
+ return None # same pitch: ambiguous
478
+ return None
479
+
480
+
481
+ def _expand_slide(tech, params, direction):
482
+ """
483
+ Resolve a 'slide' technique into 'slide_up' or 'slide_down' based on
484
+ the pre-computed direction. For other techniques, return the name
485
+ unchanged so the keyswitch-map lookup works.
486
+ """
487
+ if tech != "slide":
488
+ return tech
489
+ if direction == "down":
490
+ return "slide_down"
491
+ return "slide_up" # default + 'up'
492
+
493
+
494
+ def _build_tie_chain_map(track):
495
+ """
496
+ Walk every note in the track and compute the absolute end-tick of
497
+ each note's envelope. Tied notes share the same attack as their
498
+ progenitor, so the progenitor's note_off is set to the end of the
499
+ last tied continuation. The map is keyed by id(note).
500
+
501
+ Returns a dict {id(note): end_tick} where end_tick is the absolute
502
+ tick at which the note's note_off should fire.
503
+ """
504
+ note_end = {}
505
+ # Walk beats in time order, tracking the most recent non-tied note
506
+ # on each (string, pitch) pair.
507
+ active = {} # (string, pitch) -> id(note)
508
+ for measure in track.measures:
509
+ for voice in measure.voices:
510
+ if voice.isEmpty:
511
+ continue
512
+ for beat in voice.beats:
513
+ beat_start = int(beat.start)
514
+ beat_dur = int(beat.duration.time)
515
+ for note in beat.notes:
516
+ if note.type == guitarpro.NoteType.dead:
517
+ continue
518
+ key = (note.string, note.realValue)
519
+ note_dur = int(round(beat_dur * float(note.durationPercent)))
520
+ if note.type == guitarpro.NoteType.tie:
521
+ # Continuation of the active note on this key.
522
+ progenitor = active.get(key)
523
+ if progenitor is not None:
524
+ # Extend the progenitor's end tick.
525
+ note_end[progenitor] = beat_start + note_dur
526
+ # Note: tied notes themselves don't get their own
527
+ # note_on/note_off — they share the attack.
528
+ else:
529
+ # New attack. Register it as the active note on
530
+ # this key, and finalize its end tick (for now).
531
+ note_end[id(note)] = beat_start + note_dur
532
+ active[key] = id(note)
533
+ return note_end
534
+
535
+
536
+ # ---------------------------------------------------------------------------
537
+ # CONVERSION
538
+ # ---------------------------------------------------------------------------
539
+ def _clamp(value, lo, hi):
540
+ """Clamp an int-castable value to [lo, hi]."""
541
+ return max(lo, min(hi, int(value)))
542
+
543
+
544
+ def convert(gp5_path, output_path="output_keyswitched.mid",
545
+ keyswitch_map=None, include_bass=None,
546
+ emit_sustain_reset=None, trim_silence=None,
547
+ fallback_velocity=None, only=None, only_index=None, verbose=True):
548
+ """
549
+ Parse a Guitar Pro 5 file and write a keyswitched MIDI file.
550
+
551
+ Parameters
552
+ ----------
553
+ gp5_path : str
554
+ Path to the .gp5 input file.
555
+ output_path : str
556
+ Path for the generated MIDI file. Default: 'output_keyswitched.mid'.
557
+ keyswitch_map : dict, optional
558
+ Override the default technique -> keyswitch note mapping.
559
+ include_bass : bool, optional
560
+ If True, also process bass tracks. Defaults to INCLUDE_BASS.
561
+ emit_sustain_reset : bool, optional
562
+ If True, emit a sustain keyswitch (SUSTAIN_KEYSWITCH) at tick 0 of
563
+ every processed track. Defaults to EMIT_SUSTAIN_RESET.
564
+ fallback_velocity : int, optional
565
+ Velocity used when a note has no explicit velocity. Defaults to
566
+ DEFAULT_VELOCITY.
567
+ only : str | iterable[str] | None, optional
568
+ If given, only GP tracks whose name contains the supplied substring
569
+ (case-insensitive) are considered. Pass a list for multiple filters.
570
+ verbose : bool
571
+ If True, print a summary of which tracks were processed / skipped.
572
+ """
573
+ if keyswitch_map is None:
574
+ keyswitch_map = dict(KEYSWITCH_MAP)
575
+ if include_bass is None:
576
+ include_bass = INCLUDE_BASS
577
+ if emit_sustain_reset is None:
578
+ emit_sustain_reset = EMIT_SUSTAIN_RESET
579
+ if trim_silence is None:
580
+ trim_silence = TRIM_SILENCE
581
+ if fallback_velocity is None:
582
+ fallback_velocity = DEFAULT_VELOCITY
583
+ if only is None:
584
+ only_filters = None
585
+ elif isinstance(only, str):
586
+ only_filters = [only]
587
+ else:
588
+ only_filters = list(only)
589
+ if only_index is None:
590
+ only_index = None # explicit
591
+ # When only_index is used, switch to exact-match filtering so the
592
+ # picked track is unique (substring match would still match the
593
+ # other same-named tracks).
594
+ only_exact = False
595
+ only_track = None # pinned track object when only_index is set
596
+
597
+ song = guitarpro.parse(gp5_path)
598
+
599
+ if only_filters is not None and only_index is not None:
600
+ matched = [t for t in song.tracks
601
+ if any(f.lower() in _track_name(t).lower() for f in only_filters)]
602
+ if 1 <= only_index <= len(matched):
603
+ # Pin to a specific track by id() so the partition loop
604
+ # can match exactly one track (since name-based filters would
605
+ # still match both same-named tracks).
606
+ only_filters = None # disable name filter
607
+ only_track = matched[only_index - 1]
608
+ else:
609
+ print(f"ERROR: --only-index {only_index} out of range "
610
+ f"(only {len(matched)} matches found)", file=sys.stderr)
611
+ return None # let caller handle ambiguous / out-of-range
612
+
613
+ # PyGuitarPro stores timing on the parent beat:
614
+ # beat.start -> absolute tick of the beat
615
+ # beat.duration.time -> duration in ticks at 960 PPQ (already
616
+ # accounts for dotted/tuplet)
617
+ # note.durationPercent -> how much of the beat the note occupies
618
+ # The MIDI file uses 960 ticks per beat to match that domain exactly,
619
+ # so the 1-tick keyswitch offset is preserved precisely.
620
+ mid = mido.MidiFile(type=1, ticks_per_beat=960)
621
+
622
+ # ---- Track 0: conductor (tempo + time signature) --------------------
623
+ conductor = mid.add_track("Conductor")
624
+ # mido's set_tempo takes microseconds per quarter note, not BPM.
625
+ # Guitar Pro stores the song tempo as BPM, so we convert.
626
+ bpm = int(song.tempo) if getattr(song, "tempo", None) else 120
627
+ tempo = int(60_000_000 / bpm)
628
+ conductor.append(mido.MetaMessage("set_tempo", tempo=tempo, time=0))
629
+
630
+ if song.tracks and song.tracks[0].measures:
631
+ first_ts = song.tracks[0].measures[0].timeSignature
632
+ if first_ts is not None:
633
+ # `denominator` is a Duration struct whose `.value` is the
634
+ # actual denominator (4 for quarter, 8 for eighth, 16 for
635
+ # sixteenth). It is NOT a 2** enum like in some older GP
636
+ # versions. The MIDI `time_signature` meta uses the same
637
+ # denominator number directly.
638
+ actual_denom = int(first_ts.denominator.value)
639
+ conductor.append(mido.MetaMessage(
640
+ "time_signature",
641
+ numerator=int(first_ts.numerator),
642
+ denominator=actual_denom,
643
+ time=0,
644
+ ))
645
+
646
+ # ---- Partition tracks: included (guitar / bass) vs. skipped --------
647
+ included_guitar = []
648
+ included_bass = []
649
+ skipped_tracks = []
650
+ for gp_track in song.tracks:
651
+ name = _track_name(gp_track)
652
+ raw_name = getattr(gp_track, "name", None) or ""
653
+ if only_track is not None:
654
+ # Only the pinned track passes.
655
+ if gp_track is not only_track:
656
+ skipped_tracks.append(gp_track)
657
+ continue
658
+ elif only_filters is not None:
659
+ if only_exact:
660
+ # Match against the raw (case-preserving) track name.
661
+ if raw_name not in only_filters:
662
+ skipped_tracks.append(gp_track)
663
+ continue
664
+ else:
665
+ if not any(f.lower() in name for f in only_filters):
666
+ skipped_tracks.append(gp_track)
667
+ continue
668
+ category, _kind = _classify_track(gp_track)
669
+ if category == "guitar":
670
+ included_guitar.append(gp_track)
671
+ elif category == "bass" and include_bass:
672
+ included_bass.append(gp_track)
673
+ else:
674
+ skipped_tracks.append(gp_track)
675
+
676
+ if MERGE_BY_INSTRUMENT:
677
+ buckets = _bucket_tracks_by_kind(included_guitar, included_bass)
678
+ else:
679
+ # One output MIDI track per GP track. The channel the track had
680
+ # in the .gp5 file is preserved on the message events; the MIDI
681
+ # track itself is named after the GP track.
682
+ buckets = []
683
+ for t in included_guitar + included_bass:
684
+ ch_obj = getattr(t, "channel", None)
685
+ raw_ch = getattr(ch_obj, "channel", None)
686
+ channel = int(raw_ch) if raw_ch is not None else 0
687
+ if channel == 9: # never reuse GM percussion
688
+ channel = 0
689
+ label = t.name or "Guitar"
690
+ buckets.append((label, f"track:{label}", [t]))
691
+
692
+ if not buckets:
693
+ if verbose:
694
+ print("No guitar"
695
+ + ("/bass" if include_bass else "")
696
+ + " tracks found in this file.")
697
+ parent = os.path.dirname(output_path)
698
+ if parent:
699
+ os.makedirs(parent, exist_ok=True)
700
+ mid.save(output_path)
701
+ return output_path
702
+
703
+ # ---- Per-bucket processing ----------------------------------------
704
+ # Each bucket produces exactly one MIDI track on a single channel.
705
+ # Notes from multiple GP tracks within a bucket are merged in absolute
706
+ # tick space, then sorted, so the 1-tick-before keyswitch relationship
707
+ # is preserved across tracks.
708
+ written_buckets = []
709
+ for bucket_idx, (label, kind_name, tracks) in enumerate(buckets, start=1):
710
+ if MERGE_BY_INSTRUMENT:
711
+ channel = _midi_channel_for(kind_name)
712
+ else:
713
+ # When unmerged, the channel is the GP track's own channel so
714
+ # the output matches how the track was originally authored.
715
+ ch_obj = getattr(tracks[0], "channel", None)
716
+ raw_ch = getattr(ch_obj, "channel", None)
717
+ channel = int(raw_ch) if raw_ch is not None else 0
718
+ if channel == 9:
719
+ channel = 0
720
+
721
+ # Collect (absolute_tick, mido.Message) pairs for this bucket.
722
+ events = []
723
+ # Set of (tick, channel) where a default sustain key has already
724
+ # been queued for this bucket. Prevents one sustain key per
725
+ # note in a chord (e.g. notes 50, 45, 38 at one tick emit a
726
+ # single sustain key, not three).
727
+ pending_default_sustain = set()
728
+
729
+ for gp_track in tracks:
730
+ # Pre-collect every note in the track in time order. After
731
+ # the full list is built, compute slide direction for each
732
+ # note that has a slide — direction is determined by comparing
733
+ # the slide note's pitch to the next note on the same string
734
+ # (the slide target).
735
+ track_notes = []
736
+ for measure in gp_track.measures:
737
+ for voice in measure.voices:
738
+ if voice.isEmpty:
739
+ continue
740
+ for beat in voice.beats:
741
+ for note in beat.notes:
742
+ track_notes.append(note)
743
+ slide_direction = {}
744
+ for idx, note in enumerate(track_notes):
745
+ if getattr(note.effect, "slides", None):
746
+ slide_direction[id(note)] = _resolve_slide_direction(
747
+ note, track_notes, idx)
748
+ # Pre-compute the end-tick of every note's envelope, taking
749
+ # tied continuations into account. Without this, a note with
750
+ # tied continuations gets its note_off at the end of the
751
+ # first beat, cutting the tail short.
752
+ note_end = _build_tie_chain_map(gp_track)
753
+
754
+ for measure in gp_track.measures:
755
+ for voice in measure.voices:
756
+ if voice.isEmpty:
757
+ continue
758
+ for beat in voice.beats:
759
+ for note in beat.notes:
760
+ # Tied continuations do not replay a pitched
761
+ # note (they share the previous attack's
762
+ # envelope), but they CAN carry new
763
+ # articulations — e.g. a tied note with a
764
+ # bend means the bend starts on the tied
765
+ # side, not the original attack. Emit
766
+ # keyswitches for tied notes with techniques,
767
+ # but skip the pitched note_on/note_off.
768
+ if note.type == guitarpro.NoteType.dead:
769
+ continue
770
+ tied_loud = (note.type == guitarpro.NoteType.tie)
771
+ techniques_early = []
772
+ if tied_loud:
773
+ techniques_early = [
774
+ (t, p) for (t, p) in detect_techniques(note)
775
+ if keyswitch_map.get(_expand_slide(t, p, slide_direction.get(id(note)))) is not None
776
+ ]
777
+ if not techniques_early:
778
+ continue
779
+ # Fall through: emit keyswitches only.
780
+
781
+ # Timing lives on the beat; the note can only
782
+ # shorten its slot (durationPercent). When the
783
+ # note has a tied continuation, the note_end
784
+ # map gives the absolute end-tick of the
785
+ # entire tie chain (the note's envelope
786
+ # extends through all tied continuations).
787
+ start = int(beat.start)
788
+ note_end_tick = note_end.get(id(note))
789
+ if note_end_tick is not None:
790
+ duration = note_end_tick - start
791
+ else:
792
+ duration = int(round(beat.duration.time
793
+ * float(note.durationPercent)))
794
+ if duration < 1:
795
+ duration = 1
796
+ pitch = _clamp(note.realValue, 0, 127)
797
+
798
+ velocity = int(getattr(note, "velocity", 0) or 0)
799
+ if velocity < 1:
800
+ velocity = fallback_velocity
801
+ velocity = _clamp(velocity, 1, 127)
802
+
803
+ # ---- Emit explicit keyswitches per note ----
804
+ # Every note gets an explicit keyswitch(es)
805
+ # at `start` (the SAME tick as the pitched
806
+ # note's note_on). The keyswitch is appended
807
+ # to the event list BEFORE the pitched note_on,
808
+ # so within the same tick it arrives first in
809
+ # the MIDI stream — Sforzando accepts this
810
+ # and behaves identically to a 1-tick-before
811
+ # trigger. The natural off time is start +
812
+ # KEYSWITCH_DURATION_TICKS (a quarter note so
813
+ # it shows up in the DAW piano roll), then a
814
+ # later post-process pass clamps it to the
815
+ # next event on the same channel so two
816
+ # consecutive keyswitches never overlap.
817
+ #
818
+ # If the note has no articulation (e.g. a
819
+ # clean chord) AND the user has enabled
820
+ # EMIT_SUSTAIN_RESET, tag this note as
821
+ # 'default-sustain-candidate'. Per-tick, we
822
+ # emit a single default sustain key on the
823
+ # first such note — this guarantees the sampler
824
+ # is always in a known state, regardless of
825
+ # what the previous sample held (which fixes
826
+ # the common case where a palm-mute figure
827
+ # ends and the next clean chord is played in
828
+ # the wrong still-palm-muted state), and
829
+ # avoids duplicate sustain keys on chords.
830
+ techniques = detect_techniques(note)
831
+ if any(ks_notes_emitted for _ in []): # placeholder
832
+ pass
833
+ ks_notes_emitted = []
834
+ has_articulation = False
835
+ for tech, params in techniques:
836
+ if tech == "slide":
837
+ direction = slide_direction.get(id(note))
838
+ tech = "slide_down" if direction == "down" else "slide_up"
839
+ ks_note = keyswitch_map.get(tech)
840
+ if ks_note is None:
841
+ continue
842
+ if ks_note == 26:
843
+ import sys as _sys
844
+ ks_note = _clamp(ks_note, 0, 127)
845
+ ks_notes_emitted.append(ks_note)
846
+ has_articulation = True
847
+
848
+ if not has_articulation and emit_sustain_reset:
849
+ # Defer: emit one default sustain per
850
+ # (tick, channel) below, after the loop.
851
+ if (start, channel) not in pending_default_sustain:
852
+ pending_default_sustain.add((start, channel))
853
+ ks_notes_emitted.append(_clamp(SUSTAIN_KEYSWITCH, 0, 127))
854
+
855
+ for ks_note in ks_notes_emitted:
856
+ ks_on = start
857
+ ks_off = ks_on + KEYSWITCH_DURATION_TICKS
858
+ events.append((
859
+ ks_on,
860
+ mido.Message("note_on", channel=channel,
861
+ note=ks_note, velocity=velocity,
862
+ time=0),
863
+ ))
864
+ events.append((
865
+ ks_off,
866
+ ("keyswitch_off", ks_note, channel),
867
+ ))
868
+
869
+ # ---- The actual pitched note --------------
870
+ # Skip note_on/note_off for tied notes (the
871
+ # pitch is already sounding from the
872
+ # original attack).
873
+ if not tied_loud:
874
+ events.append((
875
+ start,
876
+ mido.Message("note_on", channel=channel,
877
+ note=pitch, velocity=velocity,
878
+ time=0),
879
+ ))
880
+ events.append((
881
+ start + duration,
882
+ mido.Message("note_off", channel=channel,
883
+ note=pitch, velocity=0,
884
+ time=0),
885
+ ))
886
+ if not events:
887
+ if verbose:
888
+ print(f" [{bucket_idx}] {label}: no notes, skipping")
889
+ continue
890
+
891
+ # ---- Resolve keyswitch_off placeholders into real note_off ----
892
+ # ---- messages with clamped ticks. Three constraints: ----
893
+ # ---- 1. keyswitch note_off must arrive at or before ----
894
+ # ---- key_on_tick + KEYSWITCH_DURATION_TICKS ----
895
+ # ---- 2. successive keyswitch note_on on the same channel/note-
896
+ # ---- is treated as a re-trigger: the previous key closes ----
897
+ # ---- *at the tick of the next note_on* and a new key opens. -
898
+ # ---- 3. Any non-sentinel note_on / note_off on the same ----
899
+ # ---- channel/note also closes the open key (same as 2). ----
900
+ keyswitch_notes = {v for v in KEYSWITCH_MAP.values() if v is not None}
901
+ keyswitch_notes.add(SUSTAIN_KEYSWITCH)
902
+ import sys as _sys
903
+ n26_post_loop = sum(1 for t, p in events
904
+ if hasattr(p, 'note') and p.note == 26
905
+ and p.type == 'note_on' and getattr(p, 'velocity', 0) > 0)
906
+ events.sort(key=lambda e: e[0])
907
+
908
+ # DEBUG: count 26 in events
909
+ n26 = sum(1 for t, p in events
910
+ if hasattr(p, 'note') and p.note == 26
911
+ and p.type == 'note_on' and getattr(p, 'velocity', 0) > 0)
912
+ n26_tup = sum(1 for t, p in events
913
+ if isinstance(p, tuple) and p[0] == 'keyswitch_off' and p[1] == 26)
914
+ if n26 > 0:
915
+ pass
916
+ def _close_oldest_open(ch, note, at_tick, out):
917
+ stack = open_keyswitch_stacks.get((ch, note))
918
+ if not stack:
919
+ return
920
+ ks_on = stack.pop(0)
921
+ if not open_keyswitch_stacks[(ch, note)]:
922
+ del open_keyswitch_stacks[(ch, note)]
923
+ cap = ks_on + KEYSWITCH_DURATION_TICKS
924
+ clamped = min(at_tick, cap)
925
+ if clamped < ks_on:
926
+ clamped = ks_on
927
+ out.append((clamped, mido.Message(
928
+ "note_off", channel=ch, note=note, velocity=0, time=0)))
929
+
930
+ resolved = []
931
+ open_keyswitch_stacks = {}
932
+ for tick, payload in events:
933
+ if isinstance(payload, tuple) and payload and payload[0] == "keyswitch_off":
934
+ _, ks_note, ch = payload
935
+ _close_oldest_open(ch, ks_note, tick, resolved)
936
+ elif isinstance(payload, mido.Message) and payload.type == "note_on" \
937
+ and payload.note in keyswitch_notes:
938
+ # Close any still-open key on the same channel/note at
939
+ # this tick (re-trigger semantics).
940
+ _close_oldest_open(payload.channel, payload.note, tick, resolved)
941
+ resolved.append((tick, payload))
942
+ open_keyswitch_stacks.setdefault(
943
+ (payload.channel, payload.note), []).append(tick)
944
+ elif isinstance(payload, mido.Message) and payload.type == "note_off" \
945
+ and payload.note in keyswitch_notes:
946
+ # An explicit note_off from the emitter (e.g. the user
947
+ # inserted one) closes the open key.
948
+ _close_oldest_open(payload.channel, payload.note, tick, resolved)
949
+ else:
950
+ resolved.append((tick, payload))
951
+ events = resolved
952
+
953
+ midi_track = mid.add_track(label)
954
+ last_tick = 0
955
+ for abs_tick, msg in events:
956
+ msg.time = max(0, abs_tick - last_tick)
957
+ last_tick = abs_tick
958
+ midi_track.append(msg)
959
+
960
+ written_buckets.append((label, kind_name, len(tracks), channel))
961
+
962
+ # ---- Trim leading silence -------------------------------------------
963
+ # If the user asked for trim_silence, find the earliest absolute tick
964
+ # across all tracks and shift every event so the first one lands at
965
+ # tick 0. This removes empty intro bars.
966
+ if trim_silence:
967
+ earliest = None
968
+ for tr in mid.tracks:
969
+ abs_t = 0
970
+ for msg in tr:
971
+ abs_t += msg.time
972
+ if isinstance(msg, mido.MetaMessage) and msg.type == "time_signature":
973
+ if abs_t > 0 and (earliest is None or abs_t < earliest):
974
+ earliest = abs_t
975
+ if isinstance(msg, mido.Message) and msg.type in (
976
+ "note_on", "note_off"):
977
+ if earliest is None or abs_t < earliest:
978
+ earliest = abs_t
979
+ if earliest is not None and earliest > 0:
980
+ for tr in mid.tracks:
981
+ # Walk events, accumulate times, rewrite each with the
982
+ # shift. Events that fall before the earliest tick are
983
+ # dropped or kept at time 0 (meta only).
984
+ rebuilt = []
985
+ abs_t = 0
986
+ first_real_done = False
987
+ for msg in tr:
988
+ new_abs = abs_t - earliest
989
+ abs_t += msg.time
990
+ if new_abs < 0:
991
+ # Drop note events; keep meta (set_tempo, track_name,
992
+ # time_signature, end_of_track) at time 0. Use
993
+ # copy(time=0) to preserve the original meta values.
994
+ if isinstance(msg, mido.MetaMessage):
995
+ rebuilt.append(msg.copy(time=0))
996
+ continue
997
+ # Update the delta time so the event lands at new_abs.
998
+ if not first_real_done:
999
+ msg.time = new_abs
1000
+ first_real_done = True
1001
+ rebuilt.append(msg)
1002
+ tr.clear()
1003
+ for m in rebuilt:
1004
+ tr.append(m)
1005
+ parent = os.path.dirname(output_path)
1006
+ if parent:
1007
+ os.makedirs(parent, exist_ok=True)
1008
+ mid.save(output_path)
1009
+
1010
+ # ---- Summary --------------------------------------------------------
1011
+ if verbose:
1012
+ for label, kind_name, n_gp, channel in written_buckets:
1013
+ if n_gp == 1:
1014
+ print(f" {label} [{kind_name}] channel {channel}")
1015
+ else:
1016
+ print(f" {label} [{kind_name}] channel {channel} "
1017
+ f"(merged {n_gp} GP tracks)")
1018
+ if skipped_tracks:
1019
+ print(f"Skipped {len(skipped_tracks)} other track(s):")
1020
+ for t in skipped_tracks:
1021
+ category, kind = _classify_track(t)
1022
+ print(f" - {t.name or '(unnamed)'} "
1023
+ f"[{kind} -> {category}]")
1024
+
1025
+ return output_path
1026
+
1027
+
1028
+ # ---------------------------------------------------------------------------
1029
+ # CLI ENTRY POINT
1030
+ # ---------------------------------------------------------------------------
1031
+ def main(argv=None):
1032
+ parser = argparse.ArgumentParser(
1033
+ prog="gp5_to_keyswitched_mid",
1034
+ description=(
1035
+ "Convert a Guitar Pro 5 file to a Sforzando keyswitched MIDI. "
1036
+ "Only guitar tracks are processed by default; pass --include-bass "
1037
+ "to also process bass tracks."
1038
+ ),
1039
+ )
1040
+ parser.add_argument("input",
1041
+ help="Path to the input .gp5 file")
1042
+ parser.add_argument("output",
1043
+ nargs="?",
1044
+ default=None,
1045
+ help="Path for the output MIDI file. If omitted, the "
1046
+ "tool derives a name from the GP track name "
1047
+ "when --only matches one track; otherwise it "
1048
+ "uses 'output_keyswitched.mid'.")
1049
+ parser.add_argument("-b", "--include-bass",
1050
+ action="store_true",
1051
+ help="Also process bass tracks (skipped by default).")
1052
+ parser.add_argument("-V", "--velocity",
1053
+ type=int,
1054
+ default=DEFAULT_VELOCITY,
1055
+ help="Fallback velocity 1-127 for notes missing one "
1056
+ "(default: %(default)s).")
1057
+ parser.add_argument("-R", "--no-sustain-reset",
1058
+ action="store_true",
1059
+ help="Do NOT emit a sustain key on every non-"
1060
+ "articulated note. By default, every note "
1061
+ "without an articulation carries a sustain "
1062
+ "key so the sampler is always in a known "
1063
+ "state (no stale palm-mute bleeding into "
1064
+ "clean chords).")
1065
+ parser.add_argument("-q", "--quiet",
1066
+ action="store_true",
1067
+ help="Suppress the per-track summary.")
1068
+ parser.add_argument("-o", "--only",
1069
+ action="append",
1070
+ default=None,
1071
+ help="Only process GP tracks whose name contains this "
1072
+ "substring (case-insensitive). Repeat for "
1073
+ "multiple names, e.g. -o Roberto -o DANY.")
1074
+ parser.add_argument("-T", "--no-trim-silence",
1075
+ action="store_true",
1076
+ help="Do NOT trim leading silence. By default the "
1077
+ "first event lands at tick 0, so empty intro "
1078
+ "bars from the GP file are removed from the "
1079
+ "MIDI output.")
1080
+ parser.add_argument("--only-index", type=int, default=None,
1081
+ help="When --only matches multiple tracks, pick "
1082
+ "the Nth (1-based) match. Useful when several "
1083
+ "tracks share a name.")
1084
+ args = parser.parse_args(argv)
1085
+
1086
+ # Resolve output path: explicit > auto-derived from --only > default.
1087
+ if args.output is None:
1088
+ args.output = _auto_output_path(args.only, args.input, only_index=args.only_index)
1089
+ if args.output is None:
1090
+ print("ERROR: --only matched multiple tracks; please supply an "
1091
+ "explicit output path or use --only-index to disambiguate.",
1092
+ file=sys.stderr)
1093
+ return 4
1094
+
1095
+ try:
1096
+ result = convert(
1097
+ args.input,
1098
+ args.output,
1099
+ include_bass=args.include_bass,
1100
+ emit_sustain_reset=not args.no_sustain_reset,
1101
+ trim_silence=not args.no_trim_silence,
1102
+ fallback_velocity=args.velocity,
1103
+ only=args.only,
1104
+ only_index=args.only_index,
1105
+ verbose=not args.quiet,
1106
+ )
1107
+ except FileNotFoundError:
1108
+ print(f"ERROR: input file not found: {args.input}", file=sys.stderr)
1109
+ return 2
1110
+ except Exception as exc: # noqa: BLE001
1111
+ print(f"ERROR: {type(exc).__name__}: {exc}", file=sys.stderr)
1112
+ return 3
1113
+
1114
+ if result is None:
1115
+ # convert() couldn't resolve the track selection.
1116
+ return 4
1117
+
1118
+ print(f"OK: wrote {result}")
1119
+ return 0
1120
+
1121
+
1122
+ if __name__ == "__main__":
1123
+ sys.exit(main())
gp_to_keyswitched_mid.js ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gp_to_keyswitched_mid.js
4
+ * ========================
5
+ * Convert a Guitar Pro 7/8 (.gp) file to per-track Sforzando keyswitched MIDI.
6
+ *
7
+ * PyGuitarPro only reads up to GP5, so this uses alphaTab (which reads GP7/8
8
+ * .gp natively) to extract notes + articulations in absolute-tick space, then
9
+ * emits a JSON event stream that a tiny mido-based writer turns into a Type-1
10
+ * MIDI file. The keyswitch mapping and sustain-reset behaviour mirror
11
+ * gp5_to_keyswitched_mid.py exactly so both tools produce compatible output.
12
+ *
13
+ * INSTALL
14
+ * npm install @coderline/alphatab (in this directory)
15
+ *
16
+ * USAGE
17
+ * node gp_to_keyswitched_mid.js <input.gp> <out_dir> [--include-drums]
18
+ * [--no-sustain-reset] [--no-trim-silence] [--velocity N]
19
+ *
20
+ * One <TrackName>_keyswitched.mid is written per non-drum track into <out_dir>.
21
+ * Tracks that share a name get a "(N)" suffix so files never collide.
22
+ *
23
+ * The script prints a JSON object to stdout that the配套 Python writer
24
+ * consumes; humans can read the per-track summary at the bottom of the output.
25
+ */
26
+
27
+ 'use strict';
28
+
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+ const at = require('@coderline/alphatab');
32
+
33
+ // ---- keyswitch map (same as gp5_to_keyswitched_mid.py) ---------------------
34
+ const KEYSWITCH_MAP = {
35
+ sustain: 17,
36
+ palm_mute: 20,
37
+ harmonic: 9,
38
+ slide_up: 24,
39
+ slide_down: 23,
40
+ slide_in: 27,
41
+ hammer: 26,
42
+ bend: 91,
43
+ };
44
+ const SUSTAIN_KEYSWITCH = 18; // SusUp (cleaner sustain)
45
+ const KEYSWITCH_DURATION_TICKS = 960; // one quarter, clamped later
46
+ const DEFAULT_VELOCITY = 100;
47
+
48
+ // alphaTab Dynamics enum -> MIDI velocity
49
+ // Off=0, ppp=1, pp=2, p=3, mp=4, mf=5, f=6, ff=7, fff=8
50
+ const DYN_VEL = { 1: 16, 2: 32, 3: 48, 4: 64, 5: 80, 6: 96, 7: 112, 8: 127 };
51
+
52
+ const args = process.argv.slice(2);
53
+ if (args.length < 2) {
54
+ console.error('Usage: node gp_to_keyswitched_mid.js <input.gp> <out_dir> [options]');
55
+ process.exit(1);
56
+ }
57
+ const inputPath = args[0];
58
+ const outDir = args[1];
59
+ const opts = {
60
+ includeDrums: args.includes('--include-drums'),
61
+ sustainReset: !args.includes('--no-sustain-reset'),
62
+ trimSilence: !args.includes('--no-trim-silence'),
63
+ fallbackVel: parseInt(args.find((a, i) => args[i - 1] === '--velocity')) || DEFAULT_VELOCITY,
64
+ };
65
+
66
+ function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v | 0)); }
67
+
68
+ function loadScore(file) {
69
+ const bytes = fs.readFileSync(file);
70
+ return at.importer.ScoreLoader.loadScoreFromBytes(bytes, new at.Settings());
71
+ }
72
+
73
+ function isDrumTrack(t) {
74
+ if (t.isPercussionTrack) return true;
75
+ const pi = t.playbackInfo || {};
76
+ if (pi.primaryChannel === 9) return true;
77
+ return /drum|kit|percussion/i.test(t.name || '');
78
+ }
79
+
80
+ function velocityFor(note, beat) {
81
+ const d = note.dynamics ?? beat.dynamics;
82
+ if (d && DYN_VEL[d]) return DYN_VEL[d];
83
+ return opts.fallbackVel;
84
+ }
85
+
86
+ // --- articulation detection -------------------------------------------------
87
+ function detectTechniques(note, beat) {
88
+ const techs = [];
89
+ if (note.isPalmMute || beat.isPalmMute) techs.push('palm_mute');
90
+ if (note.harmonicType) techs.push('harmonic');
91
+ if (note.isHammerPullOrigin) techs.push('hammer');
92
+ if (note.slideInType) techs.push('slide_in');
93
+ // slides out / to a target -> directional slide
94
+ if (note.slideOutType || note.slideTarget) {
95
+ let dir = 'up';
96
+ if (note.slideTarget) {
97
+ dir = note.slideTarget.realValue > note.realValue ? 'up' :
98
+ note.slideTarget.realValue < note.realValue ? 'down' : 'up';
99
+ } else if (note.slideOutType === 2) dir = 'down';
100
+ techs.push(dir === 'down' ? 'slide_down' : 'slide_up');
101
+ }
102
+ if (note.bendType || (note.bendPoints && note.bendPoints.length)) techs.push('bend');
103
+ return techs;
104
+ }
105
+
106
+ // --- tie-chain end-tick map -------------------------------------------------
107
+ // Mirrors _build_tie_chain_map from the GP5 tool: the origin note's note_off
108
+ // extends through every tied continuation on the same (string, pitch).
109
+ function buildNoteEndMap(track, absStartOf) {
110
+ const noteEnd = new Map(); // id(note) -> absolute end tick
111
+ const active = new Map(); // "string|pitch" -> id(origin note)
112
+ for (const st of track.staves) {
113
+ for (let bi = 0; bi < st.bars.length; bi++) {
114
+ const bar = st.bars[bi];
115
+ const barStart = absStartOf(bar);
116
+ for (const voice of bar.voices) {
117
+ if (voice.isEmpty) continue;
118
+ for (const beat of voice.beats) {
119
+ const start = barStart + (beat.playbackStart || 0);
120
+ const dur = beat.playbackDuration || 0;
121
+ for (const note of beat.notes) {
122
+ if (note.isDead) continue;
123
+ const key = note.string + '|' + note.realValue;
124
+ const nd = Math.max(1, Math.round(dur * (note.durationPercent || 1)));
125
+ if (note.isTieDestination) {
126
+ const origin = active.get(key);
127
+ if (origin !== undefined) noteEnd.set(origin, start + nd);
128
+ // tied notes don't get their own note_on/off
129
+ } else {
130
+ noteEnd.set(note.id, start + nd);
131
+ active.set(key, note.id);
132
+ }
133
+ }
134
+ }
135
+ }
136
+ }
137
+ }
138
+ return noteEnd;
139
+ }
140
+
141
+ function buildTrackEvents(track, absStartOf) {
142
+ const channel = (track.playbackInfo && track.playbackInfo.primaryChannel != null)
143
+ ? track.playbackInfo.primaryChannel : 0;
144
+ const ch = channel === 9 ? 0 : channel; // never reuse GM percussion
145
+
146
+ const noteEnd = buildNoteEndMap(track, absStartOf);
147
+
148
+ // raw events: { tick, kind:'note_on'|'note_off', note, velocity, ks?:bool }
149
+ const events = [];
150
+ const pendingSustain = new Set(); // "tick" -> already emitted sustain at this tick
151
+
152
+ for (const st of track.staves) {
153
+ for (let bi = 0; bi < st.bars.length; bi++) {
154
+ const bar = st.bars[bi];
155
+ const barStart = absStartOf(bar);
156
+ for (const voice of bar.voices) {
157
+ if (voice.isEmpty) continue;
158
+ for (const beat of voice.beats) {
159
+ const start = barStart + (beat.playbackStart || 0);
160
+ for (const note of beat.notes) {
161
+ if (note.isDead) continue;
162
+ const tied = note.isTieDestination;
163
+ const techs = detectTechniques(note, beat);
164
+ const ksNotes = [];
165
+ let hasArticulation = false;
166
+ for (const t of techs) {
167
+ const ks = KEYSWITCH_MAP[t];
168
+ if (ks != null) { ksNotes.push(ks); hasArticulation = true; }
169
+ }
170
+ // Tied notes share the previous attack's envelope. They only
171
+ // emit keyswitches when they carry a NEW articulation (e.g. a
172
+ // bend starting on the tied side). A tied note with no
173
+ // articulation emits nothing — no default sustain either.
174
+ if (!tied && !hasArticulation && opts.sustainReset) {
175
+ if (!pendingSustain.has(start)) {
176
+ pendingSustain.add(start);
177
+ ksNotes.push(SUSTAIN_KEYSWITCH);
178
+ }
179
+ }
180
+ const vel = clamp(velocityFor(note, beat), 1, 127);
181
+ for (const ks of ksNotes) {
182
+ events.push({ tick: start, kind: 'note_on', note: ks, velocity: vel, ks: true });
183
+ // sentinel: close at start+DUR (clamped later)
184
+ events.push({ tick: start + KEYSWITCH_DURATION_TICKS, kind: 'keyswitch_off', note: ks, velocity: 0, ks: true });
185
+ }
186
+ if (!tied) {
187
+ const end = noteEnd.has(note.id) ? noteEnd.get(note.id) : (start + Math.max(1, Math.round((beat.playbackDuration || 0) * (note.durationPercent || 1))));
188
+ const pitch = clamp(note.realValue, 0, 127);
189
+ events.push({ tick: start, kind: 'note_on', note: pitch, velocity: vel, ks: false });
190
+ events.push({ tick: end, kind: 'note_off', note: pitch, velocity: 0, ks: false });
191
+ }
192
+ }
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ if (!events.length) return null;
199
+
200
+ // --- clamp keyswitch_off + re-trigger (mirrors the GP5 tool) ------------
201
+ const ksSet = new Set(Object.values(KEYSWITCH_MAP));
202
+ ksSet.add(SUSTAIN_KEYSWITCH);
203
+ events.sort((a, b) => a.tick - b.tick);
204
+
205
+ const openStacks = new Map(); // "ch|note" -> [onTick,...]
206
+ const resolved = [];
207
+ function closeOldest(ch, note, atTick) {
208
+ const key = ch + '|' + note;
209
+ const stack = openStacks.get(key);
210
+ if (!stack || !stack.length) return;
211
+ const onTick = stack.shift();
212
+ if (!stack.length) openStacks.delete(key);
213
+ const cap = onTick + KEYSWITCH_DURATION_TICKS;
214
+ let clamped = Math.min(atTick, cap);
215
+ if (clamped < onTick) clamped = onTick;
216
+ resolved.push({ tick: clamped, kind: 'note_off', note, velocity: 0, ks: true });
217
+ }
218
+ for (const e of events) {
219
+ if (e.kind === 'keyswitch_off') {
220
+ closeOldest(ch, e.note, e.tick);
221
+ } else if (e.kind === 'note_on' && e.ks && ksSet.has(e.note)) {
222
+ closeOldest(ch, e.note, e.tick); // re-trigger closes previous
223
+ resolved.push(e);
224
+ const key = ch + '|' + e.note;
225
+ if (!openStacks.has(key)) openStacks.set(key, []);
226
+ openStacks.get(key).push(e.tick);
227
+ } else if (e.kind === 'note_off' && e.ks && ksSet.has(e.note)) {
228
+ closeOldest(ch, e.note, e.tick);
229
+ } else {
230
+ resolved.push(e);
231
+ }
232
+ }
233
+ let out = resolved;
234
+
235
+ // --- trim leading silence ---------------------------------------------
236
+ if (opts.trimSilence) {
237
+ let earliest = null;
238
+ for (const e of out) {
239
+ if (e.kind === 'note_on' || e.kind === 'note_off') {
240
+ if (earliest === null || e.tick < earliest) earliest = e.tick;
241
+ }
242
+ }
243
+ if (earliest != null && earliest > 0) {
244
+ out = out
245
+ .filter(e => e.tick - earliest >= 0)
246
+ .map(e => ({ ...e, tick: e.tick - earliest }));
247
+ }
248
+ }
249
+ out.sort((a, b) => a.tick - b.tick);
250
+ return { name: track.name || 'Guitar', channel: ch, events: out };
251
+ }
252
+
253
+ function main() {
254
+ const score = loadScore(inputPath);
255
+ const mbStart = new Map(); // bar.index -> start tick
256
+ for (const mb of score.masterBars) mbStart.set(mb.index, mb.start || 0);
257
+ const absStartOf = (bar) => mbStart.get(bar.index) || 0;
258
+
259
+ // tempo + time sig from first master bar
260
+ let tempo = score.tempo || 120;
261
+ const tempoAuto = score.masterBars[0] && score.masterBars[0].tempoAutomations;
262
+ if (tempoAuto && tempoAuto.length && tempoAuto[0].value) tempo = tempoAuto[0].value.bpm || tempo;
263
+ let tsNum = 4, tsDen = 4;
264
+ const mb0 = score.masterBars[0];
265
+ if (mb0) { tsNum = mb0.timeSignatureNumerator || 4; tsDen = mb0.timeSignatureDenominator || 4; }
266
+
267
+ const tracks = [];
268
+ const nameCount = {};
269
+ for (const t of score.tracks) {
270
+ if (isDrumTrack(t) && !opts.includeDrums) continue;
271
+ const built = buildTrackEvents(t, absStartOf);
272
+ if (!built) continue;
273
+ // disambiguate duplicate names
274
+ let name = built.name;
275
+ nameCount[name] = (nameCount[name] || 0) + 1;
276
+ if (nameCount[name] > 1) name = `${name} (${nameCount[name]})`;
277
+ tracks.push({ ...built, name });
278
+ }
279
+
280
+ const result = { input: inputPath, tempo: Math.round(tempo), timeSignature: { numerator: tsNum, denominator: tsDen }, tracks };
281
+ process.stdout.write(JSON.stringify(result));
282
+ }
283
+
284
+ main();
midi_to_gp5.py ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Convert a keyswitched MIDI file (output of gp5_to_keyswitched_mid.py) back
4
+ to a Guitar Pro 5/6 file.
5
+
6
+ This is the inverse of gp5_to_keyswitched_mid.py. The forward script reads
7
+ GP5 articulations from note.effect.* and emits keyswitch notes; this
8
+ script reads keyswitch notes and reconstructs note.effect.* on each
9
+ pitched note.
10
+
11
+ Supported keyswitches (Metal-GTX mapping):
12
+ MIDI 9 (A-1) -> Harmonic (natural)
13
+ MIDI 17 (F0) -> Sustain (default, no effect)
14
+ MIDI 18 (F#0) -> Sustain (default, no effect)
15
+ MIDI 20 (G#0) -> Palm Mute
16
+ MIDI 23 (B0) -> Slide Down
17
+ MIDI 24 (C1) -> Slide Up
18
+ MIDI 26 (D1) -> Hammer
19
+ MIDI 27 (D#1) -> Slide In (grace note)
20
+ MIDI 91 (G6) -> Bend
21
+
22
+ Limitations:
23
+ - Bent note shape is not reconstructed (pitch wheel automation is
24
+ rarely present in keyswitched MIDI; we use a default 1-semitone bend).
25
+ - Slide direction is inferred from the next pitched note on the same
26
+ channel.
27
+ - Slide-in grace notes are placed at the same tick as the main note,
28
+ with a 32nd-note duration.
29
+ - String/fret selection picks the lowest playable fret on the
30
+ appropriate string (heuristic).
31
+ """
32
+ import argparse
33
+ import os
34
+ import sys
35
+ from dataclasses import dataclass, field
36
+
37
+ import guitarpro
38
+ import mido
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Constants — must match gp5_to_keyswitched_mid.py
43
+ # ---------------------------------------------------------------------------
44
+ DEFAULT_KEYSWITCH_MAP = {
45
+ 17: "sustain",
46
+ 18: "sustain",
47
+ 20: "palm_mute",
48
+ 9: "harmonic",
49
+ 23: "slide_down",
50
+ 24: "slide_up",
51
+ 26: "hammer",
52
+ 27: "slide_in",
53
+ 91: "bend",
54
+ }
55
+
56
+ # Standard guitar tuning (low to high): E2 A2 D3 G3 B3 E4
57
+ # In MIDI notes: 40 45 50 55 59 64
58
+ DEFAULT_GUITAR_TUNING = (40, 45, 50, 55, 59, 64)
59
+
60
+ # Standard bass tuning (low to high): E1 A1 D2 G2
61
+ DEFAULT_BASS_TUNING = (28, 33, 38, 43)
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Data structures
66
+ # ---------------------------------------------------------------------------
67
+ @dataclass
68
+ class PitchedNote:
69
+ """A pitched note (MIDI note > 30) reconstructed from the MIDI."""
70
+ pitch: int
71
+ start_tick: int
72
+ duration_ticks: int
73
+ velocity: int
74
+ channel: int
75
+ articulation: str = "sustain" # default; overrides by keyswitch
76
+ slide_target_pitch: int = None # for slide_up / slide_down
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # MIDI parsing
81
+ # ---------------------------------------------------------------------------
82
+ def _parse_midi(midi_path):
83
+ """Read a MIDI file and return (track_name, channel, PitchedNote list).
84
+
85
+ Keyswitch notes (< 30) are matched to the next pitched note on the
86
+ same channel at the same tick (or within a few ticks). The keyswitch
87
+ determines the articulation of that pitched note.
88
+ """
89
+ mid = mido.MidiFile(midi_path)
90
+ if len(mid.tracks) < 2:
91
+ raise ValueError(
92
+ f"MIDI file has only {len(mid.tracks)} tracks; "
93
+ "expected at least 2 (conductor + audio)."
94
+ )
95
+
96
+ audio_track = mid.tracks[1]
97
+ name = audio_track.name or "Track"
98
+
99
+ # Collect all (tick, event) pairs across the audio track.
100
+ events = []
101
+ abs_t = 0
102
+ for msg in audio_track:
103
+ abs_t += msg.time
104
+ if hasattr(msg, "note"):
105
+ events.append((abs_t, msg))
106
+
107
+ # Sort by tick. Within a tick, keep original order (stable).
108
+ events.sort(key=lambda e: e[0])
109
+
110
+ # Walk events and pair on/off.
111
+ # Track open notes per (channel, pitch).
112
+ open_notes = {}
113
+ pending_keyswitches = {} # (channel, pitch) -> first tick it appeared
114
+
115
+ pitched = []
116
+ abs_t = 0
117
+ for msg in audio_track:
118
+ abs_t += msg.time
119
+ if not hasattr(msg, "note"):
120
+ continue
121
+ if msg.type == "note_on" and msg.velocity > 0:
122
+ if msg.note < 30:
123
+ # Keyswitch note_on.
124
+ pending_keyswitches[(msg.channel, msg.note)] = abs_t
125
+ else:
126
+ # Pitched note_on.
127
+ pitch = msg.note
128
+ key = (msg.channel, pitch)
129
+ # Find the keyswitch that precedes this note on the same channel.
130
+ # Prefer the most recent keyswitch on this channel.
131
+ ks_pitch = None
132
+ for ks_p_note, ks_tick in pending_keyswitches.items():
133
+ if ks_p_note[0] == msg.channel and ks_tick <= abs_t:
134
+ if ks_pitch is None or ks_tick > pending_keyswitches.get((msg.channel, ks_pitch), 0):
135
+ ks_pitch = ks_p_note[1]
136
+ articulation = DEFAULT_KEYSWITCH_MAP.get(ks_pitch, "sustain")
137
+ # Consume the keyswitch for this channel.
138
+ if ks_pitch is not None:
139
+ pending_keyswitches.pop((msg.channel, ks_pitch), None)
140
+ # Record as open note.
141
+ open_notes[key] = PitchedNote(
142
+ pitch=pitch,
143
+ start_tick=abs_t,
144
+ duration_ticks=0, # filled on note_off
145
+ velocity=msg.velocity,
146
+ channel=msg.channel,
147
+ articulation=articulation,
148
+ )
149
+ elif msg.type == "note_off" or (msg.type == "note_on" and msg.velocity == 0):
150
+ key = (msg.channel, msg.note)
151
+ if key in open_notes:
152
+ note = open_notes.pop(key)
153
+ note.duration_ticks = abs_t - note.start_tick
154
+ if note.duration_ticks < 1:
155
+ note.duration_ticks = 1
156
+ pitched.append(note)
157
+
158
+ # Resolve slide direction: for each slide_up / slide_down note, find
159
+ # the next pitched note on the same channel and compare pitches.
160
+ for i, note in enumerate(pitched):
161
+ if note.articulation not in ("slide_up", "slide_down"):
162
+ continue
163
+ for later in pitched[i + 1:]:
164
+ if later.channel == note.channel:
165
+ if later.pitch > note.pitch:
166
+ note.slide_target_pitch = later.pitch
167
+ # Direction stays as inferred by keyswitch.
168
+ elif later.pitch < note.pitch:
169
+ note.slide_target_pitch = later.pitch
170
+ break
171
+
172
+ return name, mid.tracks[0], pitched
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # GP5 construction
177
+ # ---------------------------------------------------------------------------
178
+ def _pick_string_fret(pitch, tuning):
179
+ """Pick the lowest playable fret on the appropriate string.
180
+
181
+ Handles notes below the lowest open string by using negative frets
182
+ (Guitar Pro supports frets 0-24, but our tuning may not reach low
183
+ enough notes for some songs). If the pitch is below the lowest open
184
+ string, we drop-tune by transposing the string value down.
185
+ """
186
+ best = None
187
+ for string_idx, open_note in enumerate(tuning):
188
+ fret = pitch - open_note
189
+ if fret < 0 or fret > 24:
190
+ continue
191
+ # Prefer the lowest fret (closest to nut).
192
+ if best is None or fret < best[1]:
193
+ best = (string_idx, fret)
194
+ if best is not None:
195
+ return best
196
+ # Pitch is below all strings. Drop-tune: transpose the lowest string
197
+ # down so the note is reachable. This is a hack for non-standard
198
+ # tunings; ideally the user would pass the right tuning.
199
+ lowest_open = tuning[0]
200
+ fret = pitch - (lowest_open - 12) # drop the lowest string by 12 semitones
201
+ if fret < 0 or fret > 24:
202
+ return None, None
203
+ return (0, fret)
204
+
205
+
206
+ def _articulation_to_effect(note, gp_note):
207
+ """Set gp_note.effect fields based on the articulation."""
208
+ art = note.articulation
209
+ if art == "palm_mute":
210
+ gp_note.effect.palmMute = True
211
+ elif art == "harmonic":
212
+ gp_note.effect.harmonic = guitarpro.NaturalHarmonic()
213
+ elif art in ("slide_up", "slide_down"):
214
+ gp_note.effect.slides = [guitarpro.SlideType.shiftSlideTo]
215
+ elif art == "hammer":
216
+ gp_note.effect.hammer = True
217
+ elif art == "slide_in":
218
+ # Grace note with transition=slide at the target pitch.
219
+ gp_note.effect.grace = guitarpro.GraceEffect(
220
+ duration=32,
221
+ fret=note.pitch % 12,
222
+ isDead=False,
223
+ isOnBeat=True,
224
+ transition=guitarpro.GraceEffectTransition.slide,
225
+ velocity=note.velocity,
226
+ )
227
+ elif art == "bend":
228
+ # Default to a 1-semitone bend. (Pitch wheel automation is not
229
+ # reconstructed from MIDI; this is a placeholder.)
230
+ gp_note.effect.bend = guitarpro.BendEffect(
231
+ type=guitarpro.BendType.bend,
232
+ value=0,
233
+ points=[
234
+ guitarpro.BendPoint(0, 0, False),
235
+ guitarpro.BendPoint(3, 4, False),
236
+ guitarpro.BendPoint(12, 4, False),
237
+ ],
238
+ )
239
+
240
+
241
+ def _ticks_to_duration(ticks, ticks_per_beat, beats_to_grid):
242
+ """Convert ticks to a GP5 Duration object.
243
+
244
+ GP5 represents durations as discrete values (1=whole, 2=half, 4=quarter,
245
+ 8=eighth, 16=sixteenth, 32=thirty-second). We round to the nearest.
246
+ """
247
+ beats = ticks / ticks_per_beat
248
+ value = round(4 / beats) # 4/quarter = 4/1, 4/half = 2, etc.
249
+ value = max(1, min(64, value))
250
+ if value == 1:
251
+ return guitarpro.Duration(value=1, isDotted=False, tuplet=guitarpro.Tuplet(1, 1))
252
+ if value == 3:
253
+ return guitarpro.Duration(value=2, isDotted=True, tuplet=guitarpro.Tuplet(1, 1))
254
+ return guitarpro.Duration(value=value, isDotted=False, tuplet=guitarpro.Tuplet(1, 1))
255
+
256
+
257
+ def _build_track(name, pitched, tuning, channel, ticks_per_beat):
258
+ """Build a GP5 Track from a list of PitchedNotes."""
259
+ song = guitarpro.Song()
260
+ track = guitarpro.Track(song=song)
261
+ track.name = name
262
+ track.channel = guitarpro.MidiChannel(channel=channel, instrument=25)
263
+ track.strings = [guitarpro.GuitarString(number=i + 1, value=t) for i, t in enumerate(tuning)]
264
+ track.isPercussionTrack = False
265
+ track.fretCount = 24
266
+
267
+ # Determine measure count from the latest note.
268
+ if not pitched:
269
+ return track
270
+ max_tick = max(n.start_tick + n.duration_ticks for n in pitched)
271
+ ticks_per_measure = ticks_per_beat * 4 # assume 4/4
272
+ num_measures = max(1, (max_tick + ticks_per_measure - 1) // ticks_per_measure)
273
+
274
+ # Build one Measure per row.
275
+ measures = []
276
+ for measure_idx in range(int(num_measures)):
277
+ measure_start = measure_idx * ticks_per_measure
278
+ measure_end = measure_start + ticks_per_measure
279
+ h = guitarpro.MeasureHeader(
280
+ timeSignature=guitarpro.TimeSignature(
281
+ numerator=4,
282
+ denominator=guitarpro.Duration(value=4, isDotted=False, tuplet=guitarpro.Tuplet(1, 1)),
283
+ ),
284
+ )
285
+ m = guitarpro.Measure(track=track, header=h)
286
+ m.voices[0].beats = []
287
+ # Group notes that start in this measure's beat slots.
288
+ # Create one beat per beat (quarter note). Multiple notes can
289
+ # share a beat (chord).
290
+ beats_in_measure = 4 # 4/4
291
+ beat_positions = []
292
+ for b in range(beats_in_measure):
293
+ beat_start = measure_start + b * ticks_per_beat
294
+ beat_end = beat_start + ticks_per_beat
295
+ # Find notes that start in this beat slot.
296
+ beat_notes = [n for n in pitched
297
+ if beat_start <= n.start_tick < beat_end]
298
+ beat_positions.append((beat_start, beat_notes))
299
+
300
+ for beat_start, beat_notes in beat_positions:
301
+ if not beat_notes:
302
+ # Empty beat: skip (no note, no beat).
303
+ continue
304
+ # Use the first note's duration as the beat duration.
305
+ first = beat_notes[0]
306
+ beat_dur = _ticks_to_duration(first.duration_ticks, ticks_per_beat, 4)
307
+ beat = guitarpro.Beat(
308
+ voice=m.voices[0],
309
+ notes=[],
310
+ duration=beat_dur,
311
+ start=beat_start,
312
+ )
313
+ for note in beat_notes:
314
+ string, fret = _pick_string_fret(note.pitch, tuning)
315
+ if string is None:
316
+ continue
317
+ gp_note = guitarpro.Note(
318
+ beat=beat,
319
+ string=string + 1,
320
+ value=fret,
321
+ velocity=note.velocity,
322
+ type=guitarpro.NoteType.normal,
323
+ )
324
+ _articulation_to_effect(note, gp_note)
325
+ beat.notes.append(gp_note)
326
+ if beat.notes:
327
+ m.voices[0].beats.append(beat)
328
+
329
+ measures.append(m)
330
+
331
+ track.measures = measures
332
+ return track
333
+
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # Main
337
+ # ---------------------------------------------------------------------------
338
+ def main():
339
+ parser = argparse.ArgumentParser(
340
+ description="Convert a keyswitched MIDI back to a Guitar Pro 5/6 file.",
341
+ )
342
+ parser.add_argument("midi", help="Path to the input MIDI file.")
343
+ parser.add_argument("output", help="Path to the output .gp5/.gp file.")
344
+ parser.add_argument("--instrument", choices=["guitar", "bass", "drums"],
345
+ default="guitar")
346
+ args = parser.parse_args()
347
+
348
+ midi_path = os.path.abspath(args.midi)
349
+ output_path = os.path.abspath(args.output)
350
+
351
+ if not os.path.exists(midi_path):
352
+ print(f"ERROR: MIDI file not found: {midi_path}", file=sys.stderr)
353
+ return 1
354
+
355
+ print(f"Reading {midi_path}...")
356
+ name, conductor_track, pitched = _parse_midi(midi_path)
357
+ print(f" Track name: {name!r}")
358
+ print(f" Pitched notes: {len(pitched)}")
359
+ # Count by articulation.
360
+ from collections import Counter
361
+ arts = Counter(n.articulation for n in pitched)
362
+ for art, count in arts.most_common():
363
+ print(f" {art:12s} {count}")
364
+
365
+ if not pitched:
366
+ print("ERROR: no pitched notes found in MIDI", file=sys.stderr)
367
+ return 1
368
+
369
+ # Get ticks_per_beat from MIDI.
370
+ mid = mido.MidiFile(midi_path)
371
+ ticks_per_beat = mid.ticks_per_beat
372
+
373
+ # Choose tuning.
374
+ if args.instrument == "guitar":
375
+ tuning = DEFAULT_GUITAR_TUNING
376
+ elif args.instrument == "bass":
377
+ tuning = DEFAULT_BASS_TUNING
378
+ else:
379
+ tuning = DEFAULT_GUITAR_TUNING # ignored for drums
380
+
381
+ # Determine channel from the first note.
382
+ channel = pitched[0].channel
383
+
384
+ # Build the song.
385
+ song = guitarpro.Song()
386
+ song.tempo = 120 # overwritten below
387
+ # Extract tempo from conductor.
388
+ for msg in conductor_track:
389
+ if msg.type == "set_tempo":
390
+ song.tempo = int(60_000_000 / msg.tempo)
391
+ break
392
+
393
+ # Song starts with a default Track 1; replace it with our built track.
394
+ track = _build_track(
395
+ name=name, pitched=pitched, tuning=tuning,
396
+ channel=channel, ticks_per_beat=ticks_per_beat,
397
+ )
398
+ # The Track already has a MidiChannel from the build helper; ensure
399
+ # it points at the source file's channel.
400
+ track.channel = guitarpro.MidiChannel(channel=channel, instrument=25)
401
+ song.tracks[0] = track
402
+
403
+ print(f"Writing {output_path}...")
404
+ parent = os.path.dirname(output_path)
405
+ if parent:
406
+ os.makedirs(parent, exist_ok=True)
407
+ guitarpro.write(song, output_path)
408
+ print(f"OK: wrote {output_path}")
409
+ return 0
410
+
411
+
412
+ if __name__ == "__main__":
413
+ sys.exit(main())
midjson_to_mid.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ midjson_to_mid.py
4
+ =================
5
+ Consume the JSON event stream emitted by gp_to_keyswitched_mid.js and write
6
+ one Type-1 MIDI file per track into <out_dir>, named <TrackName>_keyswitched.mid.
7
+
8
+ Usage:
9
+ node gp_to_keyswitched_mid.js input.gp out_dir > events.json
10
+ python midjson_to_mid.py events.json <out_dir>
11
+ """
12
+ import json
13
+ import os
14
+ import re
15
+ import sys
16
+
17
+ import mido
18
+
19
+ _FORBIDDEN = re.compile(r'[\\/:\*\?"<>\|]+')
20
+ _WS = re.compile(r'\s+')
21
+
22
+
23
+ def sanitize(name, default="output"):
24
+ cleaned = _FORBIDDEN.sub(' ', name or '')
25
+ cleaned = _WS.sub(' ', cleaned).strip().rstrip('. ')
26
+ return cleaned or default
27
+
28
+
29
+ def write_track(mid, name, channel, events, ticks_per_beat=960):
30
+ track = mid.add_track(name[:127] if name else "Guitar")
31
+ last = 0
32
+ for e in events:
33
+ tick = max(0, int(e['tick']))
34
+ kind = e['kind']
35
+ note = int(e['note'])
36
+ vel = int(e.get('velocity', 0))
37
+ if kind == 'note_on' and vel > 0:
38
+ msg = mido.Message('note_on', channel=channel, note=note,
39
+ velocity=max(1, min(127, vel)), time=tick - last)
40
+ else:
41
+ msg = mido.Message('note_off', channel=channel, note=note,
42
+ velocity=0, time=tick - last)
43
+ track.append(msg)
44
+ last = tick
45
+ return track
46
+
47
+
48
+ def main():
49
+ if len(sys.argv) < 3:
50
+ print("Usage: python midjson_to_mid.py <events.json> <out_dir>",
51
+ file=sys.stderr)
52
+ return 2
53
+ events_path = sys.argv[1]
54
+ out_dir = sys.argv[2]
55
+ with open(events_path, 'r', encoding='utf-8') as f:
56
+ data = json.load(f)
57
+
58
+ os.makedirs(out_dir, exist_ok=True)
59
+ tempo = int(data.get('tempo', 120))
60
+ ts = data.get('timeSignature', {})
61
+ ts_num = int(ts.get('numerator', 4))
62
+ ts_den = int(ts.get('denominator', 4))
63
+
64
+ written = []
65
+ for tr in data.get('tracks', []):
66
+ events = tr['events']
67
+ if not events:
68
+ continue
69
+ mid = mido.MidiFile(type=1, ticks_per_beat=960)
70
+ conductor = mid.add_track("Conductor")
71
+ conductor.append(mido.MetaMessage(
72
+ 'set_tempo', tempo=mido.bpm2tempo(tempo), time=0))
73
+ conductor.append(mido.MetaMessage(
74
+ 'time_signature', numerator=ts_num, denominator=ts_den, time=0))
75
+ write_track(mid, tr['name'], int(tr['channel']), events)
76
+ fname = sanitize(tr['name']) + "_keyswitched.mid"
77
+ out_path = os.path.join(out_dir, fname)
78
+ mid.save(out_path)
79
+ # stats
80
+ ks_notes = sum(1 for e in events if e['kind'] == 'note_on' and e.get('ks'))
81
+ played = sum(1 for e in events if e['kind'] == 'note_on' and not e.get('ks'))
82
+ written.append((out_path, tr['name'], tr['channel'], len(events), ks_notes, played))
83
+
84
+ for out_path, name, ch, nev, ks, played in written:
85
+ size = os.path.getsize(out_path)
86
+ print(f" {os.path.basename(out_path)} ({size:,}B, ch={ch}) "
87
+ f"events={nev} keyswitch_on={ks} played_notes={played}")
88
+ print(f"\nWrote {len(written)} file(s) to {out_dir}")
89
+ return 0
90
+
91
+
92
+ if __name__ == '__main__':
93
+ sys.exit(main())
package.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "stable-jam",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Jam Buddy — an AI music companion that listens to what you play and joins in, at your tempo, in the instrument you pick. Built for the Stability AI Challenge at Music Hackspace Montreal.",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=20",
9
+ "pnpm": ">=9"
10
+ },
11
+ "packageManager": "pnpm@9.15.9",
12
+ "workspaces": [
13
+ "apps/*",
14
+ "packages/*",
15
+ "services/*"
16
+ ],
17
+ "scripts": {
18
+ "dev": "pnpm --filter @patterntalk/web dev",
19
+ "build": "pnpm -r build",
20
+ "test": "pnpm -r test",
21
+ "test:web": "pnpm --filter @patterntalk/web test",
22
+ "lint": "pnpm -r lint",
23
+ "typecheck": "pnpm -r typecheck",
24
+ "clean": "pnpm -r exec rm -rf node_modules dist .next"
25
+ },
26
+ "devDependencies": {
27
+ "typescript": "^5.6.3"
28
+ }
29
+ }