Files
KGStudio/src/util/midiUtil.ts
T

1331 lines
42 KiB
TypeScript

import { DEBUG_MODE } from '../constants/uiConstants';
import type { TimeSignature } from '../types/projectTypes';
import { KGProject, type KeySignature } from '../core/KGProject';
import { KGMidiTrack, type InstrumentType } from '../core/track/KGMidiTrack';
import { FLUIDR3_INSTRUMENT_MAP, STANDARD_MIDI_INSTRUMENT_MAP } from '../constants/generalMidiConstants';
import { KGMidiRegion } from '../core/region/KGMidiRegion';
import { KGMidiNote } from '../core/midi/KGMidiNote';
import { generateUniqueId } from './miscUtil';
import {
MIDI_HEADER,
MIDI_TRACK,
MIDI_TIMING,
MIDI_EVENTS,
MIDI_VELOCITY,
MIDI_TIME_SIGNATURE,
MIDI_KEY_SIGNATURE,
MIDI_UTILS
} from '../constants/midiConstants';
export const pianoRollIndexToPitch = (index: number) => {
return 107 /* MIDI note B7 */ - index;
};
export const MIDI_EVENT_TICKS_PER_BEAT = 480;
export const MIDI_PITCH_BEND_MIN = 0;
export const MIDI_PITCH_BEND_CENTER = 8192;
export const MIDI_PITCH_BEND_MAX = 16383;
export const MIDI_PITCH_BEND_MIN_SIGNED = -8192;
export const MIDI_PITCH_BEND_MAX_SIGNED = 8191;
export const MIDI_CONTROLLER_MIN = 0;
export const MIDI_CONTROLLER_MAX = 127;
export const pitchToNoteName = (pitch: number) => {
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
return {
note: noteNames[pitch % 12],
octave: Math.floor(pitch / 12) - 1
};
};
export const pitchToNoteNameString = (pitch: number) => {
const { note, octave } = pitchToNoteName(pitch);
return `${note}${octave}`;
};
export const clampMidiPitchBendValue = (value: number): number => (
Math.max(MIDI_PITCH_BEND_MIN, Math.min(MIDI_PITCH_BEND_MAX, Math.round(value)))
);
export const clampMidiControllerValue = (value: number): number => (
Math.max(MIDI_CONTROLLER_MIN, Math.min(MIDI_CONTROLLER_MAX, Math.round(value)))
);
export const midiPitchBendToSignedValue = (value: number): number => (
clampMidiPitchBendValue(value) - MIDI_PITCH_BEND_CENTER
);
export const signedPitchBendToMidiValue = (value: number): number => (
clampMidiPitchBendValue(value + MIDI_PITCH_BEND_CENTER)
);
export const midiPitchBendToNormalized = (value: number): number => (
midiPitchBendToSignedValue(value) / MIDI_PITCH_BEND_CENTER
);
export const noteNameToPitch = (noteName: string): number => {
const noteMap: { [key: string]: number } = {
'C': 0, 'C#': 1, 'Cb': -1, 'D': 2, 'D#': 3, 'Db': 1, 'E': 4, 'E#': 5, 'Eb': 3,
'F': 5, 'F#': 6, 'Fb': 4, 'G': 7, 'G#': 8, 'Gb': 6, 'A': 9, 'A#': 10, 'Ab': 8,
'B': 11, 'B#': 12, 'Bb': 10
};
// Parse note name (e.g., "C4", "F#2", "Cb3", "A#7")
const match = noteName.trim().match(/^([A-G](?:#|b)?)(-?\d+)$/);
if (!match) {
throw new Error(`Invalid note name: ${noteName}`);
}
const note = match[1];
const octave = parseInt(match[2], 10);
if (!(note in noteMap)) {
throw new Error(`Invalid note: ${note}`);
}
const pitch = noteMap[note] + (octave + 1) * 12;
if (pitch < 0 || pitch > 127) {
throw new Error(`Note out of MIDI range: ${noteName}`);
}
return pitch;
};
export const beatsToBar = (beats: number, timeSignature: TimeSignature) => {
return {
bar: Math.floor(beats / timeSignature.numerator),
beatInBar: beats % timeSignature.numerator
};
};
export const formatMidiEventPosition = (
beats: number,
timeSignature: TimeSignature,
ticksPerBeat: number = MIDI_EVENT_TICKS_PER_BEAT
): string => {
const { bar, beatInBar } = beatsToBar(beats, timeSignature);
const beatInteger = Math.floor(beatInBar);
let tick = Math.round((beatInBar - beatInteger) * ticksPerBeat);
let normalizedBeat = beatInteger;
let normalizedBar = bar;
if (tick >= ticksPerBeat) {
tick = 0;
normalizedBeat += 1;
}
if (normalizedBeat >= timeSignature.numerator) {
normalizedBeat = 0;
normalizedBar += 1;
}
return `${normalizedBar + 1} ${normalizedBeat + 1} ${tick}`;
};
export const formatMidiEventLength = (
beats: number,
ticksPerBeat: number = MIDI_EVENT_TICKS_PER_BEAT
): string => {
const fullBeats = Math.floor(beats);
let tick = Math.round((beats - fullBeats) * ticksPerBeat);
let normalizedBeats = fullBeats;
if (tick >= ticksPerBeat) {
tick = 0;
normalizedBeats += 1;
}
return `${normalizedBeats} ${tick}`;
};
export type MidiEventPositionParseResult =
| { absoluteBeat: number }
| { error: string };
export const parseMidiEventPosition = (
raw: string,
timeSignature: TimeSignature,
ticksPerBeat: number = MIDI_EVENT_TICKS_PER_BEAT
): MidiEventPositionParseResult => {
const match = raw.trim().match(/^(\d+)\s+(\d+)\s+(\d+)$/);
if (!match) {
return { error: 'Use Position as "bar beat tick", for example "4 2 120".' };
}
const bar = parseInt(match[1], 10);
const beat = parseInt(match[2], 10);
let tick = parseInt(match[3], 10);
if (bar < 1) {
return { error: 'Bar number must be 1 or greater.' };
}
if (beat < 1 || beat > timeSignature.numerator) {
return { error: `Beat must be between 1 and ${timeSignature.numerator} for the current time signature.` };
}
if (tick < 0 || tick > ticksPerBeat) {
return { error: `Tick must be between 0 and ${ticksPerBeat}.` };
}
let normalizedBar = bar;
let normalizedBeat = beat;
if (tick === ticksPerBeat) {
tick = 0;
normalizedBeat += 1;
if (normalizedBeat > timeSignature.numerator) {
normalizedBeat = 1;
normalizedBar += 1;
}
}
const absoluteBeat = ((normalizedBar - 1) * timeSignature.numerator) +
(normalizedBeat - 1) +
(tick / ticksPerBeat);
return { absoluteBeat };
};
export type MidiEventPositionDeltaParseResult =
| { deltaBeats: number }
| { error: string };
export const parseMidiEventPositionDelta = (
raw: string,
timeSignature: TimeSignature,
ticksPerBeat: number = MIDI_EVENT_TICKS_PER_BEAT
): MidiEventPositionDeltaParseResult => {
const match = raw.trim().match(/^([+-])(\d+)\s+(\d+)\s+(\d+)$/);
if (!match) {
return { error: 'Use position delta as "+bars beats tick" or "-bars beats tick", for example "+0 1 120".' };
}
const sign = match[1] === '-' ? -1 : 1;
const bars = parseInt(match[2], 10);
const beats = parseInt(match[3], 10);
let tick = parseInt(match[4], 10);
if (beats < 0 || beats > timeSignature.numerator) {
return { error: `Delta beat component must be between 0 and ${timeSignature.numerator}.` };
}
if (tick < 0 || tick > ticksPerBeat) {
return { error: `Delta tick must be between 0 and ${ticksPerBeat}.` };
}
let normalizedBars = bars;
let normalizedBeats = beats;
if (tick === ticksPerBeat) {
tick = 0;
normalizedBeats += 1;
if (normalizedBeats >= timeSignature.numerator) {
normalizedBars += Math.floor(normalizedBeats / timeSignature.numerator);
normalizedBeats = normalizedBeats % timeSignature.numerator;
}
}
const deltaBeats = sign * (
(normalizedBars * timeSignature.numerator) +
normalizedBeats +
(tick / ticksPerBeat)
);
return { deltaBeats };
};
export type MidiEventLengthParseResult =
| { duration: number }
| { error: string };
export const parseMidiEventLength = (
raw: string,
ticksPerBeat: number = MIDI_EVENT_TICKS_PER_BEAT
): MidiEventLengthParseResult => {
const match = raw.trim().match(/^(\d+)\s+(\d+)$/);
if (!match) {
return { error: 'Use Length as "beats tick", for example "1 240".' };
}
let beats = parseInt(match[1], 10);
let tick = parseInt(match[2], 10);
if (beats < 0) {
return { error: 'Length beats must be 0 or greater.' };
}
if (tick < 0 || tick > ticksPerBeat) {
return { error: `Length tick must be between 0 and ${ticksPerBeat}.` };
}
if (tick === ticksPerBeat) {
tick = 0;
beats += 1;
}
const duration = beats + (tick / ticksPerBeat);
if (duration <= 0) {
return { error: 'Length must be greater than 0.' };
}
return { duration };
};
export type MidiEventLengthDeltaParseResult =
| { deltaBeats: number }
| { error: string };
export const parseMidiEventLengthDelta = (
raw: string,
ticksPerBeat: number = MIDI_EVENT_TICKS_PER_BEAT
): MidiEventLengthDeltaParseResult => {
const match = raw.trim().match(/^([+-])(\d+)\s+(\d+)$/);
if (!match) {
return { error: 'Use length delta as "+beats tick" or "-beats tick", for example "+1 240".' };
}
const sign = match[1] === '-' ? -1 : 1;
let beats = parseInt(match[2], 10);
let tick = parseInt(match[3], 10);
if (tick < 0 || tick > ticksPerBeat) {
return { error: `Length delta tick must be between 0 and ${ticksPerBeat}.` };
}
if (tick === ticksPerBeat) {
tick = 0;
beats += 1;
}
return { deltaBeats: sign * (beats + (tick / ticksPerBeat)) };
};
export const midiPercussionKeyMap: Record<number, { fullName: string; shortName: string }> = {
35: { fullName: 'Acoustic Bass Drum', shortName: 'Ac.Bass' },
36: { fullName: 'Bass Drum 1', shortName: 'BassDrum' },
37: { fullName: 'Side Stick', shortName: 'SideStick' },
38: { fullName: 'Acoustic Snare', shortName: 'Ac.Snare' },
39: { fullName: 'Hand Clap', shortName: 'HandClap' },
40: { fullName: 'Electric Snare', shortName: 'ElecSnare' },
41: { fullName: 'Low Floor Tom', shortName: 'LowFloor' },
42: { fullName: 'Closed Hi Hat', shortName: 'ClosedHH' },
43: { fullName: 'High Floor Tom', shortName: 'HighFloor' },
44: { fullName: 'Pedal Hi-Hat', shortName: 'PedalHH' },
45: { fullName: 'Low Tom', shortName: 'LowTom' },
46: { fullName: 'Open Hi-Hat', shortName: 'OpenHH' },
47: { fullName: 'Low-Mid Tom', shortName: 'LowMidTom' },
48: { fullName: 'Hi Mid Tom', shortName: 'HiMidTom' },
49: { fullName: 'Crash Cymbal 1', shortName: 'Crash1' },
50: { fullName: 'High Tom', shortName: 'HighTom' },
51: { fullName: 'Ride Cymbal 1', shortName: 'Ride1' },
52: { fullName: 'Chinese Cymbal', shortName: 'Chinese' },
53: { fullName: 'Ride Bell', shortName: 'RideBell' },
54: { fullName: 'Tambourine', shortName: 'Tambourine' },
55: { fullName: 'Splash Cymbal', shortName: 'Splash' },
56: { fullName: 'Cowbell', shortName: 'Cowbell' },
57: { fullName: 'Crash Cymbal 2', shortName: 'Crash2' },
58: { fullName: 'Vibraslap', shortName: 'Vibraslap' },
59: { fullName: 'Ride Cymbal 2', shortName: 'Ride2' },
60: { fullName: 'Hi Bongo', shortName: 'HiBongo' },
61: { fullName: 'Low Bongo', shortName: 'LowBongo' },
62: { fullName: 'Mute Hi Conga', shortName: 'MuteHiCon' },
63: { fullName: 'Open Hi Conga', shortName: 'OpenHiCon' },
64: { fullName: 'Low Conga', shortName: 'LowConga' },
65: { fullName: 'High Timbale', shortName: 'HighTimba' },
66: { fullName: 'Low Timbale', shortName: 'LowTimba' },
67: { fullName: 'High Agogo', shortName: 'HighAgo' },
68: { fullName: 'Low Agogo', shortName: 'LowAgo' },
69: { fullName: 'Cabasa', shortName: 'Cabasa' },
70: { fullName: 'Maracas', shortName: 'Maracas' },
71: { fullName: 'Short Whistle', shortName: 'ShortWhis' },
72: { fullName: 'Long Whistle', shortName: 'LongWhis' },
73: { fullName: 'Short Guiro', shortName: 'ShortGui' },
74: { fullName: 'Long Guiro', shortName: 'LongGui' },
75: { fullName: 'Claves', shortName: 'Claves' },
76: { fullName: 'Hi Wood Block', shortName: 'HiWood' },
77: { fullName: 'Low Wood Block', shortName: 'LowWood' },
78: { fullName: 'Mute Cuica', shortName: 'MuteCuica' },
79: { fullName: 'Open Cuica', shortName: 'OpenCuica' },
80: { fullName: 'Mute Triangle', shortName: 'MuteTri' },
81: { fullName: 'Open Triangle', shortName: 'OpenTri' }
};
/**
* Converts a KGSP project to standard MIDI format
* @param project - The KGProject to convert
* @returns Uint8Array containing the MIDI file data
*/
export const convertProjectToMidi = (project: KGProject): Uint8Array => {
// Calculate number of tracks (1 tempo track + MIDI tracks)
const midiTracks = project.getTracks().filter(track => track.getCurrentType() === 'KGMidiTrack') as KGMidiTrack[];
const totalTracks = 1 + midiTracks.length; // Tempo track + instrument tracks
const chunks: Uint8Array[] = [];
// Set up channel assignment map for 16 MIDI channels (0-15)
// Value is the assigned GM program number (1-128), 'DRUMS' for percussion, or null if free
const channelAssignments: (number | 'DRUMS' | null)[] = new Array(16).fill(null);
// Reserve channel 1 (index 0) for Acoustic Grand Piano (GM program 1)
channelAssignments[0] = 1;
// Reserve channel 10 (index 9) for drum kit
channelAssignments[9] = 'DRUMS';
// 1. Create MIDI header chunk
chunks.push(...createMidiHeader(totalTracks));
// 2. Create tempo/meta track
chunks.push(...createTempoTrack(project));
// 3. Create instrument tracks
midiTracks.forEach((track) => {
const instrument = track.getInstrument();
const isDrums = isDrumInstrument(instrument);
const gmProgram = getGMInstrument(instrument); // 1-128
const channel = assignChannelForInstrument(gmProgram, isDrums, channelAssignments);
chunks.push(...createInstrumentTrack(track, project, channel, gmProgram, isDrums));
});
// Combine all chunks into final MIDI file
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
chunks.forEach(chunk => {
result.set(chunk, offset);
offset += chunk.length;
});
return result;
};
/**
* Creates the MIDI file header chunk
*/
function createMidiHeader(trackCount: number): Uint8Array[] {
const chunks: Uint8Array[] = [];
// Header chunk type ("MThd")
chunks.push(MIDI_HEADER.CHUNK_TYPE);
// Header chunk size (6 bytes)
chunks.push(MIDI_HEADER.HEADER_SIZE);
// Format type 1 (multi-track)
chunks.push(MIDI_HEADER.FORMAT_1);
// Number of tracks
chunks.push(MIDI_UTILS.int16ToBytes(trackCount));
// Time division (TPQN)
chunks.push(MIDI_TIMING.TPQN_BYTES);
return chunks;
}
/**
* Creates the tempo/meta track with project settings
*/
function createTempoTrack(project: KGProject): Uint8Array[] {
const events: Uint8Array[] = [];
// Track name meta event
events.push(MIDI_UTILS.encodeVLQ(0)); // Delta time 0
events.push(new Uint8Array([MIDI_EVENTS.META_EVENT, MIDI_EVENTS.META_TRACK_NAME]));
const trackNameBytes = new TextEncoder().encode('Tempo Track');
events.push(MIDI_UTILS.encodeVLQ(trackNameBytes.length));
events.push(trackNameBytes);
// Time signature meta event
events.push(MIDI_UTILS.encodeVLQ(0)); // Delta time 0
events.push(new Uint8Array([MIDI_EVENTS.META_EVENT, MIDI_EVENTS.META_TIME_SIGNATURE, 0x04])); // 4 bytes
const timeSignature = project.getTimeSignature();
const denomPower = MIDI_TIME_SIGNATURE.DENOMINATOR_MAP[timeSignature.denominator as keyof typeof MIDI_TIME_SIGNATURE.DENOMINATOR_MAP] || 2;
events.push(new Uint8Array([
timeSignature.numerator,
denomPower,
MIDI_TIME_SIGNATURE.DEFAULT_METRONOME_PULSE,
MIDI_TIME_SIGNATURE.DEFAULT_32ND_NOTES_PER_QUARTER
]));
// Key signature meta event
events.push(MIDI_UTILS.encodeVLQ(0)); // Delta time 0
events.push(new Uint8Array([MIDI_EVENTS.META_EVENT, MIDI_EVENTS.META_KEY_SIGNATURE, 0x02])); // 2 bytes
const keySignature = getKeySignatureBytes(project.getKeySignature());
events.push(keySignature);
// Tempo meta event
events.push(MIDI_UTILS.encodeVLQ(0)); // Delta time 0
events.push(new Uint8Array([MIDI_EVENTS.META_EVENT, MIDI_EVENTS.META_TEMPO, 0x03])); // 3 bytes
const microsecondsPerQuarter = MIDI_UTILS.bpmToMicrosecondsPerQuarter(project.getBpm());
events.push(new Uint8Array([
(microsecondsPerQuarter >> 16) & 0xFF,
(microsecondsPerQuarter >> 8) & 0xFF,
microsecondsPerQuarter & 0xFF
]));
// End of track
events.push(MIDI_TRACK.END_OF_TRACK);
return createTrackChunk(events);
}
/**
* Creates a MIDI track for an instrument
*/
function createInstrumentTrack(
track: KGMidiTrack,
project: KGProject,
channel: number,
gmProgram: number,
isDrums: boolean
): Uint8Array[] {
const events: Uint8Array[] = [];
// Track name meta event
events.push(MIDI_UTILS.encodeVLQ(0)); // Delta time 0
events.push(new Uint8Array([MIDI_EVENTS.META_EVENT, MIDI_EVENTS.META_TRACK_NAME]));
const trackNameBytes = new TextEncoder().encode(track.getName());
events.push(MIDI_UTILS.encodeVLQ(trackNameBytes.length));
events.push(trackNameBytes);
// Program change (instrument selection) - skip for drums (channel 10)
if (!isDrums && channel !== 9) {
events.push(MIDI_UTILS.encodeVLQ(0)); // Delta time 0
events.push(new Uint8Array([
MIDI_EVENTS.PROGRAM_CHANGE | channel,
Math.max(0, Math.min(127, gmProgram - 1)) // MIDI program change is 0-127
]));
}
// Collect all notes from all regions and sort by absolute time
const allNotes: Array<{
note: KGMidiNote;
absoluteStartBeat: number;
absoluteEndBeat: number;
}> = [];
track.getRegions().forEach((region) => {
// Type guard to ensure we have a KGMidiRegion
if (region.getCurrentType() === 'KGMidiRegion') {
const midiRegion = region as KGMidiRegion;
midiRegion.getNotes().forEach((note: KGMidiNote) => {
allNotes.push({
note,
absoluteStartBeat: midiRegion.getStartFromBeat() + note.getStartBeat(),
absoluteEndBeat: midiRegion.getStartFromBeat() + note.getEndBeat(),
});
});
}
});
// Sort notes by start time, then by pitch for consistent ordering
allNotes.sort((a, b) => {
const timeDiff = a.absoluteStartBeat - b.absoluteStartBeat;
return timeDiff !== 0 ? timeDiff : a.note.getPitch() - b.note.getPitch();
});
// Create MIDI events for notes
const midiEvents: Array<{
tick: number;
event: Uint8Array;
}> = [];
// Add note on/off events
allNotes.forEach(({ note, absoluteStartBeat, absoluteEndBeat }) => {
const startTick = beatToTicks(absoluteStartBeat, project.getTimeSignature());
const endTick = beatToTicks(absoluteEndBeat, project.getTimeSignature());
const velocity = Math.max(MIDI_VELOCITY.MIN, Math.min(MIDI_VELOCITY.MAX, note.getVelocity()));
const pitch = Math.max(0, Math.min(127, note.getPitch()));
// Note On event
midiEvents.push({
tick: startTick,
event: new Uint8Array([
MIDI_EVENTS.NOTE_ON | channel,
pitch,
velocity
])
});
// Note Off event
midiEvents.push({
tick: endTick,
event: new Uint8Array([
MIDI_EVENTS.NOTE_OFF | channel,
pitch,
0 // Release velocity
])
});
});
// Sort all events by tick time
midiEvents.sort((a, b) => {
const timeDiff = a.tick - b.tick;
// If same time, prioritize note off before note on to avoid conflicts
if (timeDiff === 0) {
const aIsNoteOff = (a.event[0] & 0xF0) === MIDI_EVENTS.NOTE_OFF;
const bIsNoteOff = (b.event[0] & 0xF0) === MIDI_EVENTS.NOTE_OFF;
if (aIsNoteOff && !bIsNoteOff) return -1;
if (!aIsNoteOff && bIsNoteOff) return 1;
}
return timeDiff;
});
// Convert to delta time format and add to events
let previousTick = 0;
midiEvents.forEach(({ tick, event }) => {
const deltaTime = tick - previousTick;
events.push(MIDI_UTILS.encodeVLQ(deltaTime));
events.push(event);
previousTick = tick;
});
// End of track
events.push(MIDI_TRACK.END_OF_TRACK);
return createTrackChunk(events);
}
/**
* Creates a track chunk with the given events
*/
function createTrackChunk(events: Uint8Array[]): Uint8Array[] {
const chunks: Uint8Array[] = [];
// Track chunk type ("MTrk")
chunks.push(MIDI_TRACK.CHUNK_TYPE);
// Calculate total data length
const totalLength = events.reduce((sum, event) => sum + event.length, 0);
// Track chunk size
chunks.push(MIDI_UTILS.int32ToBytes(totalLength));
// Track data
chunks.push(...events);
return chunks;
}
/**
* Converts beats to MIDI ticks
*/
function beatToTicks(beats: number, timeSignature: { numerator: number; denominator: number }): number {
// In MIDI, ticks are relative to quarter notes
// Convert beats to quarter notes based on time signature
const quarterNotesPerBeat = 4 / timeSignature.denominator;
const quarterNotes = beats * quarterNotesPerBeat;
return Math.round(quarterNotes * MIDI_TIMING.TPQN);
}
/**
* Determines if an instrument is a drum kit per FluidR3 map
*/
function isDrumInstrument(instrument: InstrumentType): boolean {
const key = String(instrument);
return key === 'standard' || FLUIDR3_INSTRUMENT_MAP[key]?.group === 'PERCUSSION_KIT';
}
/**
* Assign a MIDI channel for the given GM program and drum flag and update the assignment map
* - Reserves channel 10 (index 9) for drums
* - Reserves channel 1 (index 0) for Acoustic Grand Piano (program 1)
* - Reuses channels that already match the requested program
* - Falls back to channel 1 if no free channels are available
*/
function assignChannelForInstrument(
gmProgram: number,
isDrums: boolean,
channelAssignments: (number | 'DRUMS' | null)[]
): number {
// Drums always go to channel 10
if (isDrums) {
return 9;
}
// If requesting Acoustic Grand Piano, use channel 1
if (gmProgram === 1) {
return 0;
}
// Reuse an existing channel already assigned to this program
for (let ch = 0; ch < 16; ch++) {
if (channelAssignments[ch] === gmProgram) {
return ch;
}
}
// Find a free channel (skip 0 and 9 which are reserved)
for (let ch = 0; ch < 16; ch++) {
if (ch === 0 || ch === 9) continue;
if (channelAssignments[ch] === null) {
channelAssignments[ch] = gmProgram;
return ch;
}
}
// No available channel: fall back to channel 1 (piano)
return 0;
}
/**
* Gets the General MIDI instrument number
*/
function getGMInstrument(instrument: InstrumentType): number {
return FLUIDR3_INSTRUMENT_MAP[instrument]?.midiInstrument || 1; // fallback to piano when instrument is not found
}
/**
* Gets key signature bytes for MIDI format
*/
function getKeySignatureBytes(keySignature: KeySignature): Uint8Array {
// Check sharp keys
if (keySignature in MIDI_KEY_SIGNATURE.SHARP_KEYS) {
const sharps = MIDI_KEY_SIGNATURE.SHARP_KEYS[keySignature as keyof typeof MIDI_KEY_SIGNATURE.SHARP_KEYS];
return new Uint8Array([sharps, 0]); // 0 = major
}
// Check flat keys
if (keySignature in MIDI_KEY_SIGNATURE.FLAT_KEYS) {
const flats = MIDI_KEY_SIGNATURE.FLAT_KEYS[keySignature as keyof typeof MIDI_KEY_SIGNATURE.FLAT_KEYS];
return new Uint8Array([flats & 0xFF, 0]); // Convert to unsigned byte, 0 = major
}
// Check minor keys
if (keySignature in MIDI_KEY_SIGNATURE.MINOR_KEYS) {
const accidentals = MIDI_KEY_SIGNATURE.MINOR_KEYS[keySignature as keyof typeof MIDI_KEY_SIGNATURE.MINOR_KEYS];
return new Uint8Array([accidentals & 0xFF, 1]); // Convert to unsigned byte, 1 = minor
}
// Default to C major
return new Uint8Array([0, 0]);
}
// ─── K.G.One clip MIDI import helpers ────────────────────────────────────────
export interface RawMidiNote {
startBeat: number;
endBeat: number;
pitch: number;
velocity: number;
}
/**
* Parses a MIDI binary and returns all notes from the first track that has
* notes, with beat offsets normalised so the earliest note starts at beat 0.
* Used by the K.G.One Clip drag-to-MIDI-track feature.
*/
export function parseMidiFirstTrackNotes(data: Uint8Array): {
notes: RawMidiNote[];
totalBeats: number;
} {
const midiFile = parseMidiFile(data);
const firstTrack = midiFile.tracks.find(t => t.notes.length > 0);
if (!firstTrack || firstTrack.notes.length === 0) {
return { notes: [], totalBeats: 0 };
}
const minBeat = Math.min(...firstTrack.notes.map(n => n.startBeat));
const notes: RawMidiNote[] = firstTrack.notes.map(n => ({
startBeat: n.startBeat - minBeat,
endBeat: n.endBeat - minBeat,
pitch: n.pitch,
velocity: n.velocity,
}));
const totalBeats = Math.max(...notes.map(n => n.endBeat));
return { notes, totalBeats };
}
/**
* Converts a MIDI binary file to a KGSP project
* @param midiData - The MIDI file data as Uint8Array
* @param existingProject - Optional existing project to merge into (creates new if null/undefined)
* @returns KGProject with MIDI data converted to KGSP format
*/
export const convertMidiToProject = (midiData: Uint8Array, existingProject?: KGProject | null): KGProject => {
// Create new project if none provided
const project = existingProject || new KGProject();
// Parse MIDI file
const midiFile = parseMidiFile(midiData);
// Apply project-level settings from MIDI meta events (only for new projects)
if (!existingProject || existingProject.getTracks().length === 0) {
if (midiFile.tempo) {
project.setBpm(Math.round(60000000 / midiFile.tempo)); // Convert microseconds per quarter to BPM
}
if (midiFile.timeSignature) {
project.setTimeSignature(midiFile.timeSignature);
}
if (midiFile.keySignature) {
project.setKeySignature(midiFile.keySignature);
}
}
// Get existing tracks to calculate proper track indices
const existingTracks = project.getTracks();
const startingTrackIndex = existingTracks.length;
// Convert MIDI tracks to KGSP tracks
let addedTrackCount = 0;
midiFile.tracks.forEach((midiTrack, midiTrackIndex) => {
// Skip tempo tracks (they usually don't have notes)
if (midiTrack.notes.length === 0) {
return;
}
// Determine instrument from MIDI channel and program
const instrument = getKGInstrumentFromMidi(midiTrack.channel, midiTrack.program);
// Calculate proper track index (append to existing tracks)
const actualTrackIndex = startingTrackIndex + addedTrackCount;
// Create new KGSP track
const trackId = Date.now() + midiTrackIndex; // Simple unique ID
const trackName = midiTrack.name || `Track ${actualTrackIndex + 1}`;
const kgTrack = new KGMidiTrack(trackName, trackId, instrument);
kgTrack.setTrackIndex(actualTrackIndex);
// Group notes into regions (every 16 beats for now, could be more sophisticated)
const regions = groupNotesIntoRegions(midiTrack.notes, project.getTimeSignature());
regions.forEach((regionData, regionIndex) => {
const regionId = generateUniqueId('KGMidiRegion');
const regionName = `${trackName} Region ${regionIndex + 1}`;
const region = new KGMidiRegion(
regionId,
trackId.toString(),
actualTrackIndex,
regionName,
regionData.startBeat,
regionData.length
);
// Convert MIDI notes to KG notes (with relative timing)
regionData.notes.forEach(midiNote => {
const noteId = generateUniqueId('KGMidiNote');
const relativeStartBeat = midiNote.startBeat - regionData.startBeat;
const relativeEndBeat = midiNote.endBeat - regionData.startBeat;
const kgNote = new KGMidiNote(
noteId,
relativeStartBeat,
relativeEndBeat,
midiNote.pitch,
midiNote.velocity
);
region.addNote(kgNote);
});
kgTrack.addRegion(region);
});
// Add track to project
project.getTracks().push(kgTrack);
addedTrackCount += 1;
});
return project;
};
// MIDI parsing types and interfaces
interface ParsedMidiFile {
format: number;
trackCount: number;
ticksPerQuarter: number;
tempo?: number; // microseconds per quarter note
timeSignature?: TimeSignature;
keySignature?: KeySignature;
tracks: ParsedMidiTrack[];
}
interface ParsedMidiTrack {
name?: string;
channel: number;
program?: number; // instrument program
notes: ParsedMidiNote[];
}
interface ParsedMidiNote {
startBeat: number;
endBeat: number;
pitch: number;
velocity: number;
}
interface RegionData {
startBeat: number;
length: number;
notes: ParsedMidiNote[];
}
/**
* Parses a MIDI binary file into a structured format
*/
function parseMidiFile(data: Uint8Array): ParsedMidiFile {
let offset = 0;
if (DEBUG_MODE.MIDI_IMPORT) {
console.log('🎵 Starting MIDI file parsing...');
}
// Parse header chunk
const headerChunk = parseChunk(data, offset);
offset += headerChunk.totalSize;
if (headerChunk.type !== 'MThd') {
throw new Error('Invalid MIDI file: Missing header chunk');
}
const format = readUint16(headerChunk.data, 0);
const trackCount = readUint16(headerChunk.data, 2);
const timeDivision = readUint16(headerChunk.data, 4);
if (DEBUG_MODE.MIDI_IMPORT) {
console.log('📋 MIDI Header parsed:');
console.log(` ├── Format: ${format} (${format === 0 ? 'Single track' : format === 1 ? 'Multi-track' : 'Unknown'})`);
console.log(` ├── Track count: ${trackCount}`);
console.log(` └── Time division: ${timeDivision} (${timeDivision & 0x8000 ? 'SMPTE' : 'TPQN'})`);
}
// Only support TPQN format (bit 15 = 0)
if (timeDivision & 0x8000) {
throw new Error('SMPTE time division not supported');
}
const ticksPerQuarter = timeDivision;
const midiFile: ParsedMidiFile = {
format,
trackCount,
ticksPerQuarter,
tracks: []
};
if (DEBUG_MODE.MIDI_IMPORT) {
console.log('🎼 Parsing tracks...');
}
// Parse track chunks
for (let i = 0; i < trackCount; i++) {
if (offset >= data.length) break;
if (DEBUG_MODE.MIDI_IMPORT) {
console.log(`📝 Parsing track ${i + 1}/${trackCount}...`);
}
const trackChunk = parseChunk(data, offset);
offset += trackChunk.totalSize;
if (trackChunk.type !== 'MTrk') {
console.warn(`Expected track chunk, got ${trackChunk.type}`);
continue;
}
const track = parseTrack(trackChunk.data, ticksPerQuarter, midiFile);
midiFile.tracks.push(track);
if (DEBUG_MODE.MIDI_IMPORT) {
console.log(`├── ${track.name || `Track ${i + 1}`} (Channel: ${track.channel}, Program: ${track.program ?? 'none'}, Notes: ${track.notes.length})`);
track.notes.forEach(note => {
const startTick = Math.round(note.startBeat * ticksPerQuarter);
const endTick = Math.round(note.endBeat * ticksPerQuarter);
console.log(`│ ├── ${note.pitch} | ${startTick} | ${endTick}`);
});
}
}
return midiFile;
}
/**
* Parses a generic MIDI chunk (header or track)
*/
function parseChunk(data: Uint8Array, offset: number) {
if (offset + 8 > data.length) {
throw new Error('Invalid MIDI file: Incomplete chunk header');
}
const type = String.fromCharCode(...data.slice(offset, offset + 4));
const length = readUint32(data, offset + 4);
if (offset + 8 + length > data.length) {
throw new Error('Invalid MIDI file: Chunk data exceeds file length');
}
return {
type,
length,
data: data.slice(offset + 8, offset + 8 + length),
totalSize: 8 + length
};
}
/**
* Parses a MIDI track chunk
*/
function parseTrack(data: Uint8Array, ticksPerQuarter: number, midiFile: ParsedMidiFile): ParsedMidiTrack {
let offset = 0;
let currentTick = 0;
let runningStatus = 0;
const track: ParsedMidiTrack = {
channel: 0, // Default channel
notes: []
};
// Track active notes (for note off events)
const activeNotes = new Map<string, { startTick: number; pitch: number; velocity: number }>();
let primaryChannelDetected = false;
while (offset < data.length) {
// Read delta time
const deltaTime = readVLQ(data, offset);
offset += deltaTime.bytesRead;
currentTick += deltaTime.value;
if (offset >= data.length) break;
// Read status byte (or apply running status)
let status = data[offset];
if (status < 0x80) {
// running status: use previous status, do not advance offset here
status = runningStatus;
} else {
offset++;
runningStatus = status;
}
// Handle Meta and System Exclusive separately (they don't follow the channel-voice pattern)
if (status === MIDI_EVENTS.META_EVENT) {
if (offset + 1 > data.length) break;
const metaType = data[offset];
offset += 1;
const metaLength = readVLQ(data, offset);
offset += metaLength.bytesRead;
if (offset + metaLength.value > data.length) break;
const metaData = data.slice(offset, offset + metaLength.value);
offset += metaLength.value;
switch (metaType) {
case MIDI_EVENTS.META_TRACK_NAME:
track.name = new TextDecoder().decode(metaData);
if (DEBUG_MODE.MIDI_IMPORT) {
console.log(`🏷️ Track name: "${track.name}"`);
}
break;
case MIDI_EVENTS.META_TEMPO:
if (metaData.length >= 3) {
const tempo = (metaData[0] << 16) | (metaData[1] << 8) | metaData[2];
midiFile.tempo = tempo;
const bpm = Math.round(60000000 / tempo);
if (DEBUG_MODE.MIDI_IMPORT) {
console.log(`🥁 Tempo found: ${tempo} μs/quarter note (${bpm} BPM)`);
}
}
break;
case MIDI_EVENTS.META_TIME_SIGNATURE:
if (metaData.length >= 4) {
const numerator = metaData[0];
const denominatorPower = metaData[1];
const denominator = Math.pow(2, denominatorPower);
midiFile.timeSignature = { numerator, denominator };
if (DEBUG_MODE.MIDI_IMPORT) {
console.log(`🎵 Time signature found: ${numerator}/${denominator}`);
}
}
break;
case MIDI_EVENTS.META_KEY_SIGNATURE:
if (metaData.length >= 2) {
const sharpsFlats = metaData[0];
const majorMinor = metaData[1]; // 0 = major, 1 = minor
midiFile.keySignature = getKeySignatureFromMidi(sharpsFlats, majorMinor);
if (DEBUG_MODE.MIDI_IMPORT) {
const accidentals = sharpsFlats > 127 ? sharpsFlats - 256 : sharpsFlats;
console.log(`🎼 Key signature found: ${midiFile.keySignature} (${accidentals} ${accidentals >= 0 ? 'sharps' : 'flats'}, ${majorMinor === 0 ? 'major' : 'minor'})`);
}
}
break;
case MIDI_EVENTS.META_END_OF_TRACK:
if (DEBUG_MODE.MIDI_IMPORT) {
console.log('🏁 End of track reached');
}
return track;
}
continue;
}
// System Exclusive events (0xF0/0xF7): skip their payload length
if (status === 0xF0 || status === 0xF7) {
const sysexLength = readVLQ(data, offset);
offset += sysexLength.bytesRead + Math.min(sysexLength.value, data.length - (offset));
continue;
}
const eventType = status & 0xF0;
const channel = status & 0x0F;
if (!primaryChannelDetected && eventType >= 0x80 && eventType <= 0xE0) {
track.channel = channel;
primaryChannelDetected = true;
}
switch (eventType) {
case MIDI_EVENTS.NOTE_ON: {
if (offset + 2 > data.length) break;
const pitch = data[offset];
const velocity = data[offset + 1];
offset += 2;
if (velocity > 0) {
const noteKey = `${pitch}-${channel}`;
activeNotes.set(noteKey, { startTick: currentTick, pitch, velocity });
if (DEBUG_MODE.MIDI_IMPORT) {
console.log(`🎵 Note ON: Pitch ${pitch}, Velocity ${velocity}, Tick ${currentTick}, Channel ${channel}`);
}
} else {
// velocity 0 == Note Off
const noteKey = `${pitch}-${channel}`;
const activeNote = activeNotes.get(noteKey);
if (activeNote) {
const note = {
startBeat: ticksToBeat(activeNote.startTick, ticksPerQuarter),
endBeat: ticksToBeat(currentTick, ticksPerQuarter),
pitch: activeNote.pitch,
velocity: activeNote.velocity
};
track.notes.push(note);
if (DEBUG_MODE.MIDI_IMPORT) {
console.log(`🎵 Note OFF (vel=0): Pitch ${pitch}, Duration ${currentTick - activeNote.startTick} ticks`);
}
activeNotes.delete(noteKey);
}
}
break;
}
case MIDI_EVENTS.NOTE_OFF: {
if (offset + 2 > data.length) break;
const offPitch = data[offset];
// const offVelocity = data[offset + 1]; // unused
offset += 2;
const noteKey = `${offPitch}-${channel}`;
const activeNote = activeNotes.get(noteKey);
if (activeNote) {
const note = {
startBeat: ticksToBeat(activeNote.startTick, ticksPerQuarter),
endBeat: ticksToBeat(currentTick, ticksPerQuarter),
pitch: activeNote.pitch,
velocity: activeNote.velocity
};
track.notes.push(note);
if (DEBUG_MODE.MIDI_IMPORT) {
console.log(`🎵 Note OFF: Pitch ${offPitch}, Duration ${currentTick - activeNote.startTick} ticks`);
}
activeNotes.delete(noteKey);
}
break;
}
case MIDI_EVENTS.PROGRAM_CHANGE: {
if (offset + 1 > data.length) break;
track.program = data[offset];
if (DEBUG_MODE.MIDI_IMPORT) {
console.log(`🎸 Program change: Channel ${channel}, Program ${track.program} (GM Instrument)`);
}
offset += 1;
break;
}
case MIDI_EVENTS.CONTROL_CHANGE:
case MIDI_EVENTS.POLY_PRESSURE:
case MIDI_EVENTS.CHANNEL_PRESSURE: {
// Skip these events (advance by appropriate number of data bytes)
const bytesToSkip = (eventType === MIDI_EVENTS.CHANNEL_PRESSURE) ? 1 : 2;
offset += Math.min(bytesToSkip, data.length - offset);
break;
}
case MIDI_EVENTS.PITCH_BEND: {
offset += Math.min(2, data.length - offset);
break;
}
default: {
// Unknown channel event, skip at most 2 data bytes to recover
offset += Math.min(2, data.length - offset);
break;
}
}
}
return track;
}
/**
* Reads a variable-length quantity from MIDI data
*/
function readVLQ(data: Uint8Array, offset: number): { value: number; bytesRead: number } {
let value = 0;
let bytesRead = 0;
let byte;
do {
if (offset + bytesRead >= data.length) {
throw new Error('Invalid VLQ: Unexpected end of data');
}
byte = data[offset + bytesRead];
bytesRead++;
value = (value << 7) | (byte & 0x7F);
} while (byte & 0x80);
return { value, bytesRead };
}
/**
* Reads a 16-bit unsigned integer (big-endian)
*/
function readUint16(data: Uint8Array, offset: number): number {
if (offset + 2 > data.length) {
throw new Error('Cannot read Uint16: Not enough data');
}
return (data[offset] << 8) | data[offset + 1];
}
/**
* Reads a 32-bit unsigned integer (big-endian)
*/
function readUint32(data: Uint8Array, offset: number): number {
if (offset + 4 > data.length) {
throw new Error('Cannot read Uint32: Not enough data');
}
return (data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3];
}
/**
* Converts MIDI ticks to beats
*/
function ticksToBeat(ticks: number, ticksPerQuarter: number): number {
return ticks / ticksPerQuarter;
}
/**
* Maps MIDI channel and program to KGSP instrument
*/
function getKGInstrumentFromMidi(channel: number, program?: number): InstrumentType {
// Channel 9 (0-based) is always drums
if (channel === 9) {
return 'standard' as InstrumentType;
}
// Program is 0-127 in MIDI; STANDARD_MIDI_INSTRUMENT_MAP is 1-128
if (program !== undefined) {
const gmProgram = Math.max(1, Math.min(128, program + 1));
const mapping = STANDARD_MIDI_INSTRUMENT_MAP[gmProgram as keyof typeof STANDARD_MIDI_INSTRUMENT_MAP];
if (mapping) {
return mapping.fluidR3InstrumentName as InstrumentType;
}
}
// Default to acoustic grand piano
return 'acoustic_grand_piano' as InstrumentType;
}
/**
* Converts MIDI key signature to KGSP format
*/
function getKeySignatureFromMidi(sharpsFlats: number, majorMinor: number): KeySignature {
// Handle signed byte
const accidentals = sharpsFlats > 127 ? sharpsFlats - 256 : sharpsFlats;
if (majorMinor === 0) {
// Major keys
switch (accidentals) {
case 0: return 'C major';
case 1: return 'G major';
case 2: return 'D major';
case 3: return 'A major';
case 4: return 'E major';
case 5: return 'B major';
case 6: return 'F# major';
case 7: return 'C# major';
case -1: return 'F major';
case -2: return 'Bb major';
case -3: return 'Eb major';
case -4: return 'Ab major';
case -5: return 'Db major';
case -6: return 'Gb major';
case -7: return 'Cb major';
}
} else {
// Minor keys
switch (accidentals) {
case 0: return 'A minor';
case 1: return 'E minor';
case 2: return 'B minor';
case 3: return 'F# minor';
case 4: return 'C# minor';
case 5: return 'G# minor';
case 6: return 'D# minor';
case 7: return 'A# minor';
case -1: return 'D minor';
case -2: return 'G minor';
case -3: return 'C minor';
case -4: return 'F minor';
case -5: return 'Bb minor';
case -6: return 'Eb minor';
case -7: return 'Ab minor';
}
}
// Default to C major
return 'C major';
}
/**
* Groups MIDI notes into regions based on timing
*/
function groupNotesIntoRegions(notes: ParsedMidiNote[], timeSignature: TimeSignature): RegionData[] {
if (notes.length === 0) return [];
// Sort notes by start time
const sortedNotes = [...notes].sort((a, b) => a.startBeat - b.startBeat);
const regions: RegionData[] = [];
const beatsPerBar = timeSignature.numerator;
const regionLengthInBeats = beatsPerBar * 4; // 4 bars per region
// Find the range of all notes
const firstNoteBeat = Math.floor(sortedNotes[0].startBeat);
const lastNoteBeat = Math.ceil(Math.max(...sortedNotes.map(n => n.endBeat)));
// Create regions to cover all notes
for (let regionStart = Math.floor(firstNoteBeat / regionLengthInBeats) * regionLengthInBeats;
regionStart < lastNoteBeat;
regionStart += regionLengthInBeats) {
const regionEnd = regionStart + regionLengthInBeats;
// Find notes that belong to this region
const regionNotes = sortedNotes.filter(note =>
note.startBeat >= regionStart && note.startBeat < regionEnd
);
if (regionNotes.length > 0) {
regions.push({
startBeat: regionStart,
length: regionLengthInBeats,
notes: regionNotes
});
}
}
return regions;
}