diff --git a/hmm.json b/hmm.json
index 57c9378aa..0f06acaa7 100644
--- a/hmm.json
+++ b/hmm.json
@@ -49,7 +49,7 @@
       "name": "haxeui-core",
       "type": "git",
       "dir": null,
-      "ref": "91ed8d7867c52af5ea2a9513204057d69ab33c8e",
+      "ref": "5d4ac180f85b39e72624f4b8d17925d91ebe4278",
       "url": "https://github.com/haxeui/haxeui-core"
     },
     {
diff --git a/source/funkin/data/song/SongDataUtils.hx b/source/funkin/data/song/SongDataUtils.hx
index 9a9f758b1..4ae4b1426 100644
--- a/source/funkin/data/song/SongDataUtils.hx
+++ b/source/funkin/data/song/SongDataUtils.hx
@@ -32,10 +32,7 @@ class SongDataUtils
       return new SongNoteData(time, data, length, kind);
     };
 
-    trace(notes);
-    trace(notes[0]);
     var result = [for (i in 0...notes.length) offsetNote(notes[i])];
-    trace(result);
     return result;
   }
 
@@ -54,6 +51,36 @@ class SongDataUtils
     });
   }
 
+  /**
+   * Given an array of SongNoteData objects, return a new array of SongNoteData objects
+   * which excludes any notes whose timestamps are outside of the given range.
+   * @param notes The notes to modify.
+   * @param startTime The start of the range in milliseconds.
+   * @param endTime The end of the range in milliseconds.
+   * @return The filtered array of notes.
+   */
+  public static function clampSongNoteData(notes:Array<SongNoteData>, startTime:Float, endTime:Float):Array<SongNoteData>
+  {
+    return notes.filter(function(note:SongNoteData):Bool {
+      return note.time >= startTime && note.time <= endTime;
+    });
+  }
+
+  /**
+   * Given an array of SongEventData objects, return a new array of SongEventData objects
+   * which excludes any events whose timestamps are outside of the given range.
+   * @param events The events to modify.
+   * @param startTime The start of the range in milliseconds.
+   * @param endTime The end of the range in milliseconds.
+   * @return The filtered array of events.
+   */
+  public static function clampSongEventData(events:Array<SongEventData>, startTime:Float, endTime:Float):Array<SongEventData>
+  {
+    return events.filter(function(event:SongEventData):Bool {
+      return event.time >= startTime && event.time <= endTime;
+    });
+  }
+
   /**
    * Return a new array without a certain subset of notes from an array of SongNoteData objects.
    * Does not mutate the original array.
diff --git a/source/funkin/input/Cursor.hx b/source/funkin/input/Cursor.hx
index c609c9e30..b4bf43808 100644
--- a/source/funkin/input/Cursor.hx
+++ b/source/funkin/input/Cursor.hx
@@ -142,12 +142,14 @@ class Cursor
     };
   static var assetCursorCell:Null<BitmapData> = null;
 
-  // DESIRED CURSOR: Resize NS (vertical)
-  // DESIRED CURSOR: Resize EW (horizontal)
-  // DESIRED CURSOR: Resize NESW (diagonal)
-  // DESIRED CURSOR: Resize NWSE (diagonal)
-  // DESIRED CURSOR: Help (Cursor with question mark)
-  // DESIRED CURSOR: Menu (Cursor with menu icon)
+  public static final CURSOR_SCROLL_PARAMS:CursorParams =
+    {
+      graphic: "assets/images/cursor/cursor-scroll.png",
+      scale: 0.2,
+      offsetX: -15,
+      offsetY: -15,
+    };
+  static var assetCursorScroll:Null<BitmapData> = null;
 
   static function set_cursorMode(value:Null<CursorMode>):Null<CursorMode>
   {
@@ -304,6 +306,18 @@ class Cursor
           applyCursorParams(assetCursorCell, CURSOR_CELL_PARAMS);
         }
 
+      case Scroll:
+        if (assetCursorScroll == null)
+        {
+          var bitmapData:BitmapData = Assets.getBitmapData(CURSOR_SCROLL_PARAMS.graphic);
+          assetCursorScroll = bitmapData;
+          applyCursorParams(assetCursorScroll, CURSOR_SCROLL_PARAMS);
+        }
+        else
+        {
+          applyCursorParams(assetCursorScroll, CURSOR_SCROLL_PARAMS);
+        }
+
       default:
         setCursorGraphic(null);
     }
@@ -487,6 +501,21 @@ class Cursor
           applyCursorParams(assetCursorCell, CURSOR_CELL_PARAMS);
         }
 
+      case Scroll:
+        if (assetCursorScroll == null)
+        {
+          var future:Future<BitmapData> = Assets.loadBitmapData(CURSOR_SCROLL_PARAMS.graphic);
+          future.onComplete(function(bitmapData:BitmapData) {
+            assetCursorScroll = bitmapData;
+            applyCursorParams(assetCursorScroll, CURSOR_SCROLL_PARAMS);
+          });
+          future.onError(onCursorError.bind(Scroll));
+        }
+        else
+        {
+          applyCursorParams(assetCursorScroll, CURSOR_SCROLL_PARAMS);
+        }
+
       default:
         loadCursorGraphic(null);
     }
@@ -517,6 +546,7 @@ class Cursor
     registerHaxeUICursor('zoom-out', CURSOR_ZOOM_OUT_PARAMS);
     registerHaxeUICursor('crosshair', CURSOR_CROSSHAIR_PARAMS);
     registerHaxeUICursor('cell', CURSOR_CELL_PARAMS);
+    registerHaxeUICursor('scroll', CURSOR_SCROLL_PARAMS);
   }
 
   public static function registerHaxeUICursor(id:String, params:CursorParams):Void
@@ -539,6 +569,7 @@ enum CursorMode
   ZoomOut;
   Crosshair;
   Cell;
+  Scroll;
 }
 
 /**
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 6136cf1b7..a26addbe6 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -483,6 +483,8 @@ class PlayState extends MusicBeatSubState
   var generatedMusic:Bool = false;
   var perfectMode:Bool = false;
 
+  static final BACKGROUND_COLOR:FlxColor = FlxColor.MAGENTA;
+
   /**
    * Instantiate a new PlayState.
    * @param params The parameters used to initialize the PlayState.
@@ -647,6 +649,24 @@ class PlayState extends MusicBeatSubState
     initialized = true;
   }
 
+  public override function draw():Void
+  {
+    // if (FlxG.renderBlit)
+    // {
+    //  camGame.fill(BACKGROUND_COLOR);
+    // }
+    // else if (FlxG.renderTile)
+    // {
+    //  FlxG.log.warn("PlayState background not displayed properly on tile renderer!");
+    // }
+    // else
+    // {
+    //  FlxG.log.warn("PlayState background not displayed properly, unknown renderer!");
+    // }
+
+    super.draw();
+  }
+
   function assertChartExists():Bool
   {
     // Returns null if the song failed to load or doesn't have the selected difficulty.
@@ -1297,6 +1317,7 @@ class PlayState extends MusicBeatSubState
   function initCameras():Void
   {
     camGame = new SwagCamera();
+    camGame.bgColor = BACKGROUND_COLOR; // Show a pink background behind the stage.
     camHUD = new FlxCamera();
     camHUD.bgColor.alpha = 0; // Show the game scene behind the camera.
     camCutscene = new FlxCamera();
diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx
index db1f2b69a..810d0fd93 100644
--- a/source/funkin/save/Save.hx
+++ b/source/funkin/save/Save.hx
@@ -105,7 +105,7 @@ abstract Save(RawSaveData)
             theme: ChartEditorTheme.Light,
             playtestStartTime: false,
             downscroll: false,
-            metronomeEnabled: true,
+            metronomeVolume: 1.0,
             hitsoundsEnabledPlayer: true,
             hitsoundsEnabledOpponent: true,
             instVolume: 1.0,
@@ -279,21 +279,38 @@ abstract Save(RawSaveData)
     return this.optionsChartEditor.theme;
   }
 
-  public var chartEditorMetronomeEnabled(get, set):Bool;
+  public var chartEditorMetronomeVolume(get, set):Float;
 
-  function get_chartEditorMetronomeEnabled():Bool
+  function get_chartEditorMetronomeVolume():Float
   {
-    if (this.optionsChartEditor.metronomeEnabled == null) this.optionsChartEditor.metronomeEnabled = true;
+    if (this.optionsChartEditor.metronomeVolume == null) this.optionsChartEditor.metronomeVolume = 1.0;
 
-    return this.optionsChartEditor.metronomeEnabled;
+    return this.optionsChartEditor.metronomeVolume;
   }
 
-  function set_chartEditorMetronomeEnabled(value:Bool):Bool
+  function set_chartEditorMetronomeVolume(value:Float):Float
   {
     // Set and apply.
-    this.optionsChartEditor.metronomeEnabled = value;
+    this.optionsChartEditor.metronomeVolume = value;
     flush();
-    return this.optionsChartEditor.metronomeEnabled;
+    return this.optionsChartEditor.metronomeVolume;
+  }
+
+  public var chartEditorHitsoundVolume(get, set):Float;
+
+  function get_chartEditorHitsoundVolume():Float
+  {
+    if (this.optionsChartEditor.hitsoundVolume == null) this.optionsChartEditor.hitsoundVolume = 1.0;
+
+    return this.optionsChartEditor.hitsoundVolume;
+  }
+
+  function set_chartEditorHitsoundVolume(value:Float):Float
+  {
+    // Set and apply.
+    this.optionsChartEditor.hitsoundVolume = value;
+    flush();
+    return this.optionsChartEditor.hitsoundVolume;
   }
 
   public var chartEditorHitsoundsEnabledPlayer(get, set):Bool;
@@ -981,10 +998,16 @@ typedef SaveDataChartEditorOptions =
   var ?downscroll:Bool;
 
   /**
-   * Metronome sounds in the Chart Editor.
-   * @default `true`
+   * Metronome volume in the Chart Editor.
+   * @default `1.0`
    */
