From 08b955c502c8ab137ca4270bf6c2f8c13a093ae5 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Wed, 8 Apr 2026 03:03:48 -0500 Subject: [PATCH 1/3] Have to fix libopus, because it was being built as arm64. --- .github/workflows/build-docker-server.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/build-docker-server.yml b/.github/workflows/build-docker-server.yml index 106a6e9ab..daf3c42db 100644 --- a/.github/workflows/build-docker-server.yml +++ b/.github/workflows/build-docker-server.yml @@ -277,6 +277,15 @@ jobs: with: name: ${{ matrix.artifactName }} path: headless + - name: Replace libopus for amd64 headless artifact + if: matrix.archSuffix == 'amd64' + shell: bash + run: | + set -eux + plugin_dir="headless/${{ matrix.artifactDir }}/HeadlessLinuxServer_Data/Plugins" + test -f "$plugin_dir/opus.so" + rm -f "$plugin_dir/libopus.so" + cp "$plugin_dir/opus.so" "$plugin_dir/libopus.so" - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx From a01c601f62de48e6e425647526d147941c027132 Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Wed, 8 Apr 2026 03:53:10 -0500 Subject: [PATCH 2/3] Add opus support for file reads. --- .../Drivers/Local/BasisAudioClipPlayer.cs | 280 +++++++++++++++++- 1 file changed, 273 insertions(+), 7 deletions(-) diff --git a/Basis/Packages/com.basis.framework/Drivers/Local/BasisAudioClipPlayer.cs b/Basis/Packages/com.basis.framework/Drivers/Local/BasisAudioClipPlayer.cs index 57e508fee..206056611 100644 --- a/Basis/Packages/com.basis.framework/Drivers/Local/BasisAudioClipPlayer.cs +++ b/Basis/Packages/com.basis.framework/Drivers/Local/BasisAudioClipPlayer.cs @@ -1,5 +1,6 @@ #if UNITY_SERVER using System; +using System.Collections.Generic; using System.IO; using System.Threading; using Basis.Network.Core; @@ -9,11 +10,11 @@ using static SerializableBasis; /// -/// Headless audio clip player for stress testing. Loads .wav files from a directory, +/// Headless audio clip player for stress testing. Loads .wav or .opus files from a directory, /// picks one randomly, Opus-encodes it, and sends it over the network as voice audio. /// Self-contained: has its own Opus encoder and sends directly via the network peer. /// -/// Place .wav files in: {Application.dataPath}/AudioClips/ +/// Place .wav or .opus files in: {Application.dataPath}/AudioClips/ /// If the directory is missing or empty, no audio is sent (silent headless as usual). /// /// Designed for testing what 1000+ simultaneous audio sources sound and look like. @@ -39,13 +40,13 @@ public static class BasisAudioClipPlayer private static readonly int FrameSize = (int)(FrameDurationSeconds * SampleRate); // 960 /// - /// Directory to scan for .wav files. Defaults to {Application.dataPath}/AudioClips/ + /// Directory to scan for .wav or .opus files. Defaults to {Application.dataPath}/AudioClips/ /// public static string ClipDirectory; /// /// Attempts to initialize the clip player. If the AudioClips directory exists and - /// contains .wav files, a random clip is loaded and streamed as voice audio. + /// contains supported audio files, a random clip is loaded and streamed as voice audio. /// If the directory is missing or empty, this is a no-op (silent headless as usual). /// public static bool TryInitialize() @@ -72,17 +73,17 @@ public static bool TryInitialize() return false; } - string[] files = Directory.GetFiles(dir, "*.wav"); + string[] files = FindSupportedAudioFiles(dir); if (files.Length == 0) { - BasisDebug.LogError($"[AudioClipPlayer] failed to find and .wav", BasisDebug.LogTag.Device); + BasisDebug.LogError("[AudioClipPlayer] Failed to find a supported audio file (.wav or .opus).", BasisDebug.LogTag.Device); return false; } string chosen = files[UnityEngine.Random.Range(0, files.Length)]; BasisDebug.Log($"[AudioClipPlayer] Loading: {Path.GetFileName(chosen)}", BasisDebug.LogTag.Device); - clipSamples = LoadWavAsMono48k(chosen); + clipSamples = LoadAudioAsMono48k(chosen); if (clipSamples == null || clipSamples.Length == 0) { BasisDebug.LogError($"[AudioClipPlayer] Failed to load: {chosen}", BasisDebug.LogTag.Device); @@ -225,6 +226,42 @@ private static void PlaybackLoop() } } + /// + /// Loads a supported audio file and returns 48kHz mono float samples. + /// + private static float[] LoadAudioAsMono48k(string path) + { + string extension = Path.GetExtension(path); + if (string.Equals(extension, ".wav", StringComparison.OrdinalIgnoreCase)) + { + return LoadWavAsMono48k(path); + } + + if (string.Equals(extension, ".opus", StringComparison.OrdinalIgnoreCase)) + { + return LoadOpusAsMono48k(path); + } + + BasisDebug.LogWarning($"[AudioClipPlayer] Unsupported audio file extension: {extension}", BasisDebug.LogTag.Device); + return null; + } + + private static string[] FindSupportedAudioFiles(string directory) + { + List files = new List(); + foreach (string file in Directory.EnumerateFiles(directory)) + { + string extension = Path.GetExtension(file); + if (string.Equals(extension, ".wav", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".opus", StringComparison.OrdinalIgnoreCase)) + { + files.Add(file); + } + } + + return files.ToArray(); + } + /// /// Loads a PCM WAV file and returns 48kHz mono float samples. /// Supports 8-bit, 16-bit, 24-bit, and 32-bit PCM WAV formats. @@ -321,6 +358,235 @@ private static float[] LoadWavAsMono48k(string path) } } + /// + /// Loads an Ogg Opus file and returns 48kHz mono float samples. + /// Supports mono and stereo Opus streams using channel mapping family 0. + /// + private static float[] LoadOpusAsMono48k(string path) + { + try + { + byte[] fileBytes = File.ReadAllBytes(path); + if (fileBytes.Length < 64) + { + return null; + } + + List packets = ReadOggPackets(fileBytes); + if (packets == null || packets.Count == 0) + { + return null; + } + + if (!TryParseOpusHead(packets[0], out int channels, out int preSkipSamples)) + { + BasisDebug.LogWarning("[AudioClipPlayer] Invalid OpusHead packet.", BasisDebug.LogTag.Device); + return null; + } + + if (channels < 1 || channels > 2) + { + BasisDebug.LogWarning($"[AudioClipPlayer] Only mono and stereo Opus streams are supported. Channels={channels}", BasisDebug.LogTag.Device); + return null; + } + + int audioPacketStart = 1; + if (packets.Count > 1 && IsPacketNamed(packets[1], "OpusTags")) + { + audioPacketStart = 2; + } + + if (audioPacketStart >= packets.Count) + { + BasisDebug.LogWarning("[AudioClipPlayer] Opus file contains headers but no audio packets.", BasisDebug.LogTag.Device); + return null; + } + + const int MaxOpusFrameSize = 5760; // 120ms at 48kHz + float[] decodeBuffer = new float[MaxOpusFrameSize * channels]; + List monoSamples = new List(); + int remainingPreSkip = preSkipSamples; + + using (var decoder = new OpusDecoder(SampleRate, channels, use_static: false)) + { + for (int packetIndex = audioPacketStart; packetIndex < packets.Count; packetIndex++) + { + byte[] packet = packets[packetIndex]; + if (packet.Length == 0) + { + continue; + } + + int decodedSamples = decoder.Decode(packet, packet.Length, decodeBuffer, MaxOpusFrameSize, false); + int sampleStart = 0; + if (remainingPreSkip > 0) + { + sampleStart = Math.Min(decodedSamples, remainingPreSkip); + remainingPreSkip -= sampleStart; + } + + for (int sample = sampleStart; sample < decodedSamples; sample++) + { + if (channels == 1) + { + monoSamples.Add(decodeBuffer[sample]); + } + else + { + int offset = sample * channels; + monoSamples.Add((decodeBuffer[offset] + decodeBuffer[offset + 1]) * 0.5f); + } + } + } + } + + if (remainingPreSkip > 0) + { + BasisDebug.LogWarning($"[AudioClipPlayer] Opus pre-skip exceeded decoded audio by {remainingPreSkip} samples.", BasisDebug.LogTag.Device); + } + + return monoSamples.ToArray(); + } + catch (Exception ex) + { + BasisDebug.LogError($"[AudioClipPlayer] Opus load error: {ex.Message}", BasisDebug.LogTag.Device); + return null; + } + } + + private static List ReadOggPackets(byte[] fileBytes) + { + List packets = new List(); + List packetBuffer = null; + int position = 0; + + while (position < fileBytes.Length) + { + if (position + 27 > fileBytes.Length) + { + BasisDebug.LogWarning("[AudioClipPlayer] Truncated Ogg page header.", BasisDebug.LogTag.Device); + return null; + } + + if (fileBytes[position] != 'O' || + fileBytes[position + 1] != 'g' || + fileBytes[position + 2] != 'g' || + fileBytes[position + 3] != 'S') + { + BasisDebug.LogWarning("[AudioClipPlayer] Invalid Ogg page signature.", BasisDebug.LogTag.Device); + return null; + } + + if (fileBytes[position + 4] != 0) + { + BasisDebug.LogWarning($"[AudioClipPlayer] Unsupported Ogg bitstream version: {fileBytes[position + 4]}", BasisDebug.LogTag.Device); + return null; + } + + byte headerType = fileBytes[position + 5]; + int pageSegments = fileBytes[position + 26]; + int segmentTableOffset = position + 27; + int payloadOffset = segmentTableOffset + pageSegments; + if (payloadOffset > fileBytes.Length) + { + BasisDebug.LogWarning("[AudioClipPlayer] Invalid Ogg segment table.", BasisDebug.LogTag.Device); + return null; + } + + int payloadSize = 0; + for (int index = 0; index < pageSegments; index++) + { + payloadSize += fileBytes[segmentTableOffset + index]; + } + + if (payloadOffset + payloadSize > fileBytes.Length) + { + BasisDebug.LogWarning("[AudioClipPlayer] Truncated Ogg page payload.", BasisDebug.LogTag.Device); + return null; + } + + bool continuedPacket = (headerType & 0x01) != 0; + if (continuedPacket && packetBuffer == null) + { + packetBuffer = new List(); + } + else if (!continuedPacket && packetBuffer != null && packetBuffer.Count > 0) + { + BasisDebug.LogWarning("[AudioClipPlayer] Encountered a non-continued page while a packet was still open.", BasisDebug.LogTag.Device); + return null; + } + + packetBuffer ??= new List(); + int payloadPosition = payloadOffset; + + for (int index = 0; index < pageSegments; index++) + { + int segmentSize = fileBytes[segmentTableOffset + index]; + if (segmentSize > 0) + { + packetBuffer.AddRange(new ArraySegment(fileBytes, payloadPosition, segmentSize)); + } + + payloadPosition += segmentSize; + if (segmentSize < 255) + { + packets.Add(packetBuffer.ToArray()); + packetBuffer.Clear(); + } + } + + position = payloadOffset + payloadSize; + } + + if (packetBuffer != null && packetBuffer.Count > 0) + { + BasisDebug.LogWarning("[AudioClipPlayer] Ogg stream ended with an incomplete packet.", BasisDebug.LogTag.Device); + return null; + } + + return packets; + } + + private static bool TryParseOpusHead(byte[] packet, out int channels, out int preSkipSamples) + { + channels = 0; + preSkipSamples = 0; + + if (!IsPacketNamed(packet, "OpusHead") || packet.Length < 19) + { + return false; + } + + channels = packet[9]; + preSkipSamples = BitConverter.ToUInt16(packet, 10); + int mappingFamily = packet[18]; + if (mappingFamily != 0) + { + BasisDebug.LogWarning($"[AudioClipPlayer] Unsupported Opus channel mapping family: {mappingFamily}", BasisDebug.LogTag.Device); + return false; + } + + return true; + } + + private static bool IsPacketNamed(byte[] packet, string name) + { + if (packet == null || packet.Length < name.Length) + { + return false; + } + + for (int index = 0; index < name.Length; index++) + { + if (packet[index] != name[index]) + { + return false; + } + } + + return true; + } + private static int DecodeInt24(byte[] data, int offset) { int val = data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16); From 6b841ca1f8f02a2854febb2937b0f32b0d577c0a Mon Sep 17 00:00:00 2001 From: Toys0125 Date: Wed, 8 Apr 2026 04:09:54 -0500 Subject: [PATCH 3/3] Fix log spam as we okay not emptying the buffer. --- .../com.basis.framework/Networking/BasisNetworkEvents.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs b/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs index 7a7695d98..c6b3f8e01 100644 --- a/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs +++ b/Basis/Packages/com.basis.framework/Networking/BasisNetworkEvents.cs @@ -18,7 +18,7 @@ public static async void NetworkReceiveEvent(NetPeer peer, NetPacketReader Reade case BasisNetworkCommons.ShoutVoiceChannel: BasisNetworkProfiler.AddToCounter(BasisNetworkProfilerCounter.ShoutVoice, Reader.AvailableBytes); #if UNITY_SERVER - Reader.Recycle(); + Reader.Recycle(true); #else //released inside await BasisNetworkHandleVoice.HandleShoutAudioUpdate(Reader); @@ -117,7 +117,7 @@ public static async void NetworkReceiveEvent(NetPeer peer, NetPacketReader Reade break; case BasisNetworkCommons.VoiceChannel: #if UNITY_SERVER - Reader.Recycle(); + Reader.Recycle(true); #else //released inside await BasisNetworkHandleVoice.HandleAudioUpdate(Reader, false); @@ -125,7 +125,7 @@ public static async void NetworkReceiveEvent(NetPeer peer, NetPacketReader Reade break; case BasisNetworkCommons.VoiceLargeChannel: #if UNITY_SERVER - Reader.Recycle(); + Reader.Recycle(true); #else //released inside await BasisNetworkHandleVoice.HandleAudioUpdate(Reader, true);