UNPKG

qambi

Version:

MIDI sequencer, loads MIDI files, can record and playback MIDI, uses WebMIDI and WebAudio

666 lines (562 loc) 17.5 kB
//@ flow import {MIDIEventTypes} from './constants' import {parseEvents} from './parse_events' import {context, masterGain, unlockWebAudio} from './init_audio' import Scheduler from './scheduler' import {MIDIEvent} from './midi_event' import {songFromMIDIFile, songFromMIDIFileSync} from './song_from_midifile' import {sortEvents} from './util' import {calculatePosition} from './position' import {Playhead} from './playhead' import {Metronome} from './metronome' import {addEventListener, removeEventListener, dispatchEvent} from './eventlistener' import {saveAsMIDIFile} from './save_midifile' import {update, _update} from './song.update' import {getSettings} from './settings' let instanceIndex = 0 let recordingIndex = 0 /* type songSettings = { name: string, ppq: number, bpm: number, bars: number, lowestNote: number, highestNote: number, nominator: number, denominator: number, quantizeValue: number, fixedLengthValue: number, positionType: string, useMetronome: boolean, autoSize: boolean, loop: boolean, playbackSpeed: number, autoQuantize: boolean, pitch: number, bufferTime: number, noteNameMode: string } */ /* // initialize song with tracks and part so you do not have to create them separately setup: { timeEvents: [] tracks: [ parts [] ] } */ export class Song{ static fromMIDIFile(data){ return songFromMIDIFile(data) } static fromMIDIFileSync(data){ return songFromMIDIFileSync(data) } constructor(settings: {} = {}){ this.id = `${this.constructor.name}_${instanceIndex++}_${new Date().getTime()}` let defaultSettings = getSettings(); ({ name: this.name = this.id, ppq: this.ppq = defaultSettings.ppq, bpm: this.bpm = defaultSettings.bpm, bars: this.bars = defaultSettings.bars, nominator: this.nominator = defaultSettings.nominator, denominator: this.denominator = defaultSettings.denominator, quantizeValue: this.quantizeValue = defaultSettings.quantizeValue, fixedLengthValue: this.fixedLengthValue = defaultSettings.fixedLengthValue, useMetronome: this.useMetronome = defaultSettings.useMetronome, autoSize: this.autoSize = defaultSettings.autoSize, playbackSpeed: this.playbackSpeed = defaultSettings.playbackSpeed, autoQuantize: this.autoQuantize = defaultSettings.autoQuantize, pitch: this.pitch = defaultSettings.pitch, bufferTime: this.bufferTime = defaultSettings.bufferTime, noteNameMode: this.noteNameMode = defaultSettings.noteNameMode, volume: this.volume = defaultSettings.volume, } = settings); this._timeEvents = [] this._updateTimeEvents = true this._lastEvent = new MIDIEvent(0, MIDIEventTypes.END_OF_TRACK) this._tracks = [] this._tracksById = new Map() this._parts = [] this._partsById = new Map() this._events = [] this._eventsById = new Map() this._allEvents = [] // MIDI events and metronome events this._notes = [] this._notesById = new Map() this._newEvents = [] this._movedEvents = [] this._removedEvents = [] this._transposedEvents = [] this._newParts = [] this._changedParts = [] this._removedParts = [] this._removedTracks = [] this._currentMillis = 0 this._scheduler = new Scheduler(this) this._playhead = new Playhead(this) this.playing = false this.paused = false this.recording = false this.precounting = false this.stopped = true this.looping = false this._gainNode = context.createGain() this._gainNode.gain.value = this.volume this._gainNode.connect(masterGain) this._metronome = new Metronome(this) this._metronomeEvents = [] this._updateMetronomeEvents = true this._metronome.mute(!this.useMetronome) this._loop = false this._leftLocator = {millis: 0, ticks: 0} this._rightLocator = {millis: 0, ticks: 0} this._illegalLoop = false this._loopDuration = 0 this._precountBars = 0 this._endPrecountMillis = 0 let {tracks, timeEvents} = settings //console.log(tracks, timeEvents) if(typeof timeEvents === 'undefined'){ this._timeEvents = [ new MIDIEvent(0, MIDIEventTypes.TEMPO, this.bpm), new MIDIEvent(0, MIDIEventTypes.TIME_SIGNATURE, this.nominator, this.denominator), ] }else{ this.addTimeEvents(...timeEvents) } if(typeof tracks !== 'undefined'){ this.addTracks(...tracks) } this.update() } addTimeEvents(...events){ //@TODO: filter time events on the same tick -> use the lastly added events events.forEach(event => { if(event.type === MIDIEventTypes.TIME_SIGNATURE){ this._updateMetronomeEvents = true } this._timeEvents.push(event) }) this._updateTimeEvents = true } addTracks(...tracks){ tracks.forEach((track) => { track._song = this track._gainNode.connect(this._gainNode) track._songGainNode = this._gainNode this._tracks.push(track) this._tracksById.set(track.id, track) this._newEvents.push(...track._events) this._newParts.push(...track._parts) }) } removeTracks(...tracks){ this._removedTracks.push(...tracks) } update(){ update.call(this) } play(type, ...args): void{ //unlockWebAudio() this._play(type, ...args) if(this._precountBars > 0){ dispatchEvent({type: 'precounting', data: this._currentMillis}) }else if(this._preparedForRecording === true){ dispatchEvent({type: 'start_recording', data: this._currentMillis}) }else{ dispatchEvent({type: 'play', data: this._currentMillis}) } } _play(type, ...args){ if(typeof type !== 'undefined'){ this.setPosition(type, ...args) } if(this.playing){ return } //console.log(this._currentMillis) this._reference = this._timeStamp = context.currentTime * 1000 this._scheduler.setTimeStamp(this._reference) this._startMillis = this._currentMillis if(this._precountBars > 0 && this._preparedForRecording){ // create precount events, the playhead will be moved to the first beat of the current bar let position = this.getPosition() this._metronome.createPrecountEvents(position.bar, position.bar + this._precountBars, this._reference) this._currentMillis = this._calculatePosition('barsbeats', [position.bar], 'millis').millis this._precountDuration = this._metronome.precountDuration this._endPrecountMillis = this._currentMillis + this._precountDuration // console.group('precount') // console.log('position', this.getPosition()) // console.log('_currentMillis', this._currentMillis) // console.log('endPrecountMillis', this._endPrecountMillis) // console.log('_precountDuration', this._precountDuration) // console.groupEnd('precount') //console.log('precountDuration', this._metronome.createPrecountEvents(this._precountBars, this._reference)) this.precounting = true }else { this._endPrecountMillis = 0 this.playing = true this.recording = this._preparedForRecording } //console.log(this._endPrecountMillis) if(this.paused){ this.paused = false } this._playhead.set('millis', this._currentMillis) this._scheduler.init(this._currentMillis) this._loop = this.looping && this._currentMillis <= this._rightLocator.millis this._pulse() } _pulse(): void{ if(this.playing === false && this.precounting === false){ return } if(this._performUpdate === true){ this._performUpdate = false //console.log('pulse update', this._currentMillis) _update.call(this) } let now = context.currentTime * 1000 //console.log(now, performance.now()) let diff = now - this._reference this._currentMillis += diff this._reference = now if(this._endPrecountMillis > 0){ if(this._endPrecountMillis > this._currentMillis){ this._scheduler.update(diff) requestAnimationFrame(this._pulse.bind(this)) //return because during precounting only precount metronome events get scheduled return } this.precounting = false this._endPrecountMillis = 0 this._currentMillis -= this._precountDuration if(this._preparedForRecording){ this.playing = true this.recording = true }else{ this.playing = true dispatchEvent({type: 'play', data: this._startMillis}) //dispatchEvent({type: 'play', data: this._currentMillis}) } } if(this._loop && this._currentMillis >= this._rightLocator.millis){ this._currentMillis -= this._loopDuration this._playhead.set('millis', this._currentMillis) //this._playhead.set('millis', this._leftLocator.millis) // playhead is a bit ahead only during this frame dispatchEvent({ type: 'loop', data: null }) }else{ this._playhead.update('millis', diff) } this._ticks = this._playhead.get().ticks //console.log(this._currentMillis, this._durationMillis) if(this._currentMillis >= this._durationMillis){ if(this.recording !== true || this.autoSize !== true){ this.stop() return } // add an extra bar to the size of this song let events = this._metronome.addEvents(this.bars, this.bars + 1) let tobeParsed = [...events, ...this._timeEvents] sortEvents(tobeParsed) parseEvents(tobeParsed) this._scheduler.events.push(...events) this._scheduler.numEvents += events.length let lastEvent = events[events.length - 1] let extraMillis = lastEvent.ticksPerBar * lastEvent.millisPerTick this._lastEvent.ticks += lastEvent.ticksPerBar this._lastEvent.millis += extraMillis this._durationMillis += extraMillis this.bars++ this._resized = true //console.log('length', this._lastEvent.ticks, this._lastEvent.millis, this.bars, lastEvent) } this._scheduler.update(diff) requestAnimationFrame(this._pulse.bind(this)) } pause(): void{ this.paused = !this.paused this.precounting = false if(this.paused){ this.playing = false this.allNotesOff() dispatchEvent({type: 'pause', data: this.paused}) }else{ this.play() dispatchEvent({type: 'pause', data: this.paused}) } } stop(): void{ //console.log('STOP') this.precounting = false this.allNotesOff() if(this.playing || this.paused){ this.playing = false this.paused = false } if(this._currentMillis !== 0){ this._currentMillis = 0 this._playhead.set('millis', this._currentMillis) if(this.recording){ this.stopRecording() } dispatchEvent({type: 'stop'}) } } startRecording(){ if(this._preparedForRecording === true){ return } this._recordId = `recording_${recordingIndex++}${new Date().getTime()}` this._tracks.forEach(track => { track._startRecording(this._recordId) }) this._preparedForRecording = true } stopRecording(){ if(this._preparedForRecording === false){ return } this._tracks.forEach(track => { track._stopRecording(this._recordId) }) this.update() this._preparedForRecording = false this.recording = false dispatchEvent({type: 'stop_recording'}) } undoRecording(){ this._tracks.forEach(track => { track.undoRecording(this._recordId) }) this.update() } redoRecording(){ this._tracks.forEach(track => { track.redoRecording(this._recordId) }) this.update() } setMetronome(flag){ if(typeof flag === 'undefined'){ this.useMetronome = !this.useMetronome }else{ this.useMetronome = flag } this._metronome.mute(!this.useMetronome) } configureMetronome(config){ this._metronome.configure(config) } configure(config){ if(typeof config.pitch !== 'undefined'){ if(config.pitch === this.pitch){ return } this.pitch = config.pitch this._events.forEach(event => { event.updatePitch(this.pitch) }) } if(typeof config.ppq !== 'undefined'){ if(config.ppq === this.ppq){ return } let ppqFactor = config.ppq / this.ppq this.ppq = config.ppq this._allEvents.forEach(e => { e.ticks = event.ticks * ppqFactor }) this._updateTimeEvents = true this.update() } if(typeof config.playbackSpeed !== 'undefined'){ if(config.playbackSpeed === this.playbackSpeed){ return } this.playbackSpeed = config.playbackSpeed } } allNotesOff(){ this._tracks.forEach((track) => { track.allNotesOff() }) this._scheduler.allNotesOff() this._metronome.allNotesOff() } /* panic(){ return new Promise(resolve => { this._tracks.forEach((track) => { track.disconnect(this._gainNode) }) setTimeout(() => { this._tracks.forEach((track) => { track.connect(this._gainNode) }) resolve() }, 100) }) } */ getTracks(){ return [...this._tracks] } getParts(){ return [...this._parts] } getEvents(){ return [...this._events] } getNotes(){ return [...this._notes] } calculatePosition(args){ return calculatePosition(this, args) } // @args -> see _calculatePosition setPosition(type, ...args){ let wasPlaying = this.playing if(this.playing){ this.playing = false this.allNotesOff() } let position = this._calculatePosition(type, args, 'all') //let millis = this._calculatePosition(type, args, 'millis') if(position === false){ return } this._currentMillis = position.millis //console.log(this._currentMillis) dispatchEvent({ type: 'position', data: position }) if(wasPlaying){ this._play() }else{ //@todo: get this information from let 'position' -> we have just calculated the position this._playhead.set('millis', this._currentMillis) } //console.log('setPosition', this._currentMillis) } getPosition(){ return this._playhead.get().position } getPlayhead(){ return this._playhead.get() } // @args -> see _calculatePosition setLeftLocator(type, ...args){ this._leftLocator = this._calculatePosition(type, args, 'all') if(this._leftLocator === false){ console.warn('invalid position for locator') this._leftLocator = {millis: 0, ticks: 0} return } } // @args -> see _calculatePosition setRightLocator(type, ...args){ this._rightLocator = this._calculatePosition(type, args, 'all') if(this._rightLocator === false){ this._rightLocator = {millis: 0, ticks: 0} console.warn('invalid position for locator') return } } setLoop(flag = null){ this.looping = flag !== null ? flag : !this._loop if(this._rightLocator === false || this._leftLocator === false){ this._illegalLoop = true this._loop = false this.looping = false return false } // locators can not (yet) be used to jump over a segment if(this._rightLocator.millis <= this._leftLocator.millis){ this._illegalLoop = true this._loop = false this.looping = false return false } this._loopDuration = this._rightLocator.millis - this._leftLocator.millis //console.log(this._loop, this._loopDuration) this._scheduler.beyondLoop = this._currentMillis > this._rightLocator.millis this._loop = this.looping && this._currentMillis <= this._rightLocator.millis //console.log(this._loop, this.looping) return this.looping } setPrecount(value = 0){ this._precountBars = value } /* helper method: converts user friendly position format to internal format position: - 'ticks', 96000 - 'millis', 1234 - 'percentage', 55 - 'barsbeats', 1, 4, 0, 25 -> bar, beat, sixteenth, tick - 'time', 0, 3, 49, 566 -> hours, minutes, seconds, millis */ _calculatePosition(type, args, resultType){ let target switch(type){ case 'ticks': case 'millis': case 'percentage': //target = args[0] || 0 target = args || 0 break case 'time': case 'barsbeats': case 'barsandbeats': target = args break default: console.log('unsupported type') return false } let position = calculatePosition(this, { type, target, result: resultType, }) return position } addEventListener(type, callback){ return addEventListener(type, callback) } removeEventListener(type, id){ removeEventListener(type, id) } saveAsMIDIFile(name){ saveAsMIDIFile(this, name) } setVolume(value){ if(value < 0 || value > 1){ console.log('Song.setVolume() accepts a value between 0 and 1, you entered:', value) return } this.volume = value } getVolume(){ return this.volume } setPanning(value){ if(value < -1 || value > 1){ console.log('Song.setPanning() accepts a value between -1 (full left) and 1 (full right), you entered:', value) return } this._tracks.forEach(track => { track.setPanning(value) }) this._pannerValue = value } }