From 18d4b7bfd4af1b32bbdb34fa713beebb80b42eba Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 25 Oct 2026 03:16:13 +0100 Subject: [PATCH 1/5] audio: play AltSound packs instead of the ROM's sound The Player's PinMAME settings get an AltSound section: a toggle, the folder with the packs, and when it's on, the pack's volume and how much of the ROM's own sound stays under it (none by default, like Visual PinMAME). The ROM's sound commands drive libaltsound, built into a new VpeAltSound library from native/AltSound with a C interface, two fixes on top of the pinned sources (folder-based packs, failed loads) and a Windows directory listing that leaves the current directory alone. The mix plays through the central mixer's ROM bus, or through Unity without it, and pauses with the game. Needs the OnSoundCommand event of the next pinmame-dotnet release. --- .github/workflows/build.yml | 13 + .../Plugins/linux-x64/libVpeAltSound.so.meta | 43 ++++ .../Plugins/osx/libVpeAltSound.dylib.meta | 61 +++++ .../Plugins/win-x64/VpeAltSound.dll.meta | 43 ++++ .../Plugins/win-x86/VpeAltSound.dll.meta | 43 ++++ VisualPinball.Engine.PinMAME.Unity/README.md | 4 +- .../Runtime/PinMameAltSound.cs | 230 ++++++++++++++++++ .../Runtime/PinMameAltSound.cs.meta | 2 + .../Runtime/PinMameGamelogicEngine.cs | 103 +++++++- .../Runtime/PinMamePluginFactory.cs | 25 +- native/AltSound/CMakeLists.txt | 69 ++++++ native/AltSound/VpeAltSound.cpp | 129 ++++++++++ native/AltSound/compat/dirent.c | 50 ++++ native/AltSound/compat/dirent/dirent.h | 34 +++ native/AltSound/patches/fixes.patch | 89 +++++++ 15 files changed, 928 insertions(+), 10 deletions(-) create mode 100644 VisualPinball.Engine.PinMAME.Unity/Plugins/linux-x64/libVpeAltSound.so.meta create mode 100644 VisualPinball.Engine.PinMAME.Unity/Plugins/osx/libVpeAltSound.dylib.meta create mode 100644 VisualPinball.Engine.PinMAME.Unity/Plugins/win-x64/VpeAltSound.dll.meta create mode 100644 VisualPinball.Engine.PinMAME.Unity/Plugins/win-x86/VpeAltSound.dll.meta create mode 100644 VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameAltSound.cs create mode 100644 VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameAltSound.cs.meta create mode 100644 native/AltSound/CMakeLists.txt create mode 100644 native/AltSound/VpeAltSound.cpp create mode 100644 native/AltSound/compat/dirent.c create mode 100644 native/AltSound/compat/dirent/dirent.h create mode 100644 native/AltSound/patches/fixes.patch diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ebeb1c8..2f4b9a7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,6 +34,19 @@ jobs: run: | cd VisualPinball.Engine.PinMAME dotnet build -c Release -r ${{ matrix.rid }} + - name: Build AltSound + if: matrix.rid == 'win-x64' || matrix.rid == 'win-x86' || matrix.rid == 'osx' || matrix.rid == 'linux-x64' + run: | + out="${{ runner.temp }}/altsound" + case "${{ matrix.rid }}" in + win-x64) cmake -S native/AltSound -B "$out" -A x64 ;; + win-x86) cmake -S native/AltSound -B "$out" -A Win32 ;; + osx) cmake -S native/AltSound -B "$out" -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" -DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 ;; + linux-x64) cmake -S native/AltSound -B "$out" -DCMAKE_BUILD_TYPE=Release ;; + esac + cmake --build "$out" --config Release + find "$out" -maxdepth 2 \( -name VpeAltSound.dll -o -name libVpeAltSound.dylib -o -name libVpeAltSound.so \) -exec cp {} VisualPinball.Engine.PinMAME.Unity/Plugins/${{ matrix.rid }}/ \; + ls VisualPinball.Engine.PinMAME.Unity/Plugins/${{ matrix.rid }} | grep -i altsound - run: | mkdir tmp cp -r VisualPinball.Engine.PinMAME.Unity/Plugins/${{ matrix.rid }} tmp diff --git a/VisualPinball.Engine.PinMAME.Unity/Plugins/linux-x64/libVpeAltSound.so.meta b/VisualPinball.Engine.PinMAME.Unity/Plugins/linux-x64/libVpeAltSound.so.meta new file mode 100644 index 0000000..43b2477 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Plugins/linux-x64/libVpeAltSound.so.meta @@ -0,0 +1,43 @@ +fileFormatVersion: 2 +guid: f535922288724c1996c6c904f6adac8b +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Editor: 0 + Exclude Android: 1 + Exclude Linux64: 0 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + Exclude tvOS: 1 + - first: + Editor: Editor + second: + enabled: 1 + settings: + CPU: x86_64 + DefaultValueInitialized: true + OS: Linux + - first: + Standalone: Linux64 + second: + enabled: 1 + settings: + CPU: x86_64 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.PinMAME.Unity/Plugins/osx/libVpeAltSound.dylib.meta b/VisualPinball.Engine.PinMAME.Unity/Plugins/osx/libVpeAltSound.dylib.meta new file mode 100644 index 0000000..2d20f70 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Plugins/osx/libVpeAltSound.dylib.meta @@ -0,0 +1,61 @@ +fileFormatVersion: 2 +guid: 6f621afd43e14b3ea0aed5bad7b1b808 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 0 + Exclude Linux64: 1 + Exclude OSXUniversal: 0 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + Exclude tvOS: 1 + - first: + Editor: Editor + second: + enabled: 1 + settings: + CPU: AnyCPU + DefaultValueInitialized: true + OS: OSX + - first: + Standalone: Linux64 + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + Standalone: OSXUniversal + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Standalone: Win + second: + enabled: 0 + settings: + CPU: x86 + - first: + Standalone: Win64 + second: + enabled: 0 + settings: + CPU: x86_64 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.PinMAME.Unity/Plugins/win-x64/VpeAltSound.dll.meta b/VisualPinball.Engine.PinMAME.Unity/Plugins/win-x64/VpeAltSound.dll.meta new file mode 100644 index 0000000..84ed31a --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Plugins/win-x64/VpeAltSound.dll.meta @@ -0,0 +1,43 @@ +fileFormatVersion: 2 +guid: 0bb7aa99223d434e93afc7f306c3045c +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Editor: 0 + Exclude Android: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 0 + Exclude iOS: 1 + Exclude tvOS: 1 + - first: + Editor: Editor + second: + enabled: 1 + settings: + CPU: x86_64 + DefaultValueInitialized: true + OS: Windows + - first: + Standalone: Win64 + second: + enabled: 1 + settings: + CPU: x86_64 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.PinMAME.Unity/Plugins/win-x86/VpeAltSound.dll.meta b/VisualPinball.Engine.PinMAME.Unity/Plugins/win-x86/VpeAltSound.dll.meta new file mode 100644 index 0000000..8efd886 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Plugins/win-x86/VpeAltSound.dll.meta @@ -0,0 +1,43 @@ +fileFormatVersion: 2 +guid: 5c5b5d1ffcde49c2a1a884acad957ba4 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Editor: 0 + Exclude Android: 1 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 0 + Exclude Win64: 1 + Exclude iOS: 1 + Exclude tvOS: 1 + - first: + Editor: Editor + second: + enabled: 1 + settings: + CPU: x86 + DefaultValueInitialized: true + OS: Windows + - first: + Standalone: Win + second: + enabled: 1 + settings: + CPU: x86 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Engine.PinMAME.Unity/README.md b/VisualPinball.Engine.PinMAME.Unity/README.md index f4960f7..df3df26 100644 --- a/VisualPinball.Engine.PinMAME.Unity/README.md +++ b/VisualPinball.Engine.PinMAME.Unity/README.md @@ -12,7 +12,9 @@ This package adds a new gamelogic engine called *PinMAME*. Add the component to The catalog and cache are editor-only. A player build receives the serialized hardware mappings and machine/ROM identifiers; it does not fetch or contain the source metadata. Display layouts continue to be queried directly from the selected ROM through PinMAME. -In the Player, this package contributes a **PinMAME** settings section with a global **Volume** slider, also exposed in the **Sound** mixer. The default is 100%; zero mutes playback while the ROM continues running. Both surfaces edit the same plugin-owned value. Gain is applied before the first ROM audio frame and can change during playback, independently of ROM volume and NVRAM. Raw PCM published to other integrations is unaffected; a plugin replacing audio playback owns its output gain. The settings adapter applies only to tables using PinMAME, and cannot be disabled independently of the existing gamelogic engine. +In the Player, this package contributes a **PinMAME** settings section with a global **Volume** slider, also exposed in the **Audio** mixer. The default is 100%; zero mutes playback while the ROM continues running. Both surfaces edit the same plugin-owned value. Gain is applied before the first ROM audio frame and can change during playback, independently of ROM volume and NVRAM. Raw PCM published to other integrations is unaffected; a plugin replacing audio playback owns its output gain. The settings adapter applies only to tables using PinMAME, and cannot be disabled independently of the existing gamelogic engine. + +The same section turns on **AltSound**: a pack of recorded music, callouts and effects that the ROM's sound commands trigger instead of its own sound, as in Visual PinMAME. A pack is a folder named after the ROM inside a folder named `altsound`, such as `VPinMAME/altsound/`. The **AltSound folder** setting points at that `altsound` folder; left empty, the plugin looks next to VPinMAME's ROM folder and in VPE's PinMAME folder. When it is on, **AltSound volume** sets the pack's level relative to the ROM volume, and **ROM sound under AltSound** keeps some of the ROM's own sound for packs that don't replace all of it (off by default, like Visual PinMAME). The pack plays through VPE's central mixer on the ROM bus when that is on, and through Unity otherwise. It is mixed by [libaltsound](https://github.com/vpinball/libaltsound) (BSD-3-Clause), built into `VpeAltSound` from `native/AltSound` for Windows, macOS and Linux. Existing scenes and packages that used the retired class-based definitions remain compatible through a small runtime alias resolver. New and repopulated tables serialize numeric PinMAME IDs and populated flipper-wire mappings on the table. Candidate hardware snapshots stay in the editor scene and are omitted from packaged gamelogic data, so no machine-definition classes or catalog records are shipped with the player. diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameAltSound.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameAltSound.cs new file mode 100644 index 0000000..59dc615 --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameAltSound.cs @@ -0,0 +1,230 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using AOT; +using NLog; +using UnityEngine; +using VisualPinball.Unity; +using Logger = NLog.Logger; + +namespace VisualPinball.Engine.PinMAME +{ + /// + /// Plays an AltSound pack instead of the ROM's own audio: replacement samples that PinMAME's sound commands trigger. The pack is + /// a folder named after the ROM, such as altsound/<rom>, and libaltsound (in the bundled VpeAltSound library) mixes it on + /// its own realtime thread. + /// + /// + /// The mix plays through VPE's central mixer on the ROM bus when it is on, and otherwise through the engine's own Unity filter + /// (). Start and stop are main thread only; and come from + /// the emulation thread. libaltsound is one instance per process, so only one pack plays at a time. + /// + internal sealed class PinMameAltSound : IDisposable + { + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + private const string Library = "VpeAltSound"; + private const int BlockFrames = 256; + private const int RingFrames = 16384; // about 340 ms at 48 kHz for Unity playback; a stall drops the oldest half + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + private delegate void AudioCallback(IntPtr samples, int frames, int sampleRate, int channels, IntPtr userData); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern int VpeAltSoundInit(byte[] basePath, byte[] gameName, int sampleRate, int bufferFrames, AudioCallback callback, IntPtr userData, byte[] logFolder); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern void VpeAltSoundSetHardwareGen(ulong hardwareGen); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern int VpeAltSoundCommand(int board, int command); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern void VpeAltSoundPause(int paused); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern void VpeAltSoundShutdown(); + + private static readonly AudioCallback Callback = OnAudio; + private static PinMameAltSound _current; // the instance the native callback feeds + + /// The pack's folder, for the status line. + public string Folder { get; } + + private readonly PcmAudioStream _stream; + private readonly float[] _ring = new float[RingFrames * 2]; + private long _written, _read; // frames; the native thread writes, Unity's audio thread reads + private float _gain = 1; + private bool _gainSet, _paused; + + private PinMameAltSound(string folder, PcmAudioStream stream) + { + Folder = folder; + _stream = stream; + } + + /// + /// Finds the ROM's pack and starts it, or returns null with the reason. is the folder holding a subfolder + /// per ROM, the ROM's own folder, or a folder with an altsound folder inside; empty looks next to VPinMAME's ROMs and in + /// VPE's PinMAME folder. + /// + public static PinMameAltSound Start(string folder, string romId, int sampleRate, out string status) + { + if (_current != null) { status = "Another table's AltSound is playing."; return null; } + var pack = Find(folder, romId); + if (pack == null) { + status = string.IsNullOrWhiteSpace(folder) ? $"No AltSound folder for {romId} found." : $"No AltSound folder for {romId} in {folder}."; + return null; + } + // libaltsound reads /altsound/, so the pack's parent has to be called "altsound". + var root = Path.GetDirectoryName(pack); + var basePath = Path.GetDirectoryName(root); + if (basePath == null || !string.Equals(Path.GetFileName(root), "altsound", StringComparison.OrdinalIgnoreCase)) { + status = $"AltSound packs must be in a folder named \"altsound\", {pack} isn't."; + return null; + } + var renderer = PcmAudioRenderer.Current; + var stream = renderer ? renderer.OpenStream(SoundBuses.Rom) : null; + var altSound = new PinMameAltSound(pack, stream); + _current = altSound; + int result; + try { + result = VpeAltSoundInit(Utf8(basePath), Utf8(Path.GetFileName(pack)), sampleRate, BlockFrames, Callback, IntPtr.Zero, Utf8(PinMameFolders.DataFolder)); + } catch (Exception e) when (e is DllNotFoundException || e is EntryPointNotFoundException) { + result = int.MinValue; + Logger.Warn(e, "[PinMAME] AltSound is not available on this platform."); + } + if (result == 1) { + status = $"Playing {pack}" + (stream != null ? "." : " through Unity."); + Logger.Info("[PinMAME] AltSound plays {Pack:l} at {Rate} Hz, {Output:l}.", pack, sampleRate, stream != null ? "through the central mixer" : "through Unity"); + return altSound; + } + _current = null; + stream?.Dispose(); + status = result switch { + int.MinValue => "AltSound is not available on this platform.", + -1 => $"The path of {pack} can't be read by AltSound. Move it to a folder whose name has only plain characters.", + _ => $"AltSound couldn't load {pack}. See altsound.log in {PinMameFolders.DataFolder}.", + }; + Logger.Warn("[PinMAME] {Status:l}", status); + return null; + } + + private static string Find(string folder, string romId) + { + if (string.IsNullOrWhiteSpace(romId)) return null; + var candidates = new System.Collections.Generic.List(); + if (!string.IsNullOrWhiteSpace(folder)) { + folder = folder.Trim().TrimEnd('/', '\\'); + candidates.Add(Path.Combine(folder, romId)); // the folder with a subfolder per ROM + if (string.Equals(Path.GetFileName(folder), romId, StringComparison.OrdinalIgnoreCase)) candidates.Add(folder); // the ROM's folder + candidates.Add(Path.Combine(folder, "altsound", romId)); // VPinMAME's folder + } else { + var roms = PinMame.PinMame.FindVpmPath(PinMame.PinMameFileType.Roms); + if (!string.IsNullOrEmpty(roms)) foreach (var romFolder in roms.Split(';')) { + var vpm = Path.GetDirectoryName(romFolder.Trim().TrimEnd('/', '\\')); + if (!string.IsNullOrEmpty(vpm)) candidates.Add(Path.Combine(vpm, "altsound", romId)); + } + candidates.Add(Path.Combine(PinMameFolders.DataFolder, "altsound", romId)); + } + foreach (var candidate in candidates) { + try { + if (Directory.Exists(candidate)) return Path.GetFullPath(candidate); + } catch (Exception e) when (e is ArgumentException || e is IOException || e is UnauthorizedAccessException || e is NotSupportedException) { + // an unusable configured path is reported as not found + } + } + return null; + } + + /// The ROM's hardware generation, which decides how commands are decoded. Any thread. + public void SetHardwareGen(long hardwareGen) => VpeAltSoundSetHardwareGen((ulong)hardwareGen); + + /// A command PinMAME sent to a sound board. Emulation thread. + public void Command(int board, int command) => VpeAltSoundCommand(board, command); + + /// The pack's volume, 0..1, from the main thread. + public float Gain { + set { + value = Mathf.Clamp01(value); + if (value == Volatile.Read(ref _gain) && _gainSet) return; + _gainSet = true; + Volatile.Write(ref _gain, value); + if (_stream != null) _stream.Gain = value; + } + } + + /// Pauses the pack with the game, so music continues where it was. Main thread. + public bool Paused { + set { + if (_paused == value) return; + _paused = value; + VpeAltSoundPause(value ? 1 : 0); + } + } + + [MonoPInvokeCallback(typeof(AudioCallback))] + private static void OnAudio(IntPtr samples, int frames, int sampleRate, int channels, IntPtr userData) + { + var altSound = Volatile.Read(ref _current); + if (altSound == null || samples == IntPtr.Zero || frames <= 0 || channels != 2) return; + unsafe { + var block = new ReadOnlySpan((void*)samples, frames * 2); + if (altSound._stream != null && altSound._stream.Push(block, frames, 2, sampleRate)) return; + altSound.Write(block, frames); + } + } + + // Without the mixer, the engine's Unity filter takes the mix from this ring. + private void Write(ReadOnlySpan block, int frames) + { + long written = Volatile.Read(ref _written), read = Volatile.Read(ref _read); + if (written + frames - read > RingFrames) return; // Unity isn't reading (paused or stalled): drop rather than play late + for (int i = 0; i < frames; ++i) { + int slot = (int)((written + i) % RingFrames) * 2; + _ring[slot] = block[2 * i]; + _ring[slot + 1] = block[2 * i + 1]; + } + Volatile.Write(ref _written, written + frames); + } + + /// Adds the pack to Unity's output when it doesn't play through the mixer. Unity's audio thread. + public void Mix(float[] data, int channels) + { + if (channels <= 0) return; + long written = Volatile.Read(ref _written), read = Volatile.Read(ref _read); + if (written - read > RingFrames / 2) read = written - RingFrames / 4; // fell behind: keep the latency bounded + int frames = (int)System.Math.Min(written - read, data.Length / channels); + float gain = Volatile.Read(ref _gain); + for (int i = 0; i < frames; ++i) { + int slot = (int)((read + i) % RingFrames) * 2; + float left = _ring[slot] * gain, right = _ring[slot + 1] * gain; + if (channels == 1) data[i] += (left + right) * .5f; + else { data[i * channels] += left; data[i * channels + 1] += right; } + } + Volatile.Write(ref _read, read + frames); + } + + /// Stops the pack. Main thread; the native callback has returned when this does. + public void Dispose() + { + if (_current != this) return; + try { + VpeAltSoundShutdown(); + } catch (Exception e) when (e is DllNotFoundException || e is EntryPointNotFoundException) { + // never started + } + Volatile.Write(ref _current, null); + _stream?.Dispose(); + Logger.Info("[PinMAME] AltSound stopped."); + } + + private static byte[] Utf8(string text) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(text ?? string.Empty); + Array.Resize(ref bytes, bytes.Length + 1); + return bytes; + } + } +} diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameAltSound.cs.meta b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameAltSound.cs.meta new file mode 100644 index 0000000..1fb05db --- /dev/null +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameAltSound.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 51b2d5fdff924b34db031e3638515a80 \ No newline at end of file diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs index 37f2c95..4df0d42 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs @@ -96,7 +96,7 @@ private sealed class PlaybackAudioSource : IPluginAudioSource, IPluginAudioPlayb { private readonly PinMameGamelogicEngine _engine; public ResourceKind Kind => ResourceKind.Audio; - public float PlaybackGain => Volatile.Read(ref _engine.DisableAudio) ? 0 : _engine.OutputGain; + public float PlaybackGain => Volatile.Read(ref _engine.DisableAudio) ? 0 : _engine.RomGain; internal PlaybackAudioSource(PinMameGamelogicEngine engine) { _engine = engine; } public IDisposable Subscribe(Action receive) => _engine._pluginAudio.Subscribe(receive); public IDisposable AcquirePlayback(string pluginId) => _engine._pluginAudio.AcquirePlayback(pluginId); @@ -468,9 +468,74 @@ public float OutputGain { set { if (float.IsNaN(value) || float.IsInfinity(value) || value < 0 || value > 1) throw new ArgumentOutOfRangeException(nameof(value)); Volatile.Write(ref _outputGain, value); - _pluginAudio.PlaybackGain = value; + _pluginAudio.PlaybackGain = RomGain; + ApplyAltSoundGain(); } } + /// The ROM's own audio: , or what of it stays under a playing AltSound pack. + private float RomGain => OutputGain * Volatile.Read(ref _romScale); + + // AltSound: a pack of replacement samples that the ROM's sound commands trigger, set up by the Player's PinMAME settings. + private PinMameAltSound _altSound; + private bool _altSoundEnabled, _romStarting; + private string _altSoundFolder = string.Empty, _altSoundStatus = "Off"; + private float _altSoundVolume = 1, _romUnderAltSound, _romScale = 1; + /// What AltSound does, for the Player's status lines. + public string AltSoundStatus => _altSoundStatus; + + /// Turns AltSound on or off and sets where packs are. Main thread; applies right away while the ROM runs. + public void ConfigureAltSound(bool enabled, string folder) + { + folder ??= string.Empty; + if (enabled == _altSoundEnabled && folder == _altSoundFolder) return; + _altSoundEnabled = enabled; + _altSoundFolder = folder; + if (_romStarting) StartAltSound(); + } + + /// The pack's volume and how much of the ROM's own audio stays under it, both 0..1 and relative to . + public void SetAltSoundLevels(float volume, float romVolume) + { + _altSoundVolume = Mathf.Clamp01(volume); + _romUnderAltSound = Mathf.Clamp01(romVolume); + ApplyAltSoundGain(); + } + + private void StartAltSound() + { + StopAltSound(); + if (!_altSoundEnabled) { + _altSoundStatus = "Off"; + return; + } + var altSound = PinMameAltSound.Start(_altSoundFolder, romId, AudioSettings.outputSampleRate, out _altSoundStatus); + // Published with a full fence, as the game start publishes the hardware generation, so one side sees the other + // even when the ROM starts meanwhile. Setting it twice is harmless. + Interlocked.Exchange(ref _altSound, altSound); + var hardwareGen = Interlocked.Read(ref _hardwareGen); + if (altSound != null && hardwareGen != 0) altSound.SetHardwareGen(hardwareGen); + ApplyAltSoundGain(); + } + + private void StopAltSound() + { + var altSound = Interlocked.Exchange(ref _altSound, null); + if (altSound == null) return; + altSound.Dispose(); + ApplyAltSoundGain(); + } + + // Main thread: the pack's stream gain, and the ROM's own level under it. + private void ApplyAltSoundGain() + { + var altSound = _altSound; + Volatile.Write(ref _romScale, altSound != null ? _romUnderAltSound : 1f); + _pluginAudio.PlaybackGain = RomGain; + if (altSound != null) altSound.Gain = DisableAudio ? 0 : OutputGain * _altSoundVolume; + } + + // Emulation thread, in order. A pack that stopped meanwhile drops the command. + private void OnSoundCommand(int boardNo, int cmd) => Volatile.Read(ref _altSound)?.Command(boardNo, cmd); private readonly GainControlledAudioResourceSource _pluginAudio = new(error => Logger.Warn(error, "[PinMAME] Plugin audio subscriber failed.")); private byte[] _pluginAudioBuffer = Array.Empty(); private long _pluginAudioSequence; @@ -571,6 +636,11 @@ private void Update() if (_pinMame == null) { return; } + var altSound = _altSound; + if (altSound != null) { + altSound.Paused = AudioListener.pause; + altSound.Gain = DisableAudio ? 0 : OutputGain * _altSoundVolume; // DisableAudio can change during play + } // Run what the callbacks queued even when the game isn't running (anymore): a ROM // that stops before it starts queues its failure for the diagnostics. @@ -756,6 +826,7 @@ private void OnDestroy() _pinMame.OnMechUpdated -= OnMechUpdated; _pinMame.OnSolenoidUpdated -= OnSolenoidUpdated; _pinMame.OnLogMessage -= OnNativeLogMessage; + _pinMame.OnSoundCommand -= OnSoundCommand; _pinMame.IsKeyPressed -= IsKeyPressed; } _frameBuffer.Clear(); @@ -797,6 +868,8 @@ public void StopForPlayModeExit() // the complete catalog candidate snapshots instead of the runtime MappingConfig projection. _runtimeCachesBuilt = false; #endif + _romStarting = false; + StopAltSound(); if (Interlocked.Exchange(ref _playModeExitHandled, 1) != 0) { return; } @@ -823,6 +896,7 @@ public void StopForPlayModeExit() _pinMame.OnMechUpdated -= OnMechUpdated; _pinMame.OnSolenoidUpdated -= OnSolenoidUpdated; _pinMame.OnLogMessage -= OnNativeLogMessage; + _pinMame.OnSoundCommand -= OnSoundCommand; _pinMame.IsKeyPressed -= IsKeyPressed; _pinMame.OnAudioAvailable -= OnAudioAvailable; _pinMame.OnAudioUpdated -= OnAudioUpdated; @@ -835,6 +909,8 @@ public void StopForPlayModeExit() private void RequestStopGame(string reason) { + _romStarting = false; + StopAltSound(); #if UNITY_EDITOR // In the editor, playmode shutdown is handled by StopForPlayModeExit + editor lifecycle hook. // Calling native Stop() from here can race and destabilize the editor. @@ -881,6 +957,7 @@ private void RequestStopGame(string reason) _pinMame.OnMechUpdated -= OnMechUpdated; _pinMame.OnSolenoidUpdated -= OnSolenoidUpdated; _pinMame.OnLogMessage -= OnNativeLogMessage; + _pinMame.OnSoundCommand -= OnSoundCommand; _pinMame.IsKeyPressed -= IsKeyPressed; _pinMame.OnAudioAvailable -= OnAudioAvailable; _pinMame.OnAudioUpdated -= OnAudioUpdated; @@ -1046,8 +1123,14 @@ public async Task OnInit(Player player, TableApi tableApi, BallManager ballManag _pinMame.OnMechUpdated += OnMechUpdated; _pinMame.OnSolenoidUpdated += OnSolenoidUpdated; _pinMame.OnLogMessage += OnNativeLogMessage; + _pinMame.OnSoundCommand -= OnSoundCommand; + _pinMame.OnSoundCommand += OnSoundCommand; _pinMame.IsKeyPressed += IsKeyPressed; + // before the ROM boots, so the pack hears its first commands + _romStarting = true; + StartAltSound(); + _player = player; _solenoidsEnabled = SolenoidDelay == 0; @@ -1171,7 +1254,8 @@ private void HandleGameStarted() } catch (Exception e) { Logger.Debug(e, "[PinMAME] Could not read the hardware generation."); } - Volatile.Write(ref _hardwareGen, hardwareGen); + Interlocked.Exchange(ref _hardwareGen, hardwareGen); // a full fence, see StartAltSound + Volatile.Read(ref _altSound)?.SetHardwareGen(hardwareGen); Logger.Info("[PinMAME] Game {Rom} started (hardware {HardwareGen}, libpinmame {Version}).", romId, ((PinMameHardwareGen)(ulong)hardwareGen).ToString(), _nativeVersion ?? "unknown"); _isRunning = true; @@ -1664,7 +1748,7 @@ private int HandleAudioAvailable(PinMameAudioInfo audioInfo) Logger.Info("[PinMAME] Game audio available: {Channels} channel(s) at {SampleRate} Hz, {SamplesPerFrame} samples per frame.", audioInfo.Channels, audioInfo.SampleRate, audioInfo.SamplesPerFrame); _audioInfo = audioInfo; - _queuedGain = OutputGain; // A saved mute applies to the first produced frame, including boot audio. + _queuedGain = RomGain; // A saved mute applies to the first produced frame, including boot audio. return _audioInfo.SamplesPerFrame; } @@ -1681,7 +1765,7 @@ private int HandleAudioUpdated(IntPtr framePtr, int frameSize) _audioPlaybackWasReplaced = playbackReplaced; // The local ramp was suspended during takeover. Resume at the current gain, // otherwise a saved/live mute can briefly ramp down from a stale audible level. - if (playbackReplaced || returningToLocalPlayback) _queuedGain = OutputGain; + if (playbackReplaced || returningToLocalPlayback) _queuedGain = RomGain; if (playbackReplaced || DisableAudio) return _audioInfo.SamplesPerFrame; if (_audioFilterChannels == 0) { // don't know how many channels yet @@ -1697,7 +1781,7 @@ private int HandleAudioUpdated(IntPtr framePtr, int frameSize) _audioNumSamplesInput = 0; } - float targetGain = OutputGain, previousGain = _queuedGain; + float targetGain = RomGain, previousGain = _queuedGain; float Gain(int sample) => previousGain == targetGain ? targetGain : previousGain + (targetGain - previousGain) * ((sample + 1f) / frameSize); float[] frame; if (_audioFilterChannels == _audioInfo.Channels) { // n channels -> n channels @@ -1744,6 +1828,13 @@ private int HandleAudioUpdated(IntPtr framePtr, int frameSize) } private void OnAudioFilterRead(float[] data, int channels) + { + FillRomAudio(data, channels); + // AltSound plays here when it doesn't through the central mixer, on top of what's left of the ROM + Volatile.Read(ref _altSound)?.Mix(data, channels); + } + + private void FillRomAudio(float[] data, int channels) { if (_pluginAudio.PlaybackReplaced) { Array.Clear(data, 0, data.Length); diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePluginFactory.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePluginFactory.cs index 63d6356..fef6064 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePluginFactory.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePluginFactory.cs @@ -19,7 +19,18 @@ internal sealed class PinMamePluginFactory : IPlayerPluginFactory public PluginDescriptor Descriptor { get; } = new PluginDescriptor("org.visualpinball.pinmame", "PinMAME", "ROM audio for tables using the PinMAME engine.") { EnabledByDefault = true, CanDisable = false, Category = "Gamelogic", Order = 5 }; public PluginSettingsSchema Schema { get; } = new PluginSettingsSchema(1, new PluginSettingsGroup("ROM audio", "Controls PinMAME playback without changing ROM settings.", new PluginField("Volume", "Volume", SettingKind.Integer) { Default = 100, Minimum = 0, Maximum = 100, Change = ConfigurationChange.Live, - Presentation = PluginFieldPresentation.Slider, Unit = "%", GlobalSetting = new PluginGlobalSetting(PluginGlobalSections.VolumeMixer) { Order = 20 } })); + Presentation = PluginFieldPresentation.Slider, Unit = "%", GlobalSetting = new PluginGlobalSetting(PluginGlobalSections.VolumeMixer) { Order = 20 } }), + new PluginSettingsGroup("AltSound", "Plays an AltSound pack instead of the ROM's own sound: recorded music, callouts and effects that the ROM's sound commands trigger.", + new PluginField("AltSound", "Use AltSound", SettingKind.Boolean) { Default = false, Scope = ConfigurationScope.GlobalAndGame, + Description = "Plays the pack named after the table's ROM when there is one." }, + new PluginField("AltSoundFolder", "AltSound folder", SettingKind.Folder) { Default = "", FullWidth = true, VisibleWhen = "AltSound", + Description = "The folder with a subfolder per ROM, such as VPinMAME's altsound folder. Empty looks next to VPinMAME's ROMs and in VPE's PinMAME folder." }, + new PluginField("AltSoundVolume", "AltSound volume", SettingKind.Integer) { Default = 100, Minimum = 0, Maximum = 100, Change = ConfigurationChange.Live, + Presentation = PluginFieldPresentation.Slider, Unit = "%", Scope = ConfigurationScope.GlobalAndGame, VisibleWhen = "AltSound", + Description = "The pack's level, relative to the ROM volume." }, + new PluginField("AltSoundRomVolume", "ROM sound under AltSound", SettingKind.Integer) { Default = 0, Minimum = 0, Maximum = 100, Change = ConfigurationChange.Live, + Presentation = PluginFieldPresentation.Slider, Unit = "%", Scope = ConfigurationScope.GlobalAndGame, VisibleWhen = "AltSound", + Description = "How much of the ROM's own sound still plays, for packs that don't replace all of it. Off, like Visual PinMAME." })); public IPluginSettingsProvider SettingsProvider => null; public IPlayerPlugin Create(PluginApplicationContext context) => new SettingsAdapter(context); [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] @@ -35,13 +46,20 @@ public override Task PrepareGameAsync(PluginGameContext game, CancellationToken cancellation.ThrowIfCancellationRequested(); var scene = game.Capabilities.Require(UnityPluginCapability.GameScene); _engine = scene.Table.GetComponentInChildren(true); - if (_engine != null) { _original = _engine.OutputGain; Apply(game.Settings); } + if (_engine != null) { + _original = _engine.OutputGain; + _engine.ConfigureAltSound(game.Settings.Boolean("AltSound"), game.Settings.String("AltSoundFolder")); + Apply(game.Settings); + } return Task.CompletedTask; } public PluginActionResult Apply(PluginSettingsSnapshot settings) { _application.Scheduler.VerifyOwner(); - if (_engine != null) _engine.OutputGain = Mathf.Clamp(settings.Integer("Volume", 100), 0, 100) / 100f; + if (_engine != null) { + _engine.OutputGain = Mathf.Clamp(settings.Integer("Volume", 100), 0, 100) / 100f; + _engine.SetAltSoundLevels(Mathf.Clamp(settings.Integer("AltSoundVolume", 100), 0, 100) / 100f, Mathf.Clamp(settings.Integer("AltSoundRomVolume", 0), 0, 100) / 100f); + } return new PluginActionResult(true, ""); } // nothing to say for a table without PinMAME @@ -50,6 +68,7 @@ public void DescribeStatus(PluginStatusWriter writer) if (_engine == null) return; writer.Add("ROM", string.IsNullOrEmpty(_engine.romId) ? "not set" : _engine.romId); writer.Add("Volume", Mathf.RoundToInt(_engine.OutputGain * 100f) + "%"); + writer.Add("AltSound", _engine.AltSoundStatus); } public Task ApplyAsync(PluginSettingsSnapshot settings, CancellationToken cancellation) { cancellation.ThrowIfCancellationRequested(); return Task.FromResult(Apply(settings)); } public override Task StopGameAsync(CancellationToken cancellation) { EmergencyStop(); return Task.CompletedTask; } diff --git a/native/AltSound/CMakeLists.txt b/native/AltSound/CMakeLists.txt new file mode 100644 index 0000000..5531b79 --- /dev/null +++ b/native/AltSound/CMakeLists.txt @@ -0,0 +1,69 @@ +# Builds VpeAltSound, a C interface to libaltsound (BSD-3-Clause, https://github.com/vpinball/libaltsound) for the PinMAME +# plugin. libaltsound's own API takes std::string, which can't be called from C#, so its sources are compiled into this +# library together with a small C shim. Only miniaudio's null backend is used: the mixed audio goes to the host through a +# callback, not to a device. Fixes on top of the pinned sources: patches/fixes.patch joins the sample paths of folder-based +# (PinSound-style) packs and makes AltSoundInit fail when the processor couldn't load the samples, and compat/dirent +# replaces the Windows directory listing libaltsound bundles, which changes the process's current directory and can't nest +# listings. +# +# cmake -S native/AltSound -B build/altsound -A x64 +# cmake --build build/altsound --config Release +# +# Pass -DLIBALTSOUND_DIR= to build from a local checkout instead of the pinned commit (the patch is not applied then). +cmake_minimum_required(VERSION 3.25) +project(VpeAltSound C CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_C_STANDARD 99) +set(CMAKE_CXX_VISIBILITY_PRESET hidden) +set(CMAKE_C_VISIBILITY_PRESET hidden) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +set(LIBALTSOUND_DIR "" CACHE PATH "Local libaltsound checkout; empty fetches the pinned commit") +if(LIBALTSOUND_DIR) + set(altsound_SOURCE_DIR "${LIBALTSOUND_DIR}") +else() + include(FetchContent) + FetchContent_Declare(altsound + GIT_REPOSITORY https://github.com/vpinball/libaltsound.git + GIT_TAG ce11780064a75fe23059fd3b61847ee18d477931 + PATCH_COMMAND git apply --whitespace=nowarn "${CMAKE_CURRENT_SOURCE_DIR}/patches/fixes.patch" + UPDATE_DISCONNECTED ON) + FetchContent_GetProperties(altsound) + if(NOT altsound_POPULATED) + FetchContent_Populate(altsound) # sources only: libaltsound's own project sets platform variables we don't want + endif() +endif() + +set(A "${altsound_SOURCE_DIR}") +set(ALTSOUND_SOURCES + ${A}/src/altsound_data.cpp + ${A}/src/gsound_csv_parser.cpp + ${A}/src/altsound_ini_processor.cpp + ${A}/src/altsound_logger.cpp + ${A}/src/altsound_processor_base.cpp + ${A}/src/altsound_processor.cpp + ${A}/src/altsound_file_parser.cpp + ${A}/src/altsound_csv_parser.cpp + ${A}/src/gsound_processor.cpp + ${A}/src/altsound.cpp + ${A}/src/miniaudio_private.c + ${A}/src/miniaudio_bass_compat.cpp) +if(WIN32) + list(APPEND ALTSOUND_SOURCES compat/dirent.c) +endif() + +add_library(VpeAltSound SHARED VpeAltSound.cpp ${ALTSOUND_SOURCES}) +target_include_directories(VpeAltSound PRIVATE compat ${A}/src ${A}/third-party/include) # compat first: its dirent wins +if(MSVC) + target_compile_definitions(VpeAltSound PRIVATE _CRT_SECURE_NO_WARNINGS) + target_compile_options(VpeAltSound PRIVATE /utf-8 /MP) + set_property(TARGET VpeAltSound PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") # no VC++ redistributable needed +elseif(UNIX AND NOT APPLE) + find_package(Threads REQUIRED) + target_link_libraries(VpeAltSound PRIVATE Threads::Threads ${CMAKE_DL_LIBS} m) +endif() +if(APPLE) + set_target_properties(VpeAltSound PROPERTIES PREFIX "lib" SUFFIX ".dylib") +endif() diff --git a/native/AltSound/VpeAltSound.cpp b/native/AltSound/VpeAltSound.cpp new file mode 100644 index 0000000..a005255 --- /dev/null +++ b/native/AltSound/VpeAltSound.cpp @@ -0,0 +1,129 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +// C interface to libaltsound for the PinMAME plugin. libaltsound keeps one global instance, renders on miniaudio's own +// realtime-paced thread and hands each mixed block to a callback. Commands arrive on the emulation thread while the host +// starts and stops the instance on its main thread, so every entry point runs under one lock, and commands outside a +// running instance are dropped (libaltsound itself dereferences its processor unchecked). No C++ exception crosses the +// boundary: a pack that makes libaltsound throw fails to start, or loses the one command. + +#include "altsound.h" + +#include +#include + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#define VPE_ALTSOUND_API extern "C" __declspec(dllexport) +#else +#define VPE_ALTSOUND_API extern "C" __attribute__((visibility("default"))) +#endif + +typedef void (*VpeAltSoundCallback)(const float* samples, int frames, int sampleRate, int channels, void* userData); + +extern std::mutex io_mutex; // libaltsound's lock for its channel table, which its audio thread changes when samples end + +namespace { +std::mutex gate; +bool running = false; +VpeAltSoundCallback hostCallback = nullptr; +void* hostUserData = nullptr; + +void Forward(const float* samples, size_t frames, uint32_t sampleRate, uint32_t channels, void*) +{ + // The host's callback is set before the engine starts and cleared after it stopped, so no lock is needed here. + if (hostCallback && samples && frames) + hostCallback(samples, static_cast(frames), static_cast(sampleRate), static_cast(channels), hostUserData); +} + +// libaltsound opens files with narrow paths, which Windows reads in the ANSI code page. +std::string NativePath(const char* utf8) +{ + if (!utf8) return {}; +#if defined(_WIN32) + const int wide = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, utf8, -1, nullptr, 0); + if (wide <= 0) return {}; + std::wstring w(static_cast(wide), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, utf8, -1, w.data(), wide); + BOOL lossy = FALSE; + const int narrow = WideCharToMultiByte(CP_ACP, WC_NO_BEST_FIT_CHARS, w.c_str(), -1, nullptr, 0, nullptr, &lossy); + if (narrow <= 0 || lossy) return {}; + std::string n(static_cast(narrow), '\0'); + WideCharToMultiByte(CP_ACP, WC_NO_BEST_FIT_CHARS, w.c_str(), -1, n.data(), narrow, nullptr, nullptr); + n.resize(static_cast(narrow) - 1); + return n; +#else + return utf8; +#endif +} +} + +VPE_ALTSOUND_API int VpeAltSoundVersion() { return 1; } + +// Starts AltSound for a game whose samples are in /altsound/. Paths are UTF-8; logFolder may be empty. +// The callback receives float blocks on libaltsound's audio thread until VpeAltSoundShutdown returns. Returns 1 on success, +// 0 when the folder doesn't hold a usable pack, -1 when a path can't be represented, -2 when an instance already runs. +VPE_ALTSOUND_API int VpeAltSoundInit(const char* basePath, const char* gameName, int sampleRate, int bufferFrames, + VpeAltSoundCallback callback, void* userData, const char* logFolder) +{ + std::lock_guard lock(gate); + if (running) return -2; + if (!basePath || !*basePath || !gameName || !*gameName || !callback || sampleRate < 8000 || bufferFrames <= 0) return 0; + const std::string base = NativePath(basePath), log = NativePath(logFolder); + if (base.empty() || (logFolder && *logFolder && log.empty())) return -1; + hostCallback = callback; + hostUserData = userData; + try { + AltSoundSetLogger(log, ALTSOUND_LOG_LEVEL_ERROR, false); + if (AltSoundInit(base, gameName, static_cast(sampleRate), 2, static_cast(bufferFrames))) { + AltSoundSetAudioCallback(Forward, nullptr); + running = true; + return 1; + } + } catch (...) {} + try { AltSoundShutdown(); } catch (...) {} // frees what a partial init left behind + hostCallback = nullptr; + hostUserData = nullptr; + return 0; +} + +// The ROM's hardware generation (PinMAME's value, which libaltsound shares), so commands are decoded the way that board sends them. +VPE_ALTSOUND_API void VpeAltSoundSetHardwareGen(unsigned long long hardwareGen) +{ + std::lock_guard lock(gate); + AltSoundSetHardwareGen(static_cast(hardwareGen)); +} + +// A sound command as PinMAME logs it. Returns 1 when it was handled. +VPE_ALTSOUND_API int VpeAltSoundCommand(int board, int command) +{ + std::lock_guard lock(gate); + if (!running) return 0; + try { return AltSoundProcessBoardCommand(static_cast(board), static_cast(command), 0) ? 1 : 0; } + catch (...) { return 0; } +} + +VPE_ALTSOUND_API void VpeAltSoundPause(int paused) +{ + std::lock_guard lock(gate); + if (!running) return; + // AltSoundPause walks the channel table without libaltsound's own lock, which commands and ending samples take + std::lock_guard streams(io_mutex); + try { AltSoundPause(paused != 0); } catch (...) {} +} + +// Stops the audio thread and frees the samples. The callback is not called anymore once this returns. +VPE_ALTSOUND_API void VpeAltSoundShutdown() +{ + std::lock_guard lock(gate); + if (!running) return; + running = false; + try { AltSoundShutdown(); } catch (...) {} + hostCallback = nullptr; + hostUserData = nullptr; +} diff --git a/native/AltSound/compat/dirent.c b/native/AltSound/compat/dirent.c new file mode 100644 index 0000000..3e961dc --- /dev/null +++ b/native/AltSound/compat/dirent.c @@ -0,0 +1,50 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +#ifdef _WIN32 +#include "dirent/dirent.h" + +#include +#include + +DIR* opendir(const char* name) +{ + if (!name || !*name) return NULL; + const size_t length = strlen(name); + if (length + 3 > MAX_PATH) return NULL; + char pattern[MAX_PATH]; + memcpy(pattern, name, length); + size_t end = length; + if (pattern[end - 1] != '/' && pattern[end - 1] != '\\') pattern[end++] = '\\'; + pattern[end++] = '*'; + pattern[end] = '\0'; + DIR* dir = (DIR*)calloc(1, sizeof(DIR)); + if (!dir) return NULL; + dir->handle = FindFirstFileA(pattern, &dir->data); + if (dir->handle == INVALID_HANDLE_VALUE) { free(dir); return NULL; } + dir->first = 1; + return dir; +} + +struct dirent* readdir(DIR* dir) +{ + if (!dir || dir->handle == INVALID_HANDLE_VALUE) return NULL; + if (dir->first) dir->first = 0; + else if (!FindNextFileA(dir->handle, &dir->data)) return NULL; + strncpy(dir->entry.d_name, dir->data.cFileName, MAX_PATH - 1); + dir->entry.d_name[MAX_PATH - 1] = '\0'; + dir->entry.d_namlen = (unsigned int)strlen(dir->entry.d_name); + return &dir->entry; +} + +int closedir(DIR* dir) +{ + if (!dir) return -1; + if (dir->handle != INVALID_HANDLE_VALUE) FindClose(dir->handle); + free(dir); + return 0; +} +#endif diff --git a/native/AltSound/compat/dirent/dirent.h b/native/AltSound/compat/dirent/dirent.h new file mode 100644 index 0000000..45842af --- /dev/null +++ b/native/AltSound/compat/dirent/dirent.h @@ -0,0 +1,34 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +// Replaces the dirent emulation libaltsound bundles for Windows, which changes the process's current directory and keeps a +// single static handle, so a nested listing ends the outer one. This one keeps its state per DIR and never touches the +// current directory. Paths are narrow (ANSI), as libaltsound passes them. +#pragma once +#ifdef _WIN32 + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include + +struct dirent { + unsigned int d_namlen; + char d_name[MAX_PATH]; +}; + +typedef struct vpe_dir { + HANDLE handle; + WIN32_FIND_DATAA data; + int first; + struct dirent entry; +} DIR; + +DIR* opendir(const char* name); +struct dirent* readdir(DIR* dir); +int closedir(DIR* dir); + +#endif diff --git a/native/AltSound/patches/fixes.patch b/native/AltSound/patches/fixes.patch new file mode 100644 index 0000000..8369d47 --- /dev/null +++ b/native/AltSound/patches/fixes.patch @@ -0,0 +1,89 @@ +diff --git a/src/altsound.cpp b/src/altsound.cpp +index 2946e11..93aef02 100644 +--- a/src/altsound.cpp ++++ b/src/altsound.cpp +@@ -456,6 +456,12 @@ ALTSOUNDAPI bool AltSoundInit(const string& pinmamePath, const string& gameName, + + // perform processor initialization (load samples, etc) + g_pProcessor->init(); ++ if (!g_pProcessor->isInitialized()) { ++ ALT_ERROR(0, "FAILED: AltSound processor could not load the samples"); ++ ALT_OUTDENT; ++ ALT_DEBUG(0, "END AltSoundInit()"); ++ return false; ++ } + + g_cmdData.cmd_counter = 0; + g_cmdData.stored_command = -1; +diff --git a/src/altsound_file_parser.cpp b/src/altsound_file_parser.cpp +index 1c85124..ec06ac1 100644 +--- a/src/altsound_file_parser.cpp ++++ b/src/altsound_file_parser.cpp +@@ -163,14 +163,14 @@ bool AltsoundFileParser::parse(std::vector& samples_out) + string PATH2 = PATH + entry->d_name; + + // Check for overriding gain value +- string PATHG = PATH2 + "gain.txt"; ++ string PATHG = PATH2 + '/' + "gain.txt"; + float parsedGain = parseFileValue(PATHG); + if (parsedGain != -1.0f) { + gain = parsedGain; + } + + // check for overriding ducking value +- PATHG = PATH2 + "ducking.txt"; ++ PATHG = PATH2 + '/' + "ducking.txt"; + float parsedDucking = parseFileValue(PATHG); + if (parsedDucking != -1.0f) { + ducking = parsedDucking; +@@ -191,7 +191,7 @@ bool AltsoundFileParser::parse(std::vector& samples_out) + + AltsoundSampleInfo sample; + +- sample.fname = PATH2 + entry2->d_name; ++ sample.fname = PATH2 + '/' + entry2->d_name; + + memcpy(id, ptr + 1, 6); + sample.id = std::stoul(trim(id), nullptr); +diff --git a/src/altsound_processor.hpp b/src/altsound_processor.hpp +index d25e410..5d1ac97 100644 +--- a/src/altsound_processor.hpp ++++ b/src/altsound_processor.hpp +@@ -50,6 +50,9 @@ public: + // External interface to stop currently-playing MUSIC stream + bool stopMusic() override; + ++ // whether init() loaded the samples ++ bool isInitialized() const override { return is_initialized; } ++ + // miniaudio SYNCPROC callback when jingle samples end + static void ALTSOUNDCALLBACK jingle_callback(unsigned int handle, unsigned int channel, unsigned int data, void *user); + +diff --git a/src/altsound_processor_base.hpp b/src/altsound_processor_base.hpp +index 9c5cba8..56505df 100644 +--- a/src/altsound_processor_base.hpp ++++ b/src/altsound_processor_base.hpp +@@ -63,6 +63,9 @@ public: + // initialize processing state + virtual void init(); + ++ // whether init() loaded the samples ++ virtual bool isInitialized() const { return true; } ++ + // master volume accessor/mutator + void setMasterVol(const float vol_in); + static float getMasterVol(); +diff --git a/src/gsound_processor.hpp b/src/gsound_processor.hpp +index 2b4d9b4..24d479a 100644 +--- a/src/gsound_processor.hpp ++++ b/src/gsound_processor.hpp +@@ -50,6 +50,9 @@ public: + // External interface to stop MUSIC stream + bool stopMusic() override; + ++ // whether init() loaded the samples ++ bool isInitialized() const override { return is_initialized; } ++ + // Process ROM commands to the sound board + bool handleCmd(const unsigned int cmd_combined_in) override; + From 260e30d345eceba731655983271e609419fc17ea Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 25 Oct 2026 10:10:36 +0100 Subject: [PATCH 2/5] audio: stop the AltSound pack with the ROM, survive an incompatible AltSound library The pack now stops when the ROM ends or fails to start, so looping music doesn't outlive it; each start is counted, so the end of a stopped earlier run can't stop the pack of the retry. An AltSound library that can't be loaded reports AltSound unavailable instead of failing the ROM's start. --- .../Runtime/PinMameAltSound.cs | 4 ++-- .../Runtime/PinMameGamelogicEngine.cs | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameAltSound.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameAltSound.cs index 59dc615..88ae3f5 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameAltSound.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameAltSound.cs @@ -91,7 +91,7 @@ public static PinMameAltSound Start(string folder, string romId, int sampleRate, int result; try { result = VpeAltSoundInit(Utf8(basePath), Utf8(Path.GetFileName(pack)), sampleRate, BlockFrames, Callback, IntPtr.Zero, Utf8(PinMameFolders.DataFolder)); - } catch (Exception e) when (e is DllNotFoundException || e is EntryPointNotFoundException) { + } catch (Exception e) when (e is DllNotFoundException || e is EntryPointNotFoundException || e is BadImageFormatException) { result = int.MinValue; Logger.Warn(e, "[PinMAME] AltSound is not available on this platform."); } @@ -212,7 +212,7 @@ public void Dispose() if (_current != this) return; try { VpeAltSoundShutdown(); - } catch (Exception e) when (e is DllNotFoundException || e is EntryPointNotFoundException) { + } catch (Exception e) when (e is DllNotFoundException || e is EntryPointNotFoundException || e is BadImageFormatException) { // never started } Volatile.Write(ref _current, null); diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs index 4df0d42..e1c0f92 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs @@ -478,6 +478,7 @@ public float OutputGain { // AltSound: a pack of replacement samples that the ROM's sound commands trigger, set up by the Player's PinMAME settings. private PinMameAltSound _altSound; private bool _altSoundEnabled, _romStarting; + private int _romRun; // counts StartGame calls, so the end of an earlier run can't stop the pack of a later one private string _altSoundFolder = string.Empty, _altSoundStatus = "Off"; private float _altSoundVolume = 1, _romUnderAltSound, _romScale = 1; /// What AltSound does, for the Player's status lines. @@ -517,6 +518,16 @@ private void StartAltSound() ApplyAltSoundGain(); } + // Main thread: the ROM stopped or didn't start, so its pack stops too, music included. + private void EndAltSound(int run, string reason) + { + if (run != Volatile.Read(ref _romRun)) return; + _romStarting = false; + if (_altSound == null) return; + StopAltSound(); + _altSoundStatus = "Stopped, " + reason + "."; + } + private void StopAltSound() { var altSound = Interlocked.Exchange(ref _altSound, null); @@ -1136,6 +1147,7 @@ public async Task OnInit(Player player, TableApi tableApi, BallManager ballManag try { _awaitingGameStart = true; + Interlocked.Increment(ref _romRun); _pinMame.StartGame(romId); Logger.Debug($"[PinMAME] StartGame returned. RunState={PinMame.PinMame.RunState}"); } @@ -1150,6 +1162,10 @@ public async Task OnInit(Player player, TableApi tableApi, BallManager ballManag await Task.Delay(100, ct); ResetTimeFence(); _awaitingGameStart = true; + // the stopped game's end may have stopped the pack meanwhile; start it again for this run + Interlocked.Increment(ref _romRun); + _romStarting = true; + StartAltSound(); _pinMame.StartGame(romId); Logger.Debug($"[PinMAME] StartGame retry returned. RunState={PinMame.PinMame.RunState}"); } @@ -1184,6 +1200,7 @@ public async Task OnInit(Player player, TableApi tableApi, BallManager ballManag Logger.Warn(e, "[PinMAME] Cannot start ROM {Rom}, PinMAME's native library could not be loaded.", romId); } finally { + if (_startFailure != null) EndAltSound(Volatile.Read(ref _romRun), "the ROM didn't start"); PinMameStartStopGate.Release(); } } @@ -1298,6 +1315,8 @@ private void HandleGameEnded() // Native can report ended more than once during teardown; keep it idempotent. if (_isRunning) { Logger.Info("[PinMAME] Game {Rom} ended.", romId); + var run = Volatile.Read(ref _romRun); + EnqueueMainThreadDispatch(() => EndAltSound(run, "the ROM stopped")); } else if (_awaitingGameStart && Volatile.Read(ref _stopRequested) == 0) { // PinMAME gave up while loading the ROM @@ -1309,6 +1328,8 @@ private void HandleGameEnded() // only now: the audit before the start can't tell a missing ROM from a clone that // runs from its parent's zip ReportRomMissing(); + var run = Volatile.Read(ref _romRun); + EnqueueMainThreadDispatch(() => EndAltSound(run, "the ROM didn't start")); // the player already marked the table as started; tell it otherwise EnqueueMainThreadDispatch(() => _player?.RefreshGamelogicDiagnostics()); } From 47f2e89c4b0a82fe5e1a1befbc97410233640a0e Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 25 Oct 2026 23:13:25 +0100 Subject: [PATCH 3/5] build: bump PinMame to 1.1.2 1.1.2 adds the OnSoundCommand event that AltSound needs. --- .../VisualPinball.Engine.PinMAME.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VisualPinball.Engine.PinMAME/VisualPinball.Engine.PinMAME.csproj b/VisualPinball.Engine.PinMAME/VisualPinball.Engine.PinMAME.csproj index d4e78c9..f11ba12 100644 --- a/VisualPinball.Engine.PinMAME/VisualPinball.Engine.PinMAME.csproj +++ b/VisualPinball.Engine.PinMAME/VisualPinball.Engine.PinMAME.csproj @@ -5,7 +5,7 @@ false true 9.0 - 1.1.1 + 1.1.2 3.7.0-beta2 From 9e667c662d192e4552368c8a03fd84c9b69d06cc Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 25 Oct 2026 15:39:43 +0100 Subject: [PATCH 4/5] audio: resample AltSound cleanly and set its level independently of the ROM libaltsound now mixes at the packs' 44.1 kHz: at the output rate, its own resampling (miniaudio's engine, low-pass off) put images of the treble into the audible band about 20 dB under the music, which sounded like hiss and grit. The plugin converts the mix with a 64-tap windowed-sinc resampler instead (spurs below -80 dB) and applies the pack's volume there, up to 200% and no longer scaled by the ROM volume. The game's own volume setting no longer scales the pack unless 'Follow the game's volume' is on; the wrapper keeps tracking it, so the option applies at once. The pinned libaltsound gets one more fix: a stream silenced by a zero game volume came back as NaN. Unity playback without the mixer waits for a little audio before it starts, since the mix arrives in bursts. --- VisualPinball.Engine.PinMAME.Unity/README.md | 2 +- .../Runtime/PinMameAltSound.cs | 172 ++++++++++++++++-- .../Runtime/PinMameGamelogicEngine.cs | 18 +- .../Runtime/PinMamePluginFactory.cs | 10 +- native/AltSound/CMakeLists.txt | 3 +- native/AltSound/VpeAltSound.cpp | 39 +++- native/AltSound/patches/fixes.patch | 14 ++ 7 files changed, 227 insertions(+), 31 deletions(-) diff --git a/VisualPinball.Engine.PinMAME.Unity/README.md b/VisualPinball.Engine.PinMAME.Unity/README.md index df3df26..6cc8386 100644 --- a/VisualPinball.Engine.PinMAME.Unity/README.md +++ b/VisualPinball.Engine.PinMAME.Unity/README.md @@ -14,7 +14,7 @@ The catalog and cache are editor-only. A player build receives the serialized ha In the Player, this package contributes a **PinMAME** settings section with a global **Volume** slider, also exposed in the **Audio** mixer. The default is 100%; zero mutes playback while the ROM continues running. Both surfaces edit the same plugin-owned value. Gain is applied before the first ROM audio frame and can change during playback, independently of ROM volume and NVRAM. Raw PCM published to other integrations is unaffected; a plugin replacing audio playback owns its output gain. The settings adapter applies only to tables using PinMAME, and cannot be disabled independently of the existing gamelogic engine. -The same section turns on **AltSound**: a pack of recorded music, callouts and effects that the ROM's sound commands trigger instead of its own sound, as in Visual PinMAME. A pack is a folder named after the ROM inside a folder named `altsound`, such as `VPinMAME/altsound/`. The **AltSound folder** setting points at that `altsound` folder; left empty, the plugin looks next to VPinMAME's ROM folder and in VPE's PinMAME folder. When it is on, **AltSound volume** sets the pack's level relative to the ROM volume, and **ROM sound under AltSound** keeps some of the ROM's own sound for packs that don't replace all of it (off by default, like Visual PinMAME). The pack plays through VPE's central mixer on the ROM bus when that is on, and through Unity otherwise. It is mixed by [libaltsound](https://github.com/vpinball/libaltsound) (BSD-3-Clause), built into `VpeAltSound` from `native/AltSound` for Windows, macOS and Linux. +The same section turns on **AltSound**: a pack of recorded music, callouts and effects that the ROM's sound commands trigger instead of its own sound, as in Visual PinMAME. A pack is a folder named after the ROM inside a folder named `altsound`, such as `VPinMAME/altsound/`. The **AltSound folder** setting points at that `altsound` folder; left empty, the plugin looks next to VPinMAME's ROM folder and in VPE's PinMAME folder. When it is on, **AltSound volume** sets the pack's level on its own (up to 200%, the ROM volume doesn't change it), **Follow the game's volume** lets the volume set in the game's menu scale the pack too, as in Visual PinMAME (off by default, since packs are often much quieter then), and **ROM sound under AltSound** keeps some of the ROM's own sound for packs that don't replace all of it (off by default, like Visual PinMAME). The pack plays through VPE's central mixer on the ROM bus when that is on, and through Unity otherwise. It is mixed by [libaltsound](https://github.com/vpinball/libaltsound) (BSD-3-Clause), built into `VpeAltSound` from `native/AltSound` for Windows, macOS and Linux, at the packs' 44.1 kHz; the plugin converts it to the output rate with a windowed-sinc resampler, since libaltsound's own resampling puts audible images of the treble into the mix. Existing scenes and packages that used the retired class-based definitions remain compatible through a small runtime alias resolver. New and repopulated tables serialize numeric PinMAME IDs and populated flipper-wire mappings on the table. Candidate hardware snapshots stay in the editor scene and are omitted from packaged gamelogic data, so no machine-definition classes or catalog records are shipped with the player. diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameAltSound.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameAltSound.cs index 88ae3f5..cf6a76b 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameAltSound.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameAltSound.cs @@ -32,6 +32,11 @@ internal sealed class PinMameAltSound : IDisposable private const string Library = "VpeAltSound"; private const int BlockFrames = 256; private const int RingFrames = 16384; // about 340 ms at 48 kHz for Unity playback; a stall drops the oldest half + private const int RingPrime = 2048; // what Unity playback waits for before it starts: libaltsound delivers blocks in bursts + // libaltsound mixes at the rate of the packs, which nearly all are 44.1 kHz: its own resampling (miniaudio's engine, + // which turns its low-pass off) puts images of the treble back into the audible band, about 20 dB under the music. + private const int PackRate = 44100; + private const float MaxGain = 2; [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void AudioCallback(IntPtr samples, int frames, int sampleRate, int channels, IntPtr userData); @@ -44,6 +49,8 @@ internal sealed class PinMameAltSound : IDisposable [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] private static extern void VpeAltSoundPause(int paused); [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] + private static extern void VpeAltSoundFollowGameVolume(int follow); + [DllImport(Library, CallingConvention = CallingConvention.Cdecl)] private static extern void VpeAltSoundShutdown(); private static readonly AudioCallback Callback = OnAudio; @@ -53,15 +60,22 @@ internal sealed class PinMameAltSound : IDisposable public string Folder { get; } private readonly PcmAudioStream _stream; + private readonly int _rate; // what the mix is pushed at: the output rate + private readonly Resampler _resampler; // null when the output runs at the packs' rate private readonly float[] _ring = new float[RingFrames * 2]; private long _written, _read; // frames; the native thread writes, Unity's audio thread reads - private float _gain = 1; - private bool _gainSet, _paused; + private bool _priming = true; // Unity's audio thread + private float _gain = 1, _appliedGain = 1; // set on the main thread, ramped to on the native thread + private float[] _block = Array.Empty(); // native thread + private bool? _followGameVolume; + private bool _paused; - private PinMameAltSound(string folder, PcmAudioStream stream) + private PinMameAltSound(string folder, PcmAudioStream stream, int rate) { Folder = folder; _stream = stream; + _rate = rate; + _resampler = rate != PackRate ? new Resampler(PackRate, rate) : null; } /// @@ -69,7 +83,7 @@ private PinMameAltSound(string folder, PcmAudioStream stream) /// per ROM, the ROM's own folder, or a folder with an altsound folder inside; empty looks next to VPinMAME's ROMs and in /// VPE's PinMAME folder. /// - public static PinMameAltSound Start(string folder, string romId, int sampleRate, out string status) + public static PinMameAltSound Start(string folder, string romId, int outputRate, out string status) { if (_current != null) { status = "Another table's AltSound is playing."; return null; } var pack = Find(folder, romId); @@ -86,18 +100,18 @@ public static PinMameAltSound Start(string folder, string romId, int sampleRate, } var renderer = PcmAudioRenderer.Current; var stream = renderer ? renderer.OpenStream(SoundBuses.Rom) : null; - var altSound = new PinMameAltSound(pack, stream); + var altSound = new PinMameAltSound(pack, stream, outputRate); _current = altSound; int result; try { - result = VpeAltSoundInit(Utf8(basePath), Utf8(Path.GetFileName(pack)), sampleRate, BlockFrames, Callback, IntPtr.Zero, Utf8(PinMameFolders.DataFolder)); + result = VpeAltSoundInit(Utf8(basePath), Utf8(Path.GetFileName(pack)), PackRate, BlockFrames, Callback, IntPtr.Zero, Utf8(PinMameFolders.DataFolder)); } catch (Exception e) when (e is DllNotFoundException || e is EntryPointNotFoundException || e is BadImageFormatException) { result = int.MinValue; Logger.Warn(e, "[PinMAME] AltSound is not available on this platform."); } if (result == 1) { status = $"Playing {pack}" + (stream != null ? "." : " through Unity."); - Logger.Info("[PinMAME] AltSound plays {Pack:l} at {Rate} Hz, {Output:l}.", pack, sampleRate, stream != null ? "through the central mixer" : "through Unity"); + Logger.Info("[PinMAME] AltSound plays {Pack:l} at {Rate} Hz, {Output:l}.", pack, outputRate, stream != null ? "through the central mixer" : "through Unity"); return altSound; } _current = null; @@ -144,14 +158,20 @@ private static string Find(string folder, string romId) /// A command PinMAME sent to a sound board. Emulation thread. public void Command(int board, int command) => VpeAltSoundCommand(board, command); - /// The pack's volume, 0..1, from the main thread. + /// The pack's volume, 0..2, from the main thread. Applied to the mix before it's handed on, so it can amplify. public float Gain { + set => Volatile.Write(ref _gain, Mathf.Clamp(float.IsNaN(value) ? 0 : value, 0, MaxGain)); + } + + /// + /// Whether the game's own volume setting scales the pack too, as in Visual PinMAME. Off, the pack plays at its own level and + /// VPE's volume controls alone set it. Main thread. + /// + public bool FollowGameVolume { set { - value = Mathf.Clamp01(value); - if (value == Volatile.Read(ref _gain) && _gainSet) return; - _gainSet = true; - Volatile.Write(ref _gain, value); - if (_stream != null) _stream.Gain = value; + if (_followGameVolume == value) return; + _followGameVolume = value; + VpeAltSoundFollowGameVolume(value ? 1 : 0); } } @@ -170,12 +190,39 @@ private static void OnAudio(IntPtr samples, int frames, int sampleRate, int chan var altSound = Volatile.Read(ref _current); if (altSound == null || samples == IntPtr.Zero || frames <= 0 || channels != 2) return; unsafe { - var block = new ReadOnlySpan((void*)samples, frames * 2); - if (altSound._stream != null && altSound._stream.Push(block, frames, 2, sampleRate)) return; - altSound.Write(block, frames); + var input = new ReadOnlySpan((void*)samples, frames * 2); + var resampler = altSound._resampler; + int capacity = resampler != null ? resampler.Capacity(frames) : frames; + if (altSound._block.Length < capacity * 2) altSound._block = new float[capacity * 2]; + var block = altSound._block; + int count = resampler != null ? resampler.Process(input, frames, block) : Copy(input, block, frames); + if (count <= 0) return; + altSound.ApplyGain(block, count); + var output = new ReadOnlySpan(block, 0, count * 2); + if (altSound._stream != null && altSound._stream.Push(output, count, 2, altSound._rate)) return; + altSound.Write(output, count); } } + private static int Copy(ReadOnlySpan input, float[] block, int frames) + { + input.Slice(0, frames * 2).CopyTo(block); + return frames; + } + + // Ramps from the previous gain over the block, so moving the slider doesn't click. + private void ApplyGain(float[] block, int frames) + { + float target = Volatile.Read(ref _gain), from = _appliedGain; + if (from == target && target == 1) return; + for (int i = 0; i < frames; ++i) { + float gain = from + (target - from) * (i + 1) / frames; + block[2 * i] *= gain; + block[2 * i + 1] *= gain; + } + _appliedGain = target; + } + // Without the mixer, the engine's Unity filter takes the mix from this ring. private void Write(ReadOnlySpan block, int frames) { @@ -193,13 +240,18 @@ private void Write(ReadOnlySpan block, int frames) public void Mix(float[] data, int channels) { if (channels <= 0) return; + int wanted = data.Length / channels; long written = Volatile.Read(ref _written), read = Volatile.Read(ref _read); if (written - read > RingFrames / 2) read = written - RingFrames / 4; // fell behind: keep the latency bounded - int frames = (int)System.Math.Min(written - read, data.Length / channels); - float gain = Volatile.Read(ref _gain); + if (_priming) { + if (written - read < RingPrime + wanted) return; // fill up first, so the bursts don't run it dry + _priming = false; + } + int frames = (int)System.Math.Min(written - read, wanted); + if (frames < wanted) _priming = true; // ran dry: fill up again instead of stuttering for (int i = 0; i < frames; ++i) { int slot = (int)((read + i) % RingFrames) * 2; - float left = _ring[slot] * gain, right = _ring[slot + 1] * gain; + float left = _ring[slot], right = _ring[slot + 1]; if (channels == 1) data[i] += (left + right) * .5f; else { data[i * channels] += left; data[i * channels + 1] += right; } } @@ -220,6 +272,88 @@ public void Dispose() Logger.Info("[PinMAME] AltSound stopped."); } + /// + /// Converts the stereo mix to the output rate with a 64-tap Kaiser-windowed sinc, cut off at 19.5 kHz for the 44.1 kHz packs + /// (images and aliases about 60 dB down). Native thread only. + /// + private sealed class Resampler + { + private const int Taps = 64, Half = Taps / 2, Phases = 256; + private readonly float[] _table = new float[(Phases + 1) * Taps]; // a row of taps per fractional position + private readonly double _step; // input frames per output frame + private float[] _history = new float[4096 * 2]; // interleaved input frames not used up yet + private int _count = Half - 1; // frames in _history, starting with silence so the first output has its past + private double _position = Half - 1; // input position of the next output frame, in _history + + public Resampler(int inputRate, int outputRate) + { + _step = (double)inputRate / outputRate; + double cutoff = System.Math.Min(1.0, (double)outputRate / inputRate) * 19500.0 / inputRate; // cycles per input sample + const double beta = 7.0; + for (int p = 0; p <= Phases; ++p) { + double fraction = (double)p / Phases, sum = 0; + for (int k = 0; k < Taps; ++k) { + double t = fraction + Half - 1 - k; // distance from the output position to tap k + double sinc = t == 0 ? 1 : System.Math.Sin(2 * System.Math.PI * cutoff * t) / (2 * System.Math.PI * cutoff * t); + double edge = t / Half; + double window = System.Math.Abs(edge) >= 1 ? 0 : BesselI0(beta * System.Math.Sqrt(1 - edge * edge)) / BesselI0(beta); + double value = 2 * cutoff * sinc * window; + _table[p * Taps + k] = (float)value; + sum += value; + } + for (int k = 0; k < Taps; ++k) _table[p * Taps + k] = (float)(_table[p * Taps + k] / sum); // unity gain at DC + } + } + + /// The most output frames can return for this many input frames. + public int Capacity(int frames) => (int)System.Math.Ceiling((frames + _count) / _step) + 2; + + /// Takes interleaved stereo input and writes what it can of the output; returns the output frame count. + public int Process(ReadOnlySpan input, int frames, float[] output) + { + if ((_count + frames) * 2 > _history.Length) Array.Resize(ref _history, (_count + frames) * 4); + input.Slice(0, frames * 2).CopyTo(new Span(_history, _count * 2, frames * 2)); + _count += frames; + int produced = 0; + while (true) { + int index = (int)_position; + if (index + Half >= _count) break; // needs Half input frames past the position + double phase = (_position - index) * Phases; + int row = (int)phase; + float blend = (float)(phase - row); + int first = row * Taps, second = first + Taps, start = (index - Half + 1) * 2; + float left = 0, right = 0; + for (int k = 0; k < Taps; ++k) { + float tap = _table[first + k] + (_table[second + k] - _table[first + k]) * blend; + left += _history[start + 2 * k] * tap; + right += _history[start + 2 * k + 1] * tap; + } + output[produced * 2] = left; + output[produced * 2 + 1] = right; + ++produced; + _position += _step; + } + // keep the Half - 1 frames before the next position, for its past taps + int drop = (int)_position - (Half - 1); + if (drop > 0) { + Array.Copy(_history, drop * 2, _history, 0, (_count - drop) * 2); + _count -= drop; + _position -= drop; + } + return produced; + } + + private static double BesselI0(double x) + { + double sum = 1, term = 1, half = x / 2; + for (int k = 1; k < 32; ++k) { + term *= half / k; + sum += term * term; + } + return sum; + } + } + private static byte[] Utf8(string text) { var bytes = System.Text.Encoding.UTF8.GetBytes(text ?? string.Empty); diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs index e1c0f92..9bb1d3b 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMameGamelogicEngine.cs @@ -481,6 +481,7 @@ public float OutputGain { private int _romRun; // counts StartGame calls, so the end of an earlier run can't stop the pack of a later one private string _altSoundFolder = string.Empty, _altSoundStatus = "Off"; private float _altSoundVolume = 1, _romUnderAltSound, _romScale = 1; + private bool _altSoundFollowsGameVolume; /// What AltSound does, for the Player's status lines. public string AltSoundStatus => _altSoundStatus; @@ -494,11 +495,15 @@ public void ConfigureAltSound(bool enabled, string folder) if (_romStarting) StartAltSound(); } - /// The pack's volume and how much of the ROM's own audio stays under it, both 0..1 and relative to . - public void SetAltSoundLevels(float volume, float romVolume) + /// + /// The pack's volume (0..2, on its own, not scaled by ), how much of the ROM's own audio stays under it + /// (0..1 of ), and whether the game's volume setting scales the pack too. + /// + public void SetAltSoundLevels(float volume, float romVolume, bool followGameVolume) { - _altSoundVolume = Mathf.Clamp01(volume); + _altSoundVolume = Mathf.Clamp(volume, 0, 2); _romUnderAltSound = Mathf.Clamp01(romVolume); + _altSoundFollowsGameVolume = followGameVolume; ApplyAltSoundGain(); } @@ -542,7 +547,10 @@ private void ApplyAltSoundGain() var altSound = _altSound; Volatile.Write(ref _romScale, altSound != null ? _romUnderAltSound : 1f); _pluginAudio.PlaybackGain = RomGain; - if (altSound != null) altSound.Gain = DisableAudio ? 0 : OutputGain * _altSoundVolume; + if (altSound != null) { + altSound.Gain = DisableAudio ? 0 : _altSoundVolume; + altSound.FollowGameVolume = _altSoundFollowsGameVolume; + } } // Emulation thread, in order. A pack that stopped meanwhile drops the command. @@ -650,7 +658,7 @@ private void Update() var altSound = _altSound; if (altSound != null) { altSound.Paused = AudioListener.pause; - altSound.Gain = DisableAudio ? 0 : OutputGain * _altSoundVolume; // DisableAudio can change during play + altSound.Gain = DisableAudio ? 0 : _altSoundVolume; // DisableAudio can change during play } // Run what the callbacks queued even when the game isn't running (anymore): a ROM diff --git a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePluginFactory.cs b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePluginFactory.cs index fef6064..21694c4 100644 --- a/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePluginFactory.cs +++ b/VisualPinball.Engine.PinMAME.Unity/Runtime/PinMamePluginFactory.cs @@ -25,9 +25,12 @@ internal sealed class PinMamePluginFactory : IPlayerPluginFactory Description = "Plays the pack named after the table's ROM when there is one." }, new PluginField("AltSoundFolder", "AltSound folder", SettingKind.Folder) { Default = "", FullWidth = true, VisibleWhen = "AltSound", Description = "The folder with a subfolder per ROM, such as VPinMAME's altsound folder. Empty looks next to VPinMAME's ROMs and in VPE's PinMAME folder." }, - new PluginField("AltSoundVolume", "AltSound volume", SettingKind.Integer) { Default = 100, Minimum = 0, Maximum = 100, Change = ConfigurationChange.Live, + new PluginField("AltSoundVolume", "AltSound volume", SettingKind.Integer) { Default = 100, Minimum = 0, Maximum = 200, Change = ConfigurationChange.Live, Presentation = PluginFieldPresentation.Slider, Unit = "%", Scope = ConfigurationScope.GlobalAndGame, VisibleWhen = "AltSound", - Description = "The pack's level, relative to the ROM volume." }, + Description = "The pack's level, at its own level at 100%. The ROM volume above doesn't change it." }, + new PluginField("AltSoundGameVolume", "Follow the game's volume", SettingKind.Boolean) { Default = false, Change = ConfigurationChange.Live, + Scope = ConfigurationScope.GlobalAndGame, VisibleWhen = "AltSound", + Description = "Lets the volume set in the game's own menu scale the pack too, as in Visual PinMAME. Packs are often much quieter then." }, new PluginField("AltSoundRomVolume", "ROM sound under AltSound", SettingKind.Integer) { Default = 0, Minimum = 0, Maximum = 100, Change = ConfigurationChange.Live, Presentation = PluginFieldPresentation.Slider, Unit = "%", Scope = ConfigurationScope.GlobalAndGame, VisibleWhen = "AltSound", Description = "How much of the ROM's own sound still plays, for packs that don't replace all of it. Off, like Visual PinMAME." })); @@ -58,7 +61,8 @@ public PluginActionResult Apply(PluginSettingsSnapshot settings) _application.Scheduler.VerifyOwner(); if (_engine != null) { _engine.OutputGain = Mathf.Clamp(settings.Integer("Volume", 100), 0, 100) / 100f; - _engine.SetAltSoundLevels(Mathf.Clamp(settings.Integer("AltSoundVolume", 100), 0, 100) / 100f, Mathf.Clamp(settings.Integer("AltSoundRomVolume", 0), 0, 100) / 100f); + _engine.SetAltSoundLevels(Mathf.Clamp(settings.Integer("AltSoundVolume", 100), 0, 200) / 100f, Mathf.Clamp(settings.Integer("AltSoundRomVolume", 0), 0, 100) / 100f, + settings.Boolean("AltSoundGameVolume")); } return new PluginActionResult(true, ""); } diff --git a/native/AltSound/CMakeLists.txt b/native/AltSound/CMakeLists.txt index 5531b79..8c09b76 100644 --- a/native/AltSound/CMakeLists.txt +++ b/native/AltSound/CMakeLists.txt @@ -2,7 +2,8 @@ # plugin. libaltsound's own API takes std::string, which can't be called from C#, so its sources are compiled into this # library together with a small C shim. Only miniaudio's null backend is used: the mixed audio goes to the host through a # callback, not to a device. Fixes on top of the pinned sources: patches/fixes.patch joins the sample paths of folder-based -# (PinSound-style) packs and makes AltSoundInit fail when the processor couldn't load the samples, and compat/dirent +# (PinSound-style) packs, makes AltSoundInit fail when the processor couldn't load the samples and lets a stream silenced by +# a zero game volume come back, and compat/dirent # replaces the Windows directory listing libaltsound bundles, which changes the process's current directory and can't nest # listings. # diff --git a/native/AltSound/VpeAltSound.cpp b/native/AltSound/VpeAltSound.cpp index a005255..17f5565 100644 --- a/native/AltSound/VpeAltSound.cpp +++ b/native/AltSound/VpeAltSound.cpp @@ -11,6 +11,7 @@ // boundary: a pack that makes libaltsound throw fails to start, or loses the one command. #include "altsound.h" +#include "altsound_processor_base.hpp" #include #include @@ -27,10 +28,13 @@ typedef void (*VpeAltSoundCallback)(const float* samples, int frames, int sampleRate, int channels, void* userData); extern std::mutex io_mutex; // libaltsound's lock for its channel table, which its audio thread changes when samples end +extern AltsoundProcessorBase* g_pProcessor; namespace { std::mutex gate; bool running = false; +bool followGameVolume = true; // libaltsound's default; the host turns it off +float gameVolume = 1.0f; // the game's last volume command, kept while the pack doesn't follow it VpeAltSoundCallback hostCallback = nullptr; void* hostUserData = nullptr; @@ -63,7 +67,7 @@ std::string NativePath(const char* utf8) } } -VPE_ALTSOUND_API int VpeAltSoundVersion() { return 1; } +VPE_ALTSOUND_API int VpeAltSoundVersion() { return 2; } // Starts AltSound for a game whose samples are in /altsound/. Paths are UTF-8; logFolder may be empty. // The callback receives float blocks on libaltsound's audio thread until VpeAltSoundShutdown returns. Returns 1 on success, @@ -82,6 +86,10 @@ VPE_ALTSOUND_API int VpeAltSoundInit(const char* basePath, const char* gameName, AltSoundSetLogger(log, ALTSOUND_LOG_LEVEL_ERROR, false); if (AltSoundInit(base, gameName, static_cast(sampleRate), 2, static_cast(bufferFrames))) { AltSoundSetAudioCallback(Forward, nullptr); + // libaltsound always decodes the game's volume commands, so following can be turned on and off at any time + if (g_pProcessor) g_pProcessor->romControlsVol(true); + followGameVolume = true; + gameVolume = 1.0f; running = true; return 1; } @@ -104,10 +112,37 @@ VPE_ALTSOUND_API int VpeAltSoundCommand(int board, int command) { std::lock_guard lock(gate); if (!running) return 0; - try { return AltSoundProcessBoardCommand(static_cast(board), static_cast(command), 0) ? 1 : 0; } + try { + const bool handled = AltSoundProcessBoardCommand(static_cast(board), static_cast(command), 0); + if (!followGameVolume && g_pProcessor && AltsoundProcessorBase::getGlobalVol() != 1.0f) { + // a volume command: remember it, and keep the pack at its own level + gameVolume = AltsoundProcessorBase::getGlobalVol(); + std::lock_guard streams(io_mutex); + g_pProcessor->setGlobalVol(1.0f); + } + return handled ? 1 : 0; + } catch (...) { return 0; } } +// Whether the game's own volume commands scale the pack, as in Visual PinMAME (where the pack's altsound.ini decides). Off +// plays the pack at its own level, so the host's volume controls alone set it; the game's volume is still tracked, so +// turning it back on applies the game's current volume right away. +VPE_ALTSOUND_API void VpeAltSoundFollowGameVolume(int follow) +{ + std::lock_guard lock(gate); + if (!running || !g_pProcessor || followGameVolume == (follow != 0)) return; + std::lock_guard streams(io_mutex); // setGlobalVol walks the channel table + try { + if (follow) g_pProcessor->setGlobalVol(gameVolume); + else { + gameVolume = AltsoundProcessorBase::getGlobalVol(); + g_pProcessor->setGlobalVol(1.0f); + } + followGameVolume = follow != 0; + } catch (...) {} +} + VPE_ALTSOUND_API void VpeAltSoundPause(int paused) { std::lock_guard lock(gate); diff --git a/native/AltSound/patches/fixes.patch b/native/AltSound/patches/fixes.patch index 8369d47..16434e5 100644 --- a/native/AltSound/patches/fixes.patch +++ b/native/AltSound/patches/fixes.patch @@ -45,6 +45,20 @@ index 1c85124..ec06ac1 100644 memcpy(id, ptr + 1, 6); sample.id = std::stoul(trim(id), nullptr); +diff --git a/src/altsound_processor.cpp b/src/altsound_processor.cpp +index 2b3fa18..ee2d619 100644 +--- a/src/altsound_processor.cpp ++++ b/src/altsound_processor.cpp +@@ -273,7 +273,8 @@ void AltsoundProcessorBase::setGlobalVol(const float vol_in) + for (size_t index = 0; index < channel_stream.size(); ++index) { + const auto stream = channel_stream[index]; + if (stream) { +- vol[index] = getStreamVolume(stream->hstream); ++ // a stream silenced by a zero volume has no level left to scale back up from: start over from its own gain ++ vol[index] = global_vol * master_vol > 0.0f ? getStreamVolume(stream->hstream) : stream->gain; + } + } + diff --git a/src/altsound_processor.hpp b/src/altsound_processor.hpp index d25e410..5d1ac97 100644 --- a/src/altsound_processor.hpp From 6618e3df953817427d1aabbc1cb001b768e99b8c Mon Sep 17 00:00:00 2001 From: freezy Date: Sun, 25 Oct 2026 23:21:46 +0100 Subject: [PATCH 5/5] audio: remember a game volume command to full volume while AltSound doesn't follow it While the pack doesn't follow the game's volume it played at exactly 1, so a command to full volume looked like no command and turning following on later restored an older, lower volume. It now plays at the float just under 1, which no volume command produces. --- native/AltSound/VpeAltSound.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/native/AltSound/VpeAltSound.cpp b/native/AltSound/VpeAltSound.cpp index 17f5565..04bc6ca 100644 --- a/native/AltSound/VpeAltSound.cpp +++ b/native/AltSound/VpeAltSound.cpp @@ -13,6 +13,7 @@ #include "altsound.h" #include "altsound_processor_base.hpp" +#include #include #include @@ -35,6 +36,9 @@ std::mutex gate; bool running = false; bool followGameVolume = true; // libaltsound's default; the host turns it off float gameVolume = 1.0f; // the game's last volume command, kept while the pack doesn't follow it +// The level the pack plays at while it doesn't follow the game: the float just under 1 (inaudibly lower), which no volume +// command produces, so every command shows as a change, a command to full volume included. +const float ownLevel = std::nextafter(1.0f, 0.0f); VpeAltSoundCallback hostCallback = nullptr; void* hostUserData = nullptr; @@ -114,11 +118,11 @@ VPE_ALTSOUND_API int VpeAltSoundCommand(int board, int command) if (!running) return 0; try { const bool handled = AltSoundProcessBoardCommand(static_cast(board), static_cast(command), 0); - if (!followGameVolume && g_pProcessor && AltsoundProcessorBase::getGlobalVol() != 1.0f) { + if (!followGameVolume && g_pProcessor && AltsoundProcessorBase::getGlobalVol() != ownLevel) { // a volume command: remember it, and keep the pack at its own level gameVolume = AltsoundProcessorBase::getGlobalVol(); std::lock_guard streams(io_mutex); - g_pProcessor->setGlobalVol(1.0f); + g_pProcessor->setGlobalVol(ownLevel); } return handled ? 1 : 0; } @@ -137,7 +141,7 @@ VPE_ALTSOUND_API void VpeAltSoundFollowGameVolume(int follow) if (follow) g_pProcessor->setGlobalVol(gameVolume); else { gameVolume = AltsoundProcessorBase::getGlobalVol(); - g_pProcessor->setGlobalVol(1.0f); + g_pProcessor->setGlobalVol(ownLevel); } followGameVolume = follow != 0; } catch (...) {}