-  var ?metronomeEnabled:Bool;
+  var ?metronomeVolume:Float;
+
+  /**
+   * Hitsound volume in the Chart Editor.
+   * @default `1.0`
+   */
+  var ?hitsoundVolume:Float;
 
   /**
    * If true, playtest songs from the current position in the Chart Editor.
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 519d1a54a..7c5d864cc 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -92,6 +92,7 @@ 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.NumberStepper;
 import haxe.ui.components.Slider;
 import haxe.ui.components.TextField;
@@ -100,6 +101,7 @@ import haxe.ui.containers.Frame;
 import haxe.ui.containers.menus.Menu;
 import haxe.ui.containers.menus.MenuBar;
 import haxe.ui.containers.menus.MenuItem;
+import haxe.ui.containers.menus.MenuCheckBox;
 import haxe.ui.containers.TreeView;
 import haxe.ui.containers.TreeViewNode;
 import haxe.ui.core.Component;
@@ -603,9 +605,14 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
   // Audio
 
   /**
-   * Whether to play a metronome sound while the playhead is moving.
+   * Whether to play a metronome sound while the playhead is moving, and what volume.
    */
-  var isMetronomeEnabled:Bool = true;
+  var metronomeVolume:Float = 1.0;
+
+  /**
+   * The volume to play hitsounds at.
+   */
+  var hitsoundVolume:Float = 1.0;
 
   /**
    * Whether hitsounds are enabled for the player.
@@ -653,6 +660,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   var currentScrollEase:Null<VarTween>;
 
+  /**
+   * The position where the user middle clicked to place a scroll anchor.
+   * Scroll each frame with speed based on the distance between the mouse and the scroll anchor.
+   * `null` if no scroll anchor is present.
+   */
+  var scrollAnchorScreenPos:Null<FlxPoint> = null;
+
   // Note Placement
 
   /**
@@ -1230,98 +1244,257 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
   var playbarHeadLayout:Null<ChartEditorPlaybarHead> = null;
 
   // NOTE: All the components below are automatically assigned via HaxeUI macros.
+
   /**
    * The menubar at the top of the screen.
    */
-  // var menubar:MenuBar;
+  var menubar:MenuBar;
+
   /**
    * The `File -> New Chart` menu item.
    */
-  // var menubarItemNewChart:MenuItem;
+  var menubarItemNewChart:MenuItem;
+
   /**
    * The `File -> Open Chart` menu item.
    */
-  // var menubarItemOpenChart:MenuItem;
+  var menubarItemOpenChart:MenuItem;
+
   /**
    * The `File -> Open Recent` menu.
    */
-  // var menubarOpenRecent:Menu;
+  var menubarOpenRecent:Menu;
+
   /**
    * The `File -> Save Chart` menu item.
    */
-  // var menubarItemSaveChart:MenuItem;
+  var menubarItemSaveChart:MenuItem;
+
   /**
    * The `File -> Save Chart As` menu item.
    */
-  // var menubarItemSaveChartAs:MenuItem;
+  var menubarItemSaveChartAs:MenuItem;
+
   /**
    * The `File -> Preferences` menu item.
    */
-  // var menubarItemPreferences:MenuItem;
+  var menubarItemPreferences:MenuItem;
+
   /**
    * The `File -> Exit` menu item.
    */
-  // var menubarItemExit:MenuItem;
+  var menubarItemExit:MenuItem;
+
   /**
    * The `Edit -> Undo` menu item.
    */
-  // var menubarItemUndo:MenuItem;
+  var menubarItemUndo:MenuItem;
+
   /**
    * The `Edit -> Redo` menu item.
    */
-  // var menubarItemRedo:MenuItem;
+  var menubarItemRedo:MenuItem;
+
   /**
    * The `Edit -> Cut` menu item.
    */
-  // var menubarItemCut:MenuItem;
+  var menubarItemCut:MenuItem;
+
   /**
    * The `Edit -> Copy` menu item.
    */
-  // var menubarItemCopy:MenuItem;
+  var menubarItemCopy:MenuItem;
+
   /**
    * The `Edit -> Paste` menu item.
    */
-  // var menubarItemPaste:MenuItem;
+  var menubarItemPaste:MenuItem;
+
   /**
    * The `Edit -> Paste Unsnapped` menu item.
    */
-  // var menubarItemPasteUnsnapped:MenuItem;
+  var menubarItemPasteUnsnapped:MenuItem;
+
   /**
    * The `Edit -> Delete` menu item.
    */
