Skip to content

Commit dcc1e38

Browse files
adewaleclaude
andcommitted
feat: Phase 27 - MIDI Export implementation
Implements full MIDI export functionality per specs/MIDI-EXPORT.md: Core Features: - Export sessions as Standard MIDI Files (SMF Type 1, 128 PPQN) - Drum tracks on channel 10 with GM drum note mappings - Synth tracks on channels 1-9, 11-16 with GM program changes - Swing timing applied to off-beat steps - Polyrhythm support via LCM pattern expansion - Track selection parity with audio scheduler (solo wins over mute) - Volume p-locks exported as MIDI velocity - Pitch p-locks + transpose exported with 0-127 clamping UI: - "Export MIDI" button in session controls - File System Access API for save dialog (Chrome/Edge) - Fallback auto-download for Firefox/Safari Testing: - 121 unit tests for helper functions and mappings - 52 fidelity tests with binary MIDI parsing - Verification script for manual testing Documentation: - docs/MIDI-MAPPINGS.md with complete instrument mapping reference - DAW import instructions for GarageBand, Logic, Ableton, FL Studio 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7971d75 commit dcc1e38

File tree

11 files changed

+2445
-0
lines changed

11 files changed

+2445
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
app/.wrangler/
2+
app/test-output.mid

app/package-lock.json

