midi-segmentizer
Advanced tools
| import { Midi as ToneJsMidi } from "@tonejs/midi"; | ||
| import { Note } from "."; | ||
| declare class Midi extends ToneJsMidi { | ||
| readonly simpleBpm: number; | ||
| private constructor(); | ||
| static fromBuffer(buffer: ArrayBuffer): Midi | null; | ||
| static fromBase64(str: string): Midi | null; | ||
| getAllNotes(): Note[]; | ||
| } | ||
| export default Midi; |
+39
| "use strict"; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| const midi_1 = require("@tonejs/midi"); | ||
| class Midi extends midi_1.Midi { | ||
| constructor(buffer, bpm) { | ||
| super(buffer); | ||
| this.simpleBpm = bpm; | ||
| } | ||
| static fromBuffer(buffer) { | ||
| try { | ||
| const midi = new midi_1.Midi(buffer); | ||
| const bpm = midi.header.tempos[0].bpm; | ||
| return new Midi(buffer, bpm); | ||
| } | ||
| catch (e) { | ||
| console.info("Buffer isn't valid Midi or SimpleMidi"); | ||
| console.info(e); | ||
| return null; | ||
| } | ||
| } | ||
| static fromBase64(str) { | ||
| return Midi.fromBuffer(Buffer.from(str, "base64")); | ||
| } | ||
| getAllNotes() { | ||
| const res = []; | ||
| for (const track of this.tracks) { | ||
| for (const note of track.notes) { | ||
| res.push({ | ||
| time: note.time, | ||
| midi: note.midi, | ||
| duration: note.duration, | ||
| velocity: note.velocity, | ||
| }); | ||
| } | ||
| } | ||
| return res; | ||
| } | ||
| } | ||
| exports.default = Midi; |
+44
| import { Midi as ToneJsMidi } from "@tonejs/midi"; | ||
| import { Note } from "."; | ||
| class Midi extends ToneJsMidi { | ||
| readonly simpleBpm: number; | ||
| private constructor(buffer: ArrayBuffer, bpm: number) { | ||
| super(buffer); | ||
| this.simpleBpm = bpm; | ||
| } | ||
| public static fromBuffer(buffer: ArrayBuffer): Midi | null { | ||
| try { | ||
| const midi = new ToneJsMidi(buffer); | ||
| const bpm = midi.header.tempos[0].bpm; | ||
| return new Midi(buffer, bpm); | ||
| } catch (e) { | ||
| console.info("Buffer isn't valid Midi or SimpleMidi"); | ||
| console.info(e); | ||
| return null; | ||
| } | ||
| } | ||
| public static fromBase64(str: string) { | ||
| return Midi.fromBuffer(Buffer.from(str, "base64")); | ||
| } | ||
| public getAllNotes(): Note[] { | ||
| const res = [] as Note[]; | ||
| for (const track of this.tracks) { | ||
| for (const note of track.notes) { | ||
| res.push({ | ||
| time: note.time, | ||
| midi: note.midi, | ||
| duration: note.duration, | ||
| velocity: note.velocity, | ||
| }); | ||
| } | ||
| } | ||
| return res; | ||
| } | ||
| } | ||
| export default Midi; |
@@ -14,1 +14,2 @@ import { Midi, Track } from "@tonejs/midi"; | ||
| export declare const processMidiFile: (midi: SimpleMidi) => Segment[]; | ||
| export declare function getSegmentOffset(firstNoteStart: number, beatLength: number, shift: number): number; |
+12
-5
| "use strict"; | ||
| var __importDefault = (this && this.__importDefault) || function (mod) { | ||
| return (mod && mod.__esModule) ? mod : { "default": mod }; | ||
| }; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.processMidiFile = exports.base64ToBinary = exports.getDurationOfNotes = exports.getBeatLength = exports.sliceAndDice = exports.getPartitioningArrayWithMax = exports.getSimplePartitioningArray = exports.shiftMidi = exports.getValidTracks = exports.MAX_BREATH_SECONDS = void 0; | ||
| const notes_processor_1 = require("./notes-processor"); | ||
| exports.getSegmentOffset = exports.processMidiFile = exports.base64ToBinary = exports.getDurationOfNotes = exports.getBeatLength = exports.sliceAndDice = exports.getPartitioningArrayWithMax = exports.getSimplePartitioningArray = exports.shiftMidi = exports.getValidTracks = exports.MAX_BREATH_SECONDS = void 0; | ||
| const notes_processor_1 = __importDefault(require("./notes-processor")); | ||
| exports.MAX_BREATH_SECONDS = 7; | ||
@@ -71,5 +74,3 @@ const getValidTracks = (midi) => { | ||
| finalDivisions.forEach((notes) => { | ||
| const firstNoteStartTime = notes[0].time; | ||
| const minimumOffset = firstNoteStartTime - (firstNoteStartTime % beatLength); | ||
| const offset = minimumOffset - shift; | ||
| const offset = getSegmentOffset(notes[0].time, beatLength, shift); | ||
| let lowestNote = Infinity; | ||
@@ -99,1 +100,7 @@ let highestNote = -Infinity; | ||
| exports.processMidiFile = processMidiFile; | ||
| function getSegmentOffset(firstNoteStart, beatLength, shift) { | ||
| const minimumOffset = firstNoteStart - (firstNoteStart % beatLength); | ||
| const offset = minimumOffset - shift; | ||
| return offset; | ||
| } | ||
| exports.getSegmentOffset = getSegmentOffset; |
+4
-1
| "use strict"; | ||
| var __importDefault = (this && this.__importDefault) || function (mod) { | ||
| return (mod && mod.__esModule) ? mod : { "default": mod }; | ||
| }; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| exports.segmentizeMidi = void 0; | ||
| const helpers_1 = require("./helpers"); | ||
| const simple_midi_1 = require("./simple-midi"); | ||
| const simple_midi_1 = __importDefault(require("./simple-midi")); | ||
| function segmentizeMidi(rawBase64Midi) { | ||
@@ -7,0 +10,0 @@ const simpleMidi = simple_midi_1.default.fromBase64(rawBase64Midi); |
| "use strict"; | ||
| var __importDefault = (this && this.__importDefault) || function (mod) { | ||
| return (mod && mod.__esModule) ? mod : { "default": mod }; | ||
| }; | ||
| Object.defineProperty(exports, "__esModule", { value: true }); | ||
| const midi_1 = require("@tonejs/midi"); | ||
| const helpers_1 = require("./helpers"); | ||
| const notes_processor_1 = require("./notes-processor"); | ||
| const notes_processor_1 = __importDefault(require("./notes-processor")); | ||
| function midiHasMultipleTempos(midi) { | ||
@@ -7,0 +10,0 @@ return midi.header.tempos.length !== 1; |
+3
-2
| { | ||
| "name": "midi-segmentizer", | ||
| "version": "2.1.0", | ||
| "version": "2.1.1-1", | ||
| "description": "break a simple midi file into monophonic segments", | ||
@@ -48,3 +48,4 @@ "main": "dist/index.js", | ||
| "ts-node": "^10.5.0", | ||
| "typescript": "^4.5.5" | ||
| "typescript": "^4.5.5", | ||
| "uuid": "^8.3.2" | ||
| }, | ||
@@ -51,0 +52,0 @@ "dependencies": { |
+304
-63
@@ -1,11 +0,79 @@ | ||
| import * as React from "react"; | ||
| import React, { useEffect, useRef, useState } from "react"; | ||
| import * as ReactDOM from "react-dom"; | ||
| import { v4 as uuidv4 } from "uuid"; | ||
| import { Note, Segment, segmentizeMidi } from "."; | ||
| import { Note as RawNote } from "."; | ||
| import { sample } from "./sample"; | ||
| import Midi from "./midi"; | ||
| import { getBeatLength, getSegmentOffset, shiftMidi } from "./helpers"; | ||
| const X_EXPAND = 100; | ||
| type Coord = { x: number; y: number }; | ||
| interface Note extends RawNote, Coord { | ||
| width: number; | ||
| height: number; | ||
| topLeft: Coord; | ||
| bottomRight: Coord; | ||
| selected: boolean; | ||
| offset: number; | ||
| } | ||
| function buildNote( | ||
| note: RawNote & { selected?: boolean; offset?: number } | ||
| ): Note { | ||
| // NOTE: offset is a unit of time | ||
| const height = 1000 / 127; | ||
| const noteStart = note.time; | ||
| const width = note.duration * X_EXPAND; | ||
| const offset = note.offset ?? 0; | ||
| const x = noteStart * X_EXPAND + offset * X_EXPAND; | ||
| const y = (127 - note.midi) * height; | ||
| const topLeft = { x, y }; | ||
| const bottomRight = { x: x + width, y: y + height }; | ||
| return { | ||
| ...note, | ||
| x, | ||
| y, | ||
| width, | ||
| height, | ||
| topLeft, | ||
| bottomRight, | ||
| offset, | ||
| selected: note.selected ?? false, | ||
| }; | ||
| } | ||
| interface Segment { | ||
| id: string; | ||
| notes: Note[]; | ||
| color: string; | ||
| offset: number; | ||
| } | ||
| function getBars(beatLength: number, width: number): number[] { | ||
| const res = []; | ||
| let i = 0; | ||
| while (i <= width) { | ||
| res.push(i); | ||
| i += beatLength; | ||
| } | ||
| return res; | ||
| } | ||
| function renderBars(ctx: Canvas, bars: number[], height: number) { | ||
| for (const bar of bars) { | ||
| ctx.beginPath(); | ||
| ctx.lineWidth = 1; | ||
| ctx.strokeStyle = "rgba(0, 0, 0, 0.2)"; | ||
| const x = bar * X_EXPAND; | ||
| ctx.moveTo(x, 0); | ||
| ctx.lineTo(x, height); | ||
| ctx.stroke(); | ||
| } | ||
| } | ||
| type Canvas = CanvasRenderingContext2D; | ||
| const X_EXPAND = 100; // NOTE: ideally this is based on width | ||
| function getRandomColor() { | ||
@@ -16,27 +84,12 @@ const rand = () => Math.floor(Math.random() * 255); | ||
| function renderNote( | ||
| ctx: Canvas, | ||
| note: Note, | ||
| offset: number, | ||
| w: number, | ||
| h: number, | ||
| col: string | ||
| ) { | ||
| const noteHeight = h / 127; | ||
| const noteStart = offset + note.time; | ||
| const length = note.duration * X_EXPAND; | ||
| const x = noteStart * X_EXPAND; | ||
| const y = (127 - note.midi) * noteHeight; | ||
| function renderNote(ctx: Canvas, note: Note, color?: string) { | ||
| ctx.beginPath(); | ||
| ctx.lineWidth = 2; | ||
| ctx.strokeStyle = col; | ||
| ctx.fillStyle = col; | ||
| ctx.rect(x, y, length, noteHeight); | ||
| ctx.strokeStyle = color ?? "#000000"; | ||
| ctx.rect(note.x, note.y, note.width, note.height); | ||
| ctx.stroke(); | ||
| ctx.fill(); | ||
| return { | ||
| topLeft: { x, y }, | ||
| bottomRight: { x: x + length, y: y + noteHeight }, | ||
| }; | ||
| if (note.selected) { | ||
| ctx.fillStyle = color ?? "#000000"; | ||
| ctx.fill(); | ||
| } | ||
| } | ||
@@ -59,29 +112,54 @@ | ||
| function renderSegment(ctx: Canvas, segment: Segment, w: number, h: number) { | ||
| const { notes, offset } = segment; | ||
| const col = getRandomColor(); | ||
| function renderSegments(ctx: Canvas, segments: Segment[]) { | ||
| for (const segment of segments) { | ||
| renderSegment(ctx, segment); | ||
| } | ||
| } | ||
| function renderSegment(ctx: Canvas, segment: Segment) { | ||
| let hi = Infinity; | ||
| let lo = -Infinity; | ||
| let left = Infinity; | ||
| let right = -Infinity; | ||
| for (const note of segment.notes) { | ||
| renderNote(ctx, note, segment.color); | ||
| hi = Math.min(hi, note.topLeft.y); | ||
| lo = Math.max(lo, note.bottomRight.y); | ||
| right = Math.max(right, note.bottomRight.x); | ||
| } | ||
| const left = segment.offset * X_EXPAND; | ||
| renderBox(ctx, hi, lo, left, right, segment.color); | ||
| } | ||
| function renderNotes(ctx: Canvas, notes: Note[]) { | ||
| for (const note of notes) { | ||
| const { topLeft, bottomRight } = renderNote(ctx, note, offset, w, h, col); | ||
| hi = Math.min(hi, topLeft.y); | ||
| lo = Math.max(lo, bottomRight.y); | ||
| left = Math.min(left, topLeft.x); | ||
| right = Math.max(right, bottomRight.x); | ||
| renderNote(ctx, note); | ||
| } | ||
| renderBox(ctx, hi, lo, offset * X_EXPAND, right, col); | ||
| } | ||
| function renderSegments( | ||
| ctx: Canvas, | ||
| segments: Segment[], | ||
| w: number, | ||
| h: number | ||
| ) { | ||
| for (const segment of segments) { | ||
| renderSegment(ctx, segment, w, h); | ||
| function useKeyPress(targetKey: string): boolean { | ||
| // State for keeping track of whether key is pressed | ||
| const [keyPressed, setKeyPressed] = useState(false); | ||
| // If pressed key is our target key then set to true | ||
| function downHandler({ key }: any): void { | ||
| if (key === targetKey) { | ||
| setKeyPressed(true); | ||
| } | ||
| } | ||
| // If released key is our target key then set to false | ||
| const upHandler = ({ key }: any): void => { | ||
| if (key === targetKey) { | ||
| setKeyPressed(false); | ||
| } | ||
| }; | ||
| // Add event listeners | ||
| useEffect(() => { | ||
| window.addEventListener("keydown", downHandler); | ||
| window.addEventListener("keyup", upHandler); | ||
| // Remove event listeners on cleanup | ||
| return () => { | ||
| window.removeEventListener("keydown", downHandler); | ||
| window.removeEventListener("keyup", upHandler); | ||
| }; | ||
| }, []); // Empty array ensures that effect is only run on mount and unmount | ||
| return keyPressed; | ||
| } | ||
@@ -95,15 +173,40 @@ | ||
| function App() { | ||
| const ref = React.useRef(null); | ||
| const [data, setData] = React.useState<string>(sample); | ||
| const [height, setHeight] = React.useState(500); | ||
| const [width, setWidth] = React.useState(2000); | ||
| const ref = useRef(null); | ||
| const [data, setData] = useState<string>(sample); | ||
| const [height, setHeight] = useState(800); | ||
| const [width, setWidth] = useState(2000); | ||
| const [notes, setNotes] = useState<Note[]>([]); | ||
| const [bars, setBars] = useState<number[]>([]); | ||
| const [segments, setSegments] = useState<Segment[]>([]); | ||
| const [mouseDown, setMouseDown] = useState<Coord | null>(null); | ||
| const [beatLength, setBeatLength] = useState<number | null>(null); | ||
| const [shift, setShift] = useState<number | null>(null); | ||
| const escapePressed = useKeyPress("Escape"); | ||
| React.useEffect(() => { | ||
| useEffect(() => { | ||
| if (escapePressed) { | ||
| handleClearSelection(); | ||
| } | ||
| }, [escapePressed]); | ||
| useEffect(() => { | ||
| if (data) { | ||
| const segments = segmentizeMidi(data); | ||
| if (ref.current && segments) { | ||
| const current = ref.current as any; | ||
| const ctx: CanvasRenderingContext2D = current.getContext("2d"); | ||
| clear(ctx, width, height); | ||
| renderSegments(ctx, segments, width, height); | ||
| const midi = Midi.fromBase64(data); | ||
| if (midi) { | ||
| const _beatLength = getBeatLength(midi.simpleBpm); | ||
| const _shift = _beatLength * 2; | ||
| shiftMidi(midi, _shift); | ||
| const notes = midi | ||
| .getAllNotes() | ||
| .map(buildNote) | ||
| .sort((a, b) => (b.time > a.time ? -1 : 1)); | ||
| const maxWidth = Math.max(...notes.map((n) => n.x)) + 300; | ||
| setBars(getBars(_beatLength, maxWidth)); | ||
| setWidth(maxWidth); | ||
| setBeatLength(_beatLength); | ||
| setShift(_shift); | ||
| setTimeout(() => { | ||
| // dumb hack to get rid of weird canvas dimension change display issue | ||
| setNotes(notes); | ||
| }, 100); | ||
| } | ||
@@ -113,2 +216,87 @@ } | ||
| function handleMouseDown(evt: any) { | ||
| if (ref.current) { | ||
| const rect = (ref.current as any).getBoundingClientRect(); | ||
| const x = evt.clientX - rect.left; | ||
| const y = evt.clientY - rect.top; | ||
| setMouseDown({ x, y }); | ||
| } | ||
| } | ||
| function handleClearSelection() { | ||
| setNotes( | ||
| notes.map((note) => ({ | ||
| ...note, | ||
| selected: false, | ||
| })) | ||
| ); | ||
| } | ||
| function handleSaveSegment() { | ||
| const remaining: Note[] = []; | ||
| const segmentNotes: Note[] = []; | ||
| notes.forEach((note) => { | ||
| if (note.selected) { | ||
| segmentNotes.push(note); | ||
| } else { | ||
| remaining.push(note); | ||
| } | ||
| }); | ||
| setNotes(remaining); | ||
| if (segmentNotes.length && beatLength && shift) { | ||
| // const id = | ||
| const color = getRandomColor(); | ||
| const offset = getSegmentOffset(segmentNotes[0].time, beatLength, shift); | ||
| const shiftedNotes = segmentNotes.map((n) => | ||
| // TODO: it's not the most intuitive thing to pass down offset at note level | ||
| buildNote({ ...n, time: n.time - offset, offset }) | ||
| ); | ||
| setSegments([ | ||
| ...segments, | ||
| { id: uuidv4(), color, notes: shiftedNotes, offset }, | ||
| ]); | ||
| } | ||
| } | ||
| function handleMouseUp(evt: any) { | ||
| if (ref.current && mouseDown) { | ||
| const rect = (ref.current as any).getBoundingClientRect(); | ||
| const x = evt.clientX - rect.left; | ||
| const y = evt.clientY - rect.top; | ||
| const left = Math.min(x, mouseDown.x); | ||
| const right = Math.max(x, mouseDown.x); | ||
| const top = Math.min(y, mouseDown.y); | ||
| const bottom = Math.max(y, mouseDown.y); | ||
| setMouseDown(null); | ||
| setNotes( | ||
| notes.map((note) => { | ||
| const { x: noteX, y: noteY, width, height } = note; | ||
| let clicked = false; | ||
| if ( | ||
| noteX + width >= left && | ||
| noteX <= right && | ||
| noteY + height >= top && | ||
| noteY <= bottom | ||
| ) { | ||
| clicked = true; | ||
| } | ||
| return { | ||
| ...note, | ||
| selected: clicked ? !note.selected : note.selected, | ||
| }; | ||
| }) | ||
| ); | ||
| } | ||
| } | ||
| if (ref.current && notes) { | ||
| const current = ref.current as any; | ||
| const ctx: CanvasRenderingContext2D = current.getContext("2d"); | ||
| clear(ctx, width, height); | ||
| renderNotes(ctx, notes); | ||
| renderBars(ctx, bars, height); | ||
| renderSegments(ctx, segments); | ||
| } | ||
| async function handleFileUpload(file: File) { | ||
@@ -120,10 +308,62 @@ const buffer = await file.arrayBuffer(); | ||
| function deleteSegment(segment: Segment) { | ||
| if (segments.find((s) => s.id === segment.id)) { | ||
| setSegments(segments.filter((s) => s.id !== segment.id)); | ||
| setNotes([ | ||
| ...notes, | ||
| ...segment.notes.map((note) => ({ | ||
| ...note, | ||
| time: note.offset + note.time, | ||
| selected: false, | ||
| offset: 0, | ||
| })), | ||
| ]); | ||
| } | ||
| } | ||
| function renderButtons() { | ||
| return ( | ||
| <div style={{ position: "fixed", maxWidth: "800px" }}> | ||
| {segments.map((segment) => ( | ||
| <button | ||
| key={segment.id} | ||
| onClick={() => deleteSegment(segment)} | ||
| style={{ | ||
| backgroundColor: segment.color, | ||
| color: "#FFFFFF", | ||
| }} | ||
| > | ||
| delete | ||
| </button> | ||
| ))} | ||
| </div> | ||
| ); | ||
| } | ||
| function handleDownload() { | ||
| var data = { a: 1, b: 2, c: 3 }; | ||
| var json = JSON.stringify(data); | ||
| const blob = new Blob([json], { type: "application/json" }); | ||
| const url = URL.createObjectURL(blob); | ||
| const a = document.createElement("a"); | ||
| a.download = "backup.json"; | ||
| a.href = url; | ||
| a.textContent = "Download backup.json"; | ||
| } | ||
| return ( | ||
| <> | ||
| <input | ||
| id="fileupload" | ||
| type="file" | ||
| onChange={(e) => handleFileUpload(e.target.files![0])} | ||
| /> | ||
| <div> | ||
| <div style={{ position: "fixed" }}> | ||
| <input | ||
| id="fileupload" | ||
| type="file" | ||
| onChange={(e) => handleFileUpload(e.target.files![0])} | ||
| /> | ||
| <button onClick={handleSaveSegment}>save segment</button> | ||
| <button onClick={handleDownload}>download</button> | ||
| </div> | ||
| <canvas | ||
| onMouseDown={handleMouseDown} | ||
| onMouseUp={handleMouseUp} | ||
| id="react-midiVisualizer-canvas" | ||
@@ -134,3 +374,4 @@ ref={ref} | ||
| /> | ||
| </> | ||
| {renderButtons()} | ||
| </div> | ||
| ); | ||
@@ -137,0 +378,0 @@ } |
+11
-4
@@ -94,6 +94,3 @@ import { Midi, Track } from "@tonejs/midi"; | ||
| finalDivisions.forEach((notes: LibNote[]) => { | ||
| const firstNoteStartTime = notes[0].time; | ||
| const minimumOffset = | ||
| firstNoteStartTime - (firstNoteStartTime % beatLength); | ||
| const offset = minimumOffset - shift; | ||
| const offset = getSegmentOffset(notes[0].time, beatLength, shift); | ||
@@ -125,1 +122,11 @@ let lowestNote = Infinity; | ||
| }; | ||
| export function getSegmentOffset( | ||
| firstNoteStart: number, | ||
| beatLength: number, | ||
| shift: number | ||
| ) { | ||
| const minimumOffset = firstNoteStart - (firstNoteStart % beatLength); | ||
| const offset = minimumOffset - shift; | ||
| return offset; | ||
| } |
+1
-0
@@ -13,2 +13,3 @@ { | ||
| "outDir": "dist", | ||
| "esModuleInterop": true, | ||
| "jsx": "react" | ||
@@ -15,0 +16,0 @@ }, |
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
90881
12.6%39
8.33%1456
29.54%18
5.88%3
50%