-  // var menubarItemDelete:MenuItem;
+  var menubarItemDelete:MenuItem;
+
+  /**
+   * The `Edit -> Flip Notes` menu item.
+   */
+  var menubarItemFlipNotes:MenuItem;
+
+  /**
+   * The `Edit -> Select All` menu item.
+   */
+  var menubarItemSelectAll:MenuItem;
+
+  /**
+   * The `Edit -> Select Inverse` menu item.
+   */
+  var menubarItemSelectInverse:MenuItem;
+
+  /**
+   * The `Edit -> Select None` menu item.
+   */
+  var menubarItemSelectNone:MenuItem;
+
+  /**
+   * The `Edit -> Select Region` menu item.
+   */
+  var menubarItemSelectRegion:MenuItem;
+
+  /**
+   * The `Edit -> Select Before Cursor` menu item.
+   */
+  var menubarItemSelectBeforeCursor:MenuItem;
+
+  /**
+   * The `Edit -> Select After Cursor` menu item.
+   */
+  var menubarItemSelectAfterCursor:MenuItem;
+
+  /**
+   * The `Edit -> Decrease Note Snap Precision` menu item.
+   */
+  var menuBarItemNoteSnapDecrease:MenuItem;
+
+  /**
+   * The `Edit -> Decrease Note Snap Precision` menu item.
+   */
+  var menuBarItemNoteSnapIncrease:MenuItem;
+
+  /**
+   * The `View -> Downscroll` menu item.
+   */
+  var menubarItemDownscroll:MenuCheckBox;
+
+  /**
+   * The `View -> Increase Difficulty` menu item.
+   */
+  var menubarItemDifficultyUp:MenuItem;
+
+  /**
+   * The `View -> Decrease Difficulty` menu item.
+   */
+  var menubarItemDifficultyDown:MenuItem;
+
+  /**
+   * The `Audio -> Play/Pause` menu item.
+   */
+  var menubarItemPlayPause:MenuItem;
+
+  /**
+   * The `Audio -> Load Instrumental` menu item.
+   */
+  var menubarItemLoadInstrumental:MenuItem;
+
+  /**
+   * The `Audio -> Load Vocals` menu item.
+   */
+  var menubarItemLoadVocals:MenuItem;
+
+  /**
+   * The `Audio -> Metronome Volume` label.
+   */
+  var menubarLabelVolumeMetronome:Label;
+
+  /**
+   * The `Audio -> Metronome Volume` slider.
+   */
+  var menubarItemVolumeMetronome:Slider;
+
+  /**
+   * The `Audio -> Enable Player Hitsounds` menu checkbox.
+   */
+  var menubarItemPlayerHitsounds:MenuCheckBox;
+
+  /**
+   * The `Audio -> Enable Opponent Hitsounds` menu checkbox.
+   */
+  var menubarItemOpponentHitsounds:MenuCheckBox;
+
+  /**
+   * The `Audio -> Hitsound Volume` label.
+   */
+  var menubarLabelVolumeHitsounds:Label;
+
+  /**
+   * The `Audio -> Hitsound Volume` slider.
+   */
+  var menubarItemVolumeHitsounds:Slider;
+
+  /**
+   * The `Audio -> Instrumental Volume` label.
+   */
+  var menubarLabelVolumeInstrumental:Label;
+
+  /**
+   * The `Audio -> Instrumental Volume` slider.
+   */
+  var menubarItemVolumeInstrumental:Slider;
+
+  /**
+   * The `Audio -> Vocal Volume` label.
+   */
+  var menubarLabelVolumeVocals:Label;
+
+  /**
+   * The `Audio -> Vocal Volume` slider.
+   */
+  var menubarItemVolumeVocals:Slider;
+
+  /**
+   * The `Audio -> Playback Speed` label.
+   */
+  var menubarLabelPlaybackSpeed:Label;
+
+  /**
+   * The `Audio -> Playback Speed` slider.
+   */
+  var menubarItemPlaybackSpeed:Slider;
+
   /**
    * The label by the playbar telling the song position.
    */
-  // var playbarSongPos:Label;
+  var playbarSongPos:Label;
+
   /**
    * The label by the playbar telling the song time remaining.
    */
-  // var playbarSongRemaining:Label;
+  var playbarSongRemaining:Label;
+
   /**
    * The label by the playbar telling the note snap.
    */
-  // var playbarNoteSnap:Label;
+  var playbarNoteSnap:Label;
+
   /**
    * The button by the playbar to jump to the start of the song.
    */
-  // var playbarStart:Button;
+  var playbarStart:Button;
+
   /**
    * The button by the playbar to jump backwards in the song.
    */
-  // var playbarBack:Button;
+  var playbarBack:Button;
+
   /**
    * The button by the playbar to play or pause the song.
    */
-  // var playbarPlay:Button;
+  var playbarPlay:Button;
+
   /**
    * The button by the playbar to jump forwards in the song.
    */
-  // var playbarForward:Button;
+  var playbarForward:Button;
+
   /**
    * The button by the playbar to jump to the end of the song.
    */
-  // var playbarEnd:Button;
+  var playbarEnd:Button;
+
   /**
    * RENDER OBJECTS
    */
@@ -1659,7 +1832,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     isViewDownscroll = save.chartEditorDownscroll;
     playtestStartTime = save.chartEditorPlaytestStartTime;
     currentTheme = save.chartEditorTheme;
-    isMetronomeEnabled = save.chartEditorMetronomeEnabled;
+    metronomeVolume = save.chartEditorMetronomeVolume;
+    hitsoundVolume = save.chartEditorHitsoundVolume;
     hitsoundsEnabledPlayer = save.chartEditorHitsoundsEnabledPlayer;
     hitsoundsEnabledOpponent = save.chartEditorHitsoundsEnabledOpponent;
 
@@ -1687,7 +1861,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     save.chartEditorDownscroll = isViewDownscroll;
     save.chartEditorPlaytestStartTime = playtestStartTime;
     save.chartEditorTheme = currentTheme;
-    save.chartEditorMetronomeEnabled = isMetronomeEnabled;
+    save.chartEditorMetronomeVolume = metronomeVolume;
+    save.chartEditorHitsoundVolume = hitsoundVolume;
     save.chartEditorHitsoundsEnabledPlayer = hitsoundsEnabledPlayer;
     save.chartEditorHitsoundsEnabledOpponent = hitsoundsEnabledOpponent;
 
@@ -2223,11 +2398,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     #if sys
     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;
-
     #end
 
     menubarItemUserGuide.onClick = _ -> this.openUserGuideDialog();
@@ -2256,8 +2429,12 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     menubarItemLoadInstrumental.onClick = _ -> this.openUploadInstDialog(true);
     menubarItemLoadVocals.onClick = _ -> this.openUploadVocalsDialog(true);
 
-    menubarItemMetronomeEnabled.onChange = event -> isMetronomeEnabled = event.value;
-    menubarItemMetronomeEnabled.selected = isMetronomeEnabled;
+    menubarItemVolumeMetronome.onChange = event -> {
+      var volume:Float = (event?.value ?? 0) / 100.0;
+      metronomeVolume = volume;
+      menubarLabelVolumeMetronome.text = 'Metronome - ${Std.int(event.value)}%';
+    };
+    menubarItemVolumeMetronome.value = Std.int(metronomeVolume * 100);
 
     menubarItemPlayerHitsounds.onChange = event -> hitsoundsEnabledPlayer = event.value;
     menubarItemPlayerHitsounds.selected = hitsoundsEnabledPlayer;
@@ -2265,6 +2442,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     menubarItemOpponentHitsounds.onChange = event -> hitsoundsEnabledOpponent = event.value;
     menubarItemOpponentHitsounds.selected = hitsoundsEnabledOpponent;
 
