From 4bf9f686586e03b197312afdb824b48dd3455bbb Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 9 Feb 2024 14:58:57 -0500
Subject: [PATCH] Finalize freeplay preview toolbox

---
 assets                                        |   2 +-
 source/funkin/audio/FunkinSound.hx            |  24 +
 source/funkin/audio/SoundGroup.hx             |  39 +
 source/funkin/audio/VoicesGroup.hx            |   8 +-
 source/funkin/audio/waveform/WaveformData.hx  |  32 +
 .../audio/waveform/WaveformDataParser.hx      |  29 +-
 source/funkin/data/song/SongData.hx           |  23 +-
 source/funkin/data/song/SongRegistry.hx       |   2 +-
 .../ui/debug/charting/ChartEditorState.hx     | 119 +--
 .../commands/SetAudioOffsetCommand.hx         |   6 +-
 .../commands/SetFreeplayPreviewCommand.hx     |  62 ++
 .../handlers/ChartEditorAudioHandler.hx       |  43 +-
 .../ChartEditorImportExportHandler.hx         |   5 +
 .../handlers/ChartEditorToolboxHandler.hx     |  14 +
 .../toolboxes/ChartEditorFreeplayToolbox.hx   | 693 ++++++++++++++++++
 .../toolboxes/ChartEditorOffsetsToolbox.hx    |  15 +-
 source/funkin/ui/freeplay/FreeplayState.hx    |  25 +
 17 files changed, 1067 insertions(+), 74 deletions(-)
 create mode 100644 source/funkin/ui/debug/charting/commands/SetFreeplayPreviewCommand.hx
 create mode 100644 source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx

diff --git a/assets b/assets
index 251d4640b..1941ec605 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 251d4640bd77ee0f0b6122a13f123274c43dd3f5
+Subproject commit 1941ec605d2da5a27e41588515d13d5bfc882582
diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index bc35cc0a7..e7ce68d08 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -8,6 +8,8 @@ import flixel.sound.FlxSound;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.system.FlxAssets.FlxSoundAsset;
 import funkin.util.tools.ICloneable;
+import funkin.audio.waveform.WaveformData;
+import funkin.audio.waveform.WaveformDataParser;
 import flixel.math.FlxMath;
 import openfl.Assets;
 #if (openfl >= "8.0.0")
@@ -58,6 +60,24 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
     return this.playing || this._shouldPlay;
   }
 
+  /**
+   * Waveform data for this sound.
+   * This is lazily loaded, so it will be built the first time it is accessed.
+   */
+  public var waveformData(get, never):WaveformData;
+
+  var _waveformData:Null<WaveformData> = null;
+
+  function get_waveformData():WaveformData
+  {
+    if (_waveformData == null)
+    {
+      _waveformData = WaveformDataParser.interpretFlxSound(this);
+      if (_waveformData == null) throw 'Could not interpret waveform data!';
+    }
+    return _waveformData;
+  }
+
   /**
    * Are we in a state where the song should play but time is negative?
    */
@@ -218,6 +238,10 @@ class FunkinSound extends FlxSound implements ICloneable<FunkinSound>
     // Call init to ensure the FlxSound is properly initialized.
     sound.init(this.looped, this.autoDestroy, this.onComplete);
 
+    // Oh yeah, the waveform data is the same too!
+    @:privateAccess
+    sound._waveformData = this._waveformData;
+
     return sound;
   }
 
diff --git a/source/funkin/audio/SoundGroup.hx b/source/funkin/audio/SoundGroup.hx
index 64104fee7..df3a67ae1 100644
--- a/source/funkin/audio/SoundGroup.hx
+++ b/source/funkin/audio/SoundGroup.hx
@@ -3,6 +3,7 @@ package funkin.audio;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.sound.FlxSound;
 import funkin.audio.FunkinSound;
+import flixel.tweens.FlxTween;
 
 /**
  * A group of FunkinSounds that are all synced together.
@@ -14,6 +15,8 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
 
   public var volume(get, set):Float;
 
+  public var muted(get, set):Bool;
+
   public var pitch(get, set):Float;
 
   public var playing(get, never):Bool;
@@ -124,6 +127,26 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
     });
   }
 
+  /**
+   * Fade in all the sounds in the group.
+   */
+  public function fadeIn(duration:Float, ?from:Float = 0.0, ?to:Float = 1.0, ?onComplete:FlxTween->Void):Void
+  {
+    forEachAlive(function(sound:FunkinSound) {
+      sound.fadeIn(duration, from, to, onComplete);
+    });
+  }
+
+  /**
+   * Fade out all the sounds in the group.
+   */
+  public function fadeOut(duration:Float, ?to:Float = 0.0, ?onComplete:FlxTween->Void):Void
+  {
+    forEachAlive(function(sound:FunkinSound) {
+      sound.fadeOut(duration, to, onComplete);
+    });
+  }
+
   /**
    * Stop all the sounds in the group.
    */
@@ -191,6 +214,22 @@ class SoundGroup extends FlxTypedGroup<FunkinSound>
     return volume;
   }
 
+  function get_muted():Bool
+  {
+    if (getFirstAlive() != null) return getFirstAlive().muted;
+    else
+      return false;
+  }
+
+  function set_muted(muted:Bool):Bool
+  {
+    forEachAlive(function(snd:FunkinSound) {
+      snd.muted = muted;
+    });
+
+    return muted;
+  }
+
   function get_pitch():Float
   {
     #if FLX_PITCH
diff --git a/source/funkin/audio/VoicesGroup.hx b/source/funkin/audio/VoicesGroup.hx
index 70a01f9dc..5daebc89d 100644
--- a/source/funkin/audio/VoicesGroup.hx
+++ b/source/funkin/audio/VoicesGroup.hx
@@ -116,18 +116,18 @@ class VoicesGroup extends SoundGroup
     return opponentVoices.members[index];
   }
 
-  public function buildPlayerVoiceWaveform():Null<WaveformData>
+  public function getPlayerVoiceWaveform():Null<WaveformData>
   {
     if (playerVoices.members.length == 0) return null;
 
-    return WaveformDataParser.interpretFlxSound(playerVoices.members[0]);
+    return playerVoices.members[0].waveformData;
   }
 
-  public function buildOpponentVoiceWaveform():Null<WaveformData>
+  public function getOpponentVoiceWaveform():Null<WaveformData>
   {
     if (opponentVoices.members.length == 0) return null;
 
-    return WaveformDataParser.interpretFlxSound(opponentVoices.members[0]);
+    return opponentVoices.members[0].waveformData;
   }
 
   /**
diff --git a/source/funkin/audio/waveform/WaveformData.hx b/source/funkin/audio/waveform/WaveformData.hx
index 31f8dfe02..b82d141e7 100644
--- a/source/funkin/audio/waveform/WaveformData.hx
+++ b/source/funkin/audio/waveform/WaveformData.hx
@@ -182,6 +182,38 @@ class WaveformData
     return result;
   }
 
+  /**
+   * Create a new WaveformData whose data represents the two waveforms overlayed.
+   */
+  public function merge(that:WaveformData):WaveformData
+  {
+    var result = this.clone([]);
+
+    for (channelIndex in 0...this.channels)
+    {
+      var thisChannel = this.channel(channelIndex);
+      var thatChannel = that.channel(channelIndex);
+      var resultChannel = result.channel(channelIndex);
+
+      for (index in 0...this.length)
+      {
+        var thisMinSample = thisChannel.minSample(index);
+        var thatMinSample = thatChannel.minSample(index);
+
+        var thisMaxSample = thisChannel.maxSample(index);
+        var thatMaxSample = thatChannel.maxSample(index);
+
+        resultChannel.setMinSample(index, Std.int(Math.min(thisMinSample, thatMinSample)));
+        resultChannel.setMaxSample(index, Std.int(Math.max(thisMaxSample, thatMaxSample)));
+      }
+    }
+
+    @:privateAccess
+    result.length = this.length;
+
+    return result;
+  }
+
   /**
    * Create a new WaveformData whose parameters match the current object.
    */
diff --git a/source/funkin/audio/waveform/WaveformDataParser.hx b/source/funkin/audio/waveform/WaveformDataParser.hx
index 2e5c52d13..54a142f6a 100644
--- a/source/funkin/audio/waveform/WaveformDataParser.hx
+++ b/source/funkin/audio/waveform/WaveformDataParser.hx
@@ -29,12 +29,12 @@ class WaveformDataParser
       }
       else
       {
-        trace('[WAVEFORM] Method 2 worked.');
+        // trace('[WAVEFORM] Method 2 worked.');
       }
     }
     else
     {
-      trace('[WAVEFORM] Method 1 worked.');
+      // trace('[WAVEFORM] Method 1 worked.');
     }
 
     return interpretAudioBuffer(soundBuffer);
