diff --git a/Project.xml b/Project.xml
index d7ee924f5..73b384ee0 100644
--- a/Project.xml
+++ b/Project.xml
@@ -135,6 +135,14 @@
 
 	<haxelib name="hxcpp-debug-server" if="desktop debug" />
 
+	<!--
+		With these options enabled, console popup and beep no longer occur.
+		You can still see the log messages by opening the console (F2).
+		Be sure to remove these during cleanup and bugfix testing!
+	-->
+	<haxedef name="FLX_NO_ERROR_SOUND" />
+	<haxedef name="FLX_NO_ERROR_CONSOLE" />
+
 	<!--Disable the Flixel core focus lost screen-->
 	<haxedef name="FLX_NO_FOCUS_LOST_SCREEN" />
 
diff --git a/hmm.json b/hmm.json
index c8aa048ce..f14ebdfe8 100644
--- a/hmm.json
+++ b/hmm.json
@@ -21,13 +21,6 @@
       "ref": "157eaf3",
       "url": "https://github.com/MasterEric/flixel-addons"
     },
-    {
-      "name": "flixel-addons",
-      "type": "git",
-      "dir": null,
-      "ref": "dev",
-      "url": "https://github.com/MasterEric/flixel-addons"
-    },
     {
       "name": "flixel-ui",
       "type": "haxelib",
@@ -100,8 +93,8 @@
       "name": "openfl",
       "type": "git",
       "dir": null,
-      "ref": "4f999ac",
-      "url": "https://github.com/openfl/openfl"
+      "ref": "3fd5763",
+      "url": "https://github.com/MasterEric/openfl/"
     },
     {
       "name": "polymod",
diff --git a/source/funkin/audio/FlxAudioGroup.hx b/source/funkin/audio/FlxAudioGroup.hx
new file mode 100644
index 000000000..182af3dea
--- /dev/null
+++ b/source/funkin/audio/FlxAudioGroup.hx
@@ -0,0 +1,206 @@
+package funkin.audio;
+
+import flixel.group.FlxGroup.FlxTypedGroup;
+import flixel.system.FlxSound;
+
+/**
+ * A group of FlxSounds which can be controlled as a whole.
+ * 
+ * Add sounds to the group using `add()`, and then control them
+ * as a whole using the properties and methods of this class.
+ * 
+ * It is assumed that all the sounds will play at the same time,
+ * and have the same duration.
+ */
+class FlxAudioGroup extends FlxTypedGroup<FlxSound>
+{
+	/**
+	 * The position in time of the sounds in the group.
+	 * Measured in milliseconds.
+	 */
+	public var time(get, set):Float;
+
+	function get_time():Float
+	{
+		if (getFirstAlive() != null)
+			return getFirstAlive().time;
+		else
+			return 0;
+	}
+
+	function set_time(time:Float):Float
+	{
+		forEachAlive(function(sound:FlxSound)
+		{
+			// account for different offsets per sound?
+			sound.time = time;
+		});
+
+		return time;
+	}
+
+	/**
+	 * The volume of the sounds in the group.
+	 */
+	public var volume(get, set):Float;
+
+	function get_volume():Float
+	{
+		if (getFirstAlive() != null)
+			return getFirstAlive().volume;
+		else
+			return 1.0;
+	}
+
+	function set_volume(volume:Float):Float
+	{
+		forEachAlive(function(sound:FlxSound)
+		{
+			sound.volume = volume;
+		});
+
+		return volume;
+	}
+
+	/**
+	 * The pitch of the sounds in the group, as a multiplier of 1.0x.
+	 * `2.0` would play the audio twice as fast with a higher pitch,
+	 * and `0.5` would play the audio at half speed with a lower pitch.
+	 */
+	public var pitch(get, set):Float;
+
+	function get_pitch():Float
+	{
+		#if FLX_PITCH
+		if (getFirstAlive() != null)
+			return getFirstAlive().pitch;
+		else
+		#end
+		return 1;
+	}
+
+	function set_pitch(val:Float):Float
+	{
+		#if FLX_PITCH
+		trace('Setting audio pitch to ' + val);
+		forEachAlive(function(sound:FlxSound)
+		{
+			sound.pitch = val;
+		});
+		#end
+		return val;
+	}
+
+	/**
+	 * Whether members of the group should be destroyed when they finish playing.
+	 */
+	public var autoDestroyMembers(default, set):Bool = false;
+
+	function set_autoDestroyMembers(value:Bool):Bool
+	{
+		autoDestroyMembers = value;
+		forEachAlive(function(sound:FlxSound)
+		{
+			sound.autoDestroy = value;
+		});
+		return value;
+	}
+
+	/**
+	 * Add a sound to the group.
+	 */
+	public override function add(sound:FlxSound):FlxSound
+	{
+		var result:FlxSound = super.add(sound);
+
+		if (result == null)
+			return;
+
+		// Apply parameters to the new sound.
+		result.autoDestroy = this.autoDestroyMembers;
+		result.pitch = this.pitch;
+		result.volume = this.volume;
+
+		// We have to play, then pause the sound to set the time,
+		// else the sound will restart immediately when played.
+		result.play(true, 0.0);
+		result.pause();
+		result.time = this.time;
+	}
+
+	/**
+	 * Pause all the sounds in the group.
+	 */
+	public function pause()
+	{
+		forEachAlive(function(sound:FlxSound)
+		{
+			sound.pause();
+		});
+	}
+
+	/**
+	 * Play all the sounds in the group.
+	 */
+	public function play(forceRestart:Bool = false, startTime:Float = 0.0, ?endTime:Float)
+	{
+		forEachAlive(function(sound:FlxSound)
+		{
+			sound.play(forceRestart, startTime, endTime);
+		});
+	}
+
+	/**
+	 * Resume all the sounds in the group.
+	 */
+	public function resume()
+	{
+		forEachAlive(function(sound:FlxSound)
+		{
+			sound.resume();
+		});
+	}
+
+	/**
+	 * Stop all the sounds in the group.
+	 */
+	public function stop()
+	{
+		forEachAlive(function(sound:FlxSound)
+		{
+			sound.stop();
+		});
+	}
+
+    public override function clear():Void {
+        this.stop();
+
+        super.clear();
+    }
+
+	/**
+	 * Calculates the deviation of the sounds in the group from the target time.
+	 * 
+	 * @param targetTime The time to compare the sounds to.
+	 *             If null, the current time of the first sound in the group is used.
+	 * @return The largest deviation of the sounds in the group from the target time.
+	 */
+	public function calcDeviation(?targetTime:Float):Float
+	{
+		var deviation:Float = 0;
+
+		forEachAlive(function(sound:FlxSound)
+		{
+			if (targetTime == null)
+				targetTime = sound.time;
+			else
+			{
+				var diff:Float = sound.time - targetTime;
+				if (Math.abs(diff) > Math.abs(deviation))
+					deviation = diff;
+			}
+		});
+
+		return deviation;
+	}
+}
diff --git a/source/funkin/audio/VocalGroup.hx b/source/funkin/audio/VocalGroup.hx
new file mode 100644
index 000000000..2dce0d4c0
--- /dev/null
+++ b/source/funkin/audio/VocalGroup.hx
@@ -0,0 +1,119 @@
+package funkin.audio;
+
+import flixel.system.FlxSound;
+
+/**
+ * An audio group that allows for specific control of vocal tracks.
+ */
+class VocalGroup extends FlxAudioGroup
+{
+	/**
+	 * The player's vocal track.
+	 */
+	var playerVocals:FlxSound;
+
+	/**
+	 * The opponent's vocal track.
+	 */
+	var opponentVocals:FlxSound;
+
+	/**
+	 * The volume of the player's vocal track.
+     * Nore that this value is multiplied by the overall volume of the group.
+	 */
+	public var playerVolume(default, set):Float;
+
+	function set_playerVolume(value:Float):Float
+	{
+		playerVolume = value;
+		if (playerVocals != null)
+		{
+			// Make sure volume is capped at 1.0.
+			playerVocals.volume = Math.min(playerVolume * this.volume, 1.0);
+		}
+		return playerVolume;
+	}
+
+	/**
+	 * The volume of the opponent's vocal track.
+     * Nore that this value is multiplied by the overall volume of the group.
+	 */
+	public var opponentVolume(default, set):Float;
+
+	function set_opponentVolume(value:Float):Float
+	{
+		opponentVolume = value;
+		if (opponentVocals != null)
+		{
+			// Make sure volume is capped at 1.0.
+			opponentVocals.volume = opponentVolume * this.volume;
+		}
+		return opponentVolume;
+	}
+
+	/**
+	 * Sets up the player's vocal track.
+	 * Stops and removes the existing player track if one exists.
+	 */
+	public function setPlayerVocals(sound:FlxSound):FlxSound
+	{
+		if (playerVocals != null)
+		{
+			playerVocals.stop();
+			remove(playerVocals);
+			playerVocals = null;
+		}
+
+		playerVocals = add(sound);
+		playerVocals.volume = this.playerVolume * this.volume;
+
+		return playerVocals;
+	}
+
+	/**
+	 * Sets up the opponent's vocal track.
+	 * Stops and removes the existing player track if one exists.
+	 */
+	public function setOpponentVocals(sound:FlxSound):FlxSound
+	{
+		if (opponentVocals != null)
+		{
+			opponentVocals.stop();
+			remove(opponentVocals);
+			opponentVocals = null;
+		}
+
+		opponentVocals = add(sound);
+		opponentVocals.volume = this.opponentVolume * this.volume;
+
+		return opponentVocals;
+	}
+
+    /**
+     * In this extension of FlxAudioGroup, there is a separate overall volume
+     * which affects all the members of the group.
+     */
+    var _volume = 1.0;
+
+    override function get_volume():Float
+    {
+        return _volume;
+    }
+
+	override function set_volume(value:Float):Float
+	{
+        _volume = super.set_volume(value);
+
+		if (playerVocals != null)
+		{
+			playerVocals.volume = playerVolume * _volume;
+		}
+
+		if (opponentVocals != null)
+		{
+			opponentVocals.volume = opponentVolume * _volume;
+		}
+
+		return _volume;
+	}
+}
diff --git a/source/funkin/play/HealthIcon.hx b/source/funkin/play/HealthIcon.hx
index 4c4d8caaa..9f2f61b38 100644
--- a/source/funkin/play/HealthIcon.hx
+++ b/source/funkin/play/HealthIcon.hx
@@ -196,17 +196,11 @@ class HealthIcon extends FlxSprite
 			// Make the health icons bump (the update function causes them to lerp back down).
 			if (this.width > this.height)
 			{
-				var targetSize = Std.int(CoolUtil.coolLerp(this.width + HEALTH_ICON_SIZE * 0.2, HEALTH_ICON_SIZE, 0.15));
-				targetSize = Std.int(Math.min(targetSize, HEALTH_ICON_SIZE * 1.2));
-
-				setGraphicSize(targetSize, 0);
+				setGraphicSize(this.width + (HEALTH_ICON_SIZE * this.size.x * 0.2), 0);
 			}
 			else
 			{
-				var targetSize = Std.int(CoolUtil.coolLerp(this.height + HEALTH_ICON_SIZE * 0.2, HEALTH_ICON_SIZE, 0.15));
-				targetSize = Std.int(Math.min(targetSize, HEALTH_ICON_SIZE * 1.2));
-
-				setGraphicSize(0, targetSize);
+				setGraphicSize(0, this.height + (HEALTH_ICON_SIZE * this.size.y * 0.2));
 			}
 			this.updateHitbox();
 		}
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index d349d9cdb..e26ed09d7 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -278,6 +278,26 @@ class ChartEditorState extends HaxeUIState
 		return isViewDownscroll;
 	}
 