+    menubarItemVolumeHitsound.onChange = event -> {
+      var volume:Float = (event?.value ?? 0) / 100.0;
+      hitsoundVolume = volume;
+      menubarLabelVolumeHitsound.text = 'Hitsound - ${Std.int(event.value)}%';
+    };
+    menubarItemVolumeHitsound.value = Std.int(hitsoundVolume * 100);
+
     menubarItemVolumeInstrumental.onChange = event -> {
       var volume:Float = (event?.value ?? 0) / 100.0;
       if (audioInstTrack != null) audioInstTrack.volume = volume;
@@ -2473,7 +2657,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 (metronomeVolume > 0.0 && this.subState == null && (audioInstTrack != null && audioInstTrack.playing))
     {
       playMetronomeTick(Conductor.currentBeat % 4 == 0);
     }
@@ -2514,7 +2698,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
   {
     if (audioInstTrack != null && audioInstTrack.playing)
     {
-      if (FlxG.mouse.pressedMiddle)
+      if (FlxG.keys.pressed.ALT)
       {
         // If middle mouse panning during song playback, we move ONLY the playhead, without scrolling. Neat!
 
@@ -2914,6 +3098,21 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     var shouldPause:Bool = false; // Whether to pause the song when scrolling.
     var shouldEase:Bool = false; // Whether to ease the scroll.
 
+    // Handle scroll anchor
+    if (scrollAnchorScreenPos != null)
+    {
+      var currentScreenPos = new FlxPoint(FlxG.mouse.screenX, FlxG.mouse.screenY);
+      var distance = currentScreenPos - scrollAnchorScreenPos;
+
+      var verticalDistance = distance.y;
+
+      // How much scrolling should be done based on the distance of the cursor from the anchor.
+      final ANCHOR_SCROLL_SPEED = 0.2;
+
+      scrollAmount = ANCHOR_SCROLL_SPEED * verticalDistance;
+      shouldPause = true;
+    }
+
     // Mouse Wheel = Scroll
     if (FlxG.mouse.wheel != 0 && !FlxG.keys.pressed.CONTROL)
     {
@@ -2993,18 +3192,6 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       shouldPause = true;
     }
 
-    // Middle Mouse + Drag = Scroll but move the playhead the same amount.
-    if (FlxG.mouse.pressedMiddle)
-    {
-      if (FlxG.mouse.deltaY != 0)
-      {
-        // Scroll down by the amount dragged.
-        scrollAmount += -FlxG.mouse.deltaY;
-        // Move the playhead by the same amount in the other direction so it is stationary.
-        playheadAmount += FlxG.mouse.deltaY;
-      }
-    }
-
     // SHIFT + Scroll = Scroll Fast
     if (FlxG.keys.pressed.SHIFT)
     {
@@ -3016,7 +3203,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       scrollAmount /= 10;
     }
 
-    // ALT = Move playhead instead.
+    // Alt + Drag = Scroll but move the playhead the same amount.
     if (FlxG.keys.pressed.ALT)
     {
       playheadAmount = scrollAmount;
@@ -3138,9 +3325,26 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
 
       var overlapsSelection:Bool = FlxG.mouse.overlaps(renderedSelectionSquares);
 
+      if (FlxG.mouse.justPressedMiddle)
+      {
+        if (scrollAnchorScreenPos == null)
+        {
+          scrollAnchorScreenPos = new FlxPoint(FlxG.mouse.screenX, FlxG.mouse.screenY);
+          selectionBoxStartPos = null;
+        }
+        else
+        {
+          scrollAnchorScreenPos = null;
+        }
+      }
+
       if (FlxG.mouse.justPressed)
       {
-        if (gridPlayheadScrollArea != null && FlxG.mouse.overlaps(gridPlayheadScrollArea))
+        if (scrollAnchorScreenPos != null)
+        {
+          scrollAnchorScreenPos = null;
+        }
+        else if (gridPlayheadScrollArea != null && FlxG.mouse.overlaps(gridPlayheadScrollArea))
         {
           gridPlayheadScrollAreaPressed = true;
         }
@@ -3149,7 +3353,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
           // Clicked note preview
           notePreviewScrollAreaStartPos = new FlxPoint(FlxG.mouse.screenX, FlxG.mouse.screenY);
         }
-        else if (!overlapsGrid || overlapsSelectionBorder)
+        else if (!isCursorOverHaxeUI && (!overlapsGrid || overlapsSelectionBorder))
         {
           selectionBoxStartPos = new FlxPoint(FlxG.mouse.screenX, FlxG.mouse.screenY);
           // Drawing selection box.
@@ -3432,6 +3636,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         scrollPositionInPixels = clickedPosInPixels;
         moveSongToScrollPosition();
       }
+      else if (scrollAnchorScreenPos != null)
+      {
+        // Cursor should be a scroll anchor.
+        targetCursorMode = Scroll;
+      }
       else if (dragTargetNote != null || dragTargetEvent != null)
       {
         if (FlxG.mouse.justReleased)
@@ -4345,7 +4554,16 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     var startTimestamp:Float = 0;
     if (playtestStartTime) startTimestamp = scrollPositionInMs + playheadPositionInMs;
 
-    var targetSong:Song = Song.buildRaw(currentSongId, songMetadata.values(), availableVariations, songChartData, false);
+    var targetSong:Song;
+    try
+    {
+      targetSong = Song.buildRaw(currentSongId, songMetadata.values(), availableVariations, songChartData, false);
+    }
+    catch (e)
+    {
+      this.error("Could Not Playtest", 'Got an error trying to playtest the song.\n${e}');
+      return;
+    }
 
     // TODO: Rework asset system so we can remove this.
     switch (currentSongStage)
@@ -4389,6 +4607,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
     // Override music.
     if (audioInstTrack != null) FlxG.sound.music = audioInstTrack;
     if (audioVocalTrackGroup != null) targetState.vocals = audioVocalTrackGroup;
+
+    this.persistentUpdate = false;
+    this.persistentDraw = false;
     stopWelcomeMusic();
     openSubState(targetState);
   }
@@ -4504,7 +4725,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
    */
   function playMetronomeTick(high:Bool = false):Void
   {
-    this.playSound(Paths.sound('chartingSounds/metronome${high ? '1' : '2'}'));
+    this.playSound(Paths.sound('chartingSounds/metronome${high ? '1' : '2'}'), metronomeVolume);
   }
 
   function switchToCurrentInstrumental():Void
@@ -4603,6 +4824,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
         var prevDifficulty = availableDifficulties[availableDifficulties.length - 1];
         selectedDifficulty = prevDifficulty;
 
+        Conductor.mapTimeChanges(this.currentSongMetadata.timeChanges);
+
         refreshDifficultyTreeSelection();
         refreshMetadataToolbox();
       }
@@ -4721,6 +4944,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
   @:nullSafety(Off)
   function resetConductorAfterTest(_:FlxSubState = null):Void
   {
+    this.persistentUpdate = true;
+    this.persistentDraw = true;
+
     moveSongToScrollPosition();
 
     // Reapply the volume.
@@ -5000,9 +5226,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
       switch (noteData.getStrumlineIndex())
       {
         case 0: // Player
-          if (hitsoundsEnabledPlayer) this.playSound(Paths.sound('chartingSounds/hitNotePlayer'));
+          if (hitsoundsEnabledPlayer) this.playSound(Paths.sound('chartingSounds/hitNotePlayer'), hitsoundVolume);
         case 1: // Opponent
-          if (hitsoundsEnabledOpponent) this.playSound(Paths.sound('chartingSounds/hitNoteOpponent'));
+          if (hitsoundsEnabledOpponent) this.playSound(Paths.sound('chartingSounds/hitNoteOpponent'), hitsoundVolume);
       }
     }
   }
diff --git a/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx b/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx
new file mode 100644
index 000000000..3c45c1168
--- /dev/null
+++ b/source/funkin/ui/debug/charting/commands/ChangeStartingBPMCommand.hx
@@ -0,0 +1,61 @@
+package funkin.ui.debug.charting.commands;
+
+import funkin.data.song.SongData.SongTimeChange;
+
+/**
+ * A command which changes the starting BPM of the song.
+ */
+@:nullSafety
+@:access(funkin.ui.debug.charting.ChartEditorState)
+class ChangeStartingBPMCommand implements ChartEditorCommand
+{
+  var targetBPM:Float;
+
+  var previousBPM:Float = 100;
+
+  public function new(targetBPM:Float)
+  {
+    this.targetBPM = targetBPM;
+  }
+
+  public function execute(state:ChartEditorState):Void
+  {
+    var timeChanges:Array<SongTimeChange> = state.currentSongMetadata.timeChanges;
+    if (timeChanges == null || timeChanges.length == 0)
+    {
+      previousBPM = 100;
+      timeChanges = [new SongTimeChange(0, targetBPM)];
+    }
+    else
+    {
+      previousBPM = timeChanges[0].bpm;
+      timeChanges[0].bpm = targetBPM;
+    }
+
+    state.currentSongMetadata.timeChanges = timeChanges;
+
+    Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges);
+  }
+
+  public function undo(state:ChartEditorState):Void
+  {
+    var timeChanges:Array<SongTimeChange> = state.currentSongMetadata.timeChanges;
+    if (timeChanges == null || timeChanges.length == 0)
+    {
+      timeChanges = [new SongTimeChange(0, previousBPM)];
+    }
+    else
+    {
+      timeChanges[0].bpm = previousBPM;
+    }
+
+    state.currentSongMetadata.timeChanges = timeChanges;
+
+    Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges);
+  }
+
+  public function toString():String
+  {
+    return 'Change Starting BPM to ${targetBPM}';
+  }
+}
diff --git a/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx b/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx
index 1857b44db..75382da41 100644
--- a/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx
+++ b/source/funkin/ui/debug/charting/commands/PasteItemsCommand.hx
@@ -32,10 +32,14 @@ class PasteItemsCommand implements ChartEditorCommand
       return;
     }
 
-    trace(currentClipboard.notes);
+    var stepEndOfSong:Float = Conductor.getTimeInSteps(state.songLengthInMs);
+    var stepCutoff:Float = stepEndOfSong - 1.0;
+    var msCutoff:Float = Conductor.getStepTimeInMs(stepCutoff);
 
     addedNotes = SongDataUtils.offsetSongNoteData(currentClipboard.notes, Std.int(targetTimestamp));
