diff --git a/.github/actions/setup-haxeshit/action.yml b/.github/actions/setup-haxeshit/action.yml
index 9c1fae0b1..236d29944 100644
--- a/.github/actions/setup-haxeshit/action.yml
+++ b/.github/actions/setup-haxeshit/action.yml
@@ -3,21 +3,46 @@ description: "sets up haxe shit, using HMM!"
 runs:
   using: "composite"
   steps:
-    - uses: funkincrew/ci-haxe@v3
+    - name: Install Haxe lol
+      uses: funkincrew/ci-haxe@v3.1.0
       with:
         haxe-version: 4.3.3
     - name: Config haxelib
       run: |
-        haxelib config
+        haxelib --never install haxelib 4.1.0 --global
+        haxelib --never deleterepo || true
+        haxelib --never newrepo
+        echo "HAXEPATH=$(haxelib config)" >> "$GITHUB_ENV"
+        haxelib --never git haxelib https://github.com/HaxeFoundation/haxelib.git master
       shell: bash
-    - name: Installing Haxe lol
+    - name: Gather debug info
+      run: |
+        cat << EOF >> "$GITHUB_STEP_SUMMARY"
+        ## haxe
+        - version: \`$(haxe -version)\`
+        - exe: \`$(which haxe)\`
+        ## haxelib
+        - version: \`$(haxelib version)\`
+        - exe: \`$(which haxelib)\`
+        - path: \`$HAXEPATH\`
+        ### local
+        - config: \`$(haxelib config)\`
+        - path: \`$(haxelib path haxelib || true)\`
+        ### global
+        - config: \`$(haxelib config --global)\`
+        - path: \`$(haxelib path haxelib --global || true)\`
+        ### system
+        - version: \`$(haxelib --system version)\`
+        - local: \`$(haxelib --system config)\`
+        - global: \`$(haxelib --system config --global)\`
+        EOF
+      shell: bash
+    - name: Install hmm
+      # hmm only supports global installs
       run: |
-        haxe -version
-        haxelib git haxelib https://github.com/HaxeFoundation/haxelib.git master
-        haxelib version
         haxelib --global install hmm
       shell: bash
-    - name: dependency install cache
+    - name: Restore cached dependencies
       id: cache-hmm
       uses: actions/cache@v4
       with:
diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml
index 388670a01..5a1f5609a 100644
--- a/.github/workflows/build-shit.yml
+++ b/.github/workflows/build-shit.yml
@@ -8,71 +8,80 @@ jobs:
     runs-on: [self-hosted, linux]
     container: ubuntu:23.10
     steps:
-      - name: prepare container
+      - name: Install tools missing in container
         run: |
           apt update
-          apt install sudo git curl unzip -y
-          git config --global --add safe.directory $GITHUB_WORKSPACE
-      - name: get token from gh app
+          apt install -y sudo git curl unzip
+      - name: Fix git config on posix runner
+        run: |
+          git config --global --add safe.directory ${{ github.workspace }}
+      - name: Get checkout token
         uses: actions/create-github-app-token@v1
         id: app_token
         with:
           app-id: ${{ vars.APP_ID }}
           private-key: ${{ secrets.APP_PEM }}
           owner: ${{ github.repository_owner }}
-      - name: checkout repo
+      - name: Checkout repo
         uses: funkincrew/ci-checkout@v6
         with:
           submodules: 'recursive'
           token: ${{ steps.app_token.outputs.token }}
-      - uses: ./.github/actions/setup-haxeshit
-      - name: gather game dependencies
+      - name: Install Haxe, dependencies
+        uses: ./.github/actions/setup-haxeshit
+      - name: Install native dependencies
         run: |
-          sudo apt-get install -y libx11-dev xorg-dev libgl-dev libxi-dev libxext-dev libasound2-dev libxinerama-dev libxrandr-dev libgl1-mesa-dev
-      - name: build game
+          apt install -y \
+            libx11-dev libxi-dev libxext-dev libxinerama-dev libxrandr-dev \
+            libgl-dev libgl1-mesa-dev \
+            libasound2-dev
+      - name: Build game
         run: |
           haxelib run lime build html5 -release --times -DGITHUB_BUILD
-          ls
-      - uses: ./.github/actions/upload-itch
+      - name: Upload build artifacts
+        uses: ./.github/actions/upload-itch
         with:
           butler-key: ${{ secrets.BUTLER_API_KEY}}
           build-dir: export/release/html5/bin
           target: html5
   create-nightly-win:
     runs-on: [self-hosted, windows]
+    defaults:
+      run:
+        shell: bash
     steps:
-      - name: get token from gh app
+      - name: Get checkout token
         uses: actions/create-github-app-token@v1
         id: app_token
         with:
           app-id: ${{ vars.APP_ID }}
           private-key: ${{ secrets.APP_PEM }}
           owner: ${{ github.repository_owner }}
-      - name: checkout repo
+      - name: Checkout repo
         uses: funkincrew/ci-checkout@v6
         with:
           submodules: 'recursive'
           token: ${{ steps.app_token.outputs.token }}
-      - uses: ./.github/actions/setup-haxeshit
-      - name: Make HXCPP cache dir
+      - name: Install Haxe, dependencies
+        uses: ./.github/actions/setup-haxeshit
+      - name: Setup build cache
         run: |
-          mkdir -p ${{ runner.temp }}\hxcpp_cache
+          mkdir -p ${{ runner.temp }}/hxcpp_cache
       - name: Restore build cache
         id: cache-build-win
         uses: actions/cache@v4
         with:
           path: |
-            .haxelib
             export
-            ${{ runner.temp }}\hxcpp_cache
-          key: ${{ runner.os }}-build-win-${{ github.ref_name }}-${{ hashFiles('**/hmm.json') }}
-      - name: build game
+            ${{ runner.temp }}/hxcpp_cache
+          key: ${{ runner.os }}-build-win-${{ github.ref_name }}
+      - name: Build game
         run: |
-          haxelib run lime build windows -release -DNO_REDIRECT_ASSETS_FOLDER -DGITHUB_BUILD
-          dir
+          haxelib run lime build windows -v -release -DNO_REDIRECT_ASSETS_FOLDER -DGITHUB_BUILD
         env:
           HXCPP_COMPILE_CACHE: "${{ runner.temp }}\\hxcpp_cache"
-      - uses: ./.github/actions/upload-itch
+      - name: Upload build artifacts
+        uses: ./.github/actions/upload-itch
         with:
           butler-key: ${{ secrets.BUTLER_API_KEY }}
           build-dir: export/release/windows/bin
@@ -80,78 +89,42 @@ jobs:
   create-nightly-mac:
     runs-on: [self-hosted, macos]
     steps:
-      - name: prepare container
+      - name: Fix git config on posix runner
         run: |
-          git config --global --add safe.directory $GITHUB_WORKSPACE
-      - name: get token from gh app
+          git config --global --add safe.directory ${{ github.workspace }}
+      - name: Get checkout token
         uses: actions/create-github-app-token@v1
         id: app_token
         with:
           app-id: ${{ vars.APP_ID }}
           private-key: ${{ secrets.APP_PEM }}
           owner: ${{ github.repository_owner }}
-      - name: checkout repo
+      - name: Checkout repo
         uses: funkincrew/ci-checkout@v6
         with:
           submodules: 'recursive'
           token: ${{ steps.app_token.outputs.token }}
-      - uses: ./.github/actions/setup-haxeshit
-      - name: Make HXCPP cache dir
+      - name: Install Haxe, dependencies
+        uses: ./.github/actions/setup-haxeshit
+      - name: Setup build cache
         run: |
           mkdir -p ${{ runner.temp }}/hxcpp_cache
-      - name: restore build cache
+      - name: Restore build cache
         id: cache-build-win
         uses: actions/cache@v4
         with:
           path: |
-            .haxelib
             export
             ${{ runner.temp }}/hxcpp_cache
-          key: ${{ runner.os }}-build-mac-${{ github.ref_name }}-${{ hashFiles('**/hmm.json') }}
+          key: ${{ runner.os }}-build-mac-${{ github.ref_name }}
       - name: Build game
         run: |
           haxelib run lime build macos -release --times -DGITHUB_BUILD
-          ls
         env:
           HXCPP_COMPILE_CACHE: "${{ runner.temp }}/hxcpp_cache"
-      - uses: ./.github/actions/upload-itch
+      - name: Upload build artifacts
+        uses: ./.github/actions/upload-itch
         with:
           butler-key: ${{ secrets.BUTLER_API_KEY}}
           build-dir: export/release/macos/bin
           target: macos
-
-#  test-unit-win:
-#    needs: create-nightly-win
-#    runs-on: windows-latest
-#    steps:
-#     - name: get token from gh app
-#       uses: actions/create-github-app-token@v1
-#        id: app_token
-#        with:
-#          app-id: ${{ vars.APP_ID }}
-#          private-key: ${{ secrets.APP_PEM }}
-#          owner: ${{ github.repository_owner }}
-#     - name: checkout repo
-#       uses: funkincrew/ci-checkout@v6
-#       with:
-#         submodules: 'recursive'
-#         token: ${{ steps.app_token.outputs.token }}
-#     - name: Make HXCPP cache dir
-#       run: |
-#         mkdir -p ${{ runner.temp }}\hxcpp_cache
-#     - name: Restore build cache
-#       id: cache-build-win
-#       uses: actions/cache@v4
-#       with:
-#         path: |
-#           .haxelib
-#           export
-#           ${{ runner.temp }}\hxcpp_cache
-#         key: ${{ runner.os }}-test-win-${{ github.ref_name }}-${{ hashFiles('**/hmm.json') }}
-#      - uses: ./.github/actions/setup-haxeshit
-#      - name: Run unit tests
-#        run: |
-#          cd ./tests/unit/
-#          ./start-win-native.bat
-#        env:
-#          HXCPP_COMPILE_CACHE: "${{ runner.temp }}\\hxcpp_cache"
diff --git a/.gitignore b/.gitignore
index 34a0c5590..87fd97fc5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ dump/
 export/
 RECOVER_*.fla
 shitAudio/
+.build_time
diff --git a/source/Postbuild.hx b/source/Postbuild.hx
index d48b670a4..f1827c4ab 100644
--- a/source/Postbuild.hx
+++ b/source/Postbuild.hx
@@ -1,11 +1,37 @@
 package source; // Yeah, I know...
 
+import sys.FileSystem;
+import sys.io.File;
+
 class Postbuild
 {
+  static inline final buildTimeFile = '.build_time';
+
   static function main()
   {
-    trace('Postbuild');
+    printBuildTime();
+  }
 
-    // TODO: Maybe put a 'Build took X seconds' message here?
+  static function printBuildTime()
+  {
+    // get buildEnd before fs operations since they are blocking
+    var end:Float = Sys.time();
+    if (FileSystem.exists(buildTimeFile))
+    {
+      var fi = File.read(buildTimeFile);
+      var start:Float = fi.readDouble();
+      fi.close();
+
+      sys.FileSystem.deleteFile(buildTimeFile);
+
+      var buildTime = roundToTwoDecimals(end - start);
+
+      trace('Build took: ${buildTime} seconds');
+    }
+  }
+
+  private static function roundToTwoDecimals(value:Float):Float
+  {
+    return Math.round(value * 100) / 100;
   }
 }
diff --git a/source/Prebuild.hx b/source/Prebuild.hx
index 63782fc56..18a5e2076 100644
--- a/source/Prebuild.hx
+++ b/source/Prebuild.hx
@@ -1,9 +1,22 @@
 package source; // Yeah, I know...
 
+import sys.io.File;
+
 class Prebuild
 {
+  static inline final buildTimeFile = '.build_time';
+
   static function main()
   {
-    trace('Prebuild');
+    saveBuildTime();
+    trace('Building...');
+  }
+
+  static function saveBuildTime()
+  {
+    var fo = File.write(buildTimeFile);
+    var now = Sys.time();
+    fo.writeDouble(now);
+    fo.close();
   }
 }
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 7dc20b385..2368d09f2 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -30,6 +30,7 @@ import funkin.modding.module.ModuleHandler;
 import funkin.ui.title.TitleState;
 import funkin.util.CLIUtil;
 import funkin.util.CLIUtil.CLIParams;
+import funkin.util.tools.TimerTools;
 import funkin.ui.transition.LoadingState;
 #if discord_rpc
 import Discord.DiscordClient;
@@ -219,7 +220,7 @@ class InitState extends FlxState
     // NOTE: Registries must be imported and not referenced with fully qualified names,
     // to ensure build macros work properly.
     trace('Parsing game data...');
-    var perfStart = haxe.Timer.stamp();
+    var perfStart:Float = TimerTools.start();
     SongEventRegistry.loadEventCache(); // SongEventRegistry is structured differently so it's not a BaseRegistry.
     SongRegistry.instance.loadEntries();
     LevelRegistry.instance.loadEntries();
@@ -236,9 +237,7 @@ class InitState extends FlxState
     ModuleHandler.loadModuleCache();
     ModuleHandler.callOnCreate();
 
-    var perfEnd = haxe.Timer.stamp();
-
-    trace('Parsing game data took ${Math.floor((perfEnd - perfStart) * 1000)}ms.');
+    trace('Parsing game data took: ${TimerTools.ms(perfStart)}');
   }
 
   /**
diff --git a/source/funkin/audio/visualize/SpectogramSprite.hx b/source/funkin/audio/visualize/SpectogramSprite.hx
index b4e024a4c..470dbf7fe 100644
--- a/source/funkin/audio/visualize/SpectogramSprite.hx
+++ b/source/funkin/audio/visualize/SpectogramSprite.hx
@@ -10,7 +10,6 @@ import flixel.util.FlxColor;
 import funkin.audio.visualize.PolygonSpectogram.VISTYPE;
 import funkin.audio.visualize.VisShit.CurAudioInfo;
 import funkin.audio.visualize.dsp.FFT;
-import haxe.Timer;
 import lime.system.ThreadPool;
 import lime.utils.Int16Array;
 
diff --git a/source/funkin/audio/visualize/VisShit.hx b/source/funkin/audio/visualize/VisShit.hx
index 5bfb8c7c5..204ced1e1 100644
--- a/source/funkin/audio/visualize/VisShit.hx
+++ b/source/funkin/audio/visualize/VisShit.hx
@@ -3,7 +3,6 @@ package funkin.audio.visualize;
 import flixel.math.FlxMath;
 import flixel.sound.FlxSound;
 import funkin.audio.visualize.dsp.FFT;
-import haxe.Timer;
 import lime.system.ThreadPool;
 import lime.utils.Int16Array;
 import funkin.util.MathUtil;
diff --git a/source/funkin/audio/waveform/WaveformDataParser.hx b/source/funkin/audio/waveform/WaveformDataParser.hx
index 54a142f6a..c667f2002 100644
--- a/source/funkin/audio/waveform/WaveformDataParser.hx
+++ b/source/funkin/audio/waveform/WaveformDataParser.hx
@@ -1,5 +1,7 @@
 package funkin.audio.waveform;
 
+import funkin.util.tools.TimerTools;
+
 class WaveformDataParser
 {
   static final INT16_MAX:Int = 32767;
@@ -71,7 +73,7 @@ class WaveformDataParser
 
     var outputData:Array<Int> = [];
 
-    var perfStart = haxe.Timer.stamp();
+    var perfStart:Float = TimerTools.start();
 
     for (pointIndex in 0...outputPointCount)
     {
@@ -108,8 +110,7 @@ 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.');
+    trace('[WAVEFORM] Interpreted audio buffer in ${TimerTools.seconds(perfStart)}.');
 
     return result;
   }
diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx
index d5132e160..95304d762 100644
--- a/source/funkin/play/GameOverSubState.hx
+++ b/source/funkin/play/GameOverSubState.hx
@@ -349,8 +349,8 @@ class GameOverSubState extends MusicBeatSubState
       }
       else
       {
-        isStarting = false;
         onComplete = function() {
+          isStarting = false;
           // We need to force to ensure that the non-starting music plays.
           startDeathMusic(1.0, true);
         };
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 5421f1d2d..d090b4f8a 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1830,7 +1830,8 @@ class PlayState extends MusicBeatSubState
 
     // I am going insane.
     FlxG.sound.music.volume = 1.0;
-    if (FlxG.sound.music.fadeTween != null) FlxG.sound.music.fadeTween.cancel();
+
+    FlxG.sound.music.fadeTween?.cancel();
 
     trace('Playing vocals...');
     add(vocals);
diff --git a/source/funkin/play/components/PopUpStuff.hx b/source/funkin/play/components/PopUpStuff.hx
index faab5e4dc..0fe50f513 100644
--- a/source/funkin/play/components/PopUpStuff.hx
+++ b/source/funkin/play/components/PopUpStuff.hx
@@ -3,9 +3,10 @@ package funkin.play.components;
 import flixel.FlxSprite;
 import flixel.group.FlxGroup.FlxTypedGroup;
 import flixel.tweens.FlxTween;
+import flixel.util.FlxDirection;
 import funkin.graphics.FunkinSprite;
 import funkin.play.PlayState;
-import flixel.util.FlxDirection;
+import funkin.util.tools.TimerTools;
 
 class PopUpStuff extends FlxTypedGroup<FlxSprite>
 {
@@ -16,9 +17,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
 
   public function displayRating(daRating:String)
   {
-    #if sys
-    var perfStart:Float = Sys.time();
-    #end
+    var perfStart:Float = TimerTools.start();
 
     if (daRating == null) daRating = "good";
 
@@ -60,17 +59,12 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
         startDelay: Conductor.instance.beatLengthMs * 0.001
       });
 
-    #if sys
-    var perfEnd:Float = Sys.time();
-    trace("displayRating took: " + (perfEnd - perfStart));
-    #end
+    trace('displayRating took: ${TimerTools.seconds(perfStart)}');
   }
 
   public function displayCombo(?combo:Int = 0):Int
   {
-    #if sys
-    var perfStart:Float = Sys.time();
-    #end
+    var perfStart:Float = TimerTools.start();
 
     if (combo == null) combo = 0;
 
@@ -163,10 +157,7 @@ class PopUpStuff extends FlxTypedGroup<FlxSprite>
       daLoop++;
     }
 
-    #if sys
-    var perfEnd:Float = Sys.time();
-    trace("displayCombo took: " + (perfEnd - perfStart));
-    #end
+    trace('displayCombo took: ${TimerTools.seconds(perfStart)}');
 
     return combo;
   }
diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
index 5e3ffeb42..889f5764f 100644
--- a/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
+++ b/source/funkin/ui/debug/charting/handlers/ChartEditorAudioHandler.hx
@@ -7,6 +7,7 @@ import funkin.audio.FunkinSound;
 import funkin.play.character.BaseCharacter.CharacterType;
 import funkin.util.FileUtil;
 import funkin.util.assets.SoundUtil;
+import funkin.util.tools.TimerTools;
 import funkin.audio.waveform.WaveformData;
 import funkin.audio.waveform.WaveformDataParser;
 import funkin.audio.waveform.WaveformSprite;
@@ -128,41 +129,41 @@ class ChartEditorAudioHandler
 
   public static function switchToInstrumental(state:ChartEditorState, instId:String = '', playerId:String, opponentId:String):Bool
   {
-    var perfA = haxe.Timer.stamp();
+    var perfA:Float = TimerTools.start();
 
     var result:Bool = playInstrumental(state, instId);
     if (!result) return false;
 
-    var perfB = haxe.Timer.stamp();
+    var perfB:Float = TimerTools.start();
 
     stopExistingVocals(state);
 
-    var perfC = haxe.Timer.stamp();
+    var perfC:Float = TimerTools.start();
 
     result = playVocals(state, BF, playerId, instId);
 
-    var perfD = haxe.Timer.stamp();
+    var perfD:Float = TimerTools.start();
 
     // if (!result) return false;
     result = playVocals(state, DAD, opponentId, instId);
     // if (!result) return false;
 
-    var perfE = haxe.Timer.stamp();
+    var perfE:Float = TimerTools.start();
 
     state.hardRefreshOffsetsToolbox();
 
-    var perfF = haxe.Timer.stamp();
+    var perfF:Float = TimerTools.start();
 
     state.hardRefreshFreeplayToolbox();
 
-    var perfG = haxe.Timer.stamp();
+    var perfG:Float = TimerTools.start();
 
-    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.');
+    trace('Switched to instrumental in ${TimerTools.seconds(perfA, perfB)}.');
+    trace('Stopped existing vocals in ${TimerTools.seconds(perfB, perfC)}.');
+    trace('Played BF vocals in ${TimerTools.seconds(perfC, perfD)}.');
+    trace('Played DAD vocals in ${TimerTools.seconds(perfD, perfE)}.');
+    trace('Hard refreshed offsets toolbox in ${TimerTools.seconds(perfE, perfF)}.');
+    trace('Hard refreshed freeplay toolbox in ${TimerTools.seconds(perfF, perfG)}.');
 
     return true;
   }
@@ -174,10 +175,9 @@ class ChartEditorAudioHandler
   {
     if (instId == '') instId = 'default';
     var instTrackData:Null<Bytes> = state.audioInstTrackData.get(instId);
-    var perfA = haxe.Timer.stamp();
+    var perfStart:Float = TimerTools.start();
     var instTrack:Null<FunkinSound> = SoundUtil.buildSoundFromBytes(instTrackData);
-    var perfB = haxe.Timer.stamp();
-    trace('Built instrumental track in ${perfB - perfA} seconds.');
+    trace('Built instrumental track in ${TimerTools.seconds(perfStart)} seconds.');
     if (instTrack == null) return false;
 
     stopExistingInstrumental(state);
@@ -205,10 +205,9 @@ class ChartEditorAudioHandler
   {
     var trackId:String = '${charId}${instId == '' ? '' : '-${instId}'}';
     var vocalTrackData:Null<Bytes> = state.audioVocalTrackData.get(trackId);
-    var perfStart = haxe.Timer.stamp();
+    var perfStart:Float = TimerTools.start();
     var vocalTrack:Null<FunkinSound> = SoundUtil.buildSoundFromBytes(vocalTrackData);
-    var perfEnd = haxe.Timer.stamp();
-    trace('Built vocal track in ${perfEnd - perfStart} seconds.');
+    trace('Built vocal track in ${TimerTools.seconds(perfStart)}.');
 
     if (state.audioVocalTrackGroup == null) state.audioVocalTrackGroup = new VoicesGroup();
 
@@ -219,10 +218,9 @@ class ChartEditorAudioHandler
         case BF:
           state.audioVocalTrackGroup.addPlayerVoice(vocalTrack);
 
-          var perfStart = haxe.Timer.stamp();
+          var perfStart:Float = TimerTools.start();
           var waveformData:Null<WaveformData> = vocalTrack.waveformData;
-          var perfEnd = haxe.Timer.stamp();
-          trace('Interpreted waveform data in ${perfEnd - perfStart} seconds.');
+          trace('Interpreted waveform data in ${TimerTools.seconds(perfStart)}.');
 
           if (waveformData != null)
           {
@@ -246,10 +244,9 @@ class ChartEditorAudioHandler
         case DAD:
           state.audioVocalTrackGroup.addOpponentVoice(vocalTrack);
 
-          var perfStart = haxe.Timer.stamp();
+          var perfStart:Float = TimerTools.start();
           var waveformData:Null<WaveformData> = vocalTrack.waveformData;
-          var perfEnd = haxe.Timer.stamp();
-          trace('Interpreted waveform data in ${perfEnd - perfStart} seconds.');
+          trace('Interpreted waveform data in ${TimerTools.seconds(perfStart)}.');
 
           if (waveformData != null)
           {
diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx
index 1432c9205..28d435c54 100644
--- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx
+++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorFreeplayToolbox.hx
@@ -1,21 +1,22 @@
 package funkin.ui.debug.charting.toolboxes;
 
+import flixel.addons.display.FlxTiledSprite;
+import flixel.math.FlxMath;
 import funkin.audio.SoundGroup;
+import funkin.audio.waveform.WaveformDataParser;
+import funkin.ui.debug.charting.commands.SetFreeplayPreviewCommand;
+import funkin.ui.haxeui.components.WaveformPlayer;
+import funkin.ui.freeplay.FreeplayState;
+import funkin.util.tools.TimerTools;
+import haxe.ui.backend.flixel.components.SpriteWrapper;
 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;
@@ -288,12 +289,12 @@ class ChartEditorFreeplayToolbox extends ChartEditorBaseToolbox
 
     // Build player waveform.
     // waveformMusic.waveform.forceUpdate = true;
-    var perfStart = haxe.Timer.stamp();
+    var perfStart:Float = TimerTools.start();
     var waveformData1 = playerVoice?.waveformData;
     var waveformData2 = opponentVoice?.waveformData ?? playerVoice?.waveformData; // this null check is for songs that only have 1 vocals file!
     var waveformData3 = chartEditorState.audioInstTrack.waveformData;
     var waveformData = waveformData3.merge(waveformData1).merge(waveformData2);
-    trace('Waveform data merging took: ${haxe.Timer.stamp() - perfStart} seconds');
+    trace('Waveform data merging took: ${TimerTools.seconds(perfStart)}');
 
     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.
diff --git a/source/funkin/util/plugins/MemoryGCPlugin.hx b/source/funkin/util/plugins/MemoryGCPlugin.hx
index 3df861eb5..67a4fe18e 100644
--- a/source/funkin/util/plugins/MemoryGCPlugin.hx
+++ b/source/funkin/util/plugins/MemoryGCPlugin.hx
@@ -1,6 +1,7 @@
 package funkin.util.plugins;
 
 import flixel.FlxBasic;
+import funkin.util.tools.TimerTools;
 
 /**
  * A plugin which adds functionality to press `Ins` to immediately perform memory garbage collection.
@@ -23,10 +24,9 @@ class MemoryGCPlugin extends FlxBasic
 
     if (FlxG.keys.justPressed.INSERT)
     {
-      var perfStart:Float = Sys.time();
+      var perfStart:Float = TimerTools.start();
       funkin.util.MemoryUtil.collect(true);
-      var perfEnd:Float = Sys.time();
-      trace("Memory GC took " + (perfEnd - perfStart) + " seconds");
+      trace('Memory GC took: ${TimerTools.seconds(perfStart)}');
     }
   }
 
diff --git a/source/funkin/util/tools/FloatTools.hx b/source/funkin/util/tools/FloatTools.hx
index e07ae5cb9..e34110490 100644
--- a/source/funkin/util/tools/FloatTools.hx
+++ b/source/funkin/util/tools/FloatTools.hx
@@ -12,4 +12,13 @@ class FloatTools
   {
     return Math.max(min, Math.min(max, value));
   }
+
+  /**
+    Round a float to a certain number of decimal places.
+  **/
+  public static function round(number:Float, ?precision = 2):Float
+  {
+    number *= Math.pow(10, precision);
+    return Math.round(number) / Math.pow(10, precision);
+  }
 }
diff --git a/source/funkin/util/tools/TimerTools.hx b/source/funkin/util/tools/TimerTools.hx
new file mode 100644
index 000000000..5322ada92
--- /dev/null
+++ b/source/funkin/util/tools/TimerTools.hx
@@ -0,0 +1,30 @@
+package funkin.util.tools;
+
+import funkin.util.tools.FloatTools;
+import haxe.Timer;
+
+class TimerTools
+{
+  public static function start():Float
+  {
+    return Timer.stamp();
+  }
+
+  private static function took(start:Float, ?end:Float):Float
+  {
+    var endOrNow:Float = end != null ? end : Timer.stamp();
+    return endOrNow - start;
+  }
+
+  public static function seconds(start:Float, ?end:Float, ?precision = 2):String
+  {
+    var seconds:Float = FloatTools.round(took(start, end), precision);
+    return '${seconds} seconds';
+  }
+
+  public static function ms(start:Float, ?end:Float):String
+  {
+    var seconds:Float = took(start, end);
+    return '${seconds * 1000} ms';
+  }
+}