Skip to content

Commit

Permalink
Merge pull request #23 from jrmncos/cairo-to-midi
Browse files Browse the repository at this point in the history
Adding conversion between Cairo and midi
  • Loading branch information
caseywescott authored Dec 1, 2024
2 parents 8463b56 + 304d6e9 commit 9d59c3e
Show file tree
Hide file tree
Showing 10 changed files with 402 additions and 0 deletions.
Binary file added example/HeyBulldog.mid
Binary file not shown.
8 changes: 8 additions & 0 deletions python/midi_conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ def midi_to_cairo_struct(midi_file, output_file):
mid = mido.MidiFile(midi_file)
cairo_events = []

cairo_events.append(
f"Message::HEADER(Header {{ ticksPerBeat: {mid.ticks_per_beat} }})")
for track in mid.tracks:
for msg in track:
time = format_fp32x32(msg.time)
Expand Down Expand Up @@ -37,6 +39,12 @@ def midi_to_cairo_struct(midi_file, output_file):
elif msg.type == 'polyphonic_key_pressure':
cairo_events.append(
f"Message::POLY_TOUCH(PolyTouch {{ channel: {msg.channel}, note: {msg.note}, value: {msg.value}, time: {time} }})")
elif msg.type == 'end_of_track':
cairo_events.append(
f"Message::END_OF_TRACK(EndOfTrack {{ time: {time} }})")
elif msg.type == "program_change":
cairo_events.append(
f"Message::PROGRAM_CHANGE(ProgramChange {{ channel: {msg.channel}, program: {msg.program}, time: {time} }})")

cairo_code_start = "use koji::midi::types::{Midi, Message, NoteOn, NoteOff, SetTempo, TimeSignature, ControlChange, PitchWheel, AfterTouch, PolyTouch, Modes };\nuse orion::numbers::FP32x32;\n\nfn midi() -> Midi {\n Midi {\n events: array![\n"
cairo_code_events = ',\n'.join(cairo_events)
Expand Down
7 changes: 7 additions & 0 deletions typescript/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** @type {import('ts-jest').JestConfigWithTsJest} **/
module.exports = {
testEnvironment: "node",
transform: {
"^.+.tsx?$": ["ts-jest",{}],
},
};
26 changes: 26 additions & 0 deletions typescript/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "js",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "jest",
"test:watch": "jest --watch"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@tonejs/midi": "^2.0.28",
"midi-file": "^1.2.4",
"node-fetch": "^3.3.2"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^22.9.3",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.7.2"
}
}
86 changes: 86 additions & 0 deletions typescript/src/cairoToMidiEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {Header, Midi, Track} from "@tonejs/midi";
import * as fs from "fs";
import {parseEvent, CairoParsedMidiEvent} from './cairoToMidiParser'
import {MidiData, MidiEvent, MidiHeader} from "midi-file";

export function cairoToMidi(cairoFilePath: string, outputFile: string): Midi {
const fileContent = fs.readFileSync(cairoFilePath, "utf-8");
const lines = fileContent.split("\n");

const cairoParsedMidiEvents:CairoParsedMidiEvent[] = [];

lines.forEach((line) => {
const cairoParsedEvent = parseEvent(line);
if (cairoParsedEvent) {
cairoParsedMidiEvents.push(cairoParsedEvent);
}
});

const midi = mapCairoParsedMidiEventsToMidi(cairoParsedMidiEvents);

const midiBuffer = midi.toArray();
fs.writeFileSync(outputFile, new Uint8Array(midiBuffer));

return midi
}

/**
* This function is strongly coupled to the current implementation of @tonejs/midi
* it tries to adopt cairoParsedMidiEvents inorder to use the parsing logic of the library
*/

export function mapCairoParsedMidiEventsToMidi(cairoParsedMidiEvents: CairoParsedMidiEvent[]): Midi {
const midi = new Midi();
//Base case returns an empty midi
if (!cairoParsedMidiEvents || cairoParsedMidiEvents.length === 0) {
return midi
}
const parsedHeader = cairoParsedMidiEvents.shift();
const tracks = splitByEndOfTrack(cairoParsedMidiEvents)

tracks.forEach(track => {
// CairoParsedMidiEvent has a deltaTime property that means the distance between the current event and the previous one
// @tonejs/midi needs the absoluteTime of the event, so, this piece of code calculates the absolute time based on deltas
let currentTicks = 0;
track.forEach((event, idx) => {
currentTicks += event.deltaTime;
event.absoluteTime = currentTicks;
});
})

const midiData: MidiData = {
header: parsedHeader as unknown as MidiHeader,
tracks: tracks as unknown as Array<MidiEvent[]>
}

// Events like setTempo, timeSignature, keySignature, header are used here
midi.header = new Header(midiData)

tracks.forEach(track => {
// Events like noteOn, noteOff, controller, end of track are used here
const midiTrack = new Track(track as unknown as MidiEvent[], midi.header)
midi.tracks.push(midiTrack)
})

return midi
}