+    addedNotes = SongDataUtils.clampSongNoteData(addedNotes, 0.0, msCutoff);
     addedEvents = SongDataUtils.offsetSongEventData(currentClipboard.events, Std.int(targetTimestamp));
+    addedEvents = SongDataUtils.clampSongEventData(addedEvents, 0.0, msCutoff);
 
     state.currentSongChartNoteData = state.currentSongChartNoteData.concat(addedNotes);
     state.currentSongChartEventData = state.currentSongChartEventData.concat(addedEvents);
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
index f82a123a4..2de3d8c20 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
@@ -202,7 +202,7 @@ class ChartEditorAudioHandler
    * Automatically cleans up after itself and recycles previous FlxSound instances if available, for performance.
    * @param path The path to the sound effect. Use `Paths` to build this.
    */
-  public static function playSound(_state:ChartEditorState, path:String):Void
+  public static function playSound(_state:ChartEditorState, path:String, volume:Float = 1.0):Void
   {
     var snd:FlxSound = FlxG.sound.list.recycle(FlxSound) ?? new FlxSound();
     var asset:Null<FlxSoundAsset> = FlxG.sound.cache(path);
@@ -214,6 +214,7 @@ class ChartEditorAudioHandler
     snd.loadEmbedded(asset);
     snd.autoDestroy = true;
     FlxG.sound.list.add(snd);
+    snd.volume = volume;
     snd.play();
   }
 
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
index 79937ce6f..0e7ba374c 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx
@@ -110,12 +110,12 @@ class ChartEditorDialogHandler
   {
     var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_BACKUP_AVAILABLE_LAYOUT, true, true);
     if (dialog == null) throw 'Could not locate Backup Available dialog';
