-
Notifications
You must be signed in to change notification settings - Fork 12
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #23 from jrmncos/cairo-to-midi
Adding conversion between Cairo and midi
- Loading branch information
Showing
10 changed files
with
402 additions
and
0 deletions.
There are no files selected for viewing
Binary file not shown.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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",{}], | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
||
}); | ||
}); |
Oops, something went wrong.