+	/**
+	 * Whether hitsounds are enabled for at least one character.
+	 */
+	var hitsoundsEnabled(get, null):Bool;
+
+	function get_hitsoundsEnabled():Bool
+	{
+		return hitsoundsEnabledPlayer || hitsoundsEnabledOpponent;
+	}
+
+	/**
+	 * Whether hitsounds are enabled for the player.
+	 */
+	var hitsoundsEnabledPlayer:Bool = true;
+
+	/**
+	 * Whether hitsounds are enabled for the opponent.
+	 */
+	var hitsoundsEnabledOpponent:Bool = true;
+
 	/**
 	 * Whether the user's mouse cursor is hovering over a SOLID component of the HaxeUI.
 	 * If so, ignore mouse events underneath.
@@ -1063,13 +1083,25 @@ class ChartEditorState extends HaxeUIState
 		});
 		setUISelected('menubarItemMetronomeEnabled', shouldPlayMetronome);
 
+		addUIChangeListener('menubarItemPlayerHitsounds', (event:UIEvent) ->
+		{
+			hitsoundsEnabledPlayer = event.value;
+		});
+		setUISelected('menubarItemPlayerHitsounds', hitsoundsEnabledPlayer);
+		
+		addUIChangeListener('menubarItemOpponentHitsounds', (event:UIEvent) ->
+		{
+			hitsoundsEnabledOpponent = event.value;
+		});
+		setUISelected('menubarItemOpponentHitsounds', hitsoundsEnabledOpponent);
+		
 		var instVolumeLabel:Label = findComponent('menubarLabelVolumeInstrumental', Label);
 		addUIChangeListener('menubarItemVolumeInstrumental', (event:UIEvent) ->
 		{
 			var volume:Float = event.value / 100.0;
 			if (audioInstTrack != null)
 				audioInstTrack.volume = volume;
-			instVolumeLabel.text = 'Instrumental - ${event.value}%';
+			instVolumeLabel.text = 'Instrumental - ${Std.int(event.value)}%';
 		});
 
 		var vocalsVolumeLabel:Label = findComponent('menubarLabelVolumeVocals', Label);
@@ -1078,7 +1110,7 @@ class ChartEditorState extends HaxeUIState
 			var volume:Float = event.value / 100.0;
 			if (audioVocalTrackGroup != null)
 				audioVocalTrackGroup.volume = volume;
-			vocalsVolumeLabel.text = 'Vocals - ${event.value}%';
+			vocalsVolumeLabel.text = 'Vocals - ${Std.int(event.value)}%';
 		});
 
 		var playbackSpeedLabel:Label = findComponent('menubarLabelPlaybackSpeed', Label);
@@ -1091,7 +1123,7 @@ class ChartEditorState extends HaxeUIState
 			if (audioVocalTrackGroup != null)
 				audioVocalTrackGroup.pitch = pitch;
 			#end
-			playbackSpeedLabel.text = 'Playback Speed - ${pitch}x';
+			playbackSpeedLabel.text = 'Playback Speed - ${Std.int(event.value * 100) / 100}x';
 		});
 
 		addUIChangeListener('menubarItemToggleToolboxTools', (event:UIEvent) ->
@@ -2265,7 +2297,9 @@ class ChartEditorState extends HaxeUIState
 				// If middle mouse panning during song playback, we move ONLY the playhead, without scrolling. Neat!
 
 				var oldStepTime = Conductor.currentStepTime;
+				var oldSongPosition = Conductor.songPosition;
 				Conductor.update(audioInstTrack.time);
+				handleHitsounds(oldSongPosition, Conductor.songPosition);
 				// Resync vocals.
 				if (Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
 					audioVocalTrackGroup.time = audioInstTrack.time;
@@ -2279,8 +2313,9 @@ class ChartEditorState extends HaxeUIState
 			else
 			{
 				// Else, move the entire view.
-
+				var oldSongPosition = Conductor.songPosition;
 				Conductor.update(audioInstTrack.time);
+				handleHitsounds(oldSongPosition, Conductor.songPosition);
 				// Resync vocals.
 				if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100)
 					audioVocalTrackGroup.time = audioInstTrack.time;
@@ -2302,6 +2337,36 @@ class ChartEditorState extends HaxeUIState
 		}
 	}
 
+	/**
+	 * Handle the playback of hitsounds.
+	 */
+	function handleHitsounds(oldSongPosition:Float, newSongPosition:Float):Void {
+		if (!hitsoundsEnabled)
+			return;
+
+		// Assume notes are sorted by time.
+		for (noteData in currentSongChartNoteData) {
+			if (noteData.time < oldSongPosition)
+				// Note is in the past.
+				continue;
+
+			if (noteData.time >= newSongPosition)
+				// Note is in the future.
+				return;
+
+			// Note was just hit.
+			switch (noteData.getStrumlineIndex()) {
+				case 0: // Player
+					if (hitsoundsEnabledPlayer)
+						playSound(Paths.sound('funnyNoise/funnyNoise-09'));
+				case 1: // Opponent
+					if (hitsoundsEnabledOpponent)
+						playSound(Paths.sound('funnyNoise/funnyNoise-010'));
+			}
+		}
+	}
+
+
 	function startAudioPlayback()
 	{
 		if (audioInstTrack != null)