-    dialog.onDialogClosed = function(_event) {
+    dialog.onDialogClosed = function(event) {
       state.isHaxeUIDialogOpen = false;
-      if (_event.button == DialogButton.APPLY)
+      if (event.button == DialogButton.APPLY)
       {
         // User loaded the backup! Close the welcome dialog behind this.
-        if (welcomeDialog != null) welcomeDialog.hideDialog(DialogButton.CANCEL);
+        if (welcomeDialog != null) welcomeDialog.hideDialog(DialogButton.APPLY);
       }
       else
       {
@@ -137,22 +137,22 @@ class ChartEditorDialogHandler
 
     var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
     if (buttonCancel == null) throw 'Could not locate dialogCancel button in Backup Available dialog';
-    buttonCancel.onClick = function(_event) {
+    buttonCancel.onClick = function(_) {
       // Don't hide the welcome dialog behind this.
       dialog.hideDialog(DialogButton.CANCEL);
     }
 
     var buttonGoToFolder:Null<Button> = dialog.findComponent('buttonGoToFolder', Button);
     if (buttonGoToFolder == null) throw 'Could not locate buttonGoToFolder button in Backup Available dialog';
-    buttonGoToFolder.onClick = function(_event) {
+    buttonGoToFolder.onClick = function(_) {
       state.openBackupsFolder();
       // Don't hide the welcome dialog behind this.
-      // dialog.hideDialog(DialogButton.CANCEL);
+      // Don't close this dialog.
     }
 
     var buttonOpenBackup:Null<Button> = dialog.findComponent('buttonOpenBackup', Button);
     if (buttonOpenBackup == null) throw 'Could not locate buttonOpenBackup button in Backup Available dialog';
-    buttonOpenBackup.onClick = function(_event) {
+    buttonOpenBackup.onClick = function(_) {
       var latestBackupPath:Null<String> = ChartEditorImportExportHandler.getLatestBackupPath();
 
       var result:Null<Array<String>> = (latestBackupPath != null) ? state.loadFromFNFCPath(latestBackupPath) : null;
@@ -210,20 +210,20 @@ class ChartEditorDialogHandler
     // Open the "Open Chart" wizard
     // Step 1. Open Chart
     var openChartDialog:Dialog = openChartDialog(state);
-    openChartDialog.onDialogClosed = function(_event) {
+    openChartDialog.onDialogClosed = function(event) {
       state.isHaxeUIDialogOpen = false;
-      if (_event.button == DialogButton.APPLY)
+      if (event.button == DialogButton.APPLY)
       {
         // Step 2. Upload instrumental
         var uploadInstDialog:Dialog = openUploadInstDialog(state, closable);
-        uploadInstDialog.onDialogClosed = function(_event) {
+        uploadInstDialog.onDialogClosed = function(event) {
           state.isHaxeUIDialogOpen = false;
-          if (_event.button == DialogButton.APPLY)
+          if (event.button == DialogButton.APPLY)
           {
             // Step 3. Upload Vocals
             // NOTE: Uploading vocals is optional, so we don't need to check if the user cancelled the wizard.
             var uploadVocalsDialog:Dialog = openUploadVocalsDialog(state, closable); // var uploadVocalsDialog:Dialog
-            uploadVocalsDialog.onDialogClosed = function(_event) {
+            uploadVocalsDialog.onDialogClosed = function(event) {
               state.isHaxeUIDialogOpen = false;
               state.currentWorkingFilePath = null; // Built from parts, so no .fnfc to save to.
               state.switchToCurrentInstrumental();
@@ -251,20 +251,20 @@ class ChartEditorDialogHandler
     // Step 1. Open Chart
     var openChartDialog:Null<Dialog> = openImportChartDialog(state, format);
     if (openChartDialog == null) throw 'Could not locate Import Chart dialog';
-    openChartDialog.onDialogClosed = function(_event) {
+    openChartDialog.onDialogClosed = function(event) {
       state.isHaxeUIDialogOpen = false;
-      if (_event.button == DialogButton.APPLY)
+      if (event.button == DialogButton.APPLY)
       {
         // Step 2. Upload instrumental
         var uploadInstDialog:Dialog = openUploadInstDialog(state, closable);
-        uploadInstDialog.onDialogClosed = function(_event) {
+        uploadInstDialog.onDialogClosed = function(event) {
           state.isHaxeUIDialogOpen = false;
-          if (_event.button == DialogButton.APPLY)
+          if (event.button == DialogButton.APPLY)
           {
             // Step 3. Upload Vocals
             // NOTE: Uploading vocals is optional, so we don't need to check if the user cancelled the wizard.
             var uploadVocalsDialog:Dialog = openUploadVocalsDialog(state, closable); // var uploadVocalsDialog:Dialog
-            uploadVocalsDialog.onDialogClosed = function(_event) {
+            uploadVocalsDialog.onDialogClosed = function(_) {
               state.isHaxeUIDialogOpen = false;
               state.currentWorkingFilePath = null; // New file, so no path.
               state.switchToCurrentInstrumental();
@@ -289,21 +289,21 @@ class ChartEditorDialogHandler
   public static function openCreateSongWizardBasicOnly(state:ChartEditorState, closable:Bool):Void
   {
     // Step 1. Song Metadata
-    var songMetadataDialog:Dialog = openSongMetadataDialog(state, false, Constants.DEFAULT_VARIATION);
-    songMetadataDialog.onDialogClosed = function(_event) {
+    var songMetadataDialog:Dialog = openSongMetadataDialog(state, false, Constants.DEFAULT_VARIATION, true);
+    songMetadataDialog.onDialogClosed = function(event) {
       state.isHaxeUIDialogOpen = false;
-      if (_event.button == DialogButton.APPLY)
+      if (event.button == DialogButton.APPLY)
       {
         // Step 2. Upload Instrumental
         var uploadInstDialog:Dialog = openUploadInstDialog(state, closable);
-        uploadInstDialog.onDialogClosed = function(_event) {
+        uploadInstDialog.onDialogClosed = function(event) {
           state.isHaxeUIDialogOpen = false;
-          if (_event.button == DialogButton.APPLY)
+          if (event.button == DialogButton.APPLY)
           {
             // Step 3. Upload Vocals
             // NOTE: Uploading vocals is optional, so we don't need to check if the user cancelled the wizard.
             var uploadVocalsDialog:Dialog = openUploadVocalsDialog(state, closable); // var uploadVocalsDialog:Dialog
-            uploadVocalsDialog.onDialogClosed = function(_event) {
+            uploadVocalsDialog.onDialogClosed = function(_) {
               state.isHaxeUIDialogOpen = false;
               state.currentWorkingFilePath = null; // New file, so no path.
               state.switchToCurrentInstrumental();
@@ -328,21 +328,21 @@ class ChartEditorDialogHandler
   public static function openCreateSongWizardErectOnly(state:ChartEditorState, closable:Bool):Void
   {
     // Step 1. Song Metadata
-    var songMetadataDialog:Dialog = openSongMetadataDialog(state, true, Constants.DEFAULT_VARIATION);
-    songMetadataDialog.onDialogClosed = function(_event) {
+    var songMetadataDialog:Dialog = openSongMetadataDialog(state, true, Constants.DEFAULT_VARIATION, true);
+    songMetadataDialog.onDialogClosed = function(event) {
       state.isHaxeUIDialogOpen = false;
-      if (_event.button == DialogButton.APPLY)
+      if (event.button == DialogButton.APPLY)
       {
         // Step 2. Upload Instrumental
         var uploadInstDialog:Dialog = openUploadInstDialog(state, closable);
-        uploadInstDialog.onDialogClosed = function(_event) {
+        uploadInstDialog.onDialogClosed = function(event) {
           state.isHaxeUIDialogOpen = false;
-          if (_event.button == DialogButton.APPLY)
+          if (event.button == DialogButton.APPLY)
           {
             // Step 3. Upload Vocals
             // NOTE: Uploading vocals is optional, so we don't need to check if the user cancelled the wizard.
             var uploadVocalsDialog:Dialog = openUploadVocalsDialog(state, closable); // var uploadVocalsDialog:Dialog
-            uploadVocalsDialog.onDialogClosed = function(_event) {
+            uploadVocalsDialog.onDialogClosed = function(_) {
               state.isHaxeUIDialogOpen = false;
               state.currentWorkingFilePath = null; // New file, so no path.
               state.switchToCurrentInstrumental();
@@ -367,41 +367,41 @@ class ChartEditorDialogHandler
   public static function openCreateSongWizardBasicErect(state:ChartEditorState, closable:Bool):Void
   {
     // Step 1. Song Metadata
-    var songMetadataDialog:Dialog = openSongMetadataDialog(state, false, Constants.DEFAULT_VARIATION);
-    songMetadataDialog.onDialogClosed = function(_event) {
+    var songMetadataDialog:Dialog = openSongMetadataDialog(state, false, Constants.DEFAULT_VARIATION, true);
+    songMetadataDialog.onDialogClosed = function(event) {
       state.isHaxeUIDialogOpen = false;
-      if (_event.button == DialogButton.APPLY)
+      if (event.button == DialogButton.APPLY)
       {
         // Step 2. Upload Instrumental
         var uploadInstDialog:Dialog = openUploadInstDialog(state, closable);
-        uploadInstDialog.onDialogClosed = function(_event) {
+        uploadInstDialog.onDialogClosed = function(event) {
           state.isHaxeUIDialogOpen = false;
-          if (_event.button == DialogButton.APPLY)
+          if (event.button == DialogButton.APPLY)
           {
             // Step 3. Upload Vocals
             // NOTE: Uploading vocals is optional, so we don't need to check if the user cancelled the wizard.
             var uploadVocalsDialog:Dialog = openUploadVocalsDialog(state, closable); // var uploadVocalsDialog:Dialog
-            uploadVocalsDialog.onDialogClosed = function(_event) {
+            uploadVocalsDialog.onDialogClosed = function(_) {
               state.switchToCurrentInstrumental();
               // Step 4. Song Metadata (Erect)
-              var songMetadataDialogErect:Dialog = openSongMetadataDialog(state, true, 'erect');
-              songMetadataDialogErect.onDialogClosed = function(_event) {
+              var songMetadataDialogErect:Dialog = openSongMetadataDialog(state, true, 'erect', false);
+              songMetadataDialogErect.onDialogClosed = function(event) {
                 state.isHaxeUIDialogOpen = false;
-                if (_event.button == DialogButton.APPLY)
+                if (event.button == DialogButton.APPLY)
                 {
                   // Switch to the Erect variation so uploading the instrumental applies properly.
                   state.selectedVariation = 'erect';
 
                   // Step 5. Upload Instrumental (Erect)
                   var uploadInstDialogErect:Dialog = openUploadInstDialog(state, closable);
-                  uploadInstDialogErect.onDialogClosed = function(_event) {
+                  uploadInstDialogErect.onDialogClosed = function(event) {
                     state.isHaxeUIDialogOpen = false;
-                    if (_event.button == DialogButton.APPLY)
+                    if (event.button == DialogButton.APPLY)
                     {
                       // Step 6. Upload Vocals (Erect)
                       // NOTE: Uploading vocals is optional, so we don't need to check if the user cancelled the wizard.
                       var uploadVocalsDialogErect:Dialog = openUploadVocalsDialog(state, closable); // var uploadVocalsDialog:Dialog
-                      uploadVocalsDialogErect.onDialogClosed = function(_event) {
+                      uploadVocalsDialogErect.onDialogClosed = function(_) {
                         state.isHaxeUIDialogOpen = false;
                         state.currentWorkingFilePath = null; // New file, so no path.
                         state.switchToCurrentInstrumental();
@@ -453,19 +453,19 @@ class ChartEditorDialogHandler
     var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
     if (buttonCancel == null) throw 'Could not locate dialogCancel button in Upload Instrumental dialog';
 
-    buttonCancel.onClick = function(_event) {
+    buttonCancel.onClick = function(_) {
       dialog.hideDialog(DialogButton.CANCEL);
     }
 
     var instrumentalBox:Null<Box> = dialog.findComponent('instrumentalBox', Box);
     if (instrumentalBox == null) throw 'Could not locate instrumentalBox in Upload Instrumental dialog';
 
-    instrumentalBox.onMouseOver = function(_event) {
+    instrumentalBox.onMouseOver = function(_) {
       instrumentalBox.swapClass('upload-bg', 'upload-bg-hover');
       Cursor.cursorMode = Pointer;
     }
 
-    instrumentalBox.onMouseOut = function(_event) {
+    instrumentalBox.onMouseOut = function(_) {
       instrumentalBox.swapClass('upload-bg-hover', 'upload-bg');
       Cursor.cursorMode = Default;
     }
@@ -474,7 +474,7 @@ class ChartEditorDialogHandler
 
     var dropHandler:DialogDropTarget = {component: instrumentalBox, handler: null};
 
-    instrumentalBox.onClick = function(_event) {
+    instrumentalBox.onClick = function(_) {
       Dialogs.openBinaryFile('Open Instrumental', [
         {label: 'Audio File (.ogg)', extension: 'ogg'}], function(selectedFile:SelectedFileInfo) {
           if (selectedFile != null && selectedFile.bytes != null)
@@ -533,10 +533,13 @@ class ChartEditorDialogHandler
   /**
    * Opens the dialog in the wizard where the user can set song metadata like name and artist and BPM.
    * @param state The ChartEditorState instance.
+   * @param erect Whether to create erect difficulties or normal ones.
+   * @param targetVariation The variation to create difficulties for.
+   * @param clearExistingMetadata Whether to clear existing metadata when confirming.
    * @return The dialog to open.
    */
   @:haxe.warning("-WVarInit")
-  public static function openSongMetadataDialog(state:ChartEditorState, erect:Bool, targetVariation:String):Dialog
+  public static function openSongMetadataDialog(state:ChartEditorState, erect:Bool, targetVariation:String, clearExistingMetadata:Bool):Dialog
   {
     var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_SONG_METADATA_LAYOUT, true, false);
     if (dialog == null) throw 'Could not locate Song Metadata dialog';
@@ -549,7 +552,7 @@ class ChartEditorDialogHandler
     var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
     if (buttonCancel == null) throw 'Could not locate dialogCancel button in Song Metadata dialog';
     state.isHaxeUIDialogOpen = true;
-    buttonCancel.onClick = function(_event) {
+    buttonCancel.onClick = function(_) {
       state.isHaxeUIDialogOpen = false;
       dialog.hideDialog(DialogButton.CANCEL);
     }
@@ -661,8 +664,12 @@ class ChartEditorDialogHandler
 
     var dialogContinue:Null<Button> = dialog.findComponent('dialogContinue', Button);
     if (dialogContinue == null) throw 'Could not locate dialogContinue button in Song Metadata dialog';
-    dialogContinue.onClick = (_event) -> {
-      if (targetVariation == Constants.DEFAULT_VARIATION) state.songMetadata.clear();
+    dialogContinue.onClick = (_) -> {
+      if (clearExistingMetadata)
+      {
+        state.songMetadata.clear();
+        state.songChartData.clear();
+      }
 
       state.songMetadata.set(targetVariation, newSongMetadata);
 
@@ -702,13 +709,13 @@ class ChartEditorDialogHandler
 
     var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
     if (buttonCancel == null) throw 'Could not locate dialogCancel button in Upload Vocals dialog';
-    buttonCancel.onClick = function(_event) {
+    buttonCancel.onClick = function(_) {
       dialog.hideDialog(DialogButton.CANCEL);
     }
 
     var dialogNoVocals:Null<Button> = dialog.findComponent('dialogNoVocals', Button);
     if (dialogNoVocals == null) throw 'Could not locate dialogNoVocals button in Upload Vocals dialog';
-    dialogNoVocals.onClick = function(_event) {
+    dialogNoVocals.onClick = function(_) {
       // Dismiss
       state.wipeVocalData();
       dialog.hideDialog(DialogButton.APPLY);
@@ -820,7 +827,7 @@ class ChartEditorDialogHandler
 
     var dialogContinue:Null<Button> = dialog.findComponent('dialogContinue', Button);
     if (dialogContinue == null) throw 'Could not locate dialogContinue button in Upload Vocals dialog';
-    dialogContinue.onClick = function(_event) {
+    dialogContinue.onClick = function(_) {
       // Dismiss
       dialog.hideDialog(DialogButton.APPLY);
     };
@@ -842,7 +849,7 @@ class ChartEditorDialogHandler
 
     var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
     if (buttonCancel == null) throw 'Could not locate dialogCancel button in Open Chart dialog';
-    buttonCancel.onClick = function(_event) {
+    buttonCancel.onClick = function(_) {
       dialog.hideDialog(DialogButton.CANCEL);
     }
 
@@ -856,7 +863,7 @@ class ChartEditorDialogHandler
 
     var buttonContinue:Null<Button> = dialog.findComponent('dialogContinue', Button);
     if (buttonContinue == null) throw 'Could not locate dialogContinue button in Open Chart dialog';
-    buttonContinue.onClick = function(_event) {
+    buttonContinue.onClick = function(_) {
       state.loadSong(songMetadata, songChartData);
 
       dialog.hideDialog(DialogButton.APPLY);
@@ -904,11 +911,11 @@ class ChartEditorDialogHandler
         songVariationMetadataEntryLabel.text = 'Click to browse for <song>-metadata-${variation}.json file.';
         #end
 
-        songVariationMetadataEntry.onMouseOver = function(_event) {
+        songVariationMetadataEntry.onMouseOver = function(_) {
           songVariationMetadataEntry.swapClass('upload-bg', 'upload-bg-hover');
           Cursor.cursorMode = Pointer;
         }
-        songVariationMetadataEntry.onMouseOut = function(_event) {
+        songVariationMetadataEntry.onMouseOut = function(_) {
           songVariationMetadataEntry.swapClass('upload-bg-hover', 'upload-bg');
           Cursor.cursorMode = Default;
         }
@@ -928,11 +935,11 @@ class ChartEditorDialogHandler
         songVariationChartDataEntryLabel.text = 'Click to browse for <song>-chart-${variation}.json file.';
         #end
 
-        songVariationChartDataEntry.onMouseOver = function(_event) {
+        songVariationChartDataEntry.onMouseOver = function(_) {
           songVariationChartDataEntry.swapClass('upload-bg', 'upload-bg-hover');
           Cursor.cursorMode = Pointer;
         }
-        songVariationChartDataEntry.onMouseOut = function(_event) {
+        songVariationChartDataEntry.onMouseOut = function(_) {
           songVariationChartDataEntry.swapClass('upload-bg-hover', 'upload-bg');
           Cursor.cursorMode = Default;
         }
@@ -982,7 +989,7 @@ class ChartEditorDialogHandler
       if (variation == Constants.DEFAULT_VARIATION) constructVariationEntries(songMetadataVariation.playData.songVariations);
     };
 
-    onClickMetadataVariation = function(variation:String, label:Label, _event:UIEvent) {
+    onClickMetadataVariation = function(variation:String, label:Label, _:UIEvent) {
       Dialogs.openBinaryFile('Open Chart ($variation) Metadata', [
         {label: 'JSON File (.json)', extension: 'json'}], function(selectedFile) {
           if (selectedFile != null && selectedFile.bytes != null)
@@ -1066,7 +1073,7 @@ class ChartEditorDialogHandler
       }
     };
 
-    onClickChartDataVariation = function(variation:String, label:Label, _event:UIEvent) {
+    onClickChartDataVariation = function(variation:String, label:Label, _:UIEvent) {
       Dialogs.openBinaryFile('Open Chart ($variation) Metadata', [
         {label: 'JSON File (.json)', extension: 'json'}], function(selectedFile) {
           if (selectedFile != null && selectedFile.bytes != null)
@@ -1122,7 +1129,7 @@ class ChartEditorDialogHandler
       metadataEntry.swapClass('upload-bg', 'upload-bg-hover');
       Cursor.cursorMode = Pointer;
     }
-    metadataEntry.onMouseOut = function(_event) {
+    metadataEntry.onMouseOut = function(_) {
       metadataEntry.swapClass('upload-bg-hover', 'upload-bg');
       Cursor.cursorMode = Default;
     }
@@ -1162,7 +1169,7 @@ class ChartEditorDialogHandler
     if (buttonCancel == null) throw 'Could not locate dialogCancel button in Import Chart dialog';
 
     state.isHaxeUIDialogOpen = true;
-    buttonCancel.onClick = function(_event) {
+    buttonCancel.onClick = function(_) {
       state.isHaxeUIDialogOpen = false;
       dialog.hideDialog(DialogButton.CANCEL);
     }
@@ -1170,18 +1177,18 @@ class ChartEditorDialogHandler
     var importBox:Null<Box> = dialog.findComponent('importBox', Box);
     if (importBox == null) throw 'Could not locate importBox in Import Chart dialog';
 
-    importBox.onMouseOver = function(_event) {
+    importBox.onMouseOver = function(_) {
       importBox.swapClass('upload-bg', 'upload-bg-hover');
       Cursor.cursorMode = Pointer;
     }
-    importBox.onMouseOut = function(_event) {
+    importBox.onMouseOut = function(_) {
       importBox.swapClass('upload-bg-hover', 'upload-bg');
       Cursor.cursorMode = Default;
     }
 
     var onDropFile:String->Void;
 
-    importBox.onClick = function(_event) {
+    importBox.onClick = function(_) {
       Dialogs.openBinaryFile('Import Chart - ${prettyFormat}', fileFilter != null ? [fileFilter] : [], function(selectedFile:SelectedFileInfo) {
         if (selectedFile != null && selectedFile.bytes != null)
         {
@@ -1251,13 +1258,13 @@ class ChartEditorDialogHandler
 
     var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
     if (buttonCancel == null) throw 'Could not locate dialogCancel button in Add Variation dialog';
-    buttonCancel.onClick = function(_event) {
+    buttonCancel.onClick = function(_) {
       dialog.hideDialog(DialogButton.CANCEL);
     }
 
     var buttonAdd:Null<Button> = dialog.findComponent('dialogAdd', Button);
     if (buttonAdd == null) throw 'Could not locate dialogAdd button in Add Variation dialog';
-    buttonAdd.onClick = function(_event) {
+    buttonAdd.onClick = function(_) {
       // This performs validation before the onSubmit callback is called.
       variationForm.submit();
     }
@@ -1296,12 +1303,13 @@ class ChartEditorDialogHandler
 
     var dialogBPM:Null<NumberStepper> = dialog.findComponent('dialogBPM', NumberStepper);
     if (dialogBPM == null) throw 'Could not locate dialogBPM NumberStepper in Add Variation dialog';
-    dialogBPM.value = state.currentSongMetadata.timeChanges[0].bpm;
+    var currentStartingBPM:Float = state.currentSongMetadata.timeChanges[0].bpm;
+    dialogBPM.value = currentStartingBPM;
 
     // If all validators succeeded, this callback is called.
 
     state.isHaxeUIDialogOpen = true;
-    variationForm.onSubmit = function(_event) {
+    variationForm.onSubmit = function(_) {
       state.isHaxeUIDialogOpen = false;
       trace('Add Variation dialog submitted, validation succeeded!');
 
@@ -1317,6 +1325,8 @@ class ChartEditorDialogHandler
       state.songMetadata.set(pendingVariation.variation, pendingVariation);
       state.difficultySelectDirty = true; // Force the Difficulty toolbox to update.
 
+      // Don't update conductor since we haven't switched to the new variation yet.
+
       state.success('Add Variation', 'Added new variation "${pendingVariation.variation}"');
 
       dialog.hideDialog(DialogButton.APPLY);
@@ -1341,13 +1351,13 @@ class ChartEditorDialogHandler
 
     var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
     if (buttonCancel == null) throw 'Could not locate dialogCancel button in Add Difficulty dialog';
-    buttonCancel.onClick = function(_event) {
+    buttonCancel.onClick = function(_) {
       dialog.hideDialog(DialogButton.CANCEL);
     }
 
     var buttonAdd:Null<Button> = dialog.findComponent('dialogAdd', Button);
     if (buttonAdd == null) throw 'Could not locate dialogAdd button in Add Difficulty dialog';
-    buttonAdd.onClick = function(_event) {
+    buttonAdd.onClick = function(_) {
       // This performs validation before the onSubmit callback is called.
       difficultyForm.submit();
     }
@@ -1367,7 +1377,7 @@ class ChartEditorDialogHandler
     inputScrollSpeed.value = state.currentSongChartScrollSpeed;
     labelScrollSpeed.text = 'Scroll Speed: ${inputScrollSpeed.value}x';
 
-    difficultyForm.onSubmit = function(_event) {
+    difficultyForm.onSubmit = function(_) {
       trace('Add Difficulty dialog submitted, validation succeeded!');
 
       var dialogDifficultyName:Null<TextField> = dialog.findComponent('dialogDifficultyName', TextField);
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorNotificationHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorNotificationHandler.hx
index 796e70381..5c340feb7 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorNotificationHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorNotificationHandler.hx
@@ -21,7 +21,7 @@ class ChartEditorNotificationHandler
    */
   public static function success(state:ChartEditorState, title:String, body:String):Notification
   {
-    return sendNotification(title, body, NotificationType.Success);
+    return sendNotification(state, title, body, NotificationType.Success);
   }
 
   /**
@@ -30,7 +30,7 @@ class ChartEditorNotificationHandler
    */
   public static function warning(state:ChartEditorState, title:String, body:String):Notification
   {
-    return sendNotification(title, body, NotificationType.Warning);
+    return sendNotification(state, title, body, NotificationType.Warning);
   }
 
   /**
@@ -48,7 +48,7 @@ class ChartEditorNotificationHandler
    */
   public static function error(state:ChartEditorState, title:String, body:String):Notification
   {
-    return sendNotification(title, body, NotificationType.Error);
+    return sendNotification(state, title, body, NotificationType.Error);
   }
 
   /**
@@ -66,7 +66,7 @@ class ChartEditorNotificationHandler
    */
   public static function info(state:ChartEditorState, title:String, body:String):Notification
   {
-    return sendNotification(title, body, NotificationType.Info);
+    return sendNotification(state, title, body, NotificationType.Info);
   }
 
   /**
@@ -79,7 +79,7 @@ class ChartEditorNotificationHandler
    */
   public static function infoWithActions(state:ChartEditorState, title:String, body:String, actions:Array<NotificationAction>):Notification
   {
-    return sendNotification(title, body, NotificationType.Info, actions);
+    return sendNotification(state, title, body, NotificationType.Info, actions);
   }
 
   /**
@@ -101,7 +101,7 @@ class ChartEditorNotificationHandler
     NotificationManager.instance.removeNotification(notif);
   }
 
-  static function sendNotification(title:String, body:String, ?type:NotificationType, ?actions:Array<NotificationAction>):Notification
+  static function sendNotification(state:ChartEditorState, title:String, body:String, ?type:NotificationType, ?actions:Array<NotificationAction>):Notification
   {
     #if !mac
     var actionNames:Array<String> = actions == null ? [] : actions.map(action -> action.text);
@@ -127,6 +127,8 @@ class ChartEditorNotificationHandler
           if (action != null && action.callback != null)
           {
             button.onClick = function(_) {
+              // Don't allow actions to be clicked while the playtest is open.
+              if (state.subState != null) return;
               action.callback();
             };
           }
@@ -137,6 +139,8 @@ class ChartEditorNotificationHandler
 
     return notif;
     #else
+    // TODO: Implement notifications on Mac OS OR... make sure the null is handled properly on mac?
+    return null;
     trace('WARNING: Notifications are not supported on Mac OS.');
     #end
   }
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
index af7cd774a..418f57464 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorToolboxHandler.hx
@@ -11,6 +11,7 @@ import funkin.play.character.BaseCharacter.CharacterType;
 import funkin.play.event.SongEvent;
 import funkin.data.event.SongEventData;
 import funkin.data.song.SongData.SongTimeChange;
+import funkin.ui.debug.charting.commands.ChangeStartingBPMCommand;
 import funkin.play.character.BaseCharacter.CharacterType;
 import funkin.play.character.CharacterData;
 import funkin.play.character.CharacterData.CharacterDataParser;
@@ -610,19 +611,12 @@ class ChartEditorToolboxHandler
     inputBPM.onChange = function(event:UIEvent) {
       if (event.value == null || event.value <= 0) return;
 
-      var timeChanges:Array<SongTimeChange> = state.currentSongMetadata.timeChanges;
-      if (timeChanges == null || timeChanges.length == 0)
+      // Use a command so we can undo/redo this action.
+      var startingBPM = state.currentSongMetadata.timeChanges[0].bpm;
+      if (event.value != startingBPM)
       {
-        timeChanges = [new SongTimeChange(0, event.value)];
+        state.performCommand(new ChangeStartingBPMCommand(event.value));
       }
-      else
-      {
-        timeChanges[0].bpm = event.value;
-      }
-
-      state.currentSongMetadata.timeChanges = timeChanges;
-
-      Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges);
     };
     inputBPM.value = state.currentSongMetadata.timeChanges[0].bpm;
 
diff --git a/source/funkin/util/macro/FlxMacro.hx b/source/funkin/util/macro/FlxMacro.hx
index 0c1a5fa78..cec6b72ec 100644
--- a/source/funkin/util/macro/FlxMacro.hx
+++ b/source/funkin/util/macro/FlxMacro.hx
@@ -1,5 +1,6 @@
 package funkin.util.macro;
 
+#if !display
 #if macro
 class FlxMacro
 {
@@ -33,3 +34,4 @@ class FlxMacro
   }
 }
 #end
+#end
diff --git a/source/funkin/util/macro/GitCommit.hx b/source/funkin/util/macro/GitCommit.hx
index d0c034828..b3ddd2b7e 100644
--- a/source/funkin/util/macro/GitCommit.hx
+++ b/source/funkin/util/macro/GitCommit.hx
@@ -1,5 +1,6 @@
 package funkin.util.macro;
 
+#if !display
 #if (debug || FORCE_DEBUG_VERSION)
 class GitCommit
 {
@@ -65,3 +66,4 @@ class GitCommit
   }
 }
 #end
+#end