packages/bms/src/timing/index.ts
import { Speedcore } from '../speedcore'
import { uniq, map } from '../util/lodash'
import { BMSChart } from '../bms/chart'
import { SpeedSegment } from '../speedcore/segment'
const precedence = { bpm: 1, stop: 2 }
/**
* A Timing represents the timing information of a musical score.
* A Timing object provides facilities to synchronize between
* metric time (seconds) and musical time (beats).
*
* A Timing are created from a series of actions:
*
* - BPM changes.
* - STOP action.
*/
export class Timing {
_speedcore: Speedcore<TimingSegment>
_eventBeats: number[]
/**
* Constructs a Timing with an initial BPM and specified actions.
*
* Generally, you would use `Timing.fromBMSChart` to create an instance
* from a BMSChart, but the constructor may also be used in other situations
* unrelated to the BMS file format. (e.g. bmson package)
*/
constructor(initialBPM: number, actions: TimingAction[]) {
const state = { bpm: initialBPM, beat: 0, seconds: 0 }
const segments: TimingSegment[] = []
segments.push({
t: 0,
x: 0,
dx: state.bpm / 60,
bpm: state.bpm,
inclusive: true,
})
actions = actions.slice()
actions.sort(function (a, b) {
return a.beat - b.beat || precedence[a.type] - precedence[b.type]
})
actions.forEach(function (action) {
const beat = action.beat
let seconds = state.seconds + ((beat - state.beat) * 60) / state.bpm
switch (action.type) {
case 'bpm':
state.bpm = action.bpm
segments.push({
t: seconds,
x: beat,
dx: state.bpm / 60,
bpm: state.bpm,
inclusive: true,
})
break
case 'stop':
segments.push({
t: seconds,
x: beat,
dx: 0,
bpm: state.bpm,
inclusive: true,
})
seconds += ((action.stopBeats || 0) * 60) / state.bpm
segments.push({
t: seconds,
x: beat,
dx: state.bpm / 60,
bpm: state.bpm,
inclusive: false,
})
break
default:
throw new Error('Unrecognized segment object!')
}
state.beat = beat
state.seconds = seconds
})
this._speedcore = new Speedcore(segments)
this._eventBeats = uniq(map(actions, (action) => action.beat))
}
/**
* Convert the given beat into seconds.
* @param {number} beat
*/
beatToSeconds(beat: number) {
return this._speedcore.t(beat)
}
/**
* Convert the given second into beats.
* @param {number} seconds
*/
secondsToBeat(seconds: number) {
return this._speedcore.x(seconds)
}
/**
* Returns the BPM at the specified beat.
* @param {number} beat
*/
bpmAtBeat(beat: number) {
return this._speedcore.segmentAtX(beat).bpm
}
/**
* Returns an array representing the beats where there are events.
*/
getEventBeats() {
return this._eventBeats
}
/**
* Creates a Timing instance from a BMSChart.
* @param {BMSChart} chart
*/
static fromBMSChart(chart: BMSChart) {
void BMSChart
const actions: TimingAction[] = []
chart.objects.all().forEach(function (object) {
let bpm
const beat = chart.measureToBeat(object.measure, object.fraction)
if (object.channel === '03') {
bpm = parseInt(object.value, 16)
actions.push({ type: 'bpm', beat: beat, bpm: bpm })
} else if (object.channel === '08') {
bpm = +chart.headers.get('bpm' + object.value)!
if (!isNaN(bpm)) actions.push({ type: 'bpm', beat: beat, bpm: bpm })
} else if (object.channel === '09') {
const stopBeats = +chart.headers.get('stop' + object.value)! / 48
actions.push({ type: 'stop', beat: beat, stopBeats: stopBeats })
}
})
return new Timing(+chart.headers.get('bpm')! || 60, actions)
}
}
export type TimingAction = BPMTimingAction | StopTimingAction
export interface BaseTimingAction {
/** where this action occurs */
beat: number
}
export interface BPMTimingAction extends BaseTimingAction {
type: 'bpm'
/** BPM to change to */
bpm: number
}
export interface StopTimingAction extends BaseTimingAction {
type: 'stop'
/** number of beats to stop */
stopBeats: number
}
interface TimingSegment extends SpeedSegment {
bpm: number
}