musicvis-lib
Version:
Music analysis and visualization library
662 lines (639 loc) • 20.7 kB
JavaScript
import * as midiParser from 'midi-parser-js'
import Note from './Note.js'
import { preprocessMusicXmlData } from '../fileFormats/MusicXmlParser.js'
import { preprocessMidiFileData } from '../fileFormats/MidiParser.js'
import NoteArray from './NoteArray.js'
import GuitarNote from './GuitarNote.js'
/**
* Represents a parsed MIDI or MusicXML file in a uniform format.
*/
class MusicPiece {
/**
* @deprecated Do not use this constructor, but the static methods MusicPiece.fromMidi
* and MusicPiece.fromMusicXml instead.
* @param {string} name name (e.g. file name or piece name)
* @param {TempoDefinition[]} tempos tempos
* @param {TimeSignature[]} timeSignatures time signatures
* @param {KeySignature[]} keySignatures key signatures
* @param {number[]} measureTimes time in seconds for each measure line
* @param {Track[]} tracks tracks
* @param {number[]} [xmlMeasureIndices] for each parsed measure, the index of
* the corresponding XML measure (only for MusicXML)
* @throws {'No or invalid tracks given!'} when invalid tracks are given
*/
constructor (
name,
tempos,
timeSignatures,
keySignatures,
measureTimes,
tracks,
xmlMeasureIndices
) {
if (!tracks || tracks.length === 0) {
throw new Error('No or invalid tracks given! Use .fromMidi or .fromMusicXml?')
}
this.name = name
this.measureTimes = measureTimes
this.xmlMeasureIndices = xmlMeasureIndices
this.tracks = tracks
this.duration = Math.max(...this.tracks.map(d => d.duration))
// Filter multiple identical consecutive infos
this.tempos = tempos.slice(0, 1)
let currentTempo = tempos[0]
for (const tempo of tempos) {
if (tempo.string !== currentTempo.string) {
currentTempo = tempo
this.tempos.push(tempo)
}
}
this.timeSignatures = timeSignatures.slice(0, 1)
let currentTimeSig = timeSignatures[0]
for (const timeSignature of timeSignatures) {
if (timeSignature.string !== currentTimeSig.string) {
currentTimeSig = timeSignature
this.timeSignatures.push(timeSignature)
}
}
this.keySignatures = keySignatures.slice(0, 1)
let currentKeySig = keySignatures[0]
for (const keySignature of keySignatures) {
if (keySignature.string !== currentKeySig.string) {
currentKeySig = keySignature
this.keySignatures.push(keySignature)
}
}
}
/**
* Creates a MusicPiece object from a MIDI file binary
*
* @param {string} name name
* @param {ArrayBuffer} midiFile MIDI file
* @returns {MusicPiece} new MusicPiece
* @throws {'No MIDI file content given'} when MIDI file is undefined or null
* @example <caption>In Node.js</caption>
* const file = path.join(directory, fileName);
* const data = fs.readFileSync(file, 'base64');
* const mp = MusicPiece.fromMidi(fileName, data);
* @example <caption>In the browser</caption>
* const uintArray = new Uint8Array(midiBinary);
* const MP = MusicPiece.fromMidi(filename, uintArray);
*/
static fromMidi (name, midiFile) {
if (!midiFile) {
throw new Error('No MIDI file content given')
}
const midi = midiParser.parse(midiFile)
const parsed = preprocessMidiFileData(midi)
let tempos = []
let timeSignatures = []
let keySignatures = []
let measureTimes = []
if (parsed.tracks.length > 0) {
// Tempos
tempos = parsed.tempoChanges
.map(d => new TempoDefinition(d.time, d.tempo))
// Time signatures
timeSignatures = parsed.beatTypeChanges
.map(d => new TimeSignature(d.time, [d.beats, d.beatType]))
// Key signatures
keySignatures = parsed.keySignatureChanges
.map(d => new KeySignature(d.time, d.key, d.scale))
// Measure times
measureTimes = parsed.measureLinePositions
}
// Tracks
const tracks = parsed.tracks.map((track) => new Track(
track.trackName,
track.instrumentName,
track.noteObjs,
null,
track.measureIndices,
new Map(),
new Map()
))
return new MusicPiece(
name,
tempos,
timeSignatures,
keySignatures,
measureTimes,
tracks
)
}
/**
* Creates a MusicPiece object from a MIDI file binary
*
* @deprecated This is not fully implemented yet
* @todo on hold until @tonejs/midi adds time in seconds for meta events
* @todo use @tonejs/midi for parsing, but the same information as with
* MusicPiece.fromMidi()
* @see https://github.com/Tonejs/Midi
* @param {string} name name
* @param {ArrayBuffer} midiFile MIDI file
* @returns {MusicPiece} new MusicPiece
* @throws {'No MIDI file content given'} when MIDI file is undefined or null
* @example <caption>In Node.js</caption>
* const file = path.join(directory, fileName);
* const data = fs.readFileSync(file);
* const mp = MusicPiece.fromMidi(fileName, data);
* @example <caption>In the browser</caption>
* const uintArray = new Uint8Array(midiBinary);
* const MP = MusicPiece.fromMidi(filename, uintArray);
*/
// static fromMidi2 (name, midiFile) {
// if (!midiFile) {
// throw new Error('No MIDI file content given')
// }
// const parsed = new Midi(midiFile)
// // const uintArray = new Uint8Array(midiFile);
// // const parsed = new Midi(uintArray);
// // Tracks
// const tracks = []
// for (const track of parsed.tracks) {
// if (track.notes.length === 0) { continue }
// const notes = track.notes.map(note => Note.from({
// pitch: note.midi,
// start: note.time,
// end: note.time + note.duration,
// velocity: Math.round(note.velocity * 127),
// channel: track.channel
// }))
// tracks.push(
// Track.fromMidi(
// track.trackName,
// track.instrumentName,
// notes
// )
// )
// }
// // TODO: convert ticks to seconds
// let tempos = []
// let timeSignatures = []
// let keySignatures = []
// let measureTimes = []
// if (parsed.tracks.length > 0) {
// // tempos
// tempos = parsed.header.tempos
// .map(d => new TempoDefinition(d.time, d.tempo))
// // time signatures
// timeSignatures = parsed.header.timeSignatures
// .map(d => new TimeSignature(d.time, [d.beats, d.beatType]))
// // key signatures
// keySignatures = parsed.header.keySignatures
// .map(d => new KeySignature(d.time, d.key, d.scale))
// // measure times
// measureTimes = parsed.measureLinePositions
// }
// return new MusicPiece(
// name,
// tempos,
// timeSignatures,
// keySignatures,
// measureTimes,
// tracks
// )
// }
/**
* Creates a MusicPiece object from a MusicXML string
*
* @param {string} name name
* @param {string|object} xmlFile MusicXML file content as string or object
* If it is an object, it must behave like a DOM, e.g. provide methods
* such as .querySelectorAll()
* @returns {MusicPiece} new MusicPiece
* @throws {'No MusicXML file content given'} when MusicXML file is
* undefined or null
* @example Parsing a MusicPiece in Node.js
* const jsdom = require('jsdom');
* const xmlFile = fs.readFileSync('My Song.musicxml');
* const dom = new jsdom.JSDOM(xmlFile);
* const xmlDocument = dom.window.document;
* const mp = musicvislib.MusicPiece.fromMusicXml('My Song', xmlDocument);
*/
static fromMusicXml (name, xmlFile) {
if (!xmlFile) {
throw new Error('No MusicXML file content given')
}
let xmlDocument = xmlFile
if (typeof xmlDocument === 'string') {
const parser = new DOMParser()
xmlDocument = parser.parseFromString(xmlFile, 'text/xml')
}
const parsed = preprocessMusicXmlData(xmlDocument)
let tempos = []
let timeSignatures = []
let keySignatures = []
if (parsed.parts.length > 0) {
// Tempos
tempos = parsed.parts[0].tempoChanges
.map(d => new TempoDefinition(d.time, d.tempo))
// Time signatures
timeSignatures = parsed.parts[0].beatTypeChanges
.map(d => new TimeSignature(d.time, [d.beats, d.beatType]))
// Key signatures
keySignatures = parsed.parts[0].keySignatureChanges
.map(d => new KeySignature(d.time, d.key, d.scale))
}
// Measure times
let measureTimes = []
let xmlMeasureIndices = []
if (parsed.parts.length > 0) {
measureTimes = parsed.parts[0].measureLinePositions
xmlMeasureIndices = parsed.parts[0].xmlMeasureIndices
}
// Tracks
const tracks = parsed.parts
.map((track, index) => {
for (const n of track.noteObjs) {
n.channel = index
}
return new Track(
parsed.partNames[index],
parsed.instruments[index],
track.noteObjs,
track.tuning,
track.measureIndices,
track.measureRehearsalMap,
track.noteLyricsMap,
track.xmlNoteIndices
)
})
return new MusicPiece(
name,
tempos,
timeSignatures,
keySignatures,
measureTimes,
tracks,
xmlMeasureIndices
)
}
/**
* Allows to get a MusicPiece from JSON after doing JSON.stringify()
*
* @param {string|object} json JSON
* @returns {MusicPiece} new MusicPiece
* @example
* const jsonString = mp.toJson();
* const recovered = MusicPiece.fromJson(jsonString);
*/
static fromJson (json) {
json = (typeof json === 'string') ? JSON.parse(json) : json
const tempos = json.tempos.map(d => new TempoDefinition(d.time, d.bpm))
const timeSignatures = json.timeSignatures.map(d => new TimeSignature(d.time, d.signature))
const keySignatures = json.keySignatures.map(d => new KeySignature(d.time, d.key, d.scale))
const tracks = json.tracks.map(track => Track.from(track))
return new MusicPiece(
json.name,
tempos, timeSignatures,
keySignatures,
json.measureTimes,
tracks,
json.xmlMeasureIndices
)
}
/**
* Returns a JSON-serialized representation
*
* @param {boolean} pretty true for readable (prettified) JSON
* @returns {string} JSON as string
* @example
* const jsonString = mp.toJson();
* const recovered = MusicPiece.fromJson(jsonString);
*/
toJson (pretty = false) {
const _this = {
...this,
tracks: this.tracks.map(d => d.toObject())
}
return JSON.stringify(_this, undefined, pretty ? 2 : 0)
}
/**
* Returns an array with all notes from all tracks.
*
* @deprecated use getNotesFromTracks('all') instead.
* @param {boolean} sortByTime true: sort notes by time
* @returns {Note[]} all notes of this piece
*/
getAllNotes (sortByTime = false) {
const notes = this.tracks.flatMap(t => t.notes)
if (sortByTime) {
notes.sort((a, b) => a.start - b.start)
}
return notes
}
/**
* Returns an array with notes from the specified tracks.
*
* @param {'all'|number|number[]} indices either 'all', a number, or an
* Array with numbers
* @param {boolean} sortByTime true: sort notes by time (not needed for a
* single track)
* @returns {Note[]} Array with all notes from the specified tracks
*/
getNotesFromTracks (indices = 'all', sortByTime = false) {
let notes = []
if (indices === 'all') {
// Return all notes from all tracks
notes = this.tracks.flatMap(t => t.notes)
} else if (Array.isArray(indices)) {
// Return notes from some tracks
notes = this.tracks
.filter((d, i) => indices.includes(i))
.flatMap(t => t.notes)
} else {
// Return notes from a single track
notes = this.tracks[indices].notes
// Notes in each tracks are already sorted
sortByTime = false
}
if (sortByTime) {
notes.sort((a, b) => a.start - b.start)
}
return notes
}
/**
* Transposes all or only the specified tracks by the specified number of
* (semitone) steps.
* Will return a new MusicPiece instance.
* Note pitches will be clipped to [0, 127].
* Will not change playing instructions such as string and fret.
*
* @param {number} steps number of semitones to transpose (can be negative)
* @param {'all'|number|number[]} tracks tracks to transpose
* @returns {MusicPiece} a new, transposed MusicPiece
*/
transpose (steps = 0, tracks = 'all') {
const newTracks = this.tracks.map((track, index) => {
const change = (
tracks === 'all' ||
(Array.isArray(tracks) && tracks.includes(index)) ||
tracks === index
)
const na = new NoteArray(track.notes)
let tuning = track.tuningPitches
if (change) {
// Transpose notes and tuning pitches
na.transpose(steps)
tuning = track.tuningPitches.map(d => d + steps)
}
return new Track(
track.name,
track.instrument,
na.getNotes(),
tuning,
track.measureIndices
)
})
return new MusicPiece(
this.name,
[...this.tempos],
[...this.timeSignatures],
[...this.keySignatures],
[...this.measureTimes],
newTracks
)
}
}
/**
* Used by MusicPiece, should not be used directly
*/
export class Track {
/**
* Creates a new Track.
* Notes will be sorted by Note.startPitchComparator.
*
* @deprecated Do not use this constructor, but the static methods Track.fromMidi
* and Track.fromMusicXml instead.
* @param {string} name name
* @param {string} instrument instrument name
* @param {Note[]} notes notes
* @param {number[]} [tuningPitches=null] MIDI note numbers of the track's
* tuning
* @param {number[]} [measureIndices=null] note indices where new measures
* start
* @param {Map<number,object>} measureRehearsalMap maps measure index to
* rehearsal marks
* @param {Map<number,object>} noteLyricsMap maps note index to lyrics text
* @param {number[][]} xmlNoteIndices for each parsed note, the indices of
* the XML note elements that correspond to it
* @throws {'Notes are undefined or not an array'} for invalid notes
*/
constructor (
name,
instrument,
notes,
tuningPitches = null,
measureIndices = null,
measureRehearsalMap,
noteLyricsMap,
xmlNoteIndices = null
) {
name = !name?.length ? 'unnamed' : name.replace('\u0000', '')
this.name = name
this.instrument = instrument
if (!notes || notes.length === undefined) {
throw new Error('Notes are undefined or not an array')
}
this.notes = notes.sort(Note.startPitchComparator)
this.tuningPitches = tuningPitches
this.measureIndices = measureIndices
this.measureRehearsalMap = measureRehearsalMap
this.noteLyricsMap = noteLyricsMap
this.xmlNoteIndices = xmlNoteIndices
// Computed properties
this.duration = new NoteArray(notes).getDuration()
this.hasStringFret = false
for (const note of notes) {
if (note.string !== undefined && note.fret !== undefined) {
this.hasStringFret = true
break
}
}
}
/**
* Returns an object representation of this Track, turns Maps into Arrays
* to work with JSON.stringify
*
* @returns {object} object represntation
*/
toObject () {
return {
...this,
measureRehearsalMap: [...this.measureRehearsalMap],
noteLyricsMap: [...this.noteLyricsMap]
}
}
/**
* Parses an object into a Track, must have same format as the result of
* Track.toObject().
*
* @param {object} object object represntation of a Track
* @returns {Track} track
*/
static from (object) {
const notes = object.notes.map(note => {
return note.string !== undefined && note.fret !== undefined
? GuitarNote.from(note)
: Note.from(note)
})
const measureRehearsalMap = new Map(object.measureRehearsalMap)
const noteLyricsMap = new Map(object.noteLyricsMap)
return new Track(
object.name,
object.instrument,
notes,
object.tuningPitches,
object.measureIndices,
measureRehearsalMap,
noteLyricsMap,
object.xmlNoteIndices
)
}
/**
* Returns the notes of a track grouped by their measure
*
* @todo test
* @param {function} [sortComparator] sort each measure by a comparator
* @returns {Note[][]} notes grouped by measures
* @example
* myTrack.getMeasures(Note.startPitchComparator)
*/
getMeasures (sortComparator) {
// Get notes by measures
const indices = [0, ...this.measureIndices]
const measures = []
for (let index = 1; index < indices.length; ++index) {
const notes = this.notes.slice(indices[index - 1], indices[index])
if (sortComparator) {
notes.sort(sortComparator)
}
measures.push(notes)
}
return measures
}
/**
* For each section of a piece, returns information on startMeasure, endMeasure,
* and length.
* Requires this.measureRehearsalMap to be sensible
*
* @todo test
* @returns {object[]} section information
*/
getSectionInfo () {
const sections = []
for (const [startMeasure, name] of this.measureRehearsalMap.entries()) {
sections.push({ name, startMeasure, endMeasure: null })
}
for (let index = 1; index < sections.length; ++index) {
sections[index - 1].endMeasure = sections[index].startMeasure - 1
}
// Last section ends with piece
if (sections.length > 0) {
const last = sections[sections.length - 1]
last.endMeasure = this.measureIndices.length - 1
}
// Add first empty section when first does not start at measure 0
if (sections.length > 0 && sections[0].startMeasure > 0) {
const first = {
name: '',
startMeasure: 0,
endMeasure: sections[0].startMeasure - 1
}
sections.unshift(first)
}
// No sections found? Just create a single one for the entire piece
if (sections.length === 0) {
sections.push({
name: '<No sections>',
startMeasure: 0,
endMeasure: this.measureIndices.length - 1
})
}
// Add lengths to each
for (const section of sections) {
// @ts-ignore
section.length = section.endMeasure - section.startMeasure + 1
}
return sections
}
/**
* Returns notes grouped by sections, similar to this.getMeasures.
* Requires this.measureRehearsalMap to be sensible
* sectionInfo and measures will be computed if not passed, but can be passed
* if already available to speed up computation
*
* @todo test
* @param {function} [sortComparator] of not undefined, notes in each section
* will be sorted by this, e.g., Note.startPitchComparator
* @param {object} [sectionInfo] see this.getSectionInfo
* @param {Note[][]} [measures] see this.getMeasures
* @returns {Note[][]} notes grouped by sections
*/
getSections (sortComparator, sectionInfo, measures) {
if (!sectionInfo) {
sectionInfo = this.getSectionInfo()
}
if (!measures) {
measures = this.getMeasures()
}
const indices = sectionInfo.map((d) => d.startMeasure)
const notesBySection = []
for (let index = 1; index < indices.length + 1; ++index) {
const notes = measures
.slice(indices[index - 1], indices[index])
.flat()
if (sortComparator) {
notes.sort(sortComparator)
}
notesBySection.push(notes)
}
return notesBySection
}
}
/**
* Tempo definition
*/
export class TempoDefinition {
/**
* @param {number} time in seconds
* @param {number} bpm tempo in seconds per beat
*/
constructor (time, bpm) {
this.time = time
this.bpm = bpm
this.string = `${bpm} bpm`
}
}
/**
* Time signature definition
*/
export class TimeSignature {
/**
* @param {number} time in seconds
* @param {number[]} signature time signature as [beats, beatType]
*/
constructor (time, signature) {
this.time = time
this.signature = signature
this.string = signature.join('/')
}
}
/**
* Key signature definition
*/
export class KeySignature {
/**
* @param {number} time in seconds
* @param {string} key key e.g. 'C'
* @param {string} scale scale e.g. 'major'
*/
constructor (time, key, scale) {
this.time = time
this.key = key
this.scale = scale
this.string = `${key} ${scale}`
}
}
export default MusicPiece