Spaces:
Sleeping
Sleeping
import range from "lodash/range" | |
import throttle from "lodash/throttle" | |
import { AnyEvent, MIDIControlEvents } from "midifile-ts" | |
import { computed, makeObservable, observable } from "mobx" | |
import { SendableEvent, SynthOutput } from "../../main/services/SynthOutput" | |
import { SongStore } from "../../main/stores/SongStore" | |
import { filterEventsWithRange } from "../helpers/filterEvents" | |
import { Beat, createBeatsInRange } from "../helpers/mapBeats" | |
import { | |
controllerMidiEvent, | |
noteOffMidiEvent, | |
noteOnMidiEvent, | |
} from "../midi/MidiEvent" | |
import { getStatusEvents } from "../track/selector" | |
import { ITrackMute } from "../trackMute/ITrackMute" | |
import { DistributiveOmit } from "../types" | |
import EventScheduler from "./EventScheduler" | |
import { convertTrackEvents, PlayerEvent } from "./PlayerEvent" | |
export interface LoopSetting { | |
begin: number | |
end: number | |
enabled: boolean | |
} | |
const TIMER_INTERVAL = 50 | |
const LOOK_AHEAD_TIME = 50 | |
const METRONOME_TRACK_ID = 99999 | |
export const DEFAULT_TEMPO = 120 | |
export default class Player { | |
private _currentTempo = DEFAULT_TEMPO | |
private _scheduler: EventScheduler<PlayerEvent> | null = null | |
private _songStore: SongStore | |
private _output: SynthOutput | |
private _metronomeOutput: SynthOutput | |
private _trackMute: ITrackMute | |
private _interval: number | null = null | |
private _currentTick = 0 | |
private _isPlaying = false | |
disableSeek: boolean = false | |
isMetronomeEnabled: boolean = false | |
loop: LoopSetting | null = null | |
constructor( | |
output: SynthOutput, | |
metronomeOutput: SynthOutput, | |
trackMute: ITrackMute, | |
songStore: SongStore, | |
) { | |
makeObservable<Player, "_currentTick" | "_isPlaying">(this, { | |
_currentTick: observable, | |
_isPlaying: observable, | |
loop: observable, | |
isMetronomeEnabled: observable, | |
position: computed, | |
isPlaying: computed, | |
}) | |
this._output = output | |
this._metronomeOutput = metronomeOutput | |
this._trackMute = trackMute | |
this._songStore = songStore | |
} | |
private get song() { | |
return this._songStore.song | |
} | |
private get timebase() { | |
return this.song.timebase | |
} | |
play() { | |
if (this.isPlaying) { | |
console.warn("called play() while playing. aborted.") | |
return | |
} | |
this._scheduler = new EventScheduler<PlayerEvent>( | |
(startTick, endTick) => | |
filterEventsWithRange(this.song.allEvents, startTick, endTick).concat( | |
filterEventsWithRange( | |
createBeatsInRange( | |
this.song.measures, | |
this.song.timebase, | |
startTick, | |
endTick, | |
).flatMap((b) => this.beatToEvents(b)), | |
startTick, | |
endTick, | |
), | |
), | |
() => this.allNotesOffEvents(), | |
this._currentTick, | |
this.timebase, | |
TIMER_INTERVAL + LOOK_AHEAD_TIME, | |
) | |
this._isPlaying = true | |
this._output.activate() | |
this._interval = window.setInterval(() => this._onTimer(), TIMER_INTERVAL) | |
this._output.activate() | |
} | |
set position(tick: number) { | |
if (!Number.isInteger(tick)) { | |
console.warn("Player.tick should be an integer", tick) | |
} | |
if (this.disableSeek) { | |
return | |
} | |
tick = Math.min(Math.max(Math.floor(tick), 0), this.song.endOfSong) | |
if (this._scheduler) { | |
this._scheduler.seek(tick) | |
} | |
this._currentTick = tick | |
if (this.isPlaying) { | |
this.allSoundsOff() | |
} | |
this.sendCurrentStateEvents() | |
} | |
get position() { | |
return this._currentTick | |
} | |
get isPlaying() { | |
return this._isPlaying | |
} | |
get numberOfChannels() { | |
return 0xf | |
} | |
allSoundsOffChannel(ch: number) { | |
this.sendEvent( | |
controllerMidiEvent(0, ch, MIDIControlEvents.ALL_SOUNDS_OFF, 0), | |
) | |
} | |
allSoundsOff() { | |
for (const ch of range(0, this.numberOfChannels)) { | |
this.allSoundsOffChannel(ch) | |
} | |
} | |
allSoundsOffExclude(channel: number) { | |
for (const ch of range(0, this.numberOfChannels)) { | |
if (ch !== channel) { | |
this.allSoundsOffChannel(ch) | |
} | |
} | |
} | |
private allNotesOffEvents(): DistributiveOmit<PlayerEvent, "tick">[] { | |
return range(0, this.numberOfChannels).map((ch) => ({ | |
...controllerMidiEvent(0, ch, MIDIControlEvents.ALL_NOTES_OFF, 0), | |
trackId: -1, // do not mute | |
})) | |
} | |
private resetControllers() { | |
for (const ch of range(0, this.numberOfChannels)) { | |
this.sendEvent( | |
controllerMidiEvent(0, ch, MIDIControlEvents.RESET_CONTROLLERS, 0x7f), | |
) | |
} | |
} | |
private beatToEvents(beat: Beat): PlayerEvent[] { | |
const velocity = beat.beat === 0 ? 100 : 70 | |
const noteNumber = beat.beat === 0 ? 76 : 77 | |
return [ | |
{ | |
...noteOnMidiEvent(0, 9, noteNumber, velocity), | |
tick: beat.tick, | |
trackId: METRONOME_TRACK_ID, | |
}, | |
] | |
} | |
stop() { | |
this._scheduler = null | |
this.allSoundsOff() | |
this._isPlaying = false | |
if (this._interval !== null) { | |
clearInterval(this._interval) | |
this._interval = null | |
} | |
} | |
reset() { | |
this.resetControllers() | |
this.stop() | |
this._currentTick = 0 | |
} | |
/* | |
to restore synthesizer state (e.g. pitch bend) | |
collect all previous state events | |
and send them to the synthesizer | |
*/ | |
sendCurrentStateEvents() { | |
this.song.tracks | |
.flatMap((t, i) => { | |
const statusEvents = getStatusEvents(t.events, this._currentTick) | |
statusEvents.forEach((e) => this.applyPlayerEvent(e)) | |
return convertTrackEvents(statusEvents, t.channel, i) | |
}) | |
.forEach((e) => this.sendEvent(e)) | |
} | |
get currentTempo() { | |
return this._currentTempo | |
} | |
set currentTempo(value: number) { | |
this._currentTempo = value | |
} | |
startNote( | |
{ | |
channel, | |
noteNumber, | |
velocity, | |
}: { | |
noteNumber: number | |
velocity: number | |
channel: number | |
}, | |
delayTime = 0, | |
) { | |
this._output.activate() | |
this.sendEvent(noteOnMidiEvent(0, channel, noteNumber, velocity), delayTime) | |
} | |
stopNote( | |
{ | |
channel, | |
noteNumber, | |
}: { | |
noteNumber: number | |
channel: number | |
}, | |
delayTime = 0, | |
) { | |
this.sendEvent(noteOffMidiEvent(0, channel, noteNumber, 0), delayTime) | |
} | |
// delayTime: seconds, timestampNow: milliseconds | |
sendEvent( | |
event: SendableEvent, | |
delayTime: number = 0, | |
timestampNow: number = performance.now(), | |
) { | |
this._output.sendEvent(event, delayTime, timestampNow) | |
} | |
private syncPosition = throttle(() => { | |
if (this._scheduler !== null) { | |
this._currentTick = this._scheduler.scheduledTick | |
} | |
}, 50) | |
private applyPlayerEvent( | |
e: DistributiveOmit<AnyEvent, "deltaTime" | "channel">, | |
) { | |
if (e.type !== "channel" && "subtype" in e) { | |
switch (e.subtype) { | |
case "setTempo": | |
this._currentTempo = 60000000 / e.microsecondsPerBeat | |
break | |
default: | |
break | |
} | |
} | |
} | |
private _onTimer() { | |
if (this._scheduler === null) { | |
return | |
} | |
const timestamp = performance.now() | |
this._scheduler.loop = | |
this.loop !== null && this.loop.enabled ? this.loop : null | |
const events = this._scheduler.readNextEvents(this._currentTempo, timestamp) | |
events.forEach(({ event: e, timestamp: time }) => { | |
if (e.type === "channel") { | |
const delayTime = (time - timestamp) / 1000 | |
if (e.trackId === METRONOME_TRACK_ID) { | |
if (this.isMetronomeEnabled) { | |
this._metronomeOutput.sendEvent(e, delayTime, timestamp) | |
} | |
} else if (this._trackMute.shouldPlayTrack(e.trackId)) { | |
// channel イベントを MIDI Output に送信 | |
// Send Channel Event to MIDI OUTPUT | |
this.sendEvent(e, delayTime, timestamp) | |
} | |
} else { | |
// channel イベント以外を実行 | |
// Run other than Channel Event | |
this.applyPlayerEvent(e) | |
} | |
}) | |
if (this._scheduler.scheduledTick >= this.song.endOfSong) { | |
this.stop() | |
} | |
this.syncPosition() | |
} | |
} | |