Sync from GitHub 6feaf31d
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +96 -0
- Dockerfile +48 -0
- MIDI_TO_GP5.md +76 -0
- README.md +88 -10
- apps/web/.eslintrc.json +8 -0
- apps/web/__tests__/conversation.test.ts +118 -0
- apps/web/__tests__/engine.test.ts +166 -0
- apps/web/__tests__/player.test.ts +43 -0
- apps/web/__tests__/prompt.test.ts +81 -0
- apps/web/app/api/jambuddy/detect/route.ts +98 -0
- apps/web/app/api/jambuddy/route.ts +248 -0
- apps/web/app/globals.css +320 -0
- apps/web/app/layout.tsx +31 -0
- apps/web/app/page.tsx +628 -0
- apps/web/data/onomatopoeia.json +39 -0
- apps/web/data/patterns/d-beat.json +28 -0
- apps/web/data/patterns/skank.json +20 -0
- apps/web/lib/jambuddy/player.ts +209 -0
- apps/web/lib/jambuddy/prompt.ts +287 -0
- apps/web/lib/jambuddy/recorder.ts +156 -0
- apps/web/lib/jambuddy/visualizer.tsx +195 -0
- apps/web/lib/midi/generator.ts +106 -0
- apps/web/lib/patterns/engine.ts +273 -0
- apps/web/lib/voice/conversation.ts +219 -0
- apps/web/next-env.d.ts +5 -0
- apps/web/next.config.mjs +16 -0
- apps/web/package.json +36 -0
- apps/web/postcss.config.mjs +6 -0
- apps/web/scripts/_debug_jambuddy.cjs +28 -0
- apps/web/tailwind.config.ts +29 -0
- apps/web/tsconfig.json +19 -0
- apps/web/vitest.config.ts +17 -0
- docs/01-vision.md +90 -0
- docs/02-architecture.md +271 -0
- docs/03-data-model.md +400 -0
- docs/04-ux-voice-first.md +388 -0
- docs/05-accessibility.md +294 -0
- docs/06-stable-audio-integration.md +466 -0
- docs/07-reaper-integration.md +305 -0
- docs/08-build-plan.md +244 -0
- docs/09-risks.md +314 -0
- docs/10-team-pitch.md +239 -0
- docs/CONTRIBUTING.md +160 -0
- docs/HANDOFF.md +144 -0
- docs/HARNESS.md +55 -0
- gp5_to_keyswitched_mid.py +1123 -0
- gp_to_keyswitched_mid.js +284 -0
- midi_to_gp5.py +413 -0
- midjson_to_mid.py +93 -0
- 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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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't read the notes, so tell us what you'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 |
+
}
|