| | import { Midi } from '@tonejs/midi' |
| | import { writeMidi } from 'midi-file' |
| | import type { MidiData, MidiEvent } from 'midi-file' |
| | import type { NoteEvent, ProjectSnapshot, TimeSignature } from '../types' |
| |
|
| | const DEFAULT_SIGNATURE: TimeSignature = [4, 4] |
| |
|
| | |
| | |
| | function decodeUtf8ByteString(byteString: string): string { |
| | try { |
| | const bytes = new Uint8Array(byteString.length) |
| | for (let i = 0; i < byteString.length; i++) { |
| | bytes[i] = byteString.charCodeAt(i) |
| | } |
| | return new TextDecoder('utf-8').decode(bytes) |
| | } catch { |
| | return byteString |
| | } |
| | } |
| |
|
| | |
| | |
| | function encodeUtf8ByteString(text: string): string { |
| | const bytes = new TextEncoder().encode(text) |
| | let output = '' |
| | bytes.forEach((b) => { |
| | output += String.fromCharCode(b) |
| | }) |
| | return output |
| | } |
| |
|
| | export async function importMidiFile(file: File): Promise<ProjectSnapshot> { |
| | const buffer = await file.arrayBuffer() |
| | return parseMidiBuffer(buffer) |
| | } |
| |
|
| | export async function parseMidiBuffer(buffer: ArrayBuffer): Promise<ProjectSnapshot> { |
| | const midi = new Midi(buffer) |
| | const tempo = midi.header.tempos[0]?.bpm ?? 120 |
| | const timeSignature = (midi.header.timeSignatures[0]?.timeSignature as TimeSignature | undefined) ?? DEFAULT_SIGNATURE |
| | |
| | |
| | const allNotes = midi.tracks |
| | .flatMap(t => t.notes) |
| | .sort((a, b) => a.ticks - b.ticks || a.midi - b.midi) |
| | |
| | |
| | const lyricEvents = midi.header.meta |
| | .filter((event) => event.type === 'lyrics') |
| | .sort((a, b) => a.ticks - b.ticks) |
| | |
| | |
| | |
| | const lyricsByTick = new Map<number, string[]>() |
| | for (const event of lyricEvents) { |
| | const existing = lyricsByTick.get(event.ticks) || [] |
| | existing.push(decodeUtf8ByteString(event.text)) |
| | lyricsByTick.set(event.ticks, existing) |
| | } |
| | |
| | |
| | const usedLyricIndices = new Map<number, number>() |
| | |
| | const notes: NoteEvent[] = allNotes.map((note, index) => { |
| | const beat = note.ticks / midi.header.ppq |
| | const durationBeats = note.durationTicks / midi.header.ppq |
| | |
| | let lyric = '' |
| | |
| | |
| | const lyricsAtTick = lyricsByTick.get(note.ticks) |
| | if (lyricsAtTick && lyricsAtTick.length > 0) { |
| | const usedIndex = usedLyricIndices.get(note.ticks) || 0 |
| | if (usedIndex < lyricsAtTick.length) { |
| | lyric = lyricsAtTick[usedIndex] |
| | usedLyricIndices.set(note.ticks, usedIndex + 1) |
| | } |
| | } |
| | |
| | |
| | if (!lyric) { |
| | const tolerance = midi.header.ppq / 100 |
| | for (const [tick, lyrics] of lyricsByTick.entries()) { |
| | if (Math.abs(tick - note.ticks) <= tolerance) { |
| | const usedIndex = usedLyricIndices.get(tick) || 0 |
| | if (usedIndex < lyrics.length) { |
| | lyric = lyrics[usedIndex] |
| | usedLyricIndices.set(tick, usedIndex + 1) |
| | break |
| | } |
| | } |
| | } |
| | } |
| |
|
| | return { |
| | id: `${index}-${note.midi}-${Math.round(note.ticks)}`, |
| | midi: note.midi, |
| | start: beat, |
| | duration: Math.max(durationBeats, 0.0625), |
| | velocity: note.velocity, |
| | lyric, |
| | } |
| | }) |
| |
|
| | return { tempo, timeSignature, notes, ppq: midi.header.ppq } |
| | } |
| |
|
| | |
| | type WithAbsoluteTime<T> = T & { absoluteTime: number } |
| |
|
| | export function exportMidi(snapshot: ProjectSnapshot): Blob { |
| | const ppq = snapshot.ppq ?? 480 |
| | const microsecondsPerBeat = Math.round(60000000 / snapshot.tempo) |
| |
|
| | |
| | const sortedNotes = [...snapshot.notes].sort((a, b) => a.start - b.start || a.midi - b.midi) |
| |
|
| | |
| | |
| | |
| | const events: Array<WithAbsoluteTime<MidiEvent>> = [] |
| |
|
| | |
| | sortedNotes.forEach((note) => { |
| | const startTicks = Math.round(note.start * ppq) |
| | const endTicks = Math.round((note.start + note.duration) * ppq) |
| | const velocity = Math.round(note.velocity * 127) |
| |
|
| | |
| | const lyricText = note.lyric ?? '' |
| | const encodedLyric = encodeUtf8ByteString(lyricText) |
| | |
| | |
| | events.push({ |
| | absoluteTime: startTicks, |
| | deltaTime: 0, |
| | meta: true, |
| | type: 'lyrics', |
| | text: encodedLyric, |
| | _sortKey: 1, |
| | } as WithAbsoluteTime<MidiEvent> & { _sortKey: number }) |
| |
|
| | |
| | events.push({ |
| | absoluteTime: startTicks, |
| | deltaTime: 0, |
| | type: 'noteOn', |
| | channel: 0, |
| | noteNumber: note.midi, |
| | velocity: velocity, |
| | _sortKey: 2, |
| | } as WithAbsoluteTime<MidiEvent> & { _sortKey: number }) |
| |
|
| | |
| | events.push({ |
| | absoluteTime: endTicks, |
| | deltaTime: 0, |
| | type: 'noteOff', |
| | channel: 0, |
| | noteNumber: note.midi, |
| | velocity: 0, |
| | _sortKey: 0, |
| | } as WithAbsoluteTime<MidiEvent> & { _sortKey: number }) |
| | }) |
| |
|
| | |
| | events.sort((a, b) => { |
| | const aKey = (a as { _sortKey?: number })._sortKey ?? 1 |
| | const bKey = (b as { _sortKey?: number })._sortKey ?? 1 |
| | return a.absoluteTime - b.absoluteTime || aKey - bKey |
| | }) |
| |
|
| | |
| | let lastTick = 0 |
| | events.forEach(event => { |
| | event.deltaTime = event.absoluteTime - lastTick |
| | lastTick = event.absoluteTime |
| | delete (event as { absoluteTime?: number }).absoluteTime |
| | delete (event as { _sortKey?: number })._sortKey |
| | }) |
| |
|
| | |
| | const track: MidiEvent[] = [ |
| | |
| | { |
| | deltaTime: 0, |
| | meta: true, |
| | type: 'setTempo', |
| | microsecondsPerBeat: microsecondsPerBeat, |
| | }, |
| | |
| | { |
| | deltaTime: 0, |
| | meta: true, |
| | type: 'timeSignature', |
| | numerator: snapshot.timeSignature[0], |
| | denominator: snapshot.timeSignature[1], |
| | metronome: 24, |
| | thirtyseconds: 8, |
| | }, |
| | |
| | ...events, |
| | |
| | { |
| | deltaTime: 0, |
| | meta: true, |
| | type: 'endOfTrack', |
| | }, |
| | ] |
| |
|
| | |
| | const midiData: MidiData = { |
| | header: { |
| | format: 0, |
| | numTracks: 1, |
| | ticksPerBeat: ppq, |
| | }, |
| | tracks: [track], |
| | } |
| |
|
| | const bytes = writeMidi(midiData) |
| | return new Blob([new Uint8Array(bytes)], { type: 'audio/midi' }) |
| | } |
| |
|