@@ -55,22 +55,24 @@ class WaveformDataParser
     var soundDataSampleCount:Int = Std.int(Math.ceil(soundDataRawLength / channels / (bitsPerSample == 16 ? 2 : 1)));
     var outputPointCount:Int = Std.int(Math.ceil(soundDataSampleCount / samplesPerPoint));
 
-    trace('Interpreting audio buffer:');
-    trace('  sampleRate: ${sampleRate}');
-    trace('  channels: ${channels}');
-    trace('  bitsPerSample: ${bitsPerSample}');
-    trace('  samplesPerPoint: ${samplesPerPoint}');
-    trace('  pointsPerSecond: ${pointsPerSecond}');
-    trace('  soundDataRawLength: ${soundDataRawLength}');
-    trace('  soundDataSampleCount: ${soundDataSampleCount}');
-    trace('  soundDataRawLength/4: ${soundDataRawLength / 4}');
-    trace('  outputPointCount: ${outputPointCount}');
+    // trace('Interpreting audio buffer:');
+    // trace('  sampleRate: ${sampleRate}');
+    // trace('  channels: ${channels}');
+    // trace('  bitsPerSample: ${bitsPerSample}');
+    // trace('  samplesPerPoint: ${samplesPerPoint}');
+    // trace('  pointsPerSecond: ${pointsPerSecond}');
+    // trace('  soundDataRawLength: ${soundDataRawLength}');
+    // trace('  soundDataSampleCount: ${soundDataSampleCount}');
+    // trace('  soundDataRawLength/4: ${soundDataRawLength / 4}');
+    // trace('  outputPointCount: ${outputPointCount}');
 
     var minSampleValue:Int = bitsPerSample == 16 ? INT16_MIN : INT8_MIN;
     var maxSampleValue:Int = bitsPerSample == 16 ? INT16_MAX : INT8_MAX;
 
     var outputData:Array<Int> = [];
 
+    var perfStart = haxe.Timer.stamp();
+
     for (pointIndex in 0...outputPointCount)
     {
       // minChannel1, maxChannel1, minChannel2, maxChannel2, ...
@@ -106,6 +108,9 @@ class WaveformDataParser
     var outputDataLength:Int = Std.int(outputData.length / channels / 2);
     var result = new WaveformData(null, channels, sampleRate, samplesPerPoint, bitsPerSample, outputPointCount, outputData);
 
+    var perfEnd = haxe.Timer.stamp();
+    trace('[WAVEFORM] Interpreted audio buffer in ${perfEnd - perfStart} seconds.');
+
     return result;
   }
 
diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index 52b9c19d6..7d5bc4e19 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -431,6 +431,24 @@ class SongPlayData implements ICloneable<SongPlayData>
   @:optional
   public var album:Null<String>;
 