/**
* CairoParsedMidiEvent[] includes an endOfTrack event.
* This function detect those events to split the events into tracks
*/
export function splitByEndOfTrack(events: CairoParsedMidiEvent[]): CairoParsedMidiEvent[][] {
const result: CairoParsedMidiEvent[][] = [];
let currentTrack: CairoParsedMidiEvent[] = [];

for (const event of events) {
currentTrack.push(event);

if (event.type === "endOfTrack") {
result.push(currentTrack);
currentTrack = [];
}
}

return result;
}
123 changes: 123 additions & 0 deletions typescript/src/cairoToMidiParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
export interface CairoParsedMidiEvent {
type: string;
channel: number;
noteNumber: number;
velocity: number; //how 'hard' the note is pressure. If == 0 it's and noteOff event. But a noteOff can be != 0 too.
deltaTime: number; //distance in between the event n and the event n - 1
microsecondsPerBeat: number; //the tempo
numerator: number; // with [numerador/denominator] you can generate the compass
denominator: number;
value: number;
pitch: number;
clock: number;
absoluteTime: number; // events in cairo don't have the absolute time (they only have the deltaTime), and in @tonejs/midi yes. It's required to calculate it
controllerType: number;
ticksPerBeat: number; ////the same as ppq
meta: boolean; //if the event it's for metadata
programNumber: number; //required to change instrument type, for example 24 = Nylon String Guitar
}
/**
* Parse a cairo event into a CairoParsedMidiEvent.
* CairoParsedMidiEvent contains all possibles properties for a MidiEvent of the library @tonejs/midi
* the name of the properties are the same of that library for compatibility
*/
export function parseEvent(cairoEvent: string): CairoParsedMidiEvent | null {
const match = cairoEvent.match(/Message::([A-Z_]+)\((.+)\)/);
if (!match) return null;

const [, type, content] = match;
let parsedEvent: Partial<CairoParsedMidiEvent> = { type: toCamelCase(type) };
parsedEvent.deltaTime = parseFP32x32(content.match(/time: (FP32x32 {[^}]+})/)?.[1] || "");

switch (type) {
case "HEADER":
parsedEvent.ticksPerBeat = parseInt(content.match(/ticksPerBeat: (\d+)/)?.[1] || "0");
parsedEvent.meta = true
break;
case "NOTE_ON":
case "NOTE_OFF":
parsedEvent.channel = parseInt(content.match(/channel: (\d+)/)?.[1] || "0");
parsedEvent.noteNumber = parseInt(content.match(/note: (\d+)/)?.[1] || "0");
parsedEvent.velocity = parseInt(content.match(/velocity: (\d+)/)?.[1] || "0");
// This is based on midi specification
if (parsedEvent.velocity === 0) {
parsedEvent.type = 'noteOff'
}
break;

case "SET_TEMPO":
const tempoMag = parseFP32x32(content.match(/tempo: (FP32x32 {[^}]+})/)?.[1] || "");
if (tempoMag !== undefined) {
parsedEvent.microsecondsPerBeat = tempoMag;
}
const timeOptionMatch = content.match(/time: Option::Some\((FP32x32 {[^}]+})\)/);
if (timeOptionMatch) {
parsedEvent.deltaTime = parseFP32x32(timeOptionMatch[1]);
} else {
parsedEvent.deltaTime = undefined;
}
parsedEvent.meta = true
break;

case "TIME_SIGNATURE":
parsedEvent.numerator = parseInt(content.match(/numerator: (\d+)/)?.[1] || "0");
parsedEvent.denominator = parseInt(content.match(/denominator: (\d+)/)?.[1] || "0");
parsedEvent.clock = parseInt(content.match(/clocks_per_click: (\d+)/)?.[1] || "0");
parsedEvent.meta = true
break;

case "CONTROL_CHANGE":
parsedEvent.channel = parseInt(content.match(/channel: (\d+)/)?.[1] || "0");
parsedEvent.controllerType = parseInt(content.match(/control: (\d+)/)?.[1] || "0");
parsedEvent.value = parseInt(content.match(/value: (\d+)/)?.[1] || "0");
parsedEvent.type = "controller"
break;

case "PITCH_WHEEL":
parsedEvent.channel = parseInt(content.match(/channel: (\d+)/)?.[1] || "0");
parsedEvent.pitch = parseInt(content.match(/pitch: (\d+)/)?.[1] || "0");
break;

case "AFTER_TOUCH":
parsedEvent.channel = parseInt(content.match(/channel: (\d+)/)?.[1] || "0");
parsedEvent.value = parseInt(content.match(/value: (\d+)/)?.[1] || "0");
break;