Lines changed: 43 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
},
2929
"dependencies": {
3030
"@types/qrcode": "^1.5.6",
31+
"midi-writer-js": "^3.1.1",
3132
"qrcode": "^1.5.4",
3233
"react": "^19.2.1",
3334
"react-dom": "^19.2.1",
@@ -48,6 +49,7 @@
4849
"husky": "^9.1.7",
4950
"jsdom": "^27.3.0",
5051
"lint-staged": "^16.2.7",
52+
"midi-file": "^1.2.4",
5153
"tsx": "^4.21.0",
5254
"typescript": "~5.9.3",
5355
"typescript-eslint": "^8.46.4",

app/scripts/verify-midi.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/**
2+
* MIDI Export Verification Script
3+
* Run with: npx tsx scripts/verify-midi.ts
4+
*/
5+
import { exportToMidi } from '../src/audio/midiExport';
6+
import { parseMidi } from 'midi-file';
7+
import type { MidiData } from 'midi-file';
8+
import type { Track, GridState } from '../src/types';
9+
import * as fs from 'fs';
10+
11+
// Create a test session with various track types
12+
const testTracks: Track[] = [
13+
{
14+
id: 'kick-track',
15+
name: 'Kick',
16+
sampleId: 'kick',
17+
steps: [true, false, false, false, true, false, false, false, true, false, false, false, true, false, false, false],
18+
parameterLocks: Array(16).fill(null),
19+
volume: 1,
20+
muted: false,
21+
soloed: false,
22+
stepCount: 16,
23+
},
24+
{
25+
id: 'snare-track',
26+
name: 'Snare',
27+
sampleId: 'snare',
28+
steps: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false],
29+
parameterLocks: Array(16).fill(null),
30+
volume: 1,
31+
muted: false,
32+
soloed: false,
33+
stepCount: 16,
34+
},
35+
{
36+
id: 'bass-track',
37+
name: 'Bass',
38+
sampleId: 'synth:bass',
39+
steps: [true, false, false, false, false, false, true, false, true, false, false, false, false, false, true, false],
40+
parameterLocks: [null, null, null, null, null, null, { pitch: -12 }, null, null, null, null, null, null, null, { pitch: 7 }, null],
41+
volume: 0.8,
42+
muted: false,
43+
soloed: false,
44+
stepCount: 16,
45+
transpose: -12,
46+
},
47+
{
48+
id: 'muted-track',
49+
name: 'Muted Lead',
50+
sampleId: 'synth:lead',
51+
steps: [true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true],
52+
parameterLocks: Array(16).fill(null),
53+
volume: 1,
54+
muted: true, // Should NOT be exported
55+
soloed: false,
56+
stepCount: 16,
57+
},
58+
];
59+
60+
const testState: Pick<GridState, 'tracks' | 'tempo' | 'swing'> = {
61+
tempo: 120,
62+
swing: 25,
63+
tracks: testTracks,
64+
};
65+
66+
// Type for MIDI events we care about
67+
interface MidiNoteOn {
68+
type: 'noteOn';
69+
channel: number;
70+
noteNumber: number;
71+
velocity: number;
72+
}
73+
74+
interface MidiTrackName {
75+
type: 'trackName';
76+
text: string;
77+
}
78+
79+
interface MidiProgramChange {
80+
type: 'programChange';
81+
channel: number;
82+
programNumber: number;
83+
}
84+
85+
interface MidiSetTempo {
86+
type: 'setTempo';
87+
microsecondsPerBeat: number;
88+
}
89+
90+
type MidiEvent = { type: string; deltaTime: number } & Record<string, unknown>;
91+
92+
// Export to MIDI
93+
const result = exportToMidi(testState, { sessionName: 'test-session' });
94+
95+
// Parse and analyze
96+
const midi: MidiData = parseMidi(result._midiData);
97+
98+
console.log('='.repeat(60));
99+
console.log('MIDI EXPORT VERIFICATION');
100+
console.log('='.repeat(60));
101+
102+
// File info
103+
console.log('\n📁 FILE INFO');
104+
const formatOk = midi.header.format === 1;
105+
const ppqnOk = midi.header.ticksPerBeat === 128;
106+
console.log(' Format: Type ' + midi.header.format + ' (' + (formatOk ? 'Multi-track ✓' : 'ERROR') + ')');
107+
console.log(' PPQN: ' + midi.header.ticksPerBeat + ' (' + (ppqnOk ? '✓' : 'ERROR - expected 128') + ')');
108+
console.log(' Tracks: ' + midi.header.numTracks + ' (expected 4: tempo + kick + snare + bass)');
109+
console.log(' Filename: ' + result.filename);
110+
111+
// Track details
112+
console.log('\n🎵 TRACKS');
113+
for (let i = 0; i < midi.tracks.length; i++) {
114+
const track = midi.tracks[i] as MidiEvent[];
115+
const trackNameEvent = track.find((e): e is MidiEvent & MidiTrackName => e.type === 'trackName');
116+
const trackName = trackNameEvent?.text || 'Unnamed';
117+
const noteOns = track.filter((e): e is MidiEvent & MidiNoteOn => e.type === 'noteOn' && (e as MidiNoteOn).velocity > 0);
118+
const programChange = track.find((e): e is MidiEvent & MidiProgramChange => e.type === 'programChange');
119+
const tempo = track.find((e): e is MidiEvent & MidiSetTempo => e.type === 'setTempo');
120+
121+
console.log('\n Track ' + i + ': ' + trackName);
122+
if (tempo) {
123+
const bpm = Math.round(60000000 / tempo.microsecondsPerBeat);
124+
console.log(' Tempo: ' + bpm + ' BPM ' + (bpm === 120 ? '✓' : 'ERROR'));
125+
}
126+
if (programChange) {
127+
console.log(' Program: ' + programChange.programNumber + ' (channel ' + programChange.channel + ')');
128+
}
129+
if (noteOns.length > 0) {
130+
const channels = [...new Set(noteOns.map((n) => n.channel))];
131+
const pitches = [...new Set(noteOns.map((n) => n.noteNumber))].sort((a, b) => a - b);
132+
console.log(' Notes: ' + noteOns.length);
133+
console.log(' Channels: ' + channels.join(', '));
134+
console.log(' Pitches: ' + pitches.join(', '));
135+
}
136+
}
137+
138+
// Verify muted track exclusion
139+
const allNotes = midi.tracks.flatMap((t) =>
140+
(t as MidiEvent[]).filter((e): e is MidiEvent & MidiNoteOn => e.type === 'noteOn' && (e as MidiNoteOn).velocity > 0)
141+
);
142+
console.log('\n🔇 MUTE/SOLO VERIFICATION');
143+
console.log(' Total notes in file: ' + allNotes.length);
144+
console.log(' Expected: 4 (kick) + 2 (snare) + 4 (bass) = 10 notes');
145+
console.log(' Muted track excluded: ' + (allNotes.length <= 10 ? 'YES ✓' : 'NO - ERROR!'));
146+
147+
// Verify drum channel (midi-file uses 0-indexed channels, so channel 10 = index 9)
148+
const DRUM_CHANNEL_INDEX = 9; // Channel 10 in 0-indexed
149+
const drumNotes = allNotes.filter((n) => n.channel === DRUM_CHANNEL_INDEX);
150+
console.log('\n🥁 DRUM CHANNEL VERIFICATION');
151+
console.log(' Notes on channel 10 (index 9): ' + drumNotes.length + ' (expected 6: 4 kick + 2 snare)');
152+
console.log(' Drum channel check: ' + (drumNotes.length === 6 ? '✓' : 'ERROR'));
153+
console.log(' Drum pitches: ' + [...new Set(drumNotes.map((n) => n.noteNumber))].join(', '));
154+
console.log(' Expected: 36 (kick), 38 (snare)');
155+
156+
// Verify synth channel (not drum channel)
157+
const synthNotes = allNotes.filter((n) => n.channel !== DRUM_CHANNEL_INDEX);
158+
console.log('\n🎹 SYNTH CHANNEL VERIFICATION');
159+
console.log(' Notes NOT on drum channel: ' + synthNotes.length + ' (expected 4 bass notes)');
160+
console.log(' Synth channel check: ' + (synthNotes.length === 4 ? '✓' : 'ERROR'));
161+
console.log(' Synth channels used: ' + [...new Set(synthNotes.map((n) => n.channel + 1))].join(', ') + ' (1-indexed)');
162+
console.log(' Synth pitches: ' + [...new Set(synthNotes.map((n) => n.noteNumber))].join(', '));
163+
console.log(' Expected: 48 (C3), 36 (C2 via p-lock), 55 (G3 via p-lock)');
164+
165+
// Save for manual inspection
166+
const outPath = './test-output.mid';
167+
fs.writeFileSync(outPath, Buffer.from(result._midiData));
168+
console.log('\n💾 Saved to: ' + outPath);
169+
console.log(' Open with: open test-output.mid (macOS)');
170+
console.log(' Or import into GarageBand/Logic/Ableton');
171+
172+
console.log('\n' + '='.repeat(60));
173+
console.log('VERIFICATION COMPLETE');
174+
console.log('='.repeat(60));