+  /**
+   * The start time for the audio preview in Freeplay.
+   * Defaults to 0 seconds in.
+   * @since `2.2.2`
+   */
+  @:optional
+  @:default(0)
+  public var previewStart:Int;
+
+  /**
+   * The end time for the audio preview in Freeplay.
+   * Defaults to 15 seconds in.
+   * @since `2.2.2`
+   */
+  @:optional
+  @:default(15000)
+  public var previewEnd:Int;
+
   public function new()
   {
     ratings = new Map<String, Int>();
@@ -438,6 +456,7 @@ class SongPlayData implements ICloneable<SongPlayData>
 
   public function clone():SongPlayData
   {
+    // TODO: This sucks! If you forget to update this you get weird behavior.
     var result:SongPlayData = new SongPlayData();
     result.songVariations = this.songVariations.clone();
     result.difficulties = this.difficulties.clone();
@@ -446,6 +465,8 @@ class SongPlayData implements ICloneable<SongPlayData>
     result.noteStyle = this.noteStyle;
     result.ratings = this.ratings.clone();
     result.album = this.album;
+    result.previewStart = this.previewStart;
+    result.previewEnd = this.previewEnd;
 
     return result;
   }
@@ -777,7 +798,7 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
 
       var title = eventSchema.getByName(key)?.title ?? 'UnknownField';
 
-      if (eventSchema.stringifyFieldValue(key, value) != null) trace(eventSchema.stringifyFieldValue(key, value));
+      // if (eventSchema.stringifyFieldValue(key, value) != null) trace(eventSchema.stringifyFieldValue(key, value));
       var valueStr = eventSchema.stringifyFieldValue(key, value) ?? 'UnknownValue';
 
       result += '\n- ${title}: ${valueStr}';
diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx
index b772349bc..d2a548c62 100644
--- a/source/funkin/data/song/SongRegistry.hx
+++ b/source/funkin/data/song/SongRegistry.hx
@@ -20,7 +20,7 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
    * 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.1";
+  public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.2";
 
   public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.2.x";
 
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index d0326be30..53325acb8 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -1,47 +1,52 @@
 package funkin.ui.debug.charting;
 
-import funkin.ui.debug.charting.toolboxes.ChartEditorOffsetsToolbox;
-import funkin.util.logging.CrashHandler;
-import haxe.ui.containers.HBox;
-import haxe.ui.containers.Grid;
-import haxe.ui.containers.ScrollView;
-import haxe.ui.containers.menus.MenuBar;
 import flixel.addons.display.FlxSliceSprite;
 import flixel.addons.display.FlxTiledSprite;
 import flixel.addons.transition.FlxTransitionableState;
 import flixel.FlxCamera;
 import flixel.FlxSprite;
 import flixel.FlxSubState;
+import flixel.graphics.FlxGraphic;
+import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.group.FlxSpriteGroup;
-import funkin.graphics.FunkinSprite;
 import flixel.input.keyboard.FlxKey;
+import flixel.input.mouse.FlxMouseEvent;
 import flixel.math.FlxMath;
 import flixel.math.FlxPoint;
-import flixel.graphics.FlxGraphic;
 import flixel.math.FlxRect;
 import flixel.sound.FlxSound;
+import flixel.system.debug.log.LogStyle;
 import flixel.system.FlxAssets.FlxSoundAsset;
+import flixel.text.FlxText;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
 import flixel.tweens.misc.VarTween;
-import funkin.audio.waveform.WaveformSprite;
-import haxe.ui.Toolkit;
 import flixel.util.FlxColor;
 import flixel.util.FlxSort;
 import flixel.util.FlxTimer;
+import funkin.audio.FunkinSound;
+import funkin.audio.visualize.PolygonSpectogram;
 import funkin.audio.visualize.PolygonSpectogram;
 import funkin.audio.VoicesGroup;
-import funkin.audio.FunkinSound;
+import funkin.audio.waveform.WaveformSprite;
 import funkin.data.notestyle.NoteStyleRegistry;
 import funkin.data.song.SongData.SongCharacterData;
+import funkin.data.song.SongData.SongCharacterData;
+import funkin.data.song.SongData.SongChartData;
 import funkin.data.song.SongData.SongChartData;
 import funkin.data.song.SongData.SongEventData;
+import funkin.data.song.SongData.SongEventData;
 import funkin.data.song.SongData.SongMetadata;
-import funkin.ui.debug.charting.toolboxes.ChartEditorDifficultyToolbox;
+import funkin.data.song.SongData.SongMetadata;
+import funkin.data.song.SongData.SongNoteData;
 import funkin.data.song.SongData.SongNoteData;
 import funkin.data.song.SongData.SongOffsets;
 import funkin.data.song.SongDataUtils;
+import funkin.data.song.SongDataUtils;
 import funkin.data.song.SongRegistry;
+import funkin.data.song.SongRegistry;
+import funkin.data.stage.StageData;
+import funkin.graphics.FunkinSprite;
 import funkin.input.Cursor;
 import funkin.input.TurboKeyHandler;
 import funkin.modding.events.ScriptEvent;
@@ -52,22 +57,14 @@ import funkin.play.components.HealthIcon;
 import funkin.play.notes.NoteSprite;
 import funkin.play.PlayState;
 import funkin.play.song.Song;
-import funkin.data.song.SongData.SongChartData;
-import funkin.data.song.SongRegistry;
-import funkin.data.song.SongData.SongEventData;
-import funkin.data.song.SongData.SongMetadata;
-import funkin.data.song.SongData.SongNoteData;
-import funkin.data.song.SongData.SongCharacterData;
-import funkin.data.song.SongDataUtils;
-import funkin.ui.debug.charting.commands.ChartEditorCommand;
-import funkin.data.stage.StageData;
 import funkin.save.Save;
 import funkin.ui.debug.charting.commands.AddEventsCommand;
 import funkin.ui.debug.charting.commands.AddNotesCommand;
 import funkin.ui.debug.charting.commands.ChartEditorCommand;
 import funkin.ui.debug.charting.commands.ChartEditorCommand;
-import funkin.ui.debug.charting.commands.CutItemsCommand;
+import funkin.ui.debug.charting.commands.ChartEditorCommand;
 import funkin.ui.debug.charting.commands.CopyItemsCommand;
+import funkin.ui.debug.charting.commands.CutItemsCommand;
 import funkin.ui.debug.charting.commands.DeselectAllItemsCommand;
 import funkin.ui.debug.charting.commands.DeselectItemsCommand;
 import funkin.ui.debug.charting.commands.ExtendNoteLengthCommand;
@@ -85,17 +82,22 @@ import funkin.ui.debug.charting.commands.SelectItemsCommand;
 import funkin.ui.debug.charting.commands.SetItemSelectionCommand;
 import funkin.ui.debug.charting.components.ChartEditorEventSprite;
 import funkin.ui.debug.charting.components.ChartEditorHoldNoteSprite;
+import funkin.ui.debug.charting.components.ChartEditorMeasureTicks;
 import funkin.ui.debug.charting.components.ChartEditorNotePreview;
 import funkin.ui.debug.charting.components.ChartEditorNoteSprite;
-import funkin.ui.debug.charting.components.ChartEditorMeasureTicks;
 import funkin.ui.debug.charting.components.ChartEditorPlaybarHead;
 import funkin.ui.debug.charting.components.ChartEditorSelectionSquareSprite;
 import funkin.ui.debug.charting.handlers.ChartEditorShortcutHandler;
+import funkin.ui.debug.charting.toolboxes.ChartEditorBaseToolbox;
+import funkin.ui.debug.charting.toolboxes.ChartEditorDifficultyToolbox;
+import funkin.ui.debug.charting.toolboxes.ChartEditorFreeplayToolbox;
+import funkin.ui.debug.charting.toolboxes.ChartEditorOffsetsToolbox;
 import funkin.ui.haxeui.components.CharacterPlayer;
 import funkin.ui.haxeui.HaxeUIState;
 import funkin.ui.mainmenu.MainMenuState;
 import funkin.util.Constants;
 import funkin.util.FileUtil;
+import funkin.util.logging.CrashHandler;
 import funkin.util.SortUtil;
 import funkin.util.WindowUtil;
 import haxe.DynamicAccess;
@@ -103,23 +105,26 @@ import haxe.io.Bytes;
 import haxe.io.Path;
 import haxe.ui.backend.flixel.UIRuntimeState;
 import haxe.ui.backend.flixel.UIState;
-import haxe.ui.components.DropDown;
-import haxe.ui.components.Label;
 import haxe.ui.components.Button;
+import haxe.ui.components.DropDown;
+import haxe.ui.components.Image;
+import haxe.ui.components.Label;
 import haxe.ui.components.NumberStepper;
 import haxe.ui.components.Slider;
 import haxe.ui.components.TextField;
+import haxe.ui.containers.Box;
 import haxe.ui.containers.dialogs.CollapsibleDialog;
 import haxe.ui.containers.Frame;
-import haxe.ui.containers.Box;
+import haxe.ui.containers.Grid;
+import haxe.ui.containers.HBox;
 import haxe.ui.containers.menus.Menu;
 import haxe.ui.containers.menus.MenuBar;
-import haxe.ui.containers.menus.MenuItem;
+import haxe.ui.containers.menus.MenuBar;
 import haxe.ui.containers.menus.MenuCheckBox;
+import haxe.ui.containers.menus.MenuItem;
+import haxe.ui.containers.ScrollView;
 import haxe.ui.containers.TreeView;
 import haxe.ui.containers.TreeViewNode;
-import haxe.ui.components.Image;
-import funkin.ui.debug.charting.toolboxes.ChartEditorBaseToolbox;
 import haxe.ui.core.Component;
 import haxe.ui.core.Screen;
 import haxe.ui.events.DragEvent;
@@ -127,12 +132,8 @@ import haxe.ui.events.MouseEvent;
 import haxe.ui.events.UIEvent;
 import haxe.ui.events.UIEvent;
 import haxe.ui.focus.FocusManager;
+import haxe.ui.Toolkit;
 import openfl.display.BitmapData;
-import funkin.audio.visualize.PolygonSpectogram;
-import flixel.group.FlxGroup.FlxTypedGroup;
-import flixel.input.mouse.FlxMouseEvent;
-import flixel.text.FlxText;
-import flixel.system.debug.log.LogStyle;
 
 using Lambda;
 
@@ -154,18 +155,19 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   // ==============================
   // Layouts
-  public static final CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/notedata');
-
-  public static final CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/eventdata');
-  public static final CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT:String = Paths.ui('chart-editor/toolbox/playtest-properties');
-  public static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/metadata');
-  public static final CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT:String = Paths.ui('chart-editor/toolbox/offsets');
   public static final CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT:String = Paths.ui('chart-editor/toolbox/difficulty');
+
   public static final CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/player-preview');
   public static final CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/opponent-preview');
+  public static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/metadata');
+  public static final CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT:String = Paths.ui('chart-editor/toolbox/offsets');
+  public static final CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/notedata');
+  public static final CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/eventdata');
+  public static final CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT:String = Paths.ui('chart-editor/toolbox/freeplay');
+  public static final CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT:String = Paths.ui('chart-editor/toolbox/playtest-properties');
 
   // Validation
-  public static final SUPPORTED_MUSIC_FORMATS:Array<String> = ['ogg'];
+  public static final SUPPORTED_MUSIC_FORMATS:Array<String> = #if sys ['ogg'] #else ['mp3'] #end;
 
   // Layout
 
@@ -1311,6 +1313,30 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     return currentSongMetadata.playData.noteStyle = value;
   }
 
+  var currentSongFreeplayPreviewStart(get, set):Int;
+
+  function get_currentSongFreeplayPreviewStart():Int
+  {
+    return currentSongMetadata.playData.previewStart;
+  }
+
+  function set_currentSongFreeplayPreviewStart(value:Int):Int
+  {
+    return currentSongMetadata.playData.previewStart = value;
+  }
+
+  var currentSongFreeplayPreviewEnd(get, set):Int;
+
+  function get_currentSongFreeplayPreviewEnd():Int
+  {
+    return currentSongMetadata.playData.previewEnd;
+  }
+
+  function set_currentSongFreeplayPreviewEnd(value:Int):Int
+  {
+    return currentSongMetadata.playData.previewEnd = value;
+  }
+
   var currentSongStage(get, set):String;
 
   function get_currentSongStage():String
@@ -2920,6 +2946,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     menubarItemToggleToolboxOffsets.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT, event.value);
     menubarItemToggleToolboxNotes.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT, event.value);
     menubarItemToggleToolboxEventData.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_EVENT_DATA_LAYOUT, event.value);
+    menubarItemToggleToolboxFreeplay.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT, event.value);
     menubarItemToggleToolboxPlaytestProperties.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_PLAYTEST_PROPERTIES_LAYOUT, event.value);
     menubarItemToggleToolboxPlayerPreview.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT, event.value);
     menubarItemToggleToolboxOpponentPreview.onChange = event -> this.setToolboxState(CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT, event.value);
@@ -5960,6 +5987,16 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     }
   }
 
+  function hardRefreshFreeplayToolbox():Void
+  {
+    var freeplayToolbox:ChartEditorFreeplayToolbox = cast this.getToolbox(CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT);
+    if (freeplayToolbox != null)
+    {
+      freeplayToolbox.refreshAudioPreview();
+      freeplayToolbox.refresh();
+    }
+  }
+
   /**
    * Clear the voices group.
    */
diff --git a/source/funkin/ui/debug/charting/commands/SetAudioOffsetCommand.hx b/source/funkin/ui/debug/charting/commands/SetAudioOffsetCommand.hx
index aef402244..ca1fda6b9 100644
--- a/source/funkin/ui/debug/charting/commands/SetAudioOffsetCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/SetAudioOffsetCommand.hx
@@ -52,7 +52,11 @@ class SetAudioOffsetCommand implements ChartEditorCommand
     }
 
     // Update the offsets toolbox.
-    if (refreshOffsetsToolbox) state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT);
+    if (refreshOffsetsToolbox)
+    {
+      state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT);
+      state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT);
+    }
   }
 
   public function undo(state:ChartEditorState):Void
