diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx index f333b4072..c531678ad 100644 --- a/source/funkin/Conductor.hx +++ b/source/funkin/Conductor.hx @@ -45,8 +45,6 @@ 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. */ @@ -61,6 +59,21 @@ class Conductor return currentTimeChange.bpm; } + /** + * Beats per minute of the current song at the start time. + */ + public static var startingBPM(get, never):Float; + + static 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. @@ -146,18 +159,23 @@ 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; - /** * An offset tied to the current chart file to compensate for a delay in the instrumental. */ public static var instrumentalOffset:Float = 0; + /** + * The instrumental offset, in terms of steps. + */ + public static var instrumentalOffsetSteps(get, never):Float; + + static 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. */ @@ -168,6 +186,9 @@ class Conductor */ public static var inputOffset:Float = 0; + /** + * The number of beats in a measure. May be fractional depending on the time signature. + */ public static var beatsPerMeasure(get, never):Float; static function get_beatsPerMeasure():Float @@ -176,6 +197,10 @@ class Conductor return stepsPerMeasure / Constants.STEPS_PER_BEAT; } + /** + * The number of steps in a measure. + * TODO: I don't think this can be fractional? + */ public static var stepsPerMeasure(get, never):Int; static function get_stepsPerMeasure():Int @@ -184,6 +209,21 @@ class Conductor return Std.int(timeSignatureNumerator / timeSignatureDenominator * Constants.STEPS_PER_BEAT * Constants.STEPS_PER_BEAT); } + /** + * Signal fired when the Conductor advances to a new measure. + */ + public static var measureHit(default, null):FlxSignal = new FlxSignal(); + + /** + * Signal fired when the Conductor advances to a new beat. + */ + public static var beatHit(default, null):FlxSignal = new FlxSignal(); + + /** + * Signal fired when the Conductor advances to a new step. + */ + public static var stepHit(default, null):FlxSignal = new FlxSignal(); + function new() {} /** @@ -224,29 +264,29 @@ class Conductor songPosition = (FlxG.sound.music != null) ? (FlxG.sound.music.time + instrumentalOffset + formatOffset) : 0.0; } + var oldMeasure = currentMeasure; 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) + if (Conductor.songPosition > 0.0) { - if (songPosition >= timeChanges[i].timeStamp) currentTimeChange = timeChanges[i]; + for (i in 0...timeChanges.length) + { + if (songPosition >= timeChanges[i].timeStamp) currentTimeChange = timeChanges[i]; - if (songPosition < timeChanges[i].timeStamp) break; + if (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) + else if (currentTimeChange != null && Conductor.songPosition > 0.0) { // roundDecimal prevents representing 8 as 7.9999999 currentStepTime = FlxMath.roundDecimal((currentTimeChange.beatTime * 4) + (songPosition - currentTimeChange.timeStamp) / stepLengthMs, 6); @@ -255,9 +295,6 @@ 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 { @@ -268,8 +305,6 @@ class Conductor currentStep = Math.floor(currentStepTime); currentBeat = Math.floor(currentBeatTime); currentMeasure = Math.floor(currentMeasureTime); - - currentStepTimeNoOffset = FlxMath.roundDecimal((songPositionNoOffset / stepLengthMs), 4); } // FlxSignals are really cool. @@ -282,6 +317,11 @@ class Conductor { beatHit.dispatch(); } + + if (currentMeasure != oldMeasure) + { + measureHit.dispatch(); + } } public static function mapTimeChanges(songTimeChanges:Array) diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index bf6df4423..d53aa09d7 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -796,15 +796,6 @@ class PlayState extends MusicBeatSubState } Conductor.update(); // Normal conductor update. - - if (!isGamePaused) - { - // Interpolation type beat - if (Conductor.lastSongPos != Conductor.songPosition) - { - Conductor.lastSongPos = Conductor.songPosition; - } - } } var androidPause:Bool = false; @@ -1171,12 +1162,12 @@ class PlayState extends MusicBeatSubState if (!startingSong && FlxG.sound.music != null - && (Math.abs(FlxG.sound.music.time - (Conductor.songPositionNoOffset)) > 200 - || Math.abs(vocals.checkSyncError(Conductor.songPositionNoOffset)) > 200)) + && (Math.abs(FlxG.sound.music.time - (Conductor.songPosition + Conductor.instrumentalOffset)) > 200 + || Math.abs(vocals.checkSyncError(Conductor.songPosition + Conductor.instrumentalOffset)) > 200)) { trace("VOCALS NEED RESYNC"); - if (vocals != null) trace(vocals.checkSyncError(Conductor.songPositionNoOffset)); - trace(FlxG.sound.music.time - (Conductor.songPositionNoOffset)); + if (vocals != null) trace(vocals.checkSyncError(Conductor.songPosition + Conductor.instrumentalOffset)); + trace(FlxG.sound.music.time - (Conductor.songPosition + Conductor.instrumentalOffset)); resyncVocals(); } diff --git a/source/funkin/ui/MusicBeatState.hx b/source/funkin/ui/MusicBeatState.hx index 7bdf8689c..077e9e495 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("songPositionNoOffset", Conductor.songPosition + Conductor.instrumentalOffset); FlxG.watch.addQuick("musicTime", FlxG.sound.music?.time ?? 0.0); FlxG.watch.addQuick("bpm", Conductor.bpm); FlxG.watch.addQuick("currentMeasureTime", Conductor.currentBeatTime); diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index a3a8344c8..c1476fd34 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -23,12 +23,14 @@ import flixel.util.FlxSort; import flixel.util.FlxTimer; import funkin.audio.visualize.PolygonSpectogram; import funkin.audio.VoicesGroup; +import funkin.audio.FunkinSound; import funkin.data.notestyle.NoteStyleRegistry; import funkin.data.song.SongData.SongCharacterData; import funkin.data.song.SongData.SongChartData; import funkin.data.song.SongData.SongEventData; import funkin.data.song.SongData.SongMetadata; import funkin.data.song.SongData.SongNoteData; +import funkin.data.song.SongData.SongOffsets; import funkin.data.song.SongDataUtils; import funkin.data.song.SongRegistry; import funkin.input.Cursor; @@ -245,6 +247,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Make sure playhead doesn't go outside the song. if (playheadPositionInMs > songLengthInMs) playheadPositionInMs = songLengthInMs; + onSongLengthChanged(); + return this.songLengthInMs; } @@ -884,7 +888,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState * Replaced when switching instrumentals. * `null` until an instrumental track is loaded. */ - var audioInstTrack:Null = null; + var audioInstTrack:Null = null; /** * The raw byte data for the instrumental audio tracks. @@ -1156,6 +1160,41 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState return currentSongMetadata.artist = value; } + /** + * Convenience property to get the song offset data for the current variation. + */ + var currentSongOffsets(get, set):SongOffsets; + + function get_currentSongOffsets():SongOffsets + { + if (currentSongMetadata.offsets == null) + { + // Initialize to the default value if not set. + currentSongMetadata.offsets = new SongOffsets(); + } + return currentSongMetadata.offsets; + } + + function set_currentSongOffsets(value:SongOffsets):SongOffsets + { + return currentSongMetadata.offsets = value; + } + + var currentInstrumentalOffset(get, set):Float; + + function get_currentInstrumentalOffset():Float + { + // TODO: Apply for alt instrumentals. + return currentSongOffsets.getInstrumentalOffset(); + } + + function set_currentInstrumentalOffset(value:Float):Float + { + // TODO: Apply for alt instrumentals. + currentSongOffsets.setInstrumentalOffset(value); + return value; + } + /** * The variation ID for the difficulty which is currently being edited. */ @@ -2024,7 +2063,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState playbarHeadDragging = true; // If we were dragging the playhead while the song was playing, resume playing. - if (audioInstTrack != null && audioInstTrack.playing) + if (audioInstTrack != null && audioInstTrack.isPlaying) { playbarHeadDraggingWasPlaying = true; stopAudioPlayback(); @@ -2224,8 +2263,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState menubarItemGoToBackupsFolder.onClick = _ -> this.openBackupsFolder(); #else // Disable the menu item if we're not on a desktop platform. - var menubarItemGoToBackupsFolder = findComponent('menubarItemGoToBackupsFolder', MenuItem); - if (menubarItemGoToBackupsFolder != null) menubarItemGoToBackupsFolder.disabled = true; + menubarItemGoToBackupsFolder.disabled = true; #end menubarItemUserGuide.onClick = _ -> this.openUserGuideDialog(); @@ -2435,7 +2473,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState super.update(elapsed); // These ones happen even if the modal dialog is open. - handleMusicPlayback(); + handleMusicPlayback(elapsed); handleNoteDisplay(); // These ones only happen if the modal dialog is not open. @@ -2471,7 +2509,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // dispatchEvent gets called here. if (!super.beatHit()) return false; - if (isMetronomeEnabled && this.subState == null && (audioInstTrack != null && audioInstTrack.playing)) + if (isMetronomeEnabled && this.subState == null && (audioInstTrack != null && audioInstTrack.isPlaying)) { playMetronomeTick(Conductor.currentBeat % 4 == 0); } @@ -2487,7 +2525,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // dispatchEvent gets called here. if (!super.stepHit()) return false; - if (audioInstTrack != null && audioInstTrack.playing) + if (audioInstTrack != null && audioInstTrack.isPlaying) { if (healthIconDad != null) healthIconDad.onStepHit(Conductor.currentStep); if (healthIconBF != null) healthIconBF.onStepHit(Conductor.currentStep); @@ -2508,18 +2546,26 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState /** * Handle syncronizing the conductor with the music playback. */ - function handleMusicPlayback():Void + function handleMusicPlayback(elapsed:Float):Void { - if (audioInstTrack != null && audioInstTrack.playing) + if (audioInstTrack != null) + { + // This normally gets called by FlxG.sound.update() + // but we handle instrumental updates manually to prevent FlxG.sound.music.update() + // from being called twice when we move to the PlayState. + audioInstTrack.update(elapsed); + } + + if (audioInstTrack != null && audioInstTrack.isPlaying) { if (FlxG.mouse.pressedMiddle) { // If middle mouse panning during song playback, we move ONLY the playhead, without scrolling. Neat! var oldStepTime:Float = Conductor.currentStepTime; - var oldSongPosition:Float = Conductor.songPosition; + var oldSongPosition:Float = Conductor.songPosition + Conductor.instrumentalOffset; Conductor.update(audioInstTrack.time); - handleHitsounds(oldSongPosition, Conductor.songPosition); + handleHitsounds(oldSongPosition, Conductor.songPosition + Conductor.instrumentalOffset); // Resync vocals. if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100) { @@ -2535,9 +2581,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState else { // Else, move the entire view. - var oldSongPosition:Float = Conductor.songPosition; + var oldSongPosition:Float = Conductor.songPosition + Conductor.instrumentalOffset; Conductor.update(audioInstTrack.time); - handleHitsounds(oldSongPosition, Conductor.songPosition); + handleHitsounds(oldSongPosition, Conductor.songPosition + Conductor.instrumentalOffset); // Resync vocals. if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100) { @@ -2546,7 +2592,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // We need time in fractional steps here to allow the song to actually play. // Also account for a potentially offset playhead. - scrollPositionInPixels = Conductor.currentStepTime * GRID_SIZE - playheadPositionInPixels; + scrollPositionInPixels = (Conductor.currentStepTime + Conductor.instrumentalOffsetSteps) * GRID_SIZE - playheadPositionInPixels; // DO NOT move song to scroll position here specifically. @@ -3992,8 +4038,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (playbarHeadLayout.playbarHead.value != songPosPercent) playbarHeadLayout.playbarHead.value = songPosPercent; } - var songPosSeconds:String = Std.string(Math.floor((songPos / 1000) % 60)).lpad('0', 2); - var songPosMinutes:String = Std.string(Math.floor((songPos / 1000) / 60)).lpad('0', 2); + var songPosSeconds:String = Std.string(Math.floor((Math.abs(songPos) / 1000) % 60)).lpad('0', 2); + var songPosMinutes:String = Std.string(Math.floor((Math.abs(songPos) / 1000) / 60)).lpad('0', 2); + if (songPos < 0) songPosMinutes = '-' + songPosMinutes; var songPosString:String = '${songPosMinutes}:${songPosSeconds}'; if (playbarSongPos.value != songPosString) playbarSongPos.value = songPosString; @@ -4313,6 +4360,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState { super.handleQuickWatch(); + FlxG.watch.addQuick('musicTime', audioInstTrack?.time ?? 0.0); + FlxG.watch.addQuick('scrollPosInPixels', scrollPositionInPixels); FlxG.watch.addQuick('playheadPosInPixels', playheadPositionInPixels); @@ -4340,6 +4389,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState { autoSave(); + stopWelcomeMusic(); + var startTimestamp:Float = 0; if (playtestStartTime) startTimestamp = scrollPositionInMs + playheadPositionInMs; @@ -4394,7 +4445,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState }); // Override music. - if (audioInstTrack != null) FlxG.sound.music = audioInstTrack; + if (audioInstTrack != null) + { + FlxG.sound.music = audioInstTrack; + } if (audioVocalTrackGroup != null) targetState.vocals = audioVocalTrackGroup; this.persistentUpdate = false; @@ -4523,6 +4577,23 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState this.switchToInstrumental(currentInstrumentalId, currentSongMetadata.playData.characters.player, currentSongMetadata.playData.characters.opponent); } + function onSongLengthChanged():Void + { + if (gridTiledSprite != null) gridTiledSprite.height = songLengthInPixels; + if (gridPlayheadScrollArea != null) + { + gridPlayheadScrollArea.setGraphicSize(Std.int(gridPlayheadScrollArea.width), songLengthInPixels); + gridPlayheadScrollArea.updateHitbox(); + } + + scrollPositionInPixels = 0; + playheadPositionInPixels = 0; + notePreviewDirty = true; + notePreviewViewportBoundsDirty = true; + noteDisplayDirty = true; + moveSongToScrollPosition(); + } + /** * CHART DATA FUNCTIONS */ @@ -4674,11 +4745,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // Update the songPosition in the audio tracks. if (audioInstTrack != null) { - audioInstTrack.time = scrollPositionInMs + playheadPositionInMs; + audioInstTrack.time = scrollPositionInMs + playheadPositionInMs - Conductor.instrumentalOffset; // Update the songPosition in the Conductor. Conductor.update(audioInstTrack.time); + if (audioVocalTrackGroup != null) audioVocalTrackGroup.time = audioInstTrack.time; } - if (audioVocalTrackGroup != null) audioVocalTrackGroup.time = scrollPositionInMs + playheadPositionInMs; // We need to update the note sprites because we changed the scroll position. noteDisplayDirty = true; @@ -4738,6 +4809,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState moveSongToScrollPosition(); + fadeInWelcomeMusic(7, 10); + // Reapply the volume. var instTargetVolume:Float = menubarItemVolumeInstrumental.value ?? 1.0; var vocalTargetVolume:Float = menubarItemVolumeVocals.value ?? 1.0; @@ -5034,15 +5107,17 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState { if (audioInstTrack == null) return; - if (audioInstTrack.playing) + if (audioInstTrack.isPlaying) { - fadeInWelcomeMusic(7, 10); + // Pause stopAudioPlayback(); + fadeInWelcomeMusic(7, 10); } else { - stopWelcomeMusic(); + // Play startAudioPlayback(); + stopWelcomeMusic(); } } @@ -5055,29 +5130,16 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (audioInstTrack != null) audioInstTrack.pause(); if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause(); }; - - songLengthInMs = audioInstTrack.length; - - if (gridTiledSprite != null) gridTiledSprite.height = songLengthInPixels; - if (gridPlayheadScrollArea != null) - { - gridPlayheadScrollArea.setGraphicSize(Std.int(gridPlayheadScrollArea.width), songLengthInPixels); - gridPlayheadScrollArea.updateHitbox(); - } } else { - trace('[WARN] Instrumental track was null!'); + trace('ERROR: Instrumental track is null!'); } - // Pretty much everything is going to need to be reset. - scrollPositionInPixels = 0; - playheadPositionInPixels = 0; - notePreviewDirty = true; - notePreviewViewportBoundsDirty = true; - noteDisplayDirty = true; + state.songLengthInMs = state.audioInstTrack?.length ?? 1000.0 + Conductor.instrumentalOffset; + + // Many things get reset when song length changes. healthIconsDirty = true; - moveSongToScrollPosition(); } /** diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx index 18c796cf7..2ba7ca3be 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx @@ -140,12 +140,14 @@ class ChartEditorAudioHandler { if (instId == '') instId = 'default'; var instTrackData:Null = state.audioInstTrackData.get(instId); - var instTrack:Null = SoundUtil.buildFlxSoundFromBytes(instTrackData); + var instTrack:Null = SoundUtil.buildSoundFromBytes(instTrackData); if (instTrack == null) return false; stopExistingInstrumental(state); state.audioInstTrack = instTrack; state.postLoadInstrumental(); + // Workaround for a bug where FlxG.sound.music.update() was being called twice. + FlxG.sound.list.remove(instTrack); return true; } @@ -176,17 +178,21 @@ class ChartEditorAudioHandler { case BF: state.audioVocalTrackGroup.addPlayerVoice(vocalTrack); + state.audioVocalTrackGroup.playerVoicesOffset = state.currentSongOffsets.getVocalOffset(charId); return true; case DAD: state.audioVocalTrackGroup.addOpponentVoice(vocalTrack); + state.audioVocalTrackGroup.opponentVoicesOffset = state.currentSongOffsets.getVocalOffset(charId); return true; case OTHER: state.audioVocalTrackGroup.add(vocalTrack); + // TODO: Add offset for other characters. return true; default: // Do nothing. } } + return false; } diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx index 0e7ba374c..46b4b5dc4 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx @@ -673,6 +673,7 @@ class ChartEditorDialogHandler state.songMetadata.set(targetVariation, newSongMetadata); + Conductor.instrumentalOffset = state.currentInstrumentalOffset; // Loads from the metadata. Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges); state.difficultySelectDirty = true; diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx index 23c864a07..1726fe4a2 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx @@ -115,6 +115,7 @@ class ChartEditorImportExportHandler state.songChartData = newSongChartData; Conductor.forceBPM(null); // Disable the forced BPM. + Conductor.instrumentalOffset = state.currentInstrumentalOffset; // Loads from the metadata. Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges); state.notePreviewDirty = true; diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx index 418f57464..933a7219b 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx @@ -620,6 +620,27 @@ class ChartEditorToolboxHandler }; inputBPM.value = state.currentSongMetadata.timeChanges[0].bpm; + var inputOffsetInst:Null = toolbox.findComponent('inputOffsetInst', NumberStepper); + if (inputOffsetInst == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputOffsetInst component.'; + inputOffsetInst.onChange = function(event:UIEvent) { + if (event.value == null) return; + + state.currentInstrumentalOffset = event.value; + Conductor.instrumentalOffset = event.value; + // Update song length. + state.songLengthInMs = state.audioInstTrack?.length ?? 1000.0 + Conductor.instrumentalOffset; + }; + inputOffsetInst.value = state.currentInstrumentalOffset; + + var inputOffsetVocal:Null = toolbox.findComponent('inputOffsetVocal', NumberStepper); + if (inputOffsetVocal == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputOffsetVocal component.'; + inputOffsetVocal.onChange = function(event:UIEvent) { + if (event.value == null) return; + + state.currentSongMetadata.offsets.setVocalOffset(state.currentSongMetadata.playData.characters.player, event.value); + }; + inputOffsetVocal.value = state.currentSongMetadata.offsets.getVocalOffset(state.currentSongMetadata.playData.characters.player); + var labelScrollSpeed:Null