From 0edc51ef65ca9364353151aad67a9f93be8a7cc5 Mon Sep 17 00:00:00 2001 From: Menci Date: Mon, 20 Oct 2025 03:37:11 +0800 Subject: [PATCH] [+] SoundRouting & HeadphoneVolumeMultiply (#75) * [+] SoundRouting * [+] HeadphoneVolumeMultiply --- AquaMai.Config/Types/SoundChannel.cs | 16 ++ .../GameSystem/HeadphoneVolumeMultiply.cs | 35 ++++ AquaMai.Mods/GameSystem/SoundRouting.cs | 158 ++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 AquaMai.Config/Types/SoundChannel.cs create mode 100644 AquaMai.Mods/GameSystem/HeadphoneVolumeMultiply.cs create mode 100644 AquaMai.Mods/GameSystem/SoundRouting.cs diff --git a/AquaMai.Config/Types/SoundChannel.cs b/AquaMai.Config/Types/SoundChannel.cs new file mode 100644 index 00000000..9644ffbd --- /dev/null +++ b/AquaMai.Config/Types/SoundChannel.cs @@ -0,0 +1,16 @@ +namespace AquaMai.Config.Types; + +// The enum values are the column indices in a 8x8 float BE matrix in Mai2.acf file +// It's NOT consistent with WASAPI's channel indices (bits in dwChannelMask). +public enum SoundChannel +{ + P1SpeakerLeft = 0, // Routes to WASAPI's SPEAKER_FRONT_LEFT (0x1 = 1 << 0) + P1SpeakerRight = 1, // Routes to WASAPI's SPEAKER_FRONT_RIGHT (0x2 = 1 << 1) + P1HeadphoneLeft = 2, // Routes to WASAPI's SPEAKER_FRONT_CENTER (0x4 = 1 << 2) + P1HeadphoneRight = 3, // Routes to WASAPI's SPEAKER_LOW_FREQUENCY (0x8 = 1 << 3) + P2SpeakerLeft = 6, // Routes to WASAPI's SPEAKER_BACK_LEFT (0x10) = 1 << 4) + P2SpeakerRight = 7, // Routes to WASAPI's SPEAKER_BACK_RIGHT (0x20) = 1 << 5) + P2HeadphoneLeft = 4, // Routes to WASAPI's SPEAKER_SIDE_LEFT (0x200 = 1 << 9) + P2HeadphoneRight = 5, // Routes to WASAPI's SPEAKER_SIDE_RIGHT (0x400 = 1 << 10) + None = 8, +} diff --git a/AquaMai.Mods/GameSystem/HeadphoneVolumeMultiply.cs b/AquaMai.Mods/GameSystem/HeadphoneVolumeMultiply.cs new file mode 100644 index 00000000..ee392e6c --- /dev/null +++ b/AquaMai.Mods/GameSystem/HeadphoneVolumeMultiply.cs @@ -0,0 +1,35 @@ +using HarmonyLib; + +using AquaMai.Config.Attributes; +using UnityEngine; + +namespace AquaMai.Mods.GameSystem; + +[ConfigSection( + name: "耳机音量倍率", + en: "Multiply 1P/2P headphone volume by a factor. Can decrease or increase, but not higher than original 20.", + zh: "将 1P/2P 耳机音量乘以一个倍率,可以调低或调高,但不能高于原本的 20。")] +public static class HeadphoneVolumeMultiply +{ + [ConfigEntry] + private static readonly float p1 = 1.0f; + + [ConfigEntry] + private static readonly float p2 = 1.0f; + + [HarmonyPrefix] + [HarmonyPatch(typeof(CriAtomExPlayer), "SetAisacControl", [typeof(uint), typeof(float)])] + public static void PreSetAisacControl(uint controlId, ref float value) + { + if (controlId == 2) + { + value *= p1; + value = Mathf.Min(value, 1.0f); + } + else if (controlId == 3) + { + value *= p2; + value = Mathf.Min(value, 1.0f); + } + } +} diff --git a/AquaMai.Mods/GameSystem/SoundRouting.cs b/AquaMai.Mods/GameSystem/SoundRouting.cs new file mode 100644 index 00000000..fce957e2 --- /dev/null +++ b/AquaMai.Mods/GameSystem/SoundRouting.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using AquaMai.Config.Attributes; +using AquaMai.Config.Types; +using HarmonyLib; +using MelonLoader; + +namespace AquaMai.Mods.GameSystem; + +[ConfigSection( + name: "8Ch 音频路由重映射", + en: """ + Redirect each channel in 8Ch to specific channel. + Mixing is allowed. Use "None" to mute a channel. Volumes are applied on the source channels (> 1.0 won't work, to increase volume, use HeadphoneVolumeMultiply instead). + (NOTE: For 2Ch setups, please route the channels you want to P1SpeakerLeft/Right and other channels to None.) + """, + zh: """ + 将 8Ch 的各个声道重新映射到指定的声道 + 可进行混合,可使用 "None" 以禁用一个声道,音量在源声道应用(> 1.0 不会生效,若要提高音量,请使用 HeadphoneVolumeMultiply) + (注:对于 2Ch 用户,请将需要的声道路由到 P1SpeakerLeft/Right,其他声道路由到 None) + """)] +public static class SoundRouting +{ + [ConfigEntry] + private static readonly SoundChannel routeP1SpeakerLeftTo = SoundChannel.P1SpeakerLeft; + + [ConfigEntry] + private static readonly SoundChannel routeP1SpeakerRightTo = SoundChannel.P1SpeakerRight; + + [ConfigEntry] + private static readonly SoundChannel routeP1HeadphoneLeftTo = SoundChannel.P1HeadphoneLeft; + + [ConfigEntry] + private static readonly SoundChannel routeP1HeadphoneRightTo = SoundChannel.P1HeadphoneRight; + + [ConfigEntry] + private static readonly SoundChannel routeP2SpeakerLeftTo = SoundChannel.P2SpeakerLeft; + + [ConfigEntry] + private static readonly SoundChannel routeP2SpeakerRightTo = SoundChannel.P2SpeakerRight; + + [ConfigEntry] + private static readonly SoundChannel routeP2HeadphoneLeftTo = SoundChannel.P2HeadphoneLeft; + + [ConfigEntry] + private static readonly SoundChannel routeP2HeadphoneRightTo = SoundChannel.P2HeadphoneRight; + + [ConfigEntry] + private static readonly float volumeP1Speaker = 1.0f; + + [ConfigEntry] + private static readonly float volumeP1Headphone = 1.0f; + + [ConfigEntry] + private static readonly float volumeP2Speaker = 1.0f; + + [ConfigEntry] + private static readonly float volumeP2Headphone = 1.0f; + + private const int PARAMETER_MATRIX_SIZE = 8 * 8 * 4; // 8 input * 8 output * 4 sizeof(float BE) + + private static void WriteFloatBigEndian(byte[] buffer, int index, float value) + { + var bytes = BitConverter.GetBytes(value); + if (BitConverter.IsLittleEndian) Array.Reverse(bytes); + Array.Copy(bytes, 0, buffer, index, 4); + } + + private static byte[] GenerateParameterMatrix(SoundChannel routeLeftTo, SoundChannel routeRightTo, float volume) + { + var matrix8x8 = new byte[PARAMETER_MATRIX_SIZE]; + int index = 0; + for (int i = 0; i < 2; i++) // 2ch (stereo) input + { + for (int j = 0; j < 8; j++) // 8ch output + { + if (i == 0 && j == (int)routeLeftTo || i == 1 && j == (int)routeRightTo) + { + WriteFloatBigEndian(matrix8x8, index, volume); + } + else + { + WriteFloatBigEndian(matrix8x8, index, 0.0f); + } + index += 4; + } + } + return matrix8x8; + } + + private static byte[] GetOriginalParameters() + { + var memoryStream = new MemoryStream(); + memoryStream.Write(GenerateParameterMatrix(SoundChannel.P1SpeakerLeft, SoundChannel.P1SpeakerRight, 1.0f), 0, PARAMETER_MATRIX_SIZE); + memoryStream.Write(GenerateParameterMatrix(SoundChannel.P2SpeakerLeft, SoundChannel.P2SpeakerRight, 1.0f), 0, PARAMETER_MATRIX_SIZE); + memoryStream.Write(GenerateParameterMatrix(SoundChannel.P1HeadphoneLeft, SoundChannel.P1HeadphoneRight, 1.0f), 0, PARAMETER_MATRIX_SIZE); + memoryStream.Write(GenerateParameterMatrix(SoundChannel.P2HeadphoneLeft, SoundChannel.P2HeadphoneRight, 1.0f), 0, PARAMETER_MATRIX_SIZE); + return memoryStream.ToArray(); + } + + private static byte[] GenerateParameters() + { + var memoryStream = new MemoryStream(); + memoryStream.Write(GenerateParameterMatrix(routeP1SpeakerLeftTo, routeP1SpeakerRightTo, volumeP1Speaker), 0, PARAMETER_MATRIX_SIZE); + memoryStream.Write(GenerateParameterMatrix(routeP2SpeakerLeftTo, routeP2SpeakerRightTo, volumeP2Speaker), 0, PARAMETER_MATRIX_SIZE); + memoryStream.Write(GenerateParameterMatrix(routeP1HeadphoneLeftTo, routeP1HeadphoneRightTo, volumeP1Headphone), 0, PARAMETER_MATRIX_SIZE); + memoryStream.Write(GenerateParameterMatrix(routeP2HeadphoneLeftTo, routeP2HeadphoneRightTo, volumeP2Headphone), 0, PARAMETER_MATRIX_SIZE); + return memoryStream.ToArray(); + } + + // Naive O(n*m) search but enough + private static int FindBytesInBytes(byte[] haystack, byte[] needle) + { + for (int i = 0; i < haystack.Length - needle.Length + 1; i++) if (haystack.Skip(i).Take(needle.Length).SequenceEqual(needle)) return i; + return -1; + } + + private static byte[] RewriteAcf(byte[] acfData) + { + var originalParameters = GetOriginalParameters(); + int i = FindBytesInBytes(acfData, originalParameters); + if (i == -1) + { + MelonLogger.Error("[SoundRouting] Failed to find original parameters in ACF data"); + return acfData; + } + var newParameters = GenerateParameters(); + var newAcfData = new byte[acfData.Length]; + Array.Copy(acfData, 0, newAcfData, 0, i); + Array.Copy(newParameters, 0, newAcfData, i, newParameters.Length); + Array.Copy(acfData, i + originalParameters.Length, newAcfData, i + newParameters.Length, acfData.Length - i - originalParameters.Length); + return newAcfData; + } + + private static readonly Dictionary _patchedAcfDataCache = []; + + [HarmonyPrefix] + [HarmonyPatch(typeof(CriAtomEx), "RegisterAcf", [typeof(CriFsBinder), typeof(string)])] + public static bool PreRegisterAcf(string acfPath) + { + try + { + if (!_patchedAcfDataCache.ContainsKey(acfPath)) + { + var acfData = File.ReadAllBytes(acfPath); + _patchedAcfDataCache[acfPath] = RewriteAcf(acfData); + } + CriAtomEx.RegisterAcf(_patchedAcfDataCache[acfPath]); + } + catch (Exception e) + { + MelonLogger.Error("[SoundRouting] Failed to rewrite ACF data", e); + } + return false; + } +}