diff --git a/source/funkin/ui/debug/charting/commands/SetFreeplayPreviewCommand.hx b/source/funkin/ui/debug/charting/commands/SetFreeplayPreviewCommand.hx
new file mode 100644
index 000000000..232768904
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/SetFreeplayPreviewCommand.hx
@@ -0,0 +1,62 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongDataUtils;
+
+/**
+ * Command that sets the start time or end time of the Freeplay preview.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class SetFreeplayPreviewCommand implements ChartEditorCommand
+{
+  var previousStartTime:Int = 0;
+  var previousEndTime:Int = 0;
+  var newStartTime:Null<Int> = null;
+  var newEndTime:Null<Int> = null;
+
+  public function new(newStartTime:Null<Int>, newEndTime:Null<Int>)
+  {
+    this.newStartTime = newStartTime;
+    this.newEndTime = newEndTime;
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    this.previousStartTime = state.currentSongFreeplayPreviewStart;
+    this.previousEndTime = state.currentSongFreeplayPreviewEnd;
+
+    if (newStartTime != null) state.currentSongFreeplayPreviewStart = newStartTime;
+    if (newEndTime != null) state.currentSongFreeplayPreviewEnd = newEndTime;
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    state.currentSongFreeplayPreviewStart = previousStartTime;
+    state.currentSongFreeplayPreviewEnd = previousEndTime;
+  }
+
+  public function shouldAddToHistory(state:ChartEditorState):Bool
+  {
+    return (newStartTime != null && newStartTime != previousStartTime) || (newEndTime != null && newEndTime != previousEndTime);
+  }
+
+  public function toString():String
+  {
+    var setStart = newStartTime != null && newStartTime != previousStartTime;
+    var setEnd = newEndTime != null && newEndTime != previousEndTime;
+
+    if (setStart && !setEnd)
+    {
+      return "Set Freeplay Preview Start Time";
+    }
+    else if (setEnd && !setStart)
+    {
+      return "Set Freeplay Preview End Time";
+    }
+    else
+    {
+      return "Set Freeplay Preview Start and End Times";
+    }
+  }
+}
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
index 8e40cfc42..76b2a388e 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
@@ -128,17 +128,42 @@ class ChartEditorAudioHandler
 
   public static function switchToInstrumental(state:ChartEditorState, instId:String = '', playerId:String, opponentId:String):Bool
   {
+    var perfA = haxe.Timer.stamp();
+
     var result:Bool = playInstrumental(state, instId);
     if (!result) return false;
 
+    var perfB = haxe.Timer.stamp();
+
     stopExistingVocals(state);
+
+    var perfC = haxe.Timer.stamp();
+
     result = playVocals(state, BF, playerId, instId);
+
+    var perfD = haxe.Timer.stamp();
+
     // if (!result) return false;
     result = playVocals(state, DAD, opponentId, instId);
     // if (!result) return false;
 
+    var perfE = haxe.Timer.stamp();
+
     state.hardRefreshOffsetsToolbox();
 
+    var perfF = haxe.Timer.stamp();
+
+    state.hardRefreshFreeplayToolbox();
+
+    var perfG = haxe.Timer.stamp();
+
+    trace('Switched to instrumental in ${perfB - perfA} seconds.');
+    trace('Stopped existing vocals in ${perfC - perfB} seconds.');
+    trace('Played BF vocals in ${perfD - perfC} seconds.');
+    trace('Played DAD vocals in ${perfE - perfD} seconds.');
+    trace('Hard refreshed offsets toolbox in ${perfF - perfE} seconds.');
+    trace('Hard refreshed freeplay toolbox in ${perfG - perfF} seconds.');
+
     return true;
   }
 
@@ -149,7 +174,10 @@ class ChartEditorAudioHandler
   {
     if (instId == '') instId = 'default';
     var instTrackData:Null<Bytes> = state.audioInstTrackData.get(instId);
+    var perfA = haxe.Timer.stamp();
     var instTrack:Null<FunkinSound> = SoundUtil.buildSoundFromBytes(instTrackData);
+    var perfB = haxe.Timer.stamp();
+    trace('Built instrumental track in ${perfB - perfA} seconds.');
     if (instTrack == null) return false;
 
     stopExistingInstrumental(state);
@@ -177,7 +205,10 @@ class ChartEditorAudioHandler
   {
     var trackId:String = '${charId}${instId == '' ? '' : '-${instId}'}';
     var vocalTrackData:Null<Bytes> = state.audioVocalTrackData.get(trackId);
+    var perfStart = haxe.Timer.stamp();
     var vocalTrack:Null<FunkinSound> = SoundUtil.buildSoundFromBytes(vocalTrackData);
+    var perfEnd = haxe.Timer.stamp();
+    trace('Built vocal track in ${perfEnd - perfStart} seconds.');
 
     if (state.audioVocalTrackGroup == null) state.audioVocalTrackGroup = new VoicesGroup();
 
@@ -188,7 +219,11 @@ class ChartEditorAudioHandler
         case BF:
           state.audioVocalTrackGroup.addPlayerVoice(vocalTrack);
 
-          var waveformData:Null<WaveformData> = WaveformDataParser.interpretFlxSound(vocalTrack);
+          var perfStart = haxe.Timer.stamp();
+          var waveformData:Null<WaveformData> = vocalTrack.waveformData;
+          var perfEnd = haxe.Timer.stamp();
+          trace('Interpreted waveform data in ${perfEnd - perfStart} seconds.');
+
           if (waveformData != null)
           {
             var duration:Float = Conductor.instance.getStepTimeInMs(16) * 0.001;
@@ -211,7 +246,11 @@ class ChartEditorAudioHandler
         case DAD:
           state.audioVocalTrackGroup.addOpponentVoice(vocalTrack);
 
-          var waveformData:Null<WaveformData> = WaveformDataParser.interpretFlxSound(vocalTrack);
+          var perfStart = haxe.Timer.stamp();
+          var waveformData:Null<WaveformData> = vocalTrack.waveformData;
+          var perfEnd = haxe.Timer.stamp();
+          trace('Interpreted waveform data in ${perfEnd - perfStart} seconds.');
+
           if (waveformData != null)
           {
             var duration:Float = Conductor.instance.getStepTimeInMs(16) * 0.001;
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
index 04d89e3f4..0318bf296 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorImportExportHandler.hx
@@ -28,6 +28,8 @@ class ChartEditorImportExportHandler
    */
   public static function loadSongAsTemplate(state:ChartEditorState, songId:String):Void
   {
+    trace('===============START');
+
     var song:Null<Song> = SongRegistry.instance.fetchEntry(songId);
 
     if (song == null) return;
@@ -98,11 +100,14 @@ class ChartEditorImportExportHandler
     state.isHaxeUIDialogOpen = false;
     state.currentWorkingFilePath = null; // New file, so no path.
     state.switchToCurrentInstrumental();
+
     state.postLoadInstrumental();
 
     state.refreshToolbox(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
 
     state.success('Success', 'Loaded song (${rawSongMetadata[0].songName})');
+
+    trace('===============END');
   }
 
   /**
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
index b246e653f..9e22ba833 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
@@ -36,6 +36,7 @@ import haxe.ui.containers.dialogs.Dialog.DialogEvent;
 import funkin.ui.debug.charting.toolboxes.ChartEditorBaseToolbox;
 import funkin.ui.debug.charting.toolboxes.ChartEditorMetadataToolbox;
 import funkin.ui.debug.charting.toolboxes.ChartEditorOffsetsToolbox;
+import funkin.ui.debug.charting.toolboxes.ChartEditorFreeplayToolbox;
 import funkin.ui.debug.charting.toolboxes.ChartEditorEventDataToolbox;
 import funkin.ui.debug.charting.toolboxes.ChartEditorDifficultyToolbox;
 import haxe.ui.containers.Frame;
@@ -92,6 +93,8 @@ class ChartEditorToolboxHandler
           cast(toolbox, ChartEditorBaseToolbox).refresh();
         case ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT:
           cast(toolbox, ChartEditorBaseToolbox).refresh();
+        case ChartEditorState.CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT:
+          cast(toolbox, ChartEditorBaseToolbox).refresh();
         case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
           onShowToolboxPlayerPreview(state, toolbox);
         case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
@@ -205,6 +208,8 @@ class ChartEditorToolboxHandler
         toolbox = buildToolboxMetadataLayout(state);
       case ChartEditorState.CHART_EDITOR_TOOLBOX_OFFSETS_LAYOUT:
         toolbox = buildToolboxOffsetsLayout(state);
+      case ChartEditorState.CHART_EDITOR_TOOLBOX_FREEPLAY_LAYOUT:
+        toolbox = buildToolboxFreeplayLayout(state);
       case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
         toolbox = buildToolboxPlayerPreviewLayout(state);
       case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
@@ -383,6 +388,15 @@ class ChartEditorToolboxHandler
     return toolbox;
   }
 
+  static function buildToolboxFreeplayLayout(state:ChartEditorState):Null<ChartEditorBaseToolbox>
+  {
+    var toolbox:ChartEditorBaseToolbox = ChartEditorFreeplayToolbox.build(state);
+
+    if (toolbox == null) return null;
+
+    return toolbox;
+  }
+
   static function buildToolboxEventDataLayout(state:ChartEditorState):Null<ChartEditorBaseToolbox>
   {
     var toolbox:ChartEditorBaseToolbox = ChartEditorEventDataToolbox.build(state);
diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx
new file mode 100644
index 000000000..8d3554a08
--- /dev/null
+++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx
@@ -0,0 +1,693 @@
+package funkin.ui.debug.charting.toolboxes;
+
+import funkin.audio.SoundGroup;
+import haxe.ui.components.Button;
+import haxe.ui.components.HorizontalSlider;
+import haxe.ui.components.Label;
+import flixel.addons.display.FlxTiledSprite;
+import flixel.math.FlxMath;
+import haxe.ui.components.NumberStepper;
+import haxe.ui.components.Slider;
+import haxe.ui.backend.flixel.components.SpriteWrapper;
+import funkin.ui.debug.charting.commands.SetFreeplayPreviewCommand;
+import funkin.ui.haxeui.components.WaveformPlayer;
+import funkin.audio.waveform.WaveformDataParser;
+import haxe.ui.containers.VBox;
+import haxe.ui.containers.Absolute;
+import haxe.ui.containers.ScrollView;
+import funkin.ui.freeplay.FreeplayState;
+import haxe.ui.containers.Frame;
+import haxe.ui.core.Screen;
+import haxe.ui.events.DragEvent;
+import haxe.ui.events.MouseEvent;
+import haxe.ui.events.UIEvent;
+
+/**
+ * The toolbox which allows modifying information like Song Title, Scroll Speed, Characters/Stages, and starting BPM.
+ */
+// @:nullSafety // TODO: Fix null safety when used with HaxeUI build macros.
+@:access(funkin.ui.debug.charting.ChartEditorState)
+@:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/toolboxes/freeplay.xml"))
+class ChartEditorFreeplayToolbox extends ChartEditorBaseToolbox
+{
+  var waveformContainer:Absolute;
+  var waveformScrollview:ScrollView;
+  var waveformMusic:WaveformPlayer;
+  var freeplayButtonZoomIn:Button;
+  var freeplayButtonZoomOut:Button;
+  var freeplayButtonPause:Button;
+  var freeplayButtonPlay:Button;
+  var freeplayButtonStop:Button;
+  var freeplayPreviewStart:NumberStepper;
+  var freeplayPreviewEnd:NumberStepper;
+  var freeplayTicksContainer:Absolute;
+  var playheadSprite:SpriteWrapper;
+  var previewSelectionSprite:SpriteWrapper;
+
+  static final TICK_LABEL_X_OFFSET:Float = 4.0;
+
+  static final PLAYHEAD_RIGHT_PAD:Float = 10.0;
+
+  static final BASE_SCALE:Float = 64.0;
+  static final STARTING_SCALE:Float = 1024.0;
+  static final MIN_SCALE:Float = 4.0;
+  static final WAVEFORM_ZOOM_MULT:Float = 1.5;
+
+  static final MAGIC_SCALE_BASE_TIME:Float = 5.0;
+
+  var waveformScale:Float = STARTING_SCALE;
+
+  var playheadAbsolutePos(get, set):Float;
+
+  function get_playheadAbsolutePos():Float
+  {
+    return playheadSprite.left;
+  }
+
+  function set_playheadAbsolutePos(value:Float):Float
+  {
+    return playheadSprite.left = value;
+  }
+
+  var playheadRelativePos(get, set):Float;
+
+  function get_playheadRelativePos():Float
+  {
+    return playheadSprite.left - waveformScrollview.hscrollPos;
+  }
+
+  function set_playheadRelativePos(value:Float):Float
+  {
+    return playheadSprite.left = waveformScrollview.hscrollPos + value;
+  }
+
+  var previewBoxStartPosAbsolute(get, set):Float;
+
+  function get_previewBoxStartPosAbsolute():Float
+  {
+    return previewSelectionSprite.left;
+  }
+
+  function set_previewBoxStartPosAbsolute(value:Float):Float
+  {
+    return previewSelectionSprite.left = value;
+  }
+
+  var previewBoxEndPosAbsolute(get, set):Float;
+
+  function get_previewBoxEndPosAbsolute():Float
+  {
+    return previewSelectionSprite.left + previewSelectionSprite.width;
+  }
+
+  function set_previewBoxEndPosAbsolute(value:Float):Float
+  {
+    if (value < previewBoxStartPosAbsolute) return previewSelectionSprite.left = previewBoxStartPosAbsolute;
+    return previewSelectionSprite.width = value - previewBoxStartPosAbsolute;
+  }
+
+  var previewBoxStartPosRelative(get, set):Float;
+
+  function get_previewBoxStartPosRelative():Float
+  {
+    return previewSelectionSprite.left - waveformScrollview.hscrollPos;
+  }
+
+  function set_previewBoxStartPosRelative(value:Float):Float
+  {
+    return previewSelectionSprite.left = waveformScrollview.hscrollPos + value;
+  }
+
+  var previewBoxEndPosRelative(get, set):Float;
+
+  function get_previewBoxEndPosRelative():Float
+  {
+    return previewSelectionSprite.left + previewSelectionSprite.width - waveformScrollview.hscrollPos;
+  }
+
+  function set_previewBoxEndPosRelative(value:Float):Float
+  {
+    if (value < previewBoxStartPosRelative) return previewSelectionSprite.left = previewBoxStartPosRelative;
+    return previewSelectionSprite.width = value - previewBoxStartPosRelative;
+  }
+
+  /**
+   * The amount you need to multiply the zoom by such that, at the base zoom level, one tick is equal to `MAGIC_SCALE_BASE_TIME` seconds.
+   */
+  var waveformMagicFactor:Float = 1.0;
+
+  var audioPreviewTracks:SoundGroup;
+
+  var tickTiledSprite:FlxTiledSprite;
+
+  var freeplayPreviewVolume(get, null):Float;
+
+  function get_freeplayPreviewVolume():Float
+  {
+    return freeplayMusicVolume.value * 2 / 100;
+  }
+
+  var tickLabels:Array<Label> = [];
+
+  public function new(chartEditorState2:ChartEditorState)
+  {
+    super(chartEditorState2);
+
+    initialize();
+
+    this.onDialogClosed = onClose;
+  }
+
+  function onClose(event:UIEvent)
+  {
+    chartEditorState.menubarItemToggleToolboxFreeplay.selected = false;
+  }
+
+  function initialize():Void
+  {
+    // Starting position.
+    // TODO: Save and load this.
+    this.x = 150;
+    this.y = 250;
+
+    freeplayMusicVolume.onChange = (_) -> {
+      setTrackVolume(freeplayPreviewVolume);
+    };
+    freeplayMusicMute.onClick = (_) -> {
+      toggleMuteTrack();
+    };
+    freeplayButtonZoomIn.onClick = (_) -> {
+      zoomWaveformIn();
+    };
+    freeplayButtonZoomOut.onClick = (_) -> {
+      zoomWaveformOut();
+    };
+    freeplayButtonPause.onClick = (_) -> {
+      pauseAudioPreview();
+    };
+    freeplayButtonPlay.onClick = (_) -> {
+      playAudioPreview();
+    };
+    freeplayButtonStop.onClick = (_) -> {
+      stopAudioPreview();
+    };
+    testPreview.onClick = (_) -> {
+      performPreview();
+    };
+    freeplayPreviewStart.onChange = (event:UIEvent) -> {
+      if (event.value == chartEditorState.currentSongFreeplayPreviewStart) return;
+      if (waveformDragStartPos != null) return; // The values are changing because we are dragging the preview.
+
+      chartEditorState.performCommand(new SetFreeplayPreviewCommand(event.value, null));
+      refresh();
+    }
+    freeplayPreviewEnd.onChange = (event:UIEvent) -> {
+      if (event.value == chartEditorState.currentSongFreeplayPreviewEnd) return;
+      if (waveformDragStartPos != null) return; // The values are changing because we are dragging the preview.
+
+      chartEditorState.performCommand(new SetFreeplayPreviewCommand(null, event.value));
+      refresh();
+    }
+    waveformScrollview.onScroll = (_) -> {
+      if (!audioPreviewTracks.playing)
+      {
+        // Move the playhead if it would go out of view.
+        var prevPlayheadRelativePos = playheadRelativePos;
+        playheadRelativePos = FlxMath.bound(playheadRelativePos, 0, waveformScrollview.width - PLAYHEAD_RIGHT_PAD);
+        trace('newPos: ${playheadRelativePos}');
+        var diff = playheadRelativePos - prevPlayheadRelativePos;
+
+        if (diff != 0)
+        {
+          // We have to change the song time to match the playhead position when we move it.
+          var currentWaveformIndex:Int = Std.int(playheadAbsolutePos * (waveformScale / BASE_SCALE * waveformMagicFactor));
+          var targetSongTimeSeconds:Float = waveformMusic.waveform.waveformData.indexToSeconds(currentWaveformIndex);
+          audioPreviewTracks.time = targetSongTimeSeconds * Constants.MS_PER_SEC;
+        }
+
+        addOffsetsToAudioPreview();
+      }
+      else
+      {
+        // The scrollview probably changed because the song position changed.
+        // If we try to move the song now it will glitch.
+      }
+
+      // Either way, clipRect has changed, so we need to refresh the waveforms.
+      refresh();
+    };
+
+    initializeTicks();
+
+    refreshAudioPreview();
+    refresh();
+    refreshTicks();
+
+    waveformMusic.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> {
+      onStartDragWaveform();
+    });
+
+    freeplayTicksContainer.registerEvent(MouseEvent.MOUSE_DOWN, (_) -> {
+      onStartDragPlayhead();
+    });
+  }
+
+  function initializeTicks():Void
+  {
+    tickTiledSprite = new FlxTiledSprite(chartEditorState.offsetTickBitmap, 100, chartEditorState.offsetTickBitmap.height, true, false);
+    freeplayTicksSprite.sprite = tickTiledSprite;
+    tickTiledSprite.width = 5000;
+  }
+
+  /**
+   * Pull the audio tracks from the chart editor state and create copies of them to play in the Offsets Toolbox.
+   * These must be DEEP CLONES or else the editor will affect the audio preview!
+   */
+  public function refreshAudioPreview():Void
+  {
+    if (audioPreviewTracks == null)
+    {
+      audioPreviewTracks = new SoundGroup();
+      // Make sure audioPreviewTracks (and all its children) receives update() calls.
+      chartEditorState.add(audioPreviewTracks);
+    }
+    else
+    {
+      audioPreviewTracks.stop();
+      audioPreviewTracks.clear();
+    }
+
+    var instTrack = chartEditorState.audioInstTrack.clone();
+    audioPreviewTracks.add(instTrack);
+
+    var playerVoice = chartEditorState.audioVocalTrackGroup.getPlayerVoice();
+    if (playerVoice != null) audioPreviewTracks.add(playerVoice.clone());
+
+    var opponentVoice = chartEditorState.audioVocalTrackGroup.getOpponentVoice();
+    if (opponentVoice != null) audioPreviewTracks.add(opponentVoice.clone());
+
+    // Build player waveform.
+    // waveformMusic.waveform.forceUpdate = true;
+    var perfStart = haxe.Timer.stamp();
+    var waveformData1 = playerVoice.waveformData;
+    var waveformData2 = opponentVoice.waveformData;
+    var waveformData3 = chartEditorState.audioInstTrack.waveformData;
+    var waveformData = waveformData1.merge(waveformData2).merge(waveformData3);
+    trace('Waveform data merging took: ${haxe.Timer.stamp() - perfStart} seconds');
+
+    waveformMusic.waveform.waveformData = waveformData;
+    // Set the width and duration to render the full waveform, with the clipRect applied we only render a segment of it.
+    waveformMusic.waveform.duration = instTrack.length / Constants.MS_PER_SEC;
+
+    addOffsetsToAudioPreview();
+  }
+
+  public function refreshTicks():Void
+  {
+    while (tickLabels.length > 0)
+    {
+      var label = tickLabels.pop();
+      freeplayTicksContainer.removeComponent(label);
+    }
+
+    var labelYPos:Float = chartEditorState.offsetTickBitmap.height / 2;
+    var labelHeight:Float = chartEditorState.offsetTickBitmap.height / 2;
+
+    var numberOfTicks:Int = Math.floor(waveformMusic.waveform.width / chartEditorState.offsetTickBitmap.width * 2) + 1;
+
+    for (index in 0...numberOfTicks)
+    {
+      var tickPos = chartEditorState.offsetTickBitmap.width / 2 * index;
+      var tickTime = tickPos * (waveformScale / BASE_SCALE * waveformMagicFactor) / waveformMusic.waveform.waveformData.pointsPerSecond();
+
+      var tickLabel:Label = new Label();
+      tickLabel.text = formatTime(tickTime);
+      tickLabel.styleNames = "offset-ticks-label";
+      tickLabel.height = labelHeight;
+      // Positioning within offsetTicksContainer is absolute (relative to the container itself).
+      tickLabel.top = labelYPos;
+      tickLabel.left = tickPos + TICK_LABEL_X_OFFSET;
+
+      freeplayTicksContainer.addComponent(tickLabel);
+      tickLabels.push(tickLabel);
+    }
+  }
+
+  function formatTime(seconds:Float):String
+  {
+    if (seconds <= 0) return "0.0";
+
+    var integerSeconds = Math.floor(seconds);
+    var decimalSeconds = Math.floor((seconds - integerSeconds) * 10);
+
+    if (integerSeconds < 60)
+    {
+      return '${integerSeconds}.${decimalSeconds}';
+    }
+    else
+    {
+      var integerMinutes = Math.floor(integerSeconds / 60);
+      var remainingSeconds = integerSeconds % 60;
+      var remainingSecondsPad:String = remainingSeconds < 10 ? '0$remainingSeconds' : '$remainingSeconds';
+
+      return '${integerMinutes}:${remainingSecondsPad}${decimalSeconds > 0 ? '.$decimalSeconds' : ''}';
+    }
+  }
+
+  function buildTickLabel():Void {}
+
+  public function onStartDragPlayhead():Void
+  {
+    Screen.instance.registerEvent(MouseEvent.MOUSE_MOVE, onDragPlayhead);
+    Screen.instance.registerEvent(MouseEvent.MOUSE_UP, onStopDragPlayhead);
+
+    movePlayheadToMouse();
+  }
+
+  public function onDragPlayhead(event:MouseEvent):Void
+  {
+    movePlayheadToMouse();
+  }
+
+  public function onStopDragPlayhead(event:MouseEvent):Void
+  {
+    // Stop dragging.
+    Screen.instance.unregisterEvent(MouseEvent.MOUSE_MOVE, onDragPlayhead);
+    Screen.instance.unregisterEvent(MouseEvent.MOUSE_UP, onStopDragPlayhead);
+  }
+
+  function movePlayheadToMouse():Void
+  {
+    // Determine the position of the mouse relative to the
+    var mouseXPos = FlxG.mouse.x;
+
+    var relativeMouseXPos = mouseXPos - waveformScrollview.screenX;
+    var targetPlayheadPos = relativeMouseXPos + waveformScrollview.hscrollPos;
+
+    // Move the playhead to the mouse position.
+    playheadAbsolutePos = targetPlayheadPos;
+
+    // Move the audio preview to the playhead position.
+    var currentWaveformIndex:Int = Std.int(playheadAbsolutePos * (waveformScale / BASE_SCALE * waveformMagicFactor));
+    var targetSongTimeSeconds:Float = waveformMusic.waveform.waveformData.indexToSeconds(currentWaveformIndex);
+    audioPreviewTracks.time = targetSongTimeSeconds * Constants.MS_PER_SEC;
+  }
+
+  var waveformDragStartPos:Null<Float> = null;
+
+  var waveformDragPreviewStartPos:Float;
+  var waveformDragPreviewEndPos:Float;
+
+  public function onStartDragWaveform():Void
+  {
+    waveformDragStartPos = FlxG.mouse.x;
+
+    Screen.instance.registerEvent(MouseEvent.MOUSE_MOVE, onDragWaveform);
+    Screen.instance.registerEvent(MouseEvent.MOUSE_UP, onStopDragWaveform);
+  }
+
+  public function onDragWaveform(event:MouseEvent):Void
+  {
+    // Set waveformDragPreviewStartPos and waveformDragPreviewEndPos to the position the drag started and the current mouse position.
+    // This only affects the visuals.
+
+    var currentAbsMousePos = FlxG.mouse.x;
+    var dragDiff = currentAbsMousePos - waveformDragStartPos;
+
+    var currentRelativeMousePos = currentAbsMousePos - waveformScrollview.screenX;
+    var relativeStartPos = waveformDragStartPos - waveformScrollview.screenX;
+
+    var isDraggingRight = dragDiff > 0;
+    var hasDraggedEnough = Math.abs(dragDiff) > 10;
+
+    if (hasDraggedEnough)
+    {
+      if (isDraggingRight)
+      {
+        waveformDragPreviewStartPos = relativeStartPos;
+        waveformDragPreviewEndPos = currentRelativeMousePos;
+      }
+      else
+      {
+        waveformDragPreviewStartPos = currentRelativeMousePos;
+        waveformDragPreviewEndPos = relativeStartPos;
+      }
+    }
+
+    refresh();
+  }
+
+  public function onStopDragWaveform(event:MouseEvent):Void
+  {
+    Screen.instance.unregisterEvent(MouseEvent.MOUSE_MOVE, onDragWaveform);
+    Screen.instance.unregisterEvent(MouseEvent.MOUSE_UP, onStopDragWaveform);
+
+    var previewStartPosAbsolute = waveformDragPreviewStartPos + waveformScrollview.hscrollPos;
+    var previewStartPosIndex:Int = Std.int(previewStartPosAbsolute * (waveformScale / BASE_SCALE * waveformMagicFactor));
+    var previewStartPosMs:Int = Std.int(waveformMusic.waveform.waveformData.indexToSeconds(previewStartPosIndex) * Constants.MS_PER_SEC);
+
+    var previewEndPosAbsolute = waveformDragPreviewEndPos + waveformScrollview.hscrollPos;
+    var previewEndPosIndex:Int = Std.int(previewEndPosAbsolute * (waveformScale / BASE_SCALE * waveformMagicFactor));
+    var previewEndPosMs:Int = Std.int(waveformMusic.waveform.waveformData.indexToSeconds(previewEndPosIndex) * Constants.MS_PER_SEC);
+
+    chartEditorState.performCommand(new SetFreeplayPreviewCommand(previewStartPosMs, previewEndPosMs));
+
+    waveformDragStartPos = null;
+    waveformDragPreviewStartPos = 0;
+    waveformDragPreviewEndPos = 0;
+
+    refresh();
+    addOffsetsToAudioPreview();
+  }
+
+  public function playAudioPreview():Void
+  {
+    if (isPerformingPreview) stopPerformingPreview();
+
+    audioPreviewTracks.volume = freeplayPreviewVolume;
+    audioPreviewTracks.play(false, audioPreviewTracks.time);
+  }
+
+  public function addOffsetsToAudioPreview():Void
+  {
+    var trackInst = audioPreviewTracks.members[0];
+    if (trackInst != null)
+    {
+      trackInst.time -= chartEditorState.currentInstrumentalOffset;
+    }
+
+    var trackPlayer = audioPreviewTracks.members[1];
+    if (trackPlayer != null)
+    {
+      trackPlayer.time -= chartEditorState.currentVocalOffsetPlayer;
+    }
+
+    var trackOpponent = audioPreviewTracks.members[2];
+    if (trackOpponent != null)
+    {
+      trackOpponent.time -= chartEditorState.currentVocalOffsetOpponent;
+    }
+  }
+
+  public function pauseAudioPreview():Void
+  {
+    if (isPerformingPreview) stopPerformingPreview();
+
+    audioPreviewTracks.pause();
+  }
+
+  public function stopAudioPreview():Void
+  {
+    if (isPerformingPreview) stopPerformingPreview();
+
+    audioPreviewTracks.stop();
+
+    audioPreviewTracks.time = 0;
+
+    waveformScrollview.hscrollPos = 0;
+    playheadAbsolutePos = 0 + playheadSprite.width;
+    refresh();
+    addOffsetsToAudioPreview();
+  }
+
+  public function zoomWaveformIn():Void
+  {
+    if (isPerformingPreview) stopPerformingPreview();
+
+    if (waveformScale > MIN_SCALE)
+    {
+      waveformScale = waveformScale / WAVEFORM_ZOOM_MULT;
+      if (waveformScale < MIN_SCALE) waveformScale = MIN_SCALE;
+
+      trace('Zooming in, scale: ${waveformScale}');
+
+      // Update the playhead too!
+      playheadAbsolutePos = playheadAbsolutePos * WAVEFORM_ZOOM_MULT;
+
+      // Recenter the scroll view on the playhead.
+      var vaguelyCenterPlayheadOffset = waveformScrollview.width / 8;
+      waveformScrollview.hscrollPos = playheadAbsolutePos - vaguelyCenterPlayheadOffset;
+
+      refresh();
+      refreshTicks();
+    }
+    else
+    {
+      waveformScale = MIN_SCALE;
+    }
+  }
+
+  public function zoomWaveformOut():Void
+  {
+    waveformScale = waveformScale * WAVEFORM_ZOOM_MULT;
+    if (waveformScale < MIN_SCALE) waveformScale = MIN_SCALE;
+
+    trace('Zooming out, scale: ${waveformScale}');
+
+    // Update the playhead too!
+    playheadAbsolutePos = playheadAbsolutePos / WAVEFORM_ZOOM_MULT;
+
+    // Recenter the scroll view on the playhead.
+    var vaguelyCenterPlayheadOffset = waveformScrollview.width / 8;
+    waveformScrollview.hscrollPos = playheadAbsolutePos - vaguelyCenterPlayheadOffset;
+
+    refresh();
+    refreshTicks();
+  }
+
+  public function setTrackVolume(volume:Float):Void
+  {
+    audioPreviewTracks.volume = volume;
+  }
+
+  public function muteTrack():Void
+  {
+    audioPreviewTracks.muted = true;
+  }
+
+  public function unmuteTrack():Void
+  {
+    audioPreviewTracks.muted = false;
+  }
+
+  public function toggleMuteTrack():Void
+  {
+    audioPreviewTracks.muted = !audioPreviewTracks.muted;
+  }
+
+  var isPerformingPreview:Bool = false;
+  var isFadingOutPreview:Bool = false;
+
+  public function performPreview():Void
+  {
+    isPerformingPreview = true;
+    isFadingOutPreview = false;
+    audioPreviewTracks.play(true, chartEditorState.currentSongFreeplayPreviewStart);
+    audioPreviewTracks.fadeIn(FreeplayState.FADE_IN_DURATION, FreeplayState.FADE_IN_START_VOLUME * freeplayPreviewVolume,
+      FreeplayState.FADE_IN_END_VOLUME * freeplayPreviewVolume, null);
+  }
+
+  public function stopPerformingPreview():Void
+  {
+    isPerformingPreview = false;
+    isFadingOutPreview = false;
+    audioPreviewTracks.volume = freeplayPreviewVolume;
+    audioPreviewTracks.pause();
+  }
+
+  public override function update(elapsed:Float)
+  {
+    super.update(elapsed);
+
+    if (isPerformingPreview && !audioPreviewTracks.playing)
+    {
+      stopPerformingPreview();
+    }
+
+    if (isPerformingPreview && audioPreviewTracks.playing)
+    {
+      var startFadeOutTime = chartEditorState.currentSongFreeplayPreviewEnd - (FreeplayState.FADE_OUT_DURATION * Constants.MS_PER_SEC);
+      trace('startFadeOutTime: ${audioPreviewTracks.time} >= ${startFadeOutTime}');
+      if (!isFadingOutPreview && audioPreviewTracks.time >= startFadeOutTime)
+      {
+        isFadingOutPreview = true;
+        audioPreviewTracks.fadeOut(FreeplayState.FADE_OUT_DURATION, FreeplayState.FADE_OUT_END_VOLUME * freeplayPreviewVolume, (_) -> {
+          trace('Stop performing preview! ${audioPreviewTracks.time}');
+          stopPerformingPreview();
+        });
+      }
+    }
+
+    if (audioPreviewTracks.playing)
+    {
+      var targetScrollPos:Float = waveformMusic.waveform.waveformData.secondsToIndex(audioPreviewTracks.time / Constants.MS_PER_SEC) / (waveformScale / BASE_SCALE * waveformMagicFactor);
+      // waveformScrollview.hscrollPos = targetScrollPos;
+      var prevPlayheadAbsolutePos = playheadAbsolutePos;
+      playheadAbsolutePos = targetScrollPos;
+      var playheadDiff = playheadAbsolutePos - prevPlayheadAbsolutePos;
+
+      // BEHAVIOR C.
+      // Copy Audacity!
+      // If the playhead is out of view, jump forward or backward by one screen width until it's in view.
+      if (playheadAbsolutePos < waveformScrollview.hscrollPos)
+      {
+        waveformScrollview.hscrollPos -= waveformScrollview.width;
+      }
+      if (playheadAbsolutePos > waveformScrollview.hscrollPos + waveformScrollview.width)
+      {
+        waveformScrollview.hscrollPos += waveformScrollview.width;
+      }
+    }
+    freeplayLabelTime.text = formatTime(audioPreviewTracks.time / Constants.MS_PER_SEC);
+    if (waveformDragStartPos != null && (waveformDragPreviewStartPos > 0 && waveformDragPreviewEndPos > 0))
+    {
+      var previewStartPosAbsolute = waveformDragPreviewStartPos + waveformScrollview.hscrollPos;
+      var previewStartPosIndex:Int = Std.int(previewStartPosAbsolute * (waveformScale / BASE_SCALE * waveformMagicFactor));
+      var previewStartPosMs:Int = Std.int(waveformMusic.waveform.waveformData.indexToSeconds(previewStartPosIndex) * Constants.MS_PER_SEC);
+
+      var previewEndPosAbsolute = waveformDragPreviewEndPos + waveformScrollview.hscrollPos;
+      var previewEndPosIndex:Int = Std.int(previewEndPosAbsolute * (waveformScale / BASE_SCALE * waveformMagicFactor));
+      var previewEndPosMs:Int = Std.int(waveformMusic.waveform.waveformData.indexToSeconds(previewEndPosIndex) * Constants.MS_PER_SEC);
+
+      // Set the values in milliseconds.
+      freeplayPreviewStart.value = previewStartPosMs;
+      freeplayPreviewEnd.value = previewEndPosMs;
+
+      previewBoxStartPosAbsolute = previewStartPosAbsolute;
+      previewBoxEndPosAbsolute = previewEndPosAbsolute;
+    }
+    else
+    {
+      previewBoxStartPosAbsolute = waveformMusic.waveform.waveformData.secondsToIndex(chartEditorState.currentSongFreeplayPreviewStart / Constants.MS_PER_SEC) / (waveformScale / BASE_SCALE * waveformMagicFactor);
+      previewBoxEndPosAbsolute = waveformMusic.waveform.waveformData.secondsToIndex(chartEditorState.currentSongFreeplayPreviewEnd / Constants.MS_PER_SEC) / (waveformScale / BASE_SCALE * waveformMagicFactor);
+
+      freeplayPreviewStart.value = chartEditorState.currentSongFreeplayPreviewStart;
+      freeplayPreviewEnd.value = chartEditorState.currentSongFreeplayPreviewEnd;
+    }
+  }
+
+  public override function refresh():Void
+  {
+    super.refresh();
+
+    waveformMagicFactor = MAGIC_SCALE_BASE_TIME / (chartEditorState.offsetTickBitmap.width / waveformMusic.waveform.waveformData.pointsPerSecond());
+
+    var currentZoomFactor = waveformScale / BASE_SCALE * waveformMagicFactor;
+
+    var maxWidth:Int = -1;
+
+    waveformMusic.waveform.time = -chartEditorState.currentInstrumentalOffset / Constants.MS_PER_SEC;
+    waveformMusic.waveform.width = (waveformMusic.waveform.waveformData?.length ?? 1000) / currentZoomFactor;
+    if (waveformMusic.waveform.width > maxWidth) maxWidth = Std.int(waveformMusic.waveform.width);
+    waveformMusic.waveform.height = 65;
+    waveformMusic.waveform.markDirty();
+
+    waveformContainer.width = maxWidth;
+    tickTiledSprite.width = maxWidth;
+  }
+
+  public static function build(chartEditorState:ChartEditorState):ChartEditorFreeplayToolbox
+  {
+    return new ChartEditorFreeplayToolbox(chartEditorState);
+  }
+}
diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx
index 5b26419b2..67ca82b1b 100644
--- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx
+++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorOffsetsToolbox.hx
@@ -191,7 +191,6 @@ class ChartEditorOffsetsToolbox extends ChartEditorBaseToolbox
         // Move the playhead if it would go out of view.
         var prevPlayheadRelativePos = playheadRelativePos;
         playheadRelativePos = FlxMath.bound(playheadRelativePos, 0, waveformScrollview.width - PLAYHEAD_RIGHT_PAD);
