diff --git a/.eslintrc.js b/.eslintrc.js index a08c92c..6df223a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -16,6 +16,8 @@ module.exports = { 'no-useless-constructor': 'off', 'class-methods-use-this': 'off', '@typescript-eslint/no-unused-vars': 'error', + 'import/prefer-default-export': 'off', + 'no-use-before-define': 'off', }, parserOptions: { ecmaVersion: 2022, diff --git a/.gitignore b/.gitignore index da13430..0427ad5 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ node_modules release/app/dist release/build +test-songs/ .erb/dll .idea diff --git a/package.json b/package.json index e963fdd..9c0bcc4 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,6 @@ "@types/react-dom": "^18.2.7", "@types/react-test-renderer": "^18.0.1", "@types/terser-webpack-plugin": "^5.0.4", - "@types/vexflow": "^1.2.42", "@types/webpack-bundle-analyzer": "^4.6.0", "@typescript-eslint/eslint-plugin": "^6.7.0", "@typescript-eslint/parser": "^6.7.0", diff --git a/src/main/main.ts b/src/main/main.ts index d708117..38e0569 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -35,7 +35,7 @@ ipcMain.on('load-default', async (event) => { console.log('here'); const midiData = fs.readFileSync( - '/Users/antosha/code/ch-sheet/test-songs/disturbed/notes.mid', + '/Users/antosha/code/clone-hero-sheet/test-songs/disturbed/notes.mid', ); const midi = new Midi(midiData); event.reply('load-default', midi.toJSON()); diff --git a/src/midi-parser/song.ts b/src/midi-parser/song.ts new file mode 100644 index 0000000..8dc2edc --- /dev/null +++ b/src/midi-parser/song.ts @@ -0,0 +1,411 @@ +import { HeaderJSON, MidiJSON, TrackJSON } from '@tonejs/midi'; +import { NoteJSON } from '@tonejs/midi/dist/Note'; + +export interface Note { + notes: string[]; + dotted: boolean; + duration: string; + isTriplet: boolean; + isRest: boolean; + tick: number; + durationTicks?: number; +} + +export interface Beat { + notes: Note[]; + startTick: number; + endTick: number; +} + +export interface Measure { + timeSig: [number, number]; + sigChange: boolean; + hasClef: boolean; + notes: Note[]; + beats: Beat[]; + startTick: number; + endTick: number; + durationTicks?: number; +} + +export interface RawMidiNote { + note: NoteJSON; + key: string; +} + +export interface Modifier { + forNote: number; + key: string; +} + +export interface ModifierNote { + note: NoteJSON; + modifier: Modifier; +} + +export interface Duration { + duration?: string; + isTriplet?: boolean; + dotted?: boolean; +} + +export class Song { + mapping: { [key: number]: string } = { + 96: 'f/4', + 97: 'c/5', + 98: 'g/5/x2', + 99: 'f/5/x2', + 100: 'a/5/x2', + }; + + tomModifiers: { [key: number]: Modifier } = { + 110: { + forNote: 98, + key: 'e/5', + }, + 111: { + forNote: 99, + key: 'd/5', + }, + 112: { + forNote: 100, + key: 'a/4', + }, + }; + + measures: Measure[] = []; + + rawMidiNotes: Map = new Map(); + + endOfTrackTicks: number; + + modifierNotes: ModifierNote[] = []; + + header: HeaderJSON; + + durationMap: { [key: number]: Duration }; + + constructor(data: MidiJSON) { + const drumPart = data.tracks.find((track) => track.name === 'PART DRUMS'); + + if (!drumPart) { + throw new Error('no drum part'); + } + + this.endOfTrackTicks = drumPart.endOfTrackTicks || 0; + + this.header = data.header; + + this.durationMap = this.constructDurationMap(); + + this.processNotes(drumPart); + this.createMeasures(); + this.fillBeats(); + this.extendNoteDuration(); + this.processCompositeDuration(); + this.flattenMeasures(); + } + + processNotes(trackData: TrackJSON) { + trackData.notes.forEach((note) => { + if (this.mapping[note.midi]) { + const tickData = this.rawMidiNotes.get(note.ticks) ?? []; + tickData.push({ + note, + key: this.mapping[note.midi], + }); + this.rawMidiNotes.set(note.ticks, tickData); + } else if (this.tomModifiers[note.midi]) { + this.modifierNotes.push({ + note, + modifier: this.tomModifiers[note.midi], + }); + } + }); + } + + getNoteKey(note: RawMidiNote, modifiers: ModifierNote[]) { + return ( + modifiers.find((modifier) => modifier.modifier.forNote === note.note.midi) + ?.modifier.key ?? note.key + ); + } + + createMeasures() { + const { ppq } = this.header; + const endOfTrackTicks = this.endOfTrackTicks ?? 0; + + const timeSignatures = + this.header.timeSignatures.length > 0 + ? this.header.timeSignatures + : [ + { + ticks: 0, + timeSignature: [4, 4], + }, + ]; + + let startTick = 0; + + timeSignatures.forEach((timeSigData, index) => { + const timeSignature: [number, number] = [ + timeSigData.timeSignature[0], + timeSigData.timeSignature[1], + ]; + const pulsesPerDivision = ppq / (timeSignature[1] / 4); + const totalTimeSigTicks = + (timeSignatures[index + 1]?.ticks ?? endOfTrackTicks) - + timeSigData.ticks; + + const numberOfMeasures = Math.ceil( + totalTimeSigTicks / pulsesPerDivision / timeSignature[0], + ); + + for (let measure = 0; measure < numberOfMeasures; measure += 1) { + const endTick = startTick + timeSignature[0] * pulsesPerDivision; + + this.measures.push({ + timeSig: timeSignature, + hasClef: index === 0 && measure === 0, + sigChange: measure === 0, + notes: [], + beats: this.getBeats(timeSignature, startTick, endTick), + startTick, + endTick, + }); + + startTick += timeSignature[0] * pulsesPerDivision; + } + }); + } + + getBeats( + timeSignature: [number, number], + measureStartTick: number, + measureEndTick: number, + ): Beat[] { + const numberOfBeats = timeSignature[0]; + const measureDuration = measureEndTick - measureStartTick; + const beatDuration = measureDuration / numberOfBeats; + + return new Array(numberOfBeats).fill(null).map((_, index) => ({ + startTick: measureStartTick + index * beatDuration, + endTick: measureStartTick + (index + 1) * beatDuration, + notes: [], + })); + } + + fillBeats() { + const step = 1; + + this.measures.forEach((measure) => { + measure.beats.forEach((beat) => { + for ( + let currentTick = beat.startTick; + currentTick < beat.endTick; + currentTick += step + ) { + const tickNotes = this.rawMidiNotes.get(currentTick); + + const currentModifierNotes = this.modifierNotes.filter( + (modifier) => + currentTick >= modifier.note.ticks && + currentTick <= modifier.note.ticks + modifier.note.durationTicks, + ); + + if (tickNotes) { + beat.notes.push({ + notes: tickNotes.map((note) => + this.getNoteKey(note, currentModifierNotes), + ), + isRest: false, + dotted: false, + isTriplet: false, + duration: '32', + tick: currentTick, + }); + } else if (currentTick === beat.startTick) { + beat.notes.push({ + notes: ['b/4'], + isTriplet: false, + isRest: true, + dotted: false, + duration: '32r', + tick: currentTick, + }); + } + } + }); + }); + } + + flattenMeasures() { + this.measures.forEach((measure) => { + measure.notes = this.collapseQRests( + measure.beats.map((beat) => beat.notes).flat(), + ); + }); + } + + collapseQRests(notes: Note[]) { + const result: Note[] = []; + let consecutiveRests: Note[] = []; + + notes.forEach((note) => { + if (note.duration === 'qr' && consecutiveRests.length < 4) { + consecutiveRests.push(note); + } else { + if (consecutiveRests.length > 0) { + result.push(this.getCollapsedRest(consecutiveRests)); + consecutiveRests = []; + } + + result.push(note); + } + }); + + if (consecutiveRests.length > 0) { + result.push(this.getCollapsedRest(consecutiveRests)); + } + + return result; + } + + getCollapsedRest(notes: Note[]) { + let duration: string; + let dotted = false; + switch (notes.length) { + case 2: + duration = 'hr'; + break; + case 3: + duration = 'hrd'; + dotted = true; + break; + case 4: + duration = 'wr'; + break; + default: + duration = 'qr'; + } + + return { + notes: ['b/4'], + isRest: true, + dotted, + isTriplet: false, + duration, + tick: 0, + }; + } + + extendNoteDuration() { + this.measures.forEach((measure) => { + measure.beats.forEach((beat) => { + beat.notes.forEach((note, index) => { + const noteDuration = + (beat.notes[index + 1]?.tick ?? beat.endTick) - note.tick; + + note.durationTicks = noteDuration; + + const { duration, dotted, isTriplet } = this.durationMap[ + noteDuration + ] ?? { duration: '' }; + + note.duration = duration + ? `${duration}${note.isRest ? 'r' : ''}` + : ''; + + if (dotted) { + note.dotted = true; + } + if (isTriplet) { + note.isTriplet = true; + } + }); + }); + }); + } + + processCompositeDuration() { + const availableDurations = Object.keys(this.durationMap).map((key) => + Number(key), + ); + + this.measures.forEach((measure) => { + measure.beats.forEach((beat) => { + beat.notes = beat.notes + .map((note) => { + if (note.duration) { + return note; + } + + const atomicDurations = this.getSubsets( + availableDurations, + note.durationTicks ?? 0, + ) + .sort((a, b) => a.length - b.length)[0] + .sort((a, b) => b - a); + + return atomicDurations.map((durationTicks, index) => { + const { duration, dotted, isTriplet } = + this.durationMap[durationTicks]; + + const isRest = note.isRest || index === 0; + const newNote: Note = { + isTriplet: isTriplet ?? false, + dotted: dotted ?? false, + durationTicks, + isRest, + tick: 0, + duration: `${duration}${isRest ? 'r' : ''}`, + notes: ['b/4'], + }; + + return newNote; + }); + }) + .flat(); + }); + }); + } + + getSubsets(array: number[], sum: number) { + const result: number[][] = []; + + function fork(i = 0, s = 0, t: number[] = []) { + if (s === sum) { + result.push(t); + return; + } + if (i === array.length) { + return; + } + if (s + array[i] <= sum) { + fork(i + 1, s + array[i], t.concat(array[i])); + } + fork(i + 1, s, t); + } + + fork(); + + return result; + } + + constructDurationMap() { + const { ppq } = this.header; + + return { + [ppq]: { duration: 'q' }, + [ppq / 2]: { duration: '8' }, + [ppq / 3]: { duration: '8', isTriplet: true }, + [ppq / 2 + ppq / 4]: { duration: '8d', dotted: true }, + [ppq / 4]: { duration: '16' }, + [ppq / 4 + ppq / 8]: { duration: '16d', dotted: true }, + [ppq / 6]: { duration: '16', isTriplet: true }, + [ppq / 8]: { duration: '32' }, + [ppq / 8 + ppq / 16]: { duration: '32d', dotted: true }, + [ppq / 12]: { duration: '32', isTriplet: true }, + }; + } +} diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index cd7061b..73003b3 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -3,8 +3,9 @@ import { useEffect, useRef, useState } from 'react'; import './App.css'; import { MidiJSON } from '@tonejs/midi'; -import MidiRenderer from './MidiRenderer'; +import { renderMusic } from './MidiRenderer'; import { Channels } from '../main/preload'; +import { Song } from '../midi-parser/song'; function Hello() { const [isLoaded, setIsLoaded] = useState(false); @@ -12,6 +13,10 @@ function Hello() { const [midiData, setMidiData] = useState(); const loadSong = (type: Channels) => { + if (midiData && divRef.current) { + divRef.current.removeChild(divRef.current.children[0]); + } + window.electron.ipcRenderer.on(type, (arg) => { setMidiData(arg); @@ -28,7 +33,7 @@ function Hello() { return; } - new MidiRenderer(midiData, divRef).render(); + renderMusic(divRef, new Song(midiData)); }, [isLoaded, midiData]); return ( diff --git a/src/renderer/MidiRenderer.ts b/src/renderer/MidiRenderer.ts index 148b4b7..541c517 100644 --- a/src/renderer/MidiRenderer.ts +++ b/src/renderer/MidiRenderer.ts @@ -1,80 +1,124 @@ -import { HeaderJSON, MidiJSON, TrackJSON } from '@tonejs/midi'; -import { NoteJSON } from '@tonejs/midi/dist/Note'; import React from 'react'; -import Vex from 'vexflow'; -import { Song } from './song'; +import { + RenderContext, + Renderer, + Stave, + StaveNote, + TextJustification, + Formatter, + ModifierPosition, + Beam, + Dot, + Barline, + Tuplet, +} from 'vexflow'; +import { Measure, Song } from '../midi-parser/song'; -export default class MidiRenderer { - song: Song; +const STAVE_WIDTH = 400; +const STAVE_PER_ROW = 3; +const LINE_HEIGHT = 150; - constructor( - public data: MidiJSON, - public elementRef: React.RefObject, - ) { - this.song = new Song(data); +export function renderMusic( + elementRef: React.RefObject, + song: Song, +) { + if (!elementRef.current) { + return; } - render() { - if (!this.elementRef.current) { - return; - } + const renderer = new Renderer(elementRef.current, Renderer.Backends.SVG); + + const context = renderer.getContext(); - const { Renderer, Stave, StaveNote, Formatter, Beam } = Vex.Flow; + renderer.resize( + STAVE_WIDTH * STAVE_PER_ROW + 50, + Math.ceil(song.measures.length / STAVE_PER_ROW) * LINE_HEIGHT + 50, + ); - // Create an SVG renderer and attach it to the DIV element with id="output". - const renderer = new Renderer( - this.elementRef.current, - Renderer.Backends.SVG, + song.measures.forEach((measure, index) => { + renderMeasure( + context, + measure, + index, + (index % STAVE_PER_ROW) * STAVE_WIDTH, + Math.floor(index / STAVE_PER_ROW) * LINE_HEIGHT, + index === song.measures.length - 1, ); + }); +} - // Configure the rendering context. - renderer.resize(3000, 3000); - const context = renderer.getContext(); - context.setFont('Roboto', 10); - - let xOffset = 0; - let yOffset = 0; - const verticalOffset = 100; - - this.song.measures.forEach((measure, index) => { - const stave = new Stave(xOffset, yOffset, 300); - - if (measure.hasClef) { - stave.addClef('percussion'); - } - if (measure.sigChange) { - stave.addTimeSignature(`${measure.timeSig[0]}/${measure.timeSig[1]}`); - } - stave.setContext(context).draw(); - - if (measure.tickNotes.length) { - const notes = measure.tickNotes.map((tickNote) => { - return new StaveNote({ - keys: tickNote.notes.map((n) => n.key), - duration: tickNote.duration, - align_center: tickNote.duration === 'wr', - }); - }); - - const beams = Beam.generateBeams(notes, { - flat_beams: true, - stem_direction: -1, - // flat_beam_offset: 150, - }); - - Formatter.FormatAndDraw(context, stave, notes); - - beams.forEach((b) => { - b.setContext(context).draw(); - }); - } - - if ((index + 1) % 4 === 0) { - xOffset = 0; - yOffset += verticalOffset; - } else { - xOffset += stave.getWidth(); - } - }); +function renderMeasure( + context: RenderContext, + measure: Measure, + index: number, + xOffset: number, + yOffset: number, + endMeasure: boolean, +) { + const stave = new Stave(xOffset, yOffset, STAVE_WIDTH); + + if (endMeasure) { + stave.setEndBarType(Barline.type.END); + } + if (measure.hasClef) { + stave.addClef('percussion'); } + if (measure.sigChange) { + stave.addTimeSignature(`${measure.timeSig[0]}/${measure.timeSig[1]}`); + } + + stave.setText(`${index}`, ModifierPosition.ABOVE, { + justification: TextJustification.LEFT, + }); + + stave.setContext(context).draw(); + + const tuplets: StaveNote[][] = []; + let currentTuplet: StaveNote[] | null = null; + + const notes = measure.notes.map((note) => { + const staveNote = new StaveNote({ + keys: note.notes, + duration: note.duration, + align_center: note.duration === 'wr', + }); + + if ( + (note.isTriplet && !currentTuplet) || + (note.isTriplet && currentTuplet && currentTuplet.length === 3) + ) { + currentTuplet = [staveNote]; + tuplets.push(currentTuplet); + } else if (note.isTriplet && currentTuplet) { + currentTuplet.push(staveNote); + } else if (!note.isTriplet && currentTuplet) { + currentTuplet = null; + } + + if (note.dotted) { + Dot.buildAndAttach([staveNote], { + all: true, + }); + } + return staveNote; + }); + + const drawableTuplets = tuplets.map((tupletNotes) => new Tuplet(tupletNotes)); + + const beams = Beam.generateBeams(notes, { + flat_beams: true, + stem_direction: -1, + }); + + Formatter.FormatAndDraw(context, stave, notes); + + drawableTuplets.forEach((tuplet) => { + tuplet.setContext(context).draw(); + }); + + beams.forEach((b) => { + b.setContext(context).draw(); + }); + + return stave; } diff --git a/src/renderer/song.ts b/src/renderer/song.ts deleted file mode 100644 index 1fdcff4..0000000 --- a/src/renderer/song.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { HeaderJSON, MidiJSON, TrackJSON } from '@tonejs/midi'; -import { NoteJSON } from '@tonejs/midi/dist/Note'; - -export interface Note { - key: string; -} - -export interface TickNote { - notes: Note[]; - duration: string; - tick: number; -} - -export interface Measure { - timeSig: [number, number]; - sigChange: boolean; - hasClef: boolean; - tickNotes: TickNote[]; - startTick: number; - endTick?: number; - durationTicks?: number; -} - -export interface RawMidiNote { - note: NoteJSON; - key: string; -} - -export interface Modifier { - forNote: number; - key: string; -} - -export interface ModifierNote { - note: NoteJSON; - modifier: Modifier; -} - -export class Song { - mapping: { [key: number]: string } = { - 96: 'f/4', - 97: 'c/5', - 98: 'g/5/x2', - 99: 'f/5/x2', - 100: 'a/5/x2', - }; - - tomModifiers: { [key: number]: Modifier } = { - 110: { - forNote: 98, - key: 'e/5', - }, - 111: { - forNote: 99, - key: 'd/5', - }, - 112: { - forNote: 100, - key: 'a/4', - }, - }; - - measures: Measure[] = []; - - rawMidiNotes: Map = new Map(); - - endOfTrackTicks: number; - - modifierNotes: ModifierNote[] = []; - - header: HeaderJSON; - - constructor(data: MidiJSON) { - const drumPart = data.tracks.find((track) => track.name === 'PART DRUMS'); - - if (!drumPart) { - throw new Error('no drum part'); - } - - this.endOfTrackTicks = drumPart.endOfTrackTicks || 0; - - this.header = data.header; - - this.processNotes(drumPart); - this.parse(); - this.addWholeRests(); - console.log(this.measures, this.header.ppq); - } - - processNotes(trackData: TrackJSON) { - trackData.notes.forEach((note) => { - if (this.mapping[note.midi]) { - const tickData = this.rawMidiNotes.get(note.ticks) ?? []; - tickData.push({ - note, - key: this.mapping[note.midi], - }); - this.rawMidiNotes.set(note.ticks, tickData); - } else if (this.tomModifiers[note.midi]) { - this.modifierNotes.push({ - note, - modifier: this.tomModifiers[note.midi], - }); - } - }); - } - - areTimeSigEqual(ts1: [number, number], ts2: [number, number]) { - return ts1[0] === ts2[0] && ts1[1] === ts2[1]; - } - - getCurrentTimeSig(currentTick: number) { - const { timeSignatures } = this.header; - const timeSignatureIndex = timeSignatures.findIndex( - ({ ticks }) => currentTick < ticks, - ); - return ( - (timeSignatures[ - (timeSignatureIndex === -1 - ? timeSignatures.length - : timeSignatureIndex) - 1 - ]?.timeSignature as [number, number]) ?? [4, 4] - ); - } - - getNoteKey(note: RawMidiNote, modifiers: ModifierNote[]) { - return ( - modifiers.find((modifier) => modifier.modifier.forNote === note.note.midi) - ?.modifier.key ?? note.key - ); - } - - parse() { - const { ppq } = this.header; - let currentMeasure: Measure = { - sigChange: true, - timeSig: [4, 4], - hasClef: true, - tickNotes: [], - startTick: 0, - }; - this.measures.push(currentMeasure); - - let notes: TickNote[] = []; - let currentMeasureTicks = 0; - const step = ppq / 16; - - for ( - let currentTick = 0, currentModifierNotes: ModifierNote[] = []; - currentTick < this.endOfTrackTicks; - currentTick += step - ) { - const timeSignature = this.getCurrentTimeSig(currentTick); - const pulsesPerDivision = ppq / (timeSignature[1] / 4); - - if (currentMeasureTicks === pulsesPerDivision * timeSignature[0]) { - const sigChange = !this.areTimeSigEqual( - timeSignature, - this.measures[this.measures.length - 1].timeSig, - ); - currentMeasure.endTick = currentTick; - - currentMeasure = { - timeSig: timeSignature, - sigChange, - tickNotes: notes, - startTick: currentTick, - durationTicks: currentMeasureTicks / step, - hasClef: false, - }; - - this.measures.push(currentMeasure); - - notes = []; - currentMeasureTicks = 0; - } - - const tickNotes = this.rawMidiNotes.get(currentTick); - - currentModifierNotes.push( - ...this.modifierNotes.filter( - (modifier) => modifier.note.ticks === currentTick, - ), - ); - - currentModifierNotes = currentModifierNotes.filter( - (modifier) => - modifier.note.durationTicks + modifier.note.ticks !== currentTick, - ); - - if (tickNotes) { - currentMeasure.tickNotes.push({ - notes: tickNotes.map((note) => ({ - key: this.getNoteKey(note, currentModifierNotes), - })), - duration: '32', - tick: currentMeasureTicks / step, - }); - } - - currentMeasureTicks += step; - } - } - - addWholeRests() { - this.measures.forEach((measure) => { - if (measure.tickNotes.length === 0) { - measure.tickNotes.push({ - notes: [{ key: 'c/5' }], - duration: 'wr', - tick: 0, - }); - } - }); - } - - // extendNoteDuration() { - // this.measures.forEach((measure) => { - // measure.tickNotes.forEach((tickNote, index) => { - - // }); - // }); - // } -} diff --git a/yarn.lock b/yarn.lock index afc55e4..cd8af10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2217,11 +2217,6 @@ resolved "https://registry.yarnpkg.com/@types/verror/-/verror-1.10.9.tgz#420c32adb9a2dd50b3db4c8f96501e05a0e72941" integrity sha512-MLx9Z+9lGzwEuW16ubGeNkpBDE84RpB/NyGgg6z2BTpWzKkGU451cAY3UkUzZEp72RHF585oJ3V8JVNqIplcAQ== -"@types/vexflow@^1.2.42": - version "1.2.42" - resolved "https://registry.yarnpkg.com/@types/vexflow/-/vexflow-1.2.42.tgz#76883be5ba41b5a88e226d3089162b4bb1e90cd0" - integrity sha512-bj3If1sm1FYJN0tJMV2BJNrwoeQ6xfrTt+rbmFvUytyxUhklLlW79MR4uwQ+upzrNJLVy012TnC7oZqSpEglSw== - "@types/webpack-bundle-analyzer@^4.6.0": version "4.6.3" resolved "https://registry.yarnpkg.com/@types/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.6.3.tgz#53c26f21134ca2e5049fd2af4f2ffbf8dfe87b4f"