case "POLY_TOUCH":
parsedEvent.channel = parseInt(content.match(/channel: (\d+)/)?.[1] || "0");
parsedEvent.noteNumber = parseInt(content.match(/note: (\d+)/)?.[1] || "0");
parsedEvent.value = parseInt(content.match(/value: (\d+)/)?.[1] || "0");
break;
case "END_OF_TRACK":
parsedEvent.meta = true
break;
case "PROGRAM_CHANGE":
parsedEvent.channel = parseInt(content.match(/channel: (\d+)/)?.[1] || "0");
parsedEvent.programNumber = parseInt(content.match(/program: (\d+)/)?.[1] || "0");
break;
default:
return null;
}

return parsedEvent as CairoParsedMidiEvent;
}

function toCamelCase(str: string) {
return str
.toLowerCase()
.split('_')
.map((word, index) =>
index === 0
? word
: word.charAt(0).toUpperCase() + word.slice(1)
)
.join('');
}

function parseFP32x32(fp: string): number {
const match = fp.match(/FP32x32 { mag: (\d+), sign: (\w+) }/);
if (match) {
return parseInt(match[1], 10);
}
return 0;
}
20 changes: 20 additions & 0 deletions typescript/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {cairoToMidi} from "./cairoToMidiEvent";


if (require.main === module) {
const [,, cairoFilePath, outputFile] = process.argv;

if (!cairoFilePath || !outputFile) {
console.error("Usage: ts-node index.ts <cairoFilePath> <outputFile>");
process.exit(1);
}

try {
cairoToMidi(cairoFilePath, outputFile);
console.log(`MID file was processed successfully and is located at '${outputFile}'.`);
} catch (error) {
// @ts-ignore
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
50 changes: 50 additions & 0 deletions typescript/tests/cairoToMidiEvent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Midi, Header, Track } from '@tonejs/midi';
import {CairoParsedMidiEvent} from "../src/cairoToMidiParser";
import {mapCairoParsedMidiEventsToMidi} from "../src/cairoToMidiEvent";


describe('mapCairoParsedMidiEventsToMidi', () => {
test('should correctly map CairoParsedMidiEvents to Midi format', () => {
const mockCairoParsedMidiEvents: CairoParsedMidiEvent[] = [
{ type: 'header', ticksPerBeat: 384, deltaTime: 0, meta: true } as unknown as CairoParsedMidiEvent,
{ type: 'noteOn', channel: 1, noteNumber: 60, velocity: 100, deltaTime: 120 } as unknown as CairoParsedMidiEvent,
{ type: 'noteOff', channel: 1, noteNumber: 60, velocity: 0, deltaTime: 120 } as unknown as CairoParsedMidiEvent,
{ type: 'endOfTrack', deltaTime: 0, meta: true } as unknown as CairoParsedMidiEvent,
];

const result = mapCairoParsedMidiEventsToMidi(mockCairoParsedMidiEvents);

// Check if the header was correctly assigned
expect(result.header.ppq).toBe(384);
expect(result.tracks.length).toBe(1);
//Those results are the same that library executed. NoteOn and NoteOff should be merged in a single note.
expect(result.tracks[0].notes[0]).toHaveProperty('midi', 60)
expect(result.tracks[0].notes[0]).toHaveProperty('velocity', 100/127)
expect(result.tracks[0].notes[0]).toHaveProperty('ticks', 120)
});

test('should handle empty input gracefully', () => {
const result = mapCairoParsedMidiEventsToMidi([]);
expect(result).toBeInstanceOf(Midi);
expect(result.tracks).toHaveLength(0);
});

test('should calculate absoluteTime for each event correctly', () => {
const mockCairoParsedMidiEvents: CairoParsedMidiEvent[] = [
{ type: 'header', ticksPerBeat: 480, deltaTime: 0, meta: true } as unknown as CairoParsedMidiEvent,
{ type: 'noteOn', channel: 1, noteNumber: 60, velocity: 100, deltaTime: 50 } as unknown as CairoParsedMidiEvent,
{ type: 'noteOff', channel: 1, noteNumber: 60, velocity: 0, deltaTime: 70 } as unknown as CairoParsedMidiEvent,
{ type: 'noteOn', channel: 1, noteNumber: 87, velocity: 100, deltaTime: 13 } as unknown as CairoParsedMidiEvent,
{ type: 'noteOff', channel: 1, noteNumber: 87, velocity: 0, deltaTime: 5 } as unknown as CairoParsedMidiEvent,
{ type: 'endOfTrack', deltaTime: 0, meta: true } as unknown as CairoParsedMidiEvent,
];

const result = mapCairoParsedMidiEventsToMidi(mockCairoParsedMidiEvents);

expect(result.tracks.length).toBe(1);
expect(result.tracks[0].notes.length).toBe(2);
expect(result.tracks[0].notes[0]).toHaveProperty('ticks', 50); //50 = deltaTime off the first noteOn event
expect(result.tracks[0].notes[1]).toHaveProperty('ticks', 133); // 133 = sum of the delta times before the second noteOn event

});
});
Loading

0 comments on commit 9d59c3e

Please sign in to comment.