-        trace('newPos: ${playheadRelativePos}');
         var diff = playheadRelativePos - prevPlayheadRelativePos;
 
         if (diff != 0)
@@ -271,18 +270,18 @@ class ChartEditorOffsetsToolbox extends ChartEditorBaseToolbox
 
     // Build player waveform.
     // waveformPlayer.waveform.forceUpdate = true;
-    waveformPlayer.waveform.waveformData = WaveformDataParser.interpretFlxSound(playerVoice);
+    waveformPlayer.waveform.waveformData = playerVoice.waveformData;
     // Set the width and duration to render the full waveform, with the clipRect applied we only render a segment of it.
     waveformPlayer.waveform.duration = playerVoice.length / Constants.MS_PER_SEC;
 
     // Build opponent waveform.
     // waveformOpponent.waveform.forceUpdate = true;
-    waveformOpponent.waveform.waveformData = WaveformDataParser.interpretFlxSound(opponentVoice);
+    waveformOpponent.waveform.waveformData = opponentVoice.waveformData;
     waveformOpponent.waveform.duration = opponentVoice.length / Constants.MS_PER_SEC;
 
     // Build instrumental waveform.
     // waveformInstrumental.waveform.forceUpdate = true;
-    waveformInstrumental.waveform.waveformData = WaveformDataParser.interpretFlxSound(instTrack);
+    waveformInstrumental.waveform.waveformData = chartEditorState.audioInstTrack.waveformData;
     waveformInstrumental.waveform.duration = instTrack.length / Constants.MS_PER_SEC;
 
     addOffsetsToAudioPreview();