app/src/App.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,12 @@
302302
color: #4ecdc4;
303303
}
304304

305+
/* Phase 27: MIDI download button */
306+
.download-btn:hover:not(:disabled) {
307+
border-color: #3498db;
308+
color: #3498db;
309+
}
310+
305311
/* Phase 21: Invite button - outline style (visually distinct) */
306312
.invite-btn {
307313
background: transparent;

app/src/App.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { MAX_TRACKS } from './types'
2525
import type { Track } from './types'
2626
import { logger } from './utils/logger'
2727
import { copyToClipboard } from './utils/clipboard'
28+
import { downloadMidi } from './audio/midiExport'
2829
import './App.css'
2930

3031
// Feature flags - recording is hidden (Shared Sample Recording archived)
@@ -313,6 +314,13 @@ function SessionControls({ children }: SessionControlsProps) {
313314
>
314315
New
315316
</button>
317+
<button
318+
className="session-btn download-btn"
319+
onClick={() => downloadMidi(state, sessionName)}
320+
title="Export session as MIDI file"
321+
>
322+
Export MIDI
323+
</button>
316324
{/* Phase 21: No Invite button on published sessions (spec line 298) */}
317325
{!isPublished && (
318326
<div className="share-dropdown-container">

0 commit comments

Comments
 (0)