package funkin; import funkin.util.Constants; import flixel.util.FlxSignal; import flixel.math.FlxMath; import funkin.play.song.Song.SongDifficulty; import funkin.data.song.SongData.SongTimeChange; import funkin.data.song.SongDataUtils; import funkin.save.Save; import haxe.Timer; import flixel.sound.FlxSound; /** * A core class which handles musical timing throughout the game, * both in gameplay and in menus. */ @:nullSafety class Conductor { // onBeatHit is called every quarter note // onStepHit is called every sixteenth note // 4/4 = 4 beats per measure = 16 steps per measure // 120 BPM = 120 quarter notes per minute = 2 onBeatHit per second // 120 BPM = 480 sixteenth notes per minute = 8 onStepHit per second // 60 BPM = 60 quarter notes per minute = 1 onBeatHit per second // 60 BPM = 240 sixteenth notes per minute = 4 onStepHit per second // 3/4 = 3 beats per measure = 12 steps per measure // (IDENTICAL TO 4/4 but shorter measure length) // 120 BPM = 120 quarter notes per minute = 2 onBeatHit per second // 120 BPM = 480 sixteenth notes per minute = 8 onStepHit per second // 60 BPM = 60 quarter notes per minute = 1 onBeatHit per second // 60 BPM = 240 sixteenth notes per minute = 4 onStepHit per second // 7/8 = 3.5 beats per measure = 14 steps per measure /** * The current instance of the Conductor. * If one doesn't currently exist, a new one will be created. * * You can also do stuff like store a reference to the Conductor and pass it around or temporarily replace it, * or have a second Conductor running at the same time, or other weird stuff like that if you need to. */ public static var instance:Conductor = new Conductor(); /** * Signal fired when the current Conductor instance advances to a new measure. */ public static var measureHit(default, null):FlxSignal = new FlxSignal(); /** * Signal fired when the current Conductor instance advances to a new beat. */ public static var beatHit(default, null):FlxSignal = new FlxSignal(); /** * Signal fired when the current Conductor instance advances to a new step. */ public static var stepHit(default, null):FlxSignal = new FlxSignal(); /** * The list of time changes in the song. * There should be at least one time change (at the beginning of the song) to define the BPM. */ var timeChanges:Array = []; /** * The most recent time change for the current song position. */ public var currentTimeChange(default, null):Null; /** * The current position in the song in milliseconds. * Update this every frame based on the audio position using `Conductor.instance.update()`. */ public var songPosition(default, null):Float = 0; var prevTimestamp:Float = 0; var prevTime:Float = 0; /** * Beats per minute of the current song at the current time. */ public var bpm(get, never):Float; function get_bpm():Float { if (bpmOverride != null) return bpmOverride; if (currentTimeChange == null) return Constants.DEFAULT_BPM; return currentTimeChange.bpm; } /** * Beats per minute of the current song at the start time. */ public var startingBPM(get, never):Float; function get_startingBPM():Float { if (bpmOverride != null) return bpmOverride; var timeChange = timeChanges[0]; if (timeChange == null) return Constants.DEFAULT_BPM; return timeChange.bpm; } /** * The current value set by `forceBPM`. * If false, BPM is determined by time changes. */ var bpmOverride:Null = null; /** * Duration of a measure in milliseconds. Calculated based on bpm. */ public var measureLengthMs(get, never):Float; function get_measureLengthMs():Float { return beatLengthMs * timeSignatureNumerator; } /** * Duration of a beat (quarter note) in milliseconds. Calculated based on bpm. */ public var beatLengthMs(get, never):Float; function get_beatLengthMs():Float { // Tied directly to BPM. return ((Constants.SECS_PER_MIN / bpm) * Constants.MS_PER_SEC); } /** * Duration of a step (sixtennth note) in milliseconds. Calculated based on bpm. */ public var stepLengthMs(get, never):Float; function get_stepLengthMs():Float { return beatLengthMs / timeSignatureNumerator; } public var timeSignatureNumerator(get, never):Int; function get_timeSignatureNumerator():Int { if (currentTimeChange == null) return Constants.DEFAULT_TIME_SIGNATURE_NUM; return currentTimeChange.timeSignatureNum; } public var timeSignatureDenominator(get, never):Int; function get_timeSignatureDenominator():Int { if (currentTimeChange == null) return Constants.DEFAULT_TIME_SIGNATURE_DEN; return currentTimeChange.timeSignatureDen; } /** * Current position in the song, in measures. */ public var currentMeasure(default, null):Int = 0; /** * Current position in the song, in beats. */ public var currentBeat(default, null):Int = 0; /** * Current position in the song, in steps. */ public var currentStep(default, null):Int = 0; /** * Current position in the song, in measures and fractions of a measure. */ public var currentMeasureTime(default, null):Float = 0; /** * Current position in the song, in beats and fractions of a measure. */ public var currentBeatTime(default, null):Float = 0; /** * Current position in the song, in steps and fractions of a step. */ public var currentStepTime(default, null):Float = 0; /** * An offset tied to the current chart file to compensate for a delay in the instrumental. */ public var instrumentalOffset:Float = 0; /** * The instrumental offset, in terms of steps. */ public var instrumentalOffsetSteps(get, never):Float; function get_instrumentalOffsetSteps():Float { var startingStepLengthMs:Float = ((Constants.SECS_PER_MIN / startingBPM) * Constants.MS_PER_SEC) / timeSignatureNumerator; return instrumentalOffset / startingStepLengthMs; } /** * An offset tied to the file format of the audio file being played. */ public var formatOffset:Float = 0; /** * An offset set by the user to compensate for input lag. * No matter if you're using a local conductor or not, this always loads * to/from the save file */ public var inputOffset(get, set):Int; /** * An offset set by the user to compensate for audio/visual lag * No matter if you're using a local conductor or not, this always loads * to/from the save file */ public var audioVisualOffset(get, set):Int; function get_inputOffset():Int { return Save.get().options.inputOffset; } function set_inputOffset(value:Int):Int { Save.get().options.inputOffset = value; Save.get().flush(); return Save.get().options.inputOffset; } function get_audioVisualOffset():Int { return Save.get().options.audioVisualOffset; } function set_audioVisualOffset(value:Int):Int { Save.get().options.audioVisualOffset = value; Save.get().flush(); return Save.get().options.audioVisualOffset; } /** * The number of beats in a measure. May be fractional depending on the time signature. */ public var beatsPerMeasure(get, never):Float; function get_beatsPerMeasure():Float { // NOTE: Not always an integer, for example 7/8 is 3.5 beats per measure return stepsPerMeasure / Constants.STEPS_PER_BEAT; } /** * The number of steps in a measure. * TODO: I don't think this can be fractional? */ public var stepsPerMeasure(get, never):Int; function get_stepsPerMeasure():Int { // TODO: Is this always an integer? return Std.int(timeSignatureNumerator / timeSignatureDenominator * Constants.STEPS_PER_BEAT * Constants.STEPS_PER_BEAT); } public function new() {} /** * Forcibly defines the current BPM of the song. * Useful for things like the chart editor that need to manipulate BPM in real time. * * Set to null to reset to the BPM defined by the timeChanges. * * WARNING: Avoid this for things like setting the BPM of the title screen music, * you should have a metadata file for it instead. */ public function forceBPM(?bpm:Float = null) { if (bpm != null) { trace('[CONDUCTOR] Forcing BPM to ${bpm}'); } else { // trace('[CONDUCTOR] Resetting BPM to default'); } this.bpmOverride = bpm; } /** * Update the conductor with the current song position. * BPM, current step, etc. will be re-calculated based on the song position. * * @param songPosition The current position in the song in milliseconds. * Leave blank to use the FlxG.sound.music position. * @param applyOffsets If it should apply the instrumentalOffset + formatOffset + audioVisualOffset */ public function update(?songPos:Float, applyOffsets:Bool = true, forceDispatch:Bool = false) { if (songPos == null) { // Take into account instrumental and file format song offsets. songPos = (FlxG.sound.music != null) ? (FlxG.sound.music.time + instrumentalOffset + formatOffset + audioVisualOffset) : 0.0; } else songPos += applyOffsets ? instrumentalOffset + formatOffset + audioVisualOffset : 0; var oldMeasure = this.currentMeasure; var oldBeat = this.currentBeat; var oldStep = this.currentStep; // Set the song position we are at (for purposes of calculating note positions, etc). this.songPosition = songPos; currentTimeChange = timeChanges[0]; if (this.songPosition > 0.0) { for (i in 0...timeChanges.length) { if (this.songPosition >= timeChanges[i].timeStamp) currentTimeChange = timeChanges[i]; if (this.songPosition < timeChanges[i].timeStamp) break; } } if (currentTimeChange == null && bpmOverride == null && FlxG.sound.music != null) { trace('WARNING: Conductor is broken, timeChanges is empty.'); } else if (currentTimeChange != null && this.songPosition > 0.0) { // roundDecimal prevents representing 8 as 7.9999999 this.currentStepTime = FlxMath.roundDecimal((currentTimeChange.beatTime * 4) + (this.songPosition - currentTimeChange.timeStamp) / stepLengthMs, 6); this.currentBeatTime = currentStepTime / Constants.STEPS_PER_BEAT; this.currentMeasureTime = currentStepTime / stepsPerMeasure; this.currentStep = Math.floor(currentStepTime); this.currentBeat = Math.floor(currentBeatTime); this.currentMeasure = Math.floor(currentMeasureTime); } else { // Assume a constant BPM equal to the forced value. this.currentStepTime = FlxMath.roundDecimal((songPosition / stepLengthMs), 4); this.currentBeatTime = currentStepTime / Constants.STEPS_PER_BEAT; this.currentMeasureTime = currentStepTime / stepsPerMeasure; this.currentStep = Math.floor(currentStepTime); this.currentBeat = Math.floor(currentBeatTime); this.currentMeasure = Math.floor(currentMeasureTime); } // Only fire the signal if we are THE Conductor. if (this == Conductor.instance || forceDispatch) { // FlxSignals are really cool. if (currentStep != oldStep) { Conductor.stepHit.dispatch(); } if (currentBeat != oldBeat) { Conductor.beatHit.dispatch(); } if (currentMeasure != oldMeasure) { Conductor.measureHit.dispatch(); } } // only update the timestamp if songPosition actually changed // which it doesn't do every frame! if (prevTime != this.songPosition) { // Update the timestamp for use in-between frames prevTime = this.songPosition; prevTimestamp = Std.int(Timer.stamp() * 1000); } } /** * Can be called in-between frames, usually for input related things * that can potentially get processed on exact milliseconds/timestmaps. * If you need song position, use `Conductor.instance.songPosition` instead * for use in update() related functions. * @param soundToCheck Which FlxSound object to check, defaults to FlxG.sound.music if no input * @return Float */ public function getTimeWithDiff(?soundToCheck:FlxSound):Float { if (soundToCheck == null) soundToCheck = FlxG.sound.music; // trace(this.songPosition); @:privateAccess this.songPosition = soundToCheck._channel.position; // return this.songPosition + (Std.int(Timer.stamp() * 1000) - prevTimestamp); // trace("\t--> " + this.songPosition); return this.songPosition; } public function mapTimeChanges(songTimeChanges:Array) { timeChanges = []; // Sort in place just in case it's out of order. SongDataUtils.sortTimeChanges(songTimeChanges); for (currentTimeChange in songTimeChanges) { // TODO: Maybe handle this different? // Do we care about BPM at negative timestamps? // Without any custom handling, `currentStepTime` becomes non-zero at `songPosition = 0`. if (currentTimeChange.timeStamp < 0.0) currentTimeChange.timeStamp = 0.0; if (currentTimeChange.timeStamp <= 0.0) { currentTimeChange.beatTime = 0.0; } else { // Calculate the beat time of this timestamp. currentTimeChange.beatTime = 0.0; if (currentTimeChange.timeStamp > 0.0 && timeChanges.length > 0) { var prevTimeChange:SongTimeChange = timeChanges[timeChanges.length - 1]; currentTimeChange.beatTime = FlxMath.roundDecimal(prevTimeChange.beatTime + ((currentTimeChange.timeStamp - prevTimeChange.timeStamp) * prevTimeChange.bpm / Constants.SECS_PER_MIN / Constants.MS_PER_SEC), 4); } } timeChanges.push(currentTimeChange); } if (timeChanges.length > 0) { trace('Done mapping time changes: ${timeChanges}'); } // Update currentStepTime this.update(this.songPosition, false); } /** * Given a time in milliseconds, return a time in steps. */ public function getTimeInSteps(ms:Float):Float { if (timeChanges.length == 0) { // Assume a constant BPM equal to the forced value. return Math.floor(ms / stepLengthMs); } else { var resultStep:Float = 0; var lastTimeChange:SongTimeChange = timeChanges[0]; for (timeChange in timeChanges) { if (ms >= timeChange.timeStamp) { lastTimeChange = timeChange; resultStep = lastTimeChange.beatTime * 4; } else { // This time change is after the requested time. break; } } var lastStepLengthMs:Float = ((Constants.SECS_PER_MIN / lastTimeChange.bpm) * Constants.MS_PER_SEC) / timeSignatureNumerator; var resultFractionalStep:Float = (ms - lastTimeChange.timeStamp) / lastStepLengthMs; resultStep += resultFractionalStep; // Math.floor(); return resultStep; } } /** * Given a time in steps and fractional steps, return a time in milliseconds. */ public function getStepTimeInMs(stepTime:Float):Float { if (timeChanges.length == 0) { // Assume a constant BPM equal to the forced value. return stepTime * stepLengthMs; } else { var resultMs:Float = 0; var lastTimeChange:SongTimeChange = timeChanges[0]; for (timeChange in timeChanges) { if (stepTime >= timeChange.beatTime * 4) { lastTimeChange = timeChange; resultMs = lastTimeChange.timeStamp; } else { // This time change is after the requested time. break; } } var lastStepLengthMs:Float = ((Constants.SECS_PER_MIN / lastTimeChange.bpm) * Constants.MS_PER_SEC) / timeSignatureNumerator; resultMs += (stepTime - lastTimeChange.beatTime * 4) * lastStepLengthMs; return resultMs; } } /** * Given a time in beats and fractional beats, return a time in milliseconds. */ public function getBeatTimeInMs(beatTime:Float):Float { if (timeChanges.length == 0) { // Assume a constant BPM equal to the forced value. return beatTime * stepLengthMs * Constants.STEPS_PER_BEAT; } else { var resultMs:Float = 0; var lastTimeChange:SongTimeChange = timeChanges[0]; for (timeChange in timeChanges) { if (beatTime >= timeChange.beatTime) { lastTimeChange = timeChange; resultMs = lastTimeChange.timeStamp; } else { // This time change is after the requested time. break; } } var lastStepLengthMs:Float = ((Constants.SECS_PER_MIN / lastTimeChange.bpm) * Constants.MS_PER_SEC) / timeSignatureNumerator; resultMs += (beatTime - lastTimeChange.beatTime) * lastStepLengthMs * Constants.STEPS_PER_BEAT; return resultMs; } } /** * @param conductorToUse defaults to Conductor.instance if null */ public static function watchQuick(?conductorToUse:Conductor):Void { if (conductorToUse == null) conductorToUse = Conductor.instance; FlxG.watch.addQuick("songPosition", conductorToUse.songPosition); FlxG.watch.addQuick("bpm", conductorToUse.bpm); FlxG.watch.addQuick("currentMeasureTime", conductorToUse.currentMeasureTime); FlxG.watch.addQuick("currentBeatTime", conductorToUse.currentBeatTime); FlxG.watch.addQuick("currentStepTime", conductorToUse.currentStepTime); } /** * Reset the Conductor, replacing the current instance with a fresh one. */ public static function reset():Void { Conductor.instance = new Conductor(); } }