@@ -410,8 +409,6 @@ class ChartEditorOffsetsToolbox extends ChartEditorBaseToolbox
         deltaPixels / waveformInstrumental.waveform.waveformData.pointsPerSecond() * Constants.MS_PER_SEC;
     };
 
-    trace('Moving waveform by ${deltaMousePosition} -> ${deltaPixels} -> ${deltaMilliseconds} milliseconds.');
-
     switch (dragWaveform)
     {
       case PLAYER:
@@ -537,8 +534,6 @@ class ChartEditorOffsetsToolbox extends ChartEditorBaseToolbox
       waveformScale = waveformScale / WAVEFORM_ZOOM_MULT;
       if (waveformScale < MIN_SCALE) waveformScale = MIN_SCALE;
 
-      trace('Zooming in, scale: ${waveformScale}');
-
       // Update the playhead too!
       playheadAbsolutePos = playheadAbsolutePos * WAVEFORM_ZOOM_MULT;
 
@@ -560,8 +555,6 @@ class ChartEditorOffsetsToolbox extends ChartEditorBaseToolbox
     waveformScale = waveformScale * WAVEFORM_ZOOM_MULT;
     if (waveformScale < MIN_SCALE) waveformScale = MIN_SCALE;
 
-    trace('Zooming out, scale: ${waveformScale}');
-
     // Update the playhead too!
     playheadAbsolutePos = playheadAbsolutePos / WAVEFORM_ZOOM_MULT;
 
@@ -776,7 +769,7 @@ class ChartEditorOffsetsToolbox extends ChartEditorBaseToolbox
         audioPreviewOpponentOffset = chartEditorState.currentVocalOffsetOpponent;
       }
     }
