From 1716ffc57f6229d81b3d97f66a3267dfb1fb3462 Mon Sep 17 00:00:00 2001 From: EliteMasterEric Date: Tue, 5 Dec 2023 02:44:57 -0500 Subject: [PATCH] Implement instrumental and vocal offsets into the PlayState. --- source/funkin/Conductor.hx | 40 +++++++- source/funkin/audio/VoicesGroup.hx | 65 +++++++++++- source/funkin/data/song/SongData.hx | 92 +++++++++++++++++ source/funkin/data/song/SongRegistry.hx | 2 +- .../data/song/migrator/SongData_v2_1_0.hx | 2 + source/funkin/play/PlayState.hx | 99 ++++++++++++++----- source/funkin/play/song/Song.hx | 24 +++-- source/funkin/ui/MusicBeatState.hx | 1 + source/funkin/ui/MusicBeatSubState.hx | 1 + .../funkin/ui/debug/latency/LatencyState.hx | 22 ++--- 10 files changed, 298 insertions(+), 50 deletions(-) diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx index b8ded63da..f333b4072 100644 --- a/source/funkin/Conductor.hx +++ b/source/funkin/Conductor.hx @@ -45,6 +45,8 @@ class Conductor */ public static var songPosition(default, null):Float = 0; + public static var songPositionNoOffset(default, null):Float = 0; + /** * Beats per minute of the current song at the current time. */ @@ -144,13 +146,27 @@ class Conductor */ public static var currentStepTime(default, null):Float; + public static var currentStepTimeNoOffset(default, null):Float; + public static var beatHit(default, null):FlxSignal = new FlxSignal(); public static var stepHit(default, null):FlxSignal = new FlxSignal(); public static var lastSongPos:Float; - public static var visualOffset:Float = 0; - public static var audioOffset:Float = 0; - public static var offset:Float = 0; + + /** + * An offset tied to the current chart file to compensate for a delay in the instrumental. + */ + public static var instrumentalOffset:Float = 0; + + /** + * An offset tied to the file format of the audio file being played. + */ + public static var formatOffset:Float = 0; + + /** + * An offset set by the user to compensate for input lag. + */ + public static var inputOffset:Float = 0; public static var beatsPerMeasure(get, never):Float; @@ -200,15 +216,24 @@ class Conductor * @param songPosition The current position in the song in milliseconds. * Leave blank to use the FlxG.sound.music position. */ - public static function update(songPosition:Float = null) + public static function update(?songPosition:Float) { - if (songPosition == null) songPosition = (FlxG.sound.music != null) ? FlxG.sound.music.time + Conductor.offset : 0.0; + if (songPosition == null) + { + // Take into account instrumental and file format song offsets. + songPosition = (FlxG.sound.music != null) ? (FlxG.sound.music.time + instrumentalOffset + formatOffset) : 0.0; + } var oldBeat = currentBeat; var oldStep = currentStep; + // Set the song position we are at (for purposes of calculating note positions, etc). Conductor.songPosition = songPosition; + // Exclude the offsets we just included earlier. + // This is the "true" song position that the audio should be using. + Conductor.songPositionNoOffset = Conductor.songPosition - instrumentalOffset - formatOffset; + currentTimeChange = timeChanges[0]; for (i in 0...timeChanges.length) { @@ -230,6 +255,9 @@ class Conductor currentStep = Math.floor(currentStepTime); currentBeat = Math.floor(currentBeatTime); currentMeasure = Math.floor(currentMeasureTime); + + currentStepTimeNoOffset = FlxMath.roundDecimal((currentTimeChange.beatTime * 4) + + (songPositionNoOffset - currentTimeChange.timeStamp) / stepLengthMs, 6); } else { @@ -240,6 +268,8 @@ class Conductor currentStep = Math.floor(currentStepTime); currentBeat = Math.floor(currentBeatTime); currentMeasure = Math.floor(currentMeasureTime); + + currentStepTimeNoOffset = FlxMath.roundDecimal((songPositionNoOffset / stepLengthMs), 4); } // FlxSignals are really cool. diff --git a/source/funkin/audio/VoicesGroup.hx b/source/funkin/audio/VoicesGroup.hx index 6d61e6481..8c95eb3eb 100644 --- a/source/funkin/audio/VoicesGroup.hx +++ b/source/funkin/audio/VoicesGroup.hx @@ -11,12 +11,22 @@ class VoicesGroup extends SoundGroup /** * Control the volume of only the sounds in the player group. */ - public var playerVolume(default, set):Float; + public var playerVolume(default, set):Float = 1.0; /** * Control the volume of only the sounds in the opponent group. */ - public var opponentVolume(default, set):Float; + public var opponentVolume(default, set):Float = 1.0; + + /** + * Set the time offset for the player's vocal track. + */ + public var playerVoicesOffset(default, set):Float = 0.0; + + /** + * Set the time offset for the opponent's vocal track. + */ + public var opponentVoicesOffset(default, set):Float = 0.0; public function new() { @@ -42,6 +52,57 @@ class VoicesGroup extends SoundGroup return playerVolume = volume; } + override function set_time(time:Float):Float + { + forEachAlive(function(snd) { + // account for different offsets per sound? + snd.time = time; + }); + + playerVoices.forEachAlive(function(voice:FlxSound) { + voice.time -= playerVoicesOffset; + }); + opponentVoices.forEachAlive(function(voice:FlxSound) { + voice.time -= opponentVoicesOffset; + }); + + return time; + } + + function set_playerVoicesOffset(offset:Float):Float + { + playerVoices.forEachAlive(function(voice:FlxSound) { + voice.time += playerVoicesOffset; + voice.time -= offset; + }); + return playerVoicesOffset = offset; + } + + function set_opponentVoicesOffset(offset:Float):Float + { + opponentVoices.forEachAlive(function(voice:FlxSound) { + voice.time += opponentVoicesOffset; + voice.time -= offset; + }); + return opponentVoicesOffset = offset; + } + + public override function update(elapsed:Float):Void + { + forEachAlive(function(snd) { + if (snd.time < 0) + { + // Sync the time without calling update(). + // time gets reset if it's negative. + snd.time += elapsed * 1000; + } + else + { + snd.update(elapsed); + } + }); + } + /** * Add a voice to the opponent group. */ diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx index 663dcc9bd..c0fd96855 100644 --- a/source/funkin/data/song/SongData.hx +++ b/source/funkin/data/song/SongData.hx @@ -34,6 +34,12 @@ class SongMetadata @:default(false) public var looped:Bool; + /** + * Instrumental and vocal offsets. Optional, defaults to 0. + */ + @:optional + public var offsets:SongOffsets; + /** * Data relating to the song's gameplay. */ @@ -59,6 +65,7 @@ class SongMetadata this.artist = artist; this.timeFormat = 'ms'; this.divisions = null; + this.offsets = new SongOffsets(); this.timeChanges = [new SongTimeChange(0, 100)]; this.looped = false; this.playData = new SongPlayData(); @@ -196,6 +203,90 @@ class SongTimeChange } } +/** + * Offsets to apply to the song's instrumental and vocals, relative to the chart. + * These are intended to correct for issues with the chart, or with the song's audio (for example a 10ms delay before the song starts). + * This is independent of the offsets applied in the user's settings, which are applied after these offsets and intended to correct for the user's hardware. + */ +class SongOffsets +{ + /** + * The offset, in milliseconds, to apply to the song's instrumental relative to the chart. + * For example, setting this to `-10.0` will start the instrumental 10ms earlier than the chart. + * + * Setting this to `-5000.0` means the chart start 5 seconds into the song. + * Setting this to `5000.0` means there will be 5 seconds of silence before the song starts. + */ + @:optional + @:default(0) + public var instrumental:Float; + + /** + * Apply different offsets to different alternate instrumentals. + */ + @:optional + @:default([]) + public var altInstrumentals:Map; + + /** + * The offset, in milliseconds, to apply to the song's vocals, relative to the chart. + * These are applied ON TOP OF the instrumental offset. + */ + @:optional + @:default([]) + public var vocals:Map; + + public function new(instrumental:Float = 0.0, ?altInstrumentals:Map, ?vocals:Map) + { + this.instrumental = instrumental; + this.altInstrumentals = altInstrumentals == null ? new Map() : altInstrumentals; + this.vocals = vocals == null ? new Map() : vocals; + } + + public function getInstrumentalOffset(?instrumental:String):Float + { + if (instrumental == null || instrumental == '') return this.instrumental; + + if (!this.altInstrumentals.exists(instrumental)) return this.instrumental; + + return this.altInstrumentals.get(instrumental); + } + + public function setInstrumentalOffset(value:Float, ?instrumental:String):Float + { + if (instrumental == null || instrumental == '') + { + this.instrumental = value; + } + else + { + this.altInstrumentals.set(instrumental, value); + } + return value; + } + + public function getVocalOffset(charId:String):Float + { + if (!this.vocals.exists(charId)) return 0.0; + + return this.vocals.get(charId); + } + + public function setVocalOffset(charId:String, value:Float):Float + { + this.vocals.set(charId, value); + return value; + } + + /** + * Produces a string representation suitable for debugging. + */ + public function toString():String + { + return 'SongOffsets(${this.instrumental}ms, ${this.altInstrumentals}, ${this.vocals})'; + } +} + /** * Metadata for a song only used for the music. * For example, the menu music. @@ -309,6 +400,7 @@ class SongPlayData * The difficulty ratings for this song as displayed in Freeplay. * Key is a difficulty ID or `default`. */ + @:optional @:default(['default' => 1]) public var ratings:Map; diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx index 8e0f4577d..850654eb7 100644 --- a/source/funkin/data/song/SongRegistry.hx +++ b/source/funkin/data/song/SongRegistry.hx @@ -19,7 +19,7 @@ class SongRegistry extends BaseRegistry * Handle breaking changes by incrementing this value * and adding migration to the `migrateStageData()` function. */ - public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.0"; + public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.1"; public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.2.x"; diff --git a/source/funkin/data/song/migrator/SongData_v2_1_0.hx b/source/funkin/data/song/migrator/SongData_v2_1_0.hx index 192c440e0..1a61184ed 100644 --- a/source/funkin/data/song/migrator/SongData_v2_1_0.hx +++ b/source/funkin/data/song/migrator/SongData_v2_1_0.hx @@ -16,6 +16,8 @@ class SongMetadata_v2_1_0 */ public var playData:SongPlayData_v2_1_0; + // In metadata `v2.2.1`, `SongOffsets` was added. + // var offsets:SongOffsets; // ========== // UNMODIFIED VALUES // ========== diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index 7d20f4697..6136cf1b7 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -179,6 +179,13 @@ class PlayState extends MusicBeatSubState */ public var songScore:Int = 0; + /** + * Start at this point in the song once the countdown is done. + * For example, if `startTimestamp` is `30000`, the song will start at the 30 second mark. + * Used for chart playtesting or practice. + */ + public var startTimestamp:Float = 0.0; + /** * An empty FlxObject contained in the scene. * The current gameplay camera will always follow this object. Tween its position to move the camera smoothly. @@ -254,10 +261,6 @@ class PlayState extends MusicBeatSubState */ public var disableKeys:Bool = false; - public var startTimestamp:Float = 0.0; - - var overrideMusic:Bool = false; - public var isSubState(get, never):Bool; function get_isSubState():Bool @@ -317,6 +320,18 @@ class PlayState extends MusicBeatSubState */ var skipHeldTimer:Float = 0; + /** + * Whether the PlayState was started with instrumentals and vocals already provided. + * Used by the chart editor to prevent replacing the music. + */ + var overrideMusic:Bool = false; + + /** + * After the song starts, the song offset may dictate we wait before the instrumental starts. + * This variable represents that delay, and is subtracted from until it reaches 0, before calling `music.play()` + */ + var songStartDelay:Float = 0.0; + /** * Forcibly disables all update logic while the game moves back to the Menu state. * This is used only when a critical error occurs and the game absolutely cannot continue. @@ -551,6 +566,7 @@ class PlayState extends MusicBeatSubState // Prepare the Conductor. Conductor.forceBPM(null); + Conductor.instrumentalOffset = currentChart.offsets.getInstrumentalOffset(); Conductor.mapTimeChanges(currentChart.timeChanges); Conductor.update((Conductor.beatLengthMs * -5) + startTimestamp); @@ -699,8 +715,8 @@ class PlayState extends MusicBeatSubState // Reset music properly. + FlxG.sound.music.time = Math.max(0, startTimestamp - Conductor.instrumentalOffset); FlxG.sound.music.pause(); - FlxG.sound.music.time = (startTimestamp); if (!overrideMusic) { @@ -751,17 +767,40 @@ class PlayState extends MusicBeatSubState if (isInCountdown) { Conductor.update(Conductor.songPosition + elapsed * 1000); - if (Conductor.songPosition >= startTimestamp) startSong(); + if (Conductor.songPosition >= (startTimestamp)) startSong(); } } else { - // DO NOT FORGET TO REMOVE THE HARDCODE! WHEN I MAKE BETTER OFFSET SYSTEM! + if (Constants.EXT_SOUND == 'mp3') + { + Conductor.formatOffset = Constants.MP3_DELAY_MS; + } + else + { + Conductor.formatOffset = 0.0; + } - // :nerd: um ackshually it's not 13 it's 11.97278911564 - if (Constants.EXT_SOUND == 'mp3') Conductor.offset = Constants.MP3_DELAY_MS; - - Conductor.update(); + if (songStartDelay > 0) + { + // Code to handle the song not starting yet (positive instrumental offset in metadata). + // Wait for offset to elapse before actually hitting play on the instrumental. + songStartDelay -= elapsed * 1000; + if (songStartDelay <= 0) + { + FlxG.sound.music.play(); + Conductor.update(); // Normal conductor update. + } + else + { + // Make beat events still happen. + Conductor.update(Conductor.songPosition + elapsed * 1000); + } + } + else + { + Conductor.update(); // Normal conductor update. + } if (!isGamePaused) { @@ -1137,12 +1176,12 @@ class PlayState extends MusicBeatSubState if (!startingSong && FlxG.sound.music != null - && (Math.abs(FlxG.sound.music.time - (Conductor.songPosition - Conductor.offset)) > 200 - || Math.abs(vocals.checkSyncError(Conductor.songPosition - Conductor.offset)) > 200)) + && (Math.abs(FlxG.sound.music.time - (Conductor.songPositionNoOffset)) > 200 + || Math.abs(vocals.checkSyncError(Conductor.songPositionNoOffset)) > 200)) { trace("VOCALS NEED RESYNC"); - if (vocals != null) trace(vocals.checkSyncError(Conductor.songPosition - Conductor.offset)); - trace(FlxG.sound.music.time - (Conductor.songPosition - Conductor.offset)); + if (vocals != null) trace(vocals.checkSyncError(Conductor.songPositionNoOffset)); + trace(FlxG.sound.music.time - (Conductor.songPositionNoOffset)); resyncVocals(); } @@ -1684,12 +1723,28 @@ class PlayState extends MusicBeatSubState } FlxG.sound.music.onComplete = endSong; - FlxG.sound.music.play(); - FlxG.sound.music.time = startTimestamp; + // A negative instrumental offset means the song skips the first few milliseconds of the track. + // This just gets added into the startTimestamp behavior so we don't need to do anything extra. + FlxG.sound.music.time = startTimestamp - Conductor.instrumentalOffset; + trace('Playing vocals...'); add(vocals); - vocals.play(); - resyncVocals(); + + if (FlxG.sound.music.time < 0) + { + // A positive instrumentalOffset means Conductor.songPosition will still be negative after the countdown elapses. + trace('POSITIVE OFFSET: Waiting to start song...'); + songStartDelay = Math.abs(FlxG.sound.music.time); + FlxG.sound.music.time = 0; + FlxG.sound.music.pause(); + vocals.pause(); + } + else + { + FlxG.sound.music.play(); + vocals.play(); + resyncVocals(); + } #if discord_rpc // Updating Discord Rich Presence (with Time Left) @@ -1698,7 +1753,7 @@ class PlayState extends MusicBeatSubState if (startTimestamp > 0) { - FlxG.sound.music.time = startTimestamp; + FlxG.sound.music.time = startTimestamp - Conductor.instrumentalOffset; handleSkippedNotes(); } } @@ -1710,13 +1765,13 @@ class PlayState extends MusicBeatSubState { if (_exiting || vocals == null) return; - // Skip this if the music is paused (GameOver, Pause menu, etc.) + // Skip this if the music is paused (GameOver, Pause menu, start-of-song offset, etc.) if (!FlxG.sound.music.playing) return; + if (songStartDelay > 0) return; vocals.pause(); FlxG.sound.music.play(); - Conductor.update(); vocals.time = FlxG.sound.music.time; vocals.play(false, FlxG.sound.music.time); diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx index 3d7903724..1cba42f60 100644 --- a/source/funkin/play/song/Song.hx +++ b/source/funkin/play/song/Song.hx @@ -1,21 +1,22 @@ package funkin.play.song; -import funkin.util.SortUtil; import flixel.sound.FlxSound; -import openfl.utils.Assets; -import funkin.modding.events.ScriptEvent; -import funkin.modding.IScriptedClass; import funkin.audio.VoicesGroup; -import funkin.data.song.SongRegistry; +import funkin.data.IRegistryEntry; +import funkin.data.song.SongData.SongCharacterData; import funkin.data.song.SongData.SongChartData; import funkin.data.song.SongData.SongEventData; -import funkin.data.song.SongData.SongNoteData; -import funkin.data.song.SongRegistry; import funkin.data.song.SongData.SongMetadata; -import funkin.data.song.SongData.SongCharacterData; +import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongOffsets; import funkin.data.song.SongData.SongTimeChange; import funkin.data.song.SongData.SongTimeFormat; -import funkin.data.IRegistryEntry; +import funkin.data.song.SongRegistry; +import funkin.data.song.SongRegistry; +import funkin.modding.events.ScriptEvent; +import funkin.modding.IScriptedClass; +import funkin.util.SortUtil; +import openfl.utils.Assets; /** * This is a data structure managing information about the current song. @@ -172,6 +173,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry = null; public var looped:Bool = false; + public var offsets:SongOffsets = new SongOffsets(); public var generatedBy:String = SongRegistry.DEFAULT_GENERATEDBY; public var timeChanges:Array = []; @@ -542,6 +545,9 @@ class SongDifficulty } } + result.playerVoicesOffset = offsets.getVocalOffset(characters.player); + result.opponentVoicesOffset = offsets.getVocalOffset(characters.opponent); + return result; } } diff --git a/source/funkin/ui/MusicBeatState.hx b/source/funkin/ui/MusicBeatState.hx index 4e0e19d5e..7bdf8689c 100644 --- a/source/funkin/ui/MusicBeatState.hx +++ b/source/funkin/ui/MusicBeatState.hx @@ -84,6 +84,7 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler { // Display Conductor info in the watch window. FlxG.watch.addQuick("songPosition", Conductor.songPosition); + FlxG.watch.addQuick("musicTime", FlxG.sound.music?.time ?? 0.0); FlxG.watch.addQuick("bpm", Conductor.bpm); FlxG.watch.addQuick("currentMeasureTime", Conductor.currentBeatTime); FlxG.watch.addQuick("currentBeatTime", Conductor.currentBeatTime); diff --git a/source/funkin/ui/MusicBeatSubState.hx b/source/funkin/ui/MusicBeatSubState.hx index 64df6ee71..9dd755b62 100644 --- a/source/funkin/ui/MusicBeatSubState.hx +++ b/source/funkin/ui/MusicBeatSubState.hx @@ -66,6 +66,7 @@ class MusicBeatSubState extends FlxTransitionableSubState implements IEventHandl // Display Conductor info in the watch window. FlxG.watch.addQuick("songPosition", Conductor.songPosition); + FlxG.watch.addQuick("musicTime", FlxG.sound.music?.time ?? 0.0); FlxG.watch.addQuick("bpm", Conductor.bpm); FlxG.watch.addQuick("currentMeasureTime", Conductor.currentBeatTime); FlxG.watch.addQuick("currentBeatTime", Conductor.currentBeatTime); diff --git a/source/funkin/ui/debug/latency/LatencyState.hx b/source/funkin/ui/debug/latency/LatencyState.hx index 673b866f8..18b0010b2 100644 --- a/source/funkin/ui/debug/latency/LatencyState.hx +++ b/source/funkin/ui/debug/latency/LatencyState.hx @@ -192,15 +192,15 @@ class LatencyState extends MusicBeatSubState if (FlxG.keys.pressed.D) FlxG.sound.music.time += 1000 * FlxG.elapsed; - Conductor.update(swagSong.getTimeWithDiff() - Conductor.offset); + Conductor.update(swagSong.getTimeWithDiff() - Conductor.inputOffset); // Conductor.songPosition += (Timer.stamp() * 1000) - FlxG.sound.music.prevTimestamp; songPosVis.x = songPosToX(Conductor.songPosition); - songVisFollowAudio.x = songPosToX(Conductor.songPosition - Conductor.audioOffset); - songVisFollowVideo.x = songPosToX(Conductor.songPosition - Conductor.visualOffset); + songVisFollowAudio.x = songPosToX(Conductor.songPosition - Conductor.instrumentalOffset); + songVisFollowVideo.x = songPosToX(Conductor.songPosition - Conductor.inputOffset); - offsetText.text = "AUDIO Offset: " + Conductor.audioOffset + "ms"; - offsetText.text += "\nVIDOE Offset: " + Conductor.visualOffset + "ms"; + offsetText.text = "INST Offset: " + Conductor.instrumentalOffset + "ms"; + offsetText.text += "\nINPUT Offset: " + Conductor.inputOffset + "ms"; offsetText.text += "\ncurrentStep: " + Conductor.currentStep; offsetText.text += "\ncurrentBeat: " + Conductor.currentBeat; @@ -221,24 +221,24 @@ class LatencyState extends MusicBeatSubState { if (FlxG.keys.justPressed.RIGHT) { - Conductor.audioOffset += 1 * multiply; + Conductor.instrumentalOffset += 1.0 * multiply; } if (FlxG.keys.justPressed.LEFT) { - Conductor.audioOffset -= 1 * multiply; + Conductor.instrumentalOffset -= 1.0 * multiply; } } else { if (FlxG.keys.justPressed.RIGHT) { - Conductor.visualOffset += 1 * multiply; + Conductor.inputOffset += 1.0 * multiply; } if (FlxG.keys.justPressed.LEFT) { - Conductor.visualOffset -= 1 * multiply; + Conductor.inputOffset -= 1.0 * multiply; } } @@ -250,7 +250,7 @@ class LatencyState extends MusicBeatSubState }*/ noteGrp.forEach(function(daNote:NoteSprite) { - daNote.y = (strumLine.y - ((Conductor.songPosition - Conductor.audioOffset) - daNote.noteData.time) * 0.45); + daNote.y = (strumLine.y - ((Conductor.songPosition - Conductor.instrumentalOffset) - daNote.noteData.time) * 0.45); daNote.x = strumLine.x + 30; if (daNote.y < strumLine.y) daNote.alpha = 0.5; @@ -271,7 +271,7 @@ class LatencyState extends MusicBeatSubState var closestBeat:Int = Math.round(Conductor.songPosition / Conductor.beatLengthMs) % diffGrp.members.length; var getDiff:Float = Conductor.songPosition - (closestBeat * Conductor.beatLengthMs); - getDiff -= Conductor.visualOffset; + getDiff -= Conductor.inputOffset; // lil fix for end of song if (closestBeat == 0 && getDiff >= Conductor.beatLengthMs * 2) getDiff -= FlxG.sound.music.length;