-
+    offsetLabelTime.text = formatTime(audioPreviewTracks.time / Constants.MS_PER_SEC);
     // Keep the playhead in view.
     // playheadRelativePos = FlxMath.bound(playheadRelativePos, waveformScrollview.hscrollPos + 1,
     //   Math.min(waveformScrollview.hscrollPos + waveformScrollview.width, waveformContainer.width));
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index de6484fd3..b23ca6e54 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -56,6 +56,31 @@ import lime.utils.Assets;
 
 class FreeplayState extends MusicBeatSubState
 {
+  /**
+   * For the audio preview, the duration of the fade-in effect.
+   */
+  public static final FADE_IN_DURATION:Float = 0.5;
+
+  /**
+   * For the audio preview, the duration of the fade-out effect.
+   */
+  public static final FADE_OUT_DURATION:Float = 0.25;
+
+  /**
+   * For the audio preview, the volume at which the fade-in starts.
+   */
+  public static final FADE_IN_START_VOLUME:Float = 0.25;
+
+  /**
+   * For the audio preview, the volume at which the fade-in ends.
+   */
+  public static final FADE_IN_END_VOLUME:Float = 1.0;
+
+  /**
+   * For the audio preview, the volume at which the fade-out starts.
+   */
+  public static final FADE_OUT_END_VOLUME:Float = 0.0;
+
   var songs:Array<Null<FreeplaySongData>> = [];
 
   var diffIdsCurrent:Array<String> = [];