diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetLibrary.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetLibrary.cs
index e6b1f2380..c5f3362c9 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetLibrary.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/AssetBrowser/AssetLibrary.cs
@@ -30,7 +30,7 @@ namespace VisualPinball.Unity.Editor
/// This is the root node of the asset library.
///
/// The data itself is stored in a sub object, . This sub object contains
- /// references to the asset meta data, as well as the categories.
+ /// references to the asset metadata, as well as the categories.
///
[CreateAssetMenu(fileName = "Library", menuName = "Pinball/Asset Library", order = 300)]
public class AssetLibrary : ScriptableObject, ISerializationCallbackReceiver
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs
index 6c6d9b250..87f41ed4e 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Import/VpxSceneConverter.cs
@@ -163,6 +163,8 @@ public GameObject Convert(bool applyPatch = true, string tableName = null)
ConfigurePlayer(componentLookup);
+ SetUpAudio();
+
// patch
if (_applyPatch) {
_patcher?.PostPatch(_tableGo);
@@ -523,6 +525,12 @@ private void CreateTrough(Dictionary components)
InstantiateAndPersistPrefab(item, components);
}
+ private void SetUpAudio()
+ {
+ _tableGo.AddComponent();
+ _tableGo.AddComponent();
+ }
+
private void CreateFileHierarchy()
{
if (!Directory.Exists("Assets/Tables/")) {
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/BinaryEventSoundInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/BinaryEventSoundInspector.cs
new file mode 100644
index 000000000..b74a2048a
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/BinaryEventSoundInspector.cs
@@ -0,0 +1,37 @@
+// Visual Pinball Engine
+// Copyright (C) 2025 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace VisualPinball.Unity.Editor
+{
+ [CanEditMultipleObjects]
+ public class BinaryEventSoundInspector : SoundComponentInspector
+ {
+ [SerializeField]
+ private VisualTreeAsset binaryEventSoundInspectorXml;
+
+ public override VisualElement CreateInspectorGUI()
+ {
+ var root = base.CreateInspectorGUI();
+ var inspectorUi = binaryEventSoundInspectorXml.Instantiate();
+ root.Add(inspectorUi);
+ return root;
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/BinaryEventSoundInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/BinaryEventSoundInspector.cs.meta
new file mode 100644
index 000000000..fe2fbd354
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/BinaryEventSoundInspector.cs.meta
@@ -0,0 +1,15 @@
+fileFormatVersion: 2
+guid: 65e51f0d7750c174dbfbbf7e0aae53f1
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences:
+ - soundComponentInspectorXml: {fileID: 9197481963319205126, guid: 86610575353b6f44aa325d86ee6d779b,
+ type: 3}
+ - binaryEventSoundInspectorXml: {fileID: 9197481963319205126, guid: 4f53d90e909a16548a163671ab9f2132,
+ type: 3}
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/BinaryEventSoundInspector.uxml b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/BinaryEventSoundInspector.uxml
new file mode 100644
index 000000000..60ab4349f
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/BinaryEventSoundInspector.uxml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/BinaryEventSoundInspector.uxml.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/BinaryEventSoundInspector.uxml.meta
new file mode 100644
index 000000000..258dfa17f
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/BinaryEventSoundInspector.uxml.meta
@@ -0,0 +1,10 @@
+fileFormatVersion: 2
+guid: 4f53d90e909a16548a163671ab9f2132
+ScriptedImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 2
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+ script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CalloutAssetInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CalloutAssetInspector.cs
new file mode 100644
index 000000000..e422c0b78
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CalloutAssetInspector.cs
@@ -0,0 +1,41 @@
+// Visual Pinball Engine
+// Copyright (C) 2025 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+// ReSharper disable InconsistentNaming
+
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace VisualPinball.Unity.Editor
+{
+ [CustomEditor(typeof(CalloutAsset)), CanEditMultipleObjects]
+ public class CalloutAssetInspector : SoundAssetInspector
+ {
+ [SerializeField]
+ private VisualTreeAsset _calloutAssetInspector;
+
+ public override VisualElement CreateInspectorGUI()
+ {
+ var root = new VisualElement();
+ var baseUi = base.CreateInspectorGUI();
+ root.Add(baseUi);
+ var subUi = _calloutAssetInspector.Instantiate();
+ root.Add(subUi);
+ return root;
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CalloutAssetInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CalloutAssetInspector.cs.meta
new file mode 100644
index 000000000..b30a1ad78
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CalloutAssetInspector.cs.meta
@@ -0,0 +1,15 @@
+fileFormatVersion: 2
+guid: 25eabf71e394bd4468492886b4af8eae
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences:
+ - _soundAssetInspectorAsset: {fileID: 9197481963319205126, guid: 84e4ce75c16723f428f39b87bcd555a1,
+ type: 3}
+ - _calloutAssetInspector: {fileID: 9197481963319205126, guid: c4009cb1062e5514c9aedd330f512328,
+ type: 3}
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CalloutAssetInspector.uxml b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CalloutAssetInspector.uxml
new file mode 100644
index 000000000..76fa0e858
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CalloutAssetInspector.uxml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CalloutAssetInspector.uxml.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CalloutAssetInspector.uxml.meta
new file mode 100644
index 000000000..9b1ab79ee
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CalloutAssetInspector.uxml.meta
@@ -0,0 +1,10 @@
+fileFormatVersion: 2
+guid: c4009cb1062e5514c9aedd330f512328
+ScriptedImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 2
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+ script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CoilSoundComponentInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CoilSoundInspector.cs
similarity index 83%
rename from VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CoilSoundComponentInspector.cs
rename to VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CoilSoundInspector.cs
index f710accf1..92dd8455d 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CoilSoundComponentInspector.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CoilSoundInspector.cs
@@ -1,5 +1,5 @@
// Visual Pinball Engine
-// Copyright (C) 2023 freezy and VPE Team
+// Copyright (C) 2025 freezy and VPE Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
@@ -23,15 +23,15 @@
namespace VisualPinball.Unity.Editor
{
[CustomEditor(typeof(CoilSoundComponent)), CanEditMultipleObjects]
- public class CoilSoundComponentInspector : SoundComponentInspector
+ public class CoilSoundInspector : BinaryEventSoundInspector
{
[SerializeField]
- private VisualTreeAsset inspectorXml;
+ private VisualTreeAsset coilSoundInspectorXml;
public override VisualElement CreateInspectorGUI()
{
var root = base.CreateInspectorGUI();
- var inspectorUi = inspectorXml.Instantiate();
+ var inspectorUi = coilSoundInspectorXml.Instantiate();
root.Add(inspectorUi);
var coilNameDropdown = root.Q("coil-name");
var coilNameProp = serializedObject.FindProperty(nameof(CoilSoundComponent.CoilName));
@@ -43,7 +43,11 @@ public override VisualElement CreateInspectorGUI()
private Dictionary GetAvailableCoils()
{
var targetComponent = target as Component;
- if (targetComponent != null && targetComponent.TryGetComponent(out var coilDevice)) {
+ if (
+ targetComponent != null
+ && targetComponent.TryGetComponent(out var coilDevice)
+ )
+ {
return coilDevice.AvailableCoils.ToDictionary(
i => i.Id,
i => string.IsNullOrWhiteSpace(i.Description) ? i.Id : i.Description
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CoilSoundInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CoilSoundInspector.cs.meta
new file mode 100644
index 000000000..203c6335b
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CoilSoundInspector.cs.meta
@@ -0,0 +1,17 @@
+fileFormatVersion: 2
+guid: efd14260f42063e4aadba8b265bad210
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences:
+ - soundComponentInspectorXml: {fileID: 9197481963319205126, guid: 86610575353b6f44aa325d86ee6d779b,
+ type: 3}
+ - binaryEventSoundInspectorXml: {fileID: 9197481963319205126, guid: 4f53d90e909a16548a163671ab9f2132,
+ type: 3}
+ - coilSoundInspectorXml: {fileID: 9197481963319205126, guid: 2e43810c575be6d46ae19bed7c1b38bd,
+ type: 3}
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CoilSoundComponentInspector.uxml b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CoilSoundInspector.uxml
similarity index 100%
rename from VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CoilSoundComponentInspector.uxml
rename to VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CoilSoundInspector.uxml
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CoilSoundComponentInspector.uxml.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CoilSoundInspector.uxml.meta
similarity index 100%
rename from VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CoilSoundComponentInspector.uxml.meta
rename to VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CoilSoundInspector.uxml.meta
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/MusicAssetInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/MusicAssetInspector.cs
new file mode 100644
index 000000000..07908844a
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/MusicAssetInspector.cs
@@ -0,0 +1,39 @@
+// Visual Pinball Engine
+// Copyright (C) 2025 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace VisualPinball.Unity.Editor
+{
+ [CustomEditor(typeof(MusicAsset)), CanEditMultipleObjects]
+ public class MusicAssetInspector : SoundAssetInspector
+ {
+ [SerializeField]
+ private VisualTreeAsset _musicAssetInspectorAsset;
+
+ public override VisualElement CreateInspectorGUI()
+ {
+ var root = new VisualElement();
+ var baseUi = base.CreateInspectorGUI();
+ root.Add(baseUi);
+ var subUi = _musicAssetInspectorAsset.Instantiate();
+ root.Add(subUi);
+ return root;
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/MusicAssetInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/MusicAssetInspector.cs.meta
new file mode 100644
index 000000000..05694000e
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/MusicAssetInspector.cs.meta
@@ -0,0 +1,15 @@
+fileFormatVersion: 2
+guid: ff8590e7b8c275545a4e3572fbd5346f
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences:
+ - _soundAssetInspectorAsset: {fileID: 9197481963319205126, guid: 84e4ce75c16723f428f39b87bcd555a1,
+ type: 3}
+ - _musicAssetInspectorAsset: {fileID: 9197481963319205126, guid: a628fd30c34ffb74a8d1a9d170031b1c,
+ type: 3}
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/MusicAssetInspector.uxml b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/MusicAssetInspector.uxml
new file mode 100644
index 000000000..76fa0e858
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/MusicAssetInspector.uxml
@@ -0,0 +1,3 @@
+
+
+
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/MusicAssetInspector.uxml.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/MusicAssetInspector.uxml.meta
new file mode 100644
index 000000000..22a3ac554
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/MusicAssetInspector.uxml.meta
@@ -0,0 +1,10 @@
+fileFormatVersion: 2
+guid: a628fd30c34ffb74a8d1a9d170031b1c
+ScriptedImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 2
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+ script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundAssetInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundAssetInspector.cs
index c2719719a..7292ac1a6 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundAssetInspector.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundAssetInspector.cs
@@ -1,113 +1,52 @@
-// Visual Pinball Engine
-// Copyright (C) 2023 freezy and VPE Team
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with this program. If not, see .
-
-using System;
-using System.Threading;
-using UnityEditor;
-using UnityEditor.UIElements;
-using UnityEngine;
-using UnityEngine.UIElements;
-
-namespace VisualPinball.Unity.Editor
-{
- [CustomEditor(typeof(SoundAsset)), CanEditMultipleObjects]
- public class SoundAssetInspector : UnityEditor.Editor
- {
- private CancellationTokenSource _allowFadeCts;
- private CancellationTokenSource _instantCts;
-
- [SerializeField]
- private VisualTreeAsset _inspectorXml;
-
- private Button _playButton;
-
- public override VisualElement CreateInspectorGUI()
- {
- var ui = _inspectorXml.Instantiate();
- _playButton = ui.Q("play-button");
- _playButton.clicked += OnPlayButtonClicked;
- var loopField = ui.Q("loop");
- var fadeInTimeField = ui.Q("fade-in-time");
- var fadeOutTimeField = ui.Q("fade-out-time");
- loopField.RegisterValueChangeCallback(e => {
- var loop = e.changedProperty.boolValue;
- var displayStyle = loop ? DisplayStyle.Flex : DisplayStyle.None;
- fadeInTimeField.style.display = displayStyle;
- fadeOutTimeField.style.display = displayStyle;
- });
- return ui;
- }
-
- private void OnEnable()
- {
- _allowFadeCts = new();
- _instantCts = new();
- }
-
- private void OnDisable()
- {
- RemoveNullClips();
- _instantCts.Cancel();
- _instantCts.Dispose();
- _instantCts = null;
- _allowFadeCts.Dispose();
- _allowFadeCts = null;
- }
-
- private void RemoveNullClips()
- {
- var clipsProp = serializedObject.FindProperty(nameof(SoundAsset.Clips));
- for (var i = clipsProp.arraySize -1; i >= 0; i--) {
- if (clipsProp.GetArrayElementAtIndex(i).objectReferenceValue == null)
- clipsProp.DeleteArrayElementAtIndex(i);
- }
- serializedObject.ApplyModifiedPropertiesWithoutUndo();
- }
-
- private async void OnPlayButtonClicked()
- {
- _playButton.clicked -= OnPlayButtonClicked;
- _playButton.clicked += OnStopButtonClicked;
- _playButton.text = "Stop";
- try {
- var soundAsset = target as SoundAsset;
- await SoundUtils.PlayInEditorPreviewScene(soundAsset, _allowFadeCts.Token, _instantCts.Token);
- } catch (OperationCanceledException) { } finally {
- _playButton.clicked -= OnStopButtonClicked;
- _playButton.clicked -= OnStopForrealButtonClicked;
- _playButton.clicked += OnPlayButtonClicked;
- _playButton.text = "Play";
- }
- }
-
- private void OnStopButtonClicked()
- {
- _playButton.clicked -= OnStopButtonClicked;
- _playButton.clicked += OnStopForrealButtonClicked;
- _allowFadeCts.Cancel();
- _allowFadeCts.Dispose();
- _allowFadeCts = new();
- }
-
- private void OnStopForrealButtonClicked()
- {
- _playButton.clicked -= OnStopForrealButtonClicked;
- _instantCts.Cancel();
- _instantCts.Dispose();
- _instantCts = new();
- }
- }
-}
+// Visual Pinball Engine
+// Copyright (C) 2025 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+// ReSharper disable InconsistentNaming
+
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace VisualPinball.Unity.Editor
+{
+ [CustomEditor(typeof(SoundAsset)), CanEditMultipleObjects]
+ public class SoundAssetInspector : UnityEditor.Editor
+ {
+ [SerializeField]
+ private VisualTreeAsset _soundAssetInspectorAsset;
+
+ public override VisualElement CreateInspectorGUI()
+ {
+ return _soundAssetInspectorAsset.Instantiate();
+ }
+
+ private void OnDisable()
+ {
+ RemoveNullClips();
+ }
+
+ private void RemoveNullClips()
+ {
+ var clipsProp = serializedObject.FindProperty(nameof(SoundAsset.Clips));
+ for (var i = clipsProp.arraySize -1; i >= 0; i--) {
+ if (clipsProp.GetArrayElementAtIndex(i).objectReferenceValue == null)
+ clipsProp.DeleteArrayElementAtIndex(i);
+ }
+ serializedObject.ApplyModifiedPropertiesWithoutUndo();
+ }
+
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundAssetInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundAssetInspector.cs.meta
index 50b2c5a64..5acbf55e4 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundAssetInspector.cs.meta
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundAssetInspector.cs.meta
@@ -4,7 +4,7 @@ MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences:
- - _inspectorXml: {fileID: 9197481963319205126, guid: 84e4ce75c16723f428f39b87bcd555a1,
+ - _soundAssetInspectorAsset: {fileID: 9197481963319205126, guid: 84e4ce75c16723f428f39b87bcd555a1,
type: 3}
executionOrder: 0
icon: {instanceID: 0}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundAssetInspector.uxml b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundAssetInspector.uxml
index ab69a35e7..54ed896fe 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundAssetInspector.uxml
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundAssetInspector.uxml
@@ -1,15 +1,7 @@
-
-
+
-
-
-
-
-
-
-
-
-
+
+
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundComponentInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundComponentInspector.cs
index eb99058cc..cde99da5c 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundComponentInspector.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundComponentInspector.cs
@@ -1,5 +1,5 @@
// Visual Pinball Engine
-// Copyright (C) 2023 freezy and VPE Team
+// Copyright (C) 2025 freezy and VPE Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
@@ -14,27 +14,61 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
-// ReSharper disable InconsistentNaming
-
-using UnityEditor;
-using UnityEngine.UIElements;
-using UnityEditor.UIElements;
using System.Collections.Generic;
using System.Linq;
+using UnityEditor;
+using UnityEditor.UIElements;
+using UnityEngine;
+using UnityEngine.UIElements;
namespace VisualPinball.Unity.Editor
{
[CustomEditor(typeof(SoundComponent), editorForChildClasses: true), CanEditMultipleObjects]
public class SoundComponentInspector : UnityEditor.Editor
{
+ [SerializeField]
+ private VisualTreeAsset soundComponentInspectorXml;
+
public override VisualElement CreateInspectorGUI()
{
var container = new VisualElement();
AddHelpBoxes(container);
- InspectorElement.FillDefaultInspector(container, serializedObject, this);
+ container.Add(soundComponentInspectorXml.Instantiate());
+ ConfigureFieldVisibility(container);
return container;
}
+ private void ConfigureFieldVisibility(VisualElement container)
+ {
+ var soundAssetProp = serializedObject.FindProperty(nameof(SoundComponent.SoundAsset));
+ var calloutFields = container.Q("callout-fields");
+ var musicFields = container.Q("music-fields");
+ var effectFields = container.Q("sound-effect-fields");
+ UpdateFieldVisibility(soundAssetProp);
+ var soundAssetField = container.Q("sound-asset");
+ soundAssetField.TrackPropertyValue(soundAssetProp, UpdateFieldVisibility);
+
+ void UpdateFieldVisibility(SerializedProperty prop)
+ {
+ var soundAsset = prop.objectReferenceValue as SoundAsset;
+ if (soundAsset == null)
+ {
+ calloutFields.style.display = DisplayStyle.None;
+ musicFields.style.display = DisplayStyle.None;
+ effectFields.style.display = DisplayStyle.None;
+ }
+ else
+ {
+ calloutFields.style.display =
+ soundAsset is CalloutAsset ? DisplayStyle.Flex : DisplayStyle.None;
+ musicFields.style.display =
+ soundAsset is MusicAsset ? DisplayStyle.Flex : DisplayStyle.None;
+ effectFields.style.display =
+ soundAsset is SoundEffectAsset ? DisplayStyle.Flex : DisplayStyle.None;
+ }
+ }
+ }
+
protected virtual void AddHelpBoxes(VisualElement container)
{
MissingComponentHelpBox(container);
@@ -46,7 +80,8 @@ protected void InvalidSoundAssetHelpBox(VisualElement container)
{
var helpBox = new HelpBox(
"The selected sound asset is invalid. Make sure it has at least one audio clip.",
- HelpBoxMessageType.Warning);
+ HelpBoxMessageType.Warning
+ );
container.Add(helpBox);
var soundAssetProp = serializedObject.FindProperty(nameof(SoundComponent.SoundAsset));
UpdateVisibility(soundAssetProp);
@@ -55,51 +90,53 @@ protected void InvalidSoundAssetHelpBox(VisualElement container)
void UpdateVisibility(SerializedProperty prop)
{
var soundAsset = prop.objectReferenceValue as SoundAsset;
- if (soundAsset == null || soundAsset.IsValid()) {
+ if (soundAsset == null || soundAsset.IsValid())
helpBox.style.display = DisplayStyle.None;
-
- } else {
+ else
helpBox.style.display = DisplayStyle.Flex;
- }
}
}
protected void MissingComponentHelpBox(VisualElement container)
{
- if (target != null && target is SoundComponent) {
- var soundComp = target as SoundComponent;
+ if (target != null && target is SoundComponent soundComp)
+ {
var requiredType = soundComp.GetRequiredType();
- if (requiredType != null && !soundComp.TryGetComponent(requiredType, out _)) {
- container.Add(new HelpBox($"This component needs a component of type {requiredType.Name} on the same game object to work.",
- HelpBoxMessageType.Error));
- }
+ if (requiredType != null && !soundComp.TryGetComponent(requiredType, out var _))
+ container.Add(
+ new HelpBox(
+ $"This component needs a component of type "
+ + $"{requiredType.Name} on the same game object to work.",
+ HelpBoxMessageType.Error
+ )
+ );
}
}
private bool AllTargetsSupportLoopingSoundAssets()
{
- foreach (var t in targets) {
- if (t == null) {
+ foreach (var t in targets)
+ {
+ if (t == null)
continue;
- }
-
- if (t is not SoundComponent) {
+ if (t is not SoundComponent)
continue;
- }
-
- if (!(t as SoundComponent).SupportsLoopingSoundAssets()) {
+ if (!(t as SoundComponent).SupportsLoopingSoundAssets())
return false;
- }
}
return true;
}
protected void InfiniteLoopHelpBox(VisualElement container)
{
- var helpBox = new HelpBox("The assigned sound asset loops, but this component " +
- "provides no mechanism to stop it or is not configured to do so. Either assign" +
- " a sound asset that does not loop or (if possible) configure this component to" +
- " stop the sound.", HelpBoxMessageType.Warning);
+ var soundAssetProp = serializedObject.FindProperty(nameof(SoundAsset));
+ var helpBox = new HelpBox(
+ "The assigned sound asset loops, but this component "
+ + "provides no mechanism to stop it or is not configured to do so. Either assign"
+ + " a sound asset that does not loop or (if possible) configure this component to"
+ + " stop the sound.",
+ HelpBoxMessageType.Warning
+ );
container.Add(helpBox);
UpdateVisbility(serializedObject);
helpBox.TrackSerializedObjectValue(serializedObject, UpdateVisbility);
@@ -108,15 +145,18 @@ void UpdateVisbility(SerializedObject obj)
{
var prop = obj.FindProperty(nameof(SoundComponent.SoundAsset));
var soundAsset = prop.objectReferenceValue as SoundAsset;
- if (soundAsset && soundAsset.Loop && !AllTargetsSupportLoopingSoundAssets()) {
+ if (soundAsset && soundAsset.Loop && !AllTargetsSupportLoopingSoundAssets())
helpBox.style.display = DisplayStyle.Flex;
- } else {
+ else
helpBox.style.display = DisplayStyle.None;
- }
}
}
- protected static void ConfigureDropdown(DropdownField dropdown, SerializedProperty boundProp, Dictionary idsToDisplayNames)
+ protected static void ConfigureDropdown(
+ DropdownField dropdown,
+ SerializedProperty boundProp,
+ Dictionary idsToDisplayNames
+ )
{
var displayNamesToIds = idsToDisplayNames.ToDictionary(i => i.Value, i => i.Key);
dropdown.choices = displayNamesToIds.Keys.ToList();
@@ -134,10 +174,10 @@ void UpdateProperty(DropdownField dd)
void UpdateDropdown(SerializedProperty property)
{
var id = property.stringValue;
- if (idsToDisplayNames.TryGetValue(id, out var displayName)) {
+ if (idsToDisplayNames.TryGetValue(id, out var displayName))
dropdown.value = displayName;
-
- } else if (string.IsNullOrEmpty(id) && idsToDisplayNames.Count > 0) {
+ else if (string.IsNullOrEmpty(id) && idsToDisplayNames.Count > 0)
+ {
dropdown.value = idsToDisplayNames.First().Value;
property.stringValue = idsToDisplayNames.First().Key;
property.serializedObject.ApplyModifiedPropertiesWithoutUndo();
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundComponentInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundComponentInspector.cs.meta
index eb68aad9c..7950096db 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundComponentInspector.cs.meta
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundComponentInspector.cs.meta
@@ -3,7 +3,9 @@ guid: ab250a07b4d24884c85a0ad075020852
MonoImporter:
externalObjects: {}
serializedVersion: 2
- defaultReferences: []
+ defaultReferences:
+ - soundComponentInspectorXml: {fileID: 9197481963319205126, guid: 86610575353b6f44aa325d86ee6d779b,
+ type: 3}
executionOrder: 0
icon: {instanceID: 0}
userData:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundComponentInspector.uxml b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundComponentInspector.uxml
new file mode 100644
index 000000000..ccd5fa193
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundComponentInspector.uxml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundComponentInspector.uxml.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundComponentInspector.uxml.meta
new file mode 100644
index 000000000..b8b8f1259
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundComponentInspector.uxml.meta
@@ -0,0 +1,10 @@
+fileFormatVersion: 2
+guid: 86610575353b6f44aa325d86ee6d779b
+ScriptedImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 2
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+ script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundEffectAssetInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundEffectAssetInspector.cs
new file mode 100644
index 000000000..8fc25450c
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundEffectAssetInspector.cs
@@ -0,0 +1,115 @@
+// Visual Pinball Engine
+// Copyright (C) 2025 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+// ReSharper disable InconsistentNaming
+
+using System;
+using System.Threading;
+using UnityEditor;
+using UnityEditor.UIElements;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace VisualPinball.Unity.Editor
+{
+ [CustomEditor(typeof(SoundEffectAsset)), CanEditMultipleObjects]
+ public class SoundEffectAssetInspector : SoundAssetInspector
+ {
+ [SerializeField]
+ private VisualTreeAsset _soundEffectAssetInspectorAsset;
+
+ private CancellationTokenSource _allowFadeCts;
+ private CancellationTokenSource _instantCts;
+ private Button _playButton;
+
+ public override VisualElement CreateInspectorGUI()
+ {
+ var root = new VisualElement();
+ var baseUi = base.CreateInspectorGUI();
+ root.Add(baseUi);
+ var subUi = _soundEffectAssetInspectorAsset.Instantiate();
+ root.Add(subUi);
+
+ // Hide fade out options when loop is disabled
+ var loopField = subUi.Q("loop");
+ var fadeInTimeField = subUi.Q("fade-in-time");
+ var fadeOutTimeField = subUi.Q("fade-out-time");
+ loopField.RegisterValueChangeCallback(e =>
+ {
+ var loop = e.changedProperty.boolValue;
+ var displayStyle = loop ? DisplayStyle.Flex : DisplayStyle.None;
+ fadeInTimeField.style.display = displayStyle;
+ fadeOutTimeField.style.display = displayStyle;
+ });
+
+ _playButton = subUi.Q("play-button");
+ _playButton.clicked += OnPlayButtonClicked;
+ return root;
+ }
+
+ private void OnEnable()
+ {
+ _allowFadeCts = new();
+ _instantCts = new();
+ }
+
+ private void OnDisable()
+ {
+ _instantCts.Cancel();
+ _instantCts.Dispose();
+ _instantCts = null;
+ _allowFadeCts.Dispose();
+ _allowFadeCts = null;
+ }
+
+ private async void OnPlayButtonClicked()
+ {
+ _playButton.clicked -= OnPlayButtonClicked;
+ _playButton.clicked += OnStopButtonClicked;
+ _playButton.text = "Stop";
+ try
+ {
+ var soundAsset = target as SoundEffectAsset;
+ await soundAsset.PlayInEditorPreviewScene(_allowFadeCts.Token, _instantCts.Token);
+ }
+ catch (OperationCanceledException) { }
+ finally
+ {
+ _playButton.clicked -= OnStopButtonClicked;
+ _playButton.clicked -= OnStopForrealButtonClicked;
+ _playButton.clicked += OnPlayButtonClicked;
+ _playButton.text = "Play";
+ }
+ }
+
+ private void OnStopButtonClicked()
+ {
+ _playButton.clicked -= OnStopButtonClicked;
+ _playButton.clicked += OnStopForrealButtonClicked;
+ _allowFadeCts.Cancel();
+ _allowFadeCts.Dispose();
+ _allowFadeCts = new();
+ }
+
+ private void OnStopForrealButtonClicked()
+ {
+ _playButton.clicked -= OnStopForrealButtonClicked;
+ _instantCts.Cancel();
+ _instantCts.Dispose();
+ _instantCts = new();
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundEffectAssetInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundEffectAssetInspector.cs.meta
new file mode 100644
index 000000000..fe3706e0e
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundEffectAssetInspector.cs.meta
@@ -0,0 +1,15 @@
+fileFormatVersion: 2
+guid: 1a1855717caab984d91790c7b31bb2ca
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences:
+ - _soundAssetInspectorAsset: {fileID: 9197481963319205126, guid: 84e4ce75c16723f428f39b87bcd555a1,
+ type: 3}
+ - _soundEffectAssetInspectorAsset: {fileID: 9197481963319205126, guid: 021c87a0b57b77d449760c46575f0270,
+ type: 3}
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundEffectAssetInspector.uxml b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundEffectAssetInspector.uxml
new file mode 100644
index 000000000..14f7e9ab7
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundEffectAssetInspector.uxml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundEffectAssetInspector.uxml.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundEffectAssetInspector.uxml.meta
new file mode 100644
index 000000000..72eb91486
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SoundEffectAssetInspector.uxml.meta
@@ -0,0 +1,10 @@
+fileFormatVersion: 2
+guid: 021c87a0b57b77d449760c46575f0270
+ScriptedImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 2
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+ script: {fileID: 13804, guid: 0000000000000000e000000000000000, type: 0}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SwitchSoundComponentInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SwitchSoundComponentInspector.cs.meta
deleted file mode 100644
index 4babc6faa..000000000
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SwitchSoundComponentInspector.cs.meta
+++ /dev/null
@@ -1,13 +0,0 @@
-fileFormatVersion: 2
-guid: dc422535e407a8643b36952499cb6095
-MonoImporter:
- externalObjects: {}
- serializedVersion: 2
- defaultReferences:
- - inspectorXml: {fileID: 9197481963319205126, guid: 309992e82c095df4fbcdc6d516f59392,
- type: 3}
- executionOrder: 0
- icon: {instanceID: 0}
- userData:
- assetBundleName:
- assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SwitchSoundComponentInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SwitchSoundInspector.cs
similarity index 81%
rename from VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SwitchSoundComponentInspector.cs
rename to VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SwitchSoundInspector.cs
index 57fc0d2e8..0a514db70 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SwitchSoundComponentInspector.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SwitchSoundInspector.cs
@@ -1,5 +1,5 @@
// Visual Pinball Engine
-// Copyright (C) 2023 freezy and VPE Team
+// Copyright (C) 2025 freezy and VPE Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
@@ -23,15 +23,15 @@
namespace VisualPinball.Unity.Editor
{
[CustomEditor(typeof(SwitchSoundComponent)), CanEditMultipleObjects]
- public class SwitchSoundComponentInspector : SoundComponentInspector
+ public class SwitchSoundInspector : BinaryEventSoundInspector
{
[SerializeField]
- private VisualTreeAsset inspectorXml;
+ private VisualTreeAsset switchSoundInspectorXml;
public override VisualElement CreateInspectorGUI()
{
var root = base.CreateInspectorGUI();
- var inspectorUi = inspectorXml.Instantiate();
+ var inspectorUi = switchSoundInspectorXml.Instantiate();
root.Add(inspectorUi);
var switchNameDropdown = root.Q("switch-name");
var switchNameProp = serializedObject.FindProperty(nameof(SwitchSoundComponent.SwitchName));
@@ -42,8 +42,12 @@ public override VisualElement CreateInspectorGUI()
private Dictionary GetAvailableSwitches()
{
- var targetComponent = target as Component;
- if (targetComponent != null && targetComponent.TryGetComponent(out var switchDevice)) {
+ if (
+ target != null
+ && target is Component t
+ && t.TryGetComponent(out var switchDevice)
+ )
+ {
return switchDevice.AvailableSwitches.ToDictionary(
i => i.Id,
i => string.IsNullOrWhiteSpace(i.Description) ? i.Id : i.Description
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SwitchSoundInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SwitchSoundInspector.cs.meta
new file mode 100644
index 000000000..806eb6235
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SwitchSoundInspector.cs.meta
@@ -0,0 +1,17 @@
+fileFormatVersion: 2
+guid: dc422535e407a8643b36952499cb6095
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences:
+ - soundComponentInspectorXml: {fileID: 9197481963319205126, guid: 86610575353b6f44aa325d86ee6d779b,
+ type: 3}
+ - binaryEventSoundInspectorXml: {fileID: 9197481963319205126, guid: 4f53d90e909a16548a163671ab9f2132,
+ type: 3}
+ - switchSoundInspectorXml: {fileID: 9197481963319205126, guid: 309992e82c095df4fbcdc6d516f59392,
+ type: 3}
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SwitchSoundComponentInspector.uxml b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SwitchSoundInspector.uxml
similarity index 100%
rename from VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SwitchSoundComponentInspector.uxml
rename to VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SwitchSoundInspector.uxml
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SwitchSoundComponentInspector.uxml.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SwitchSoundInspector.uxml.meta
similarity index 100%
rename from VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SwitchSoundComponentInspector.uxml.meta
rename to VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/SwitchSoundInspector.uxml.meta
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/CommonPackables.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/CommonPackables.cs
index 207826d09..a0888fd7d 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/CommonPackables.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/CommonPackables.cs
@@ -96,7 +96,7 @@ public static byte[] Pack(ScriptableObject obj)
return PackageApi.Packer.Pack(obj);
}
- public static byte[] PackMeta(ScriptableObject obj) => PackageApi.Packer.Pack(new MetaPackable { InstanceId = obj.GetInstanceID() });
+ public static byte[] PackMeta(MetaPackable mp) => PackageApi.Packer.Pack(mp);
public static MetaPackable UnpackMeta(byte[] data) => PackageApi.Packer.Unpack(data);
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/IPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/IPackable.cs
index ca5c28c4d..875eb055f 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/IPackable.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/IPackable.cs
@@ -23,6 +23,13 @@ namespace VisualPinball.Unity
///
public interface IPackable
{
+ ///
+ /// Packs the component data into a byte array.
+ ///
+ /// Returning null will not even create the component for the game object. For components
+ /// with no data, return instead.
+ ///
+ /// Component data in binary
byte[] Pack();
byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files);
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackWithAttribute.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackWithAttribute.cs
new file mode 100644
index 000000000..91149b3a3
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackWithAttribute.cs
@@ -0,0 +1,72 @@
+// Visual Pinball Engine
+// Copyright (C) 2025 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System;
+using System.Reflection;
+using UnityEngine;
+
+namespace VisualPinball.Unity
+{
+ ///
+ /// This is used for packing scriptable objects that reference files (and in the future, other scriptable objects).
+ ///
+ /// It allows specifying a packer class that is instantiated during packaging and unpacking
+ /// and that handles these references correctly. It's currently not used for anything else, such
+ /// as components. (We already have full control over these, but it might save us from IPackable.
+ /// To be followed up).
+ ///
+ [AttributeUsage(AttributeTargets.Class, Inherited = false)]
+ public class PackWithAttribute : Attribute
+ {
+ public Type PackerType { get; }
+
+ public PackWithAttribute(Type packerType)
+ {
+ PackerType = packerType;
+ }
+ }
+
+ public interface IPacker
+ {
+ MetaPackable Pack(int instanceId, T obj, PackagedFiles files);
+ MetaPackable Unpack(byte[] bytes, T obj, PackagedFiles files);
+ }
+
+ public static class PackerFactory
+ {
+ public static IPacker GetPacker() => GetPacker(typeof(T));
+ public static IPacker GetPacker(Type t) => GetPacker(t);
+
+ private static IPacker GetPacker(Type t)
+ {
+ // look for the PackWithAttribute on T
+ var attr = t.GetCustomAttribute();
+ if (attr == null) {
+ return null;
+ }
+
+ // check whether the PackerType implements IPacker
+ var packerType = attr.PackerType;
+ if (!typeof(IPacker).IsAssignableFrom(packerType)) {
+ throw new InvalidOperationException($"Type {packerType.FullName} does not implement IPacker<{typeof(T).Name}>.");
+ }
+
+ // construct an instance of the PackerType
+ var packer = (IPacker)Activator.CreateInstance(packerType);
+ return packer;
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackWithAttribute.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackWithAttribute.cs.meta
new file mode 100644
index 000000000..51189f31c
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackWithAttribute.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: dfdaa23c3d3e4d599987a4e95b62d7f7
+timeCreated: 1741528320
\ No newline at end of file
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs
index 676b02707..52d128ffb 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackageApi.cs
@@ -207,7 +207,7 @@ public interface IDataPacker
public byte[] Pack(T obj);
///
- ///
+ /// An empty object. Just means that the component is written, but no other data is stored.
///
///
public byte[] Empty { get; }
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedFiles.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedFiles.cs
index a00fd0e59..2b76bacb4 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedFiles.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/PackagedFiles.cs
@@ -151,19 +151,29 @@ public Mesh GetColliderMesh(string guid)
private readonly HashSet _scriptableObjects = new();
private readonly Dictionary _deserializedAssets = new();
+ private readonly Dictionary _assetMetas = new();
- public int AddAsset(ScriptableObject scriptableObject)
+ public int AddAsset(T scriptableObject) where T : ScriptableObject
{
if (scriptableObject == null) {
return 0;
}
+ var instanceId = scriptableObject.GetInstanceID();
if (!_typeLookup.HasType(scriptableObject.GetType())) {
throw new Exception($"Unsupported asset type {scriptableObject.GetType().FullName}");
}
- _scriptableObjects.Add(scriptableObject);
- return scriptableObject.GetInstanceID();
+ if (!_assetMetas.ContainsKey(instanceId)) {
+ _scriptableObjects.Add(scriptableObject);
+ var packer = PackerFactory.GetPacker();
+ _assetMetas.Add(instanceId, packer != null
+ ? packer.Pack(instanceId, scriptableObject, this)
+ : new MetaPackable { InstanceId = scriptableObject.GetInstanceID() }
+ );
+ }
+
+ return instanceId;
}
public T GetAsset(int instanceId) where T : ScriptableObject
@@ -193,7 +203,7 @@ public void PackAssets()
// pack meta
var fileMeta = assetTypeFolder.AddFile($"{name}.meta", PackageApi.Packer.FileExtension);
- fileMeta.SetData(MetaPackable.PackMeta(so));
+ fileMeta.SetData(MetaPackable.PackMeta(_assetMetas[so.GetInstanceID()]));
}
}
@@ -213,7 +223,7 @@ public void UnpackAssets(string assetPath)
if (!assetTypeFolder.TryGetFile(metaFilename, out var metaFile)) {
throw new Exception($"Cannot find meta file {metaFilename} for {assetFile.Name}");
}
- var meta = MetaPackable.UnpackMeta(metaFile.GetData());
+
var type = _typeLookup.GetType(assetTypeFolder.Name);
if (type == null) {
throw new Exception($"Unknown asset type {assetTypeFolder.Name}");
@@ -224,6 +234,11 @@ public void UnpackAssets(string assetPath)
throw new Exception($"Failed to unpack asset {assetFile.Name}");
}
+ var packer = PackerFactory.GetPacker(type);
+ var meta = packer == null
+ ? MetaPackable.UnpackMeta(metaFile.GetData())
+ : packer.Unpack(metaFile.GetData(), asset, this);
+
var folder = Path.Combine(assetPath, assetTypeFolder.Name);
if (!Directory.Exists(folder)) {
Directory.CreateDirectory(folder);
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/BinaryEventSoundComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/BinaryEventSoundComponent.cs
index 848c3eb3a..1e663e1a8 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Sound/BinaryEventSoundComponent.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/BinaryEventSoundComponent.cs
@@ -1,5 +1,5 @@
// Visual Pinball Engine
-// Copyright (C) 2023 freezy and VPE Team
+// Copyright (C) 2025 freezy and VPE Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
@@ -27,25 +27,22 @@ namespace VisualPinball.Unity
///
public abstract class BinaryEventSoundComponent : EventSoundComponent where TEventSource : class
{
- [FormerlySerializedAs("_startWhen")]
public StartWhen StartWhen = StartWhen.TurnedOn;
-
- [FormerlySerializedAs("_stopWhen")]
public StopWhen StopWhen = StopWhen.Never;
- protected override async void OnEvent(object sender, TEventArgs e)
+ protected override void OnEvent(object sender, TEventArgs e)
{
bool isEnabled = InterpretAsBinary(e);
if ((isEnabled && StopWhen == StopWhen.TurnedOn) ||
(!isEnabled && StopWhen == StopWhen.TurnedOff))
{
- Stop(allowFade: true);
+ StopAllSounds(allowFade: true);
}
if ((isEnabled && StartWhen == StartWhen.TurnedOn) ||
(!isEnabled && StartWhen == StartWhen.TurnedOff))
{
- await Play();
+ StartSound();
}
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutAsset.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutAsset.cs
new file mode 100644
index 000000000..26b3da0dc
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutAsset.cs
@@ -0,0 +1,42 @@
+// Visual Pinball Engine
+// Copyright (C) 2025 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using UnityEngine;
+
+namespace VisualPinball.Unity
+{
+ [CreateAssetMenu(
+ fileName = "CalloutAsset",
+ menuName = "Pinball/Sound/CalloutAsset",
+ order = 102
+ )]
+ [PackAs("CalloutAsset")]
+ [PackWith(typeof(SoundAssetPacker))]
+ public class CalloutAsset : SoundAsset
+ {
+ public override bool Loop => false;
+
+ [SerializeField]
+ [Range(0f, 1f)]
+ private float _volume = 1f;
+
+ public override void ConfigureAudioSource(AudioSource audioSource)
+ {
+ base.ConfigureAudioSource(audioSource);
+ audioSource.volume = _volume;
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutAsset.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutAsset.cs.meta
new file mode 100644
index 000000000..0bfe7b7ac
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutAsset.cs.meta
@@ -0,0 +1,13 @@
+fileFormatVersion: 2
+guid: 2cfd1a7f71a6fab4a911f6c735c4a8b9
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences:
+ - _audioMixerGroup: {fileID: -4147333927001089018, guid: 9784dda595e200a4dbef54316a45f16e,
+ type: 2}
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 22fa0f3254800a748abe0d6b46930179, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutCoordinator.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutCoordinator.cs
new file mode 100644
index 000000000..04d9d925a
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutCoordinator.cs
@@ -0,0 +1,221 @@
+// Visual Pinball Engine
+// Copyright (C) 2025 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+// ReSharper disable InconsistentNaming
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using NLog;
+using UnityEngine;
+using Logger = NLog.Logger;
+
+namespace VisualPinball.Unity
+{
+ public enum CalloutRequestStatus
+ {
+ UnknownId,
+ Queued,
+ Playing,
+ Finished,
+ }
+
+ ///
+ /// Manages playback of callouts. Maintains a queue of callout requests. Supports prioritizing
+ /// certain callouts over others and enforcing a minimum pause between two callouts.
+ ///
+ [PackAs("CalloutCoordinator")]
+ public class CalloutCoordinator : MonoBehaviour, IPackable
+ {
+ #region Data
+
+ [Range(0f, 3f)]
+ [Tooltip("How many seconds to pause after a callout before the next one can be started")]
+ public float PauseDuration = 0.5f;
+
+ #endregion
+
+ #region Packaging
+
+ public byte[] Pack() => CalloutCoordinatorPackable.Pack(this);
+
+ public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) => null;
+
+ public void Unpack(byte[] bytes) => CalloutCoordinatorPackable.Unpack(bytes, this);
+
+ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files) { }
+
+ #endregion
+
+ private readonly List _calloutQ = new();
+ private CancellationTokenSource _loopCts;
+ private CancellationTokenSource _currentCalloutCts;
+ private Task _loopTask;
+ private TaskCompletionSource _waitForNewCalloutTcs;
+ private int _requestCounter;
+ private int _idOfCurrentlyPlayingRequest = -1;
+
+ private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
+
+ public void EnqueueCallout(CalloutRequest request, out int requestId)
+ {
+ request.Index = _requestCounter;
+ requestId = _requestCounter;
+ _requestCounter++;
+ var i = _calloutQ.FindIndex(x => x.Priority < request.Priority);
+ if (i != -1)
+ _calloutQ.Insert(i, request);
+ else
+ _calloutQ.Add(request);
+ _waitForNewCalloutTcs?.TrySetResult(true);
+ }
+
+ public void DequeueCallout(int requestId)
+ {
+ var status = GetRequestStatus(requestId);
+ switch (status)
+ {
+ case CalloutRequestStatus.Queued:
+ var index = _calloutQ.FindIndex(x => x.Index == requestId);
+ _calloutQ.RemoveAt(index);
+ break;
+ case CalloutRequestStatus.Playing:
+ _currentCalloutCts.Cancel();
+ break;
+ case CalloutRequestStatus.Finished:
+ Logger.Info(
+ $"Cannot dequeue callout request with id '{requestId}' because it already "
+ + "finished playing."
+ );
+ break;
+ case CalloutRequestStatus.UnknownId:
+ Logger.Error(
+ $"Cannot dequeue callout request with id '{requestId}' because no such "
+ + "request was previously made."
+ );
+ break;
+ }
+ }
+
+ public CalloutRequestStatus GetRequestStatus(int requestId)
+ {
+ if (requestId < 0 || requestId >= _requestCounter)
+ return CalloutRequestStatus.UnknownId;
+ if (_calloutQ.Any(x => x.Index == requestId))
+ return CalloutRequestStatus.Queued;
+ if (requestId == _idOfCurrentlyPlayingRequest)
+ return CalloutRequestStatus.Playing;
+ return CalloutRequestStatus.Finished;
+ }
+
+ private void OnEnable()
+ {
+ _loopCts = new();
+ _loopTask = CalloutLoop(_loopCts.Token);
+ }
+
+ private async void OnDisable()
+ {
+ _loopCts.Cancel();
+ _loopCts.Dispose();
+ _waitForNewCalloutTcs?.TrySetCanceled();
+ try
+ {
+ await _loopTask;
+ }
+ catch (OperationCanceledException) { }
+ }
+
+ private async Task CalloutLoop(CancellationToken ct)
+ {
+ while (true)
+ {
+ _calloutQ.RemoveAll(x => x.IsExpired());
+
+ if (_calloutQ.Count == 0)
+ {
+ _waitForNewCalloutTcs = new();
+ await _waitForNewCalloutTcs.Task;
+ }
+
+ var request = _calloutQ[0];
+ _calloutQ.RemoveAt(0);
+
+ _currentCalloutCts = new CancellationTokenSource();
+
+ try
+ {
+ using var playCts = CancellationTokenSource.CreateLinkedTokenSource(
+ ct,
+ _currentCalloutCts.Token
+ );
+
+ _idOfCurrentlyPlayingRequest = request.Index;
+ await Play(request, playCts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ // If only the current callout was canceled, continue, but if it's the whole
+ // loop, throw
+ ct.ThrowIfCancellationRequested();
+ }
+ finally
+ {
+ _idOfCurrentlyPlayingRequest = -1;
+ _currentCalloutCts.Dispose();
+ }
+
+ await Task.Delay(TimeSpan.FromSeconds(PauseDuration), ct);
+ }
+ }
+
+ private async Task Play(CalloutRequest request, CancellationToken ct)
+ {
+ ct.ThrowIfCancellationRequested();
+ var calloutGo = GetCalloutGameObject(request.CalloutAsset.name);
+ var audioSource = calloutGo.AddComponent();
+
+ try
+ {
+ request.CalloutAsset.ConfigureAudioSource(audioSource);
+ audioSource.volume *= request.Volume;
+ audioSource.Play();
+ await SoundAsset.WaitUntilAudioStops(audioSource, ct);
+ }
+ finally
+ {
+ if (audioSource != null)
+ {
+ if (Application.isPlaying)
+ Destroy(audioSource);
+ else
+ DestroyImmediate(audioSource);
+ }
+ Destroy(calloutGo);
+ }
+ }
+
+ private GameObject GetCalloutGameObject(string calloutName)
+ {
+ var calloutsGoName = $"Callout: {calloutName}";
+ var calloutGo = new GameObject(calloutsGoName);
+ calloutGo.transform.SetParent(transform, false);
+ return calloutGo;
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutCoordinator.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutCoordinator.cs.meta
new file mode 100644
index 000000000..739362f58
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutCoordinator.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 9a5b2585dbd60ec4c88d8ead8b06c545
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2a19a503f9b847f42bfcd456ec56f4b9, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutRequest.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutRequest.cs
new file mode 100644
index 000000000..6fbd91c51
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutRequest.cs
@@ -0,0 +1,68 @@
+// Visual Pinball Engine
+// Copyright (C) 2025 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using UnityEngine;
+
+namespace VisualPinball.Unity
+{
+ ///
+ /// Used to request from CalloutCoordinator to play a callout.
+ ///
+ public struct CalloutRequest
+ {
+ public SoundPriority Priority { get; private set; }
+
+ ///
+ /// Set by CalloutCoordinator to uniquely identify this request so it can be removed
+ /// later.
+ ///
+ public int Index { get; set; }
+
+ public readonly CalloutAsset CalloutAsset;
+ public readonly float Volume;
+ private readonly float _deadline;
+
+ ///
+ /// Create a callout
+ ///
+ /// The callout asset to play
+ /// Higher priority callouts will play before lower priority ones
+ /// How many seconds to wait in the queue before discarding the request. -1 = no limit
+ public CalloutRequest(
+ CalloutAsset calloutAsset,
+ SoundPriority priority,
+ float maxQueueTime = -1f,
+ float volume = 1f
+ )
+ {
+ CalloutAsset = calloutAsset;
+ Priority = priority;
+ if (maxQueueTime != -1f)
+ _deadline = Time.time + maxQueueTime;
+ else
+ _deadline = -1f;
+ Index = -1;
+ Volume = volume;
+ }
+
+ public readonly bool IsExpired()
+ {
+ if (_deadline == -1f)
+ return false;
+ return Time.time > _deadline;
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutRequest.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutRequest.cs.meta
new file mode 100644
index 000000000..65bec5c1c
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutRequest.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: df6efae3e44adcb4ba572487750d7b96
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2a19a503f9b847f42bfcd456ec56f4b9, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutRequester.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutRequester.cs
new file mode 100644
index 000000000..37cb6944c
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutRequester.cs
@@ -0,0 +1,66 @@
+// Visual Pinball Engine
+// Copyright (C) 2025 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+// ReSharper disable InconsistentNaming
+
+using UnityEngine;
+
+namespace VisualPinball.Unity
+{
+ ///
+ /// Requests a callout from the callout coordinator when enabled. Intended for testing.
+ ///
+ [PackAs("CalloutRequester")]
+ public class CalloutRequester : MonoBehaviour, IPackable
+ {
+ #region Data
+
+ public CalloutAsset CalloutAsset;
+ public SoundPriority Priority = SoundPriority.Medium;
+ public float MaxQueueTime = -1f;
+
+ #endregion
+
+ #region Packaging
+
+ public byte[] Pack() => PackageApi.Packer.Empty;
+
+ public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files)
+ => CalloutRequesterPackable.Pack(this, files);
+
+ public void Unpack(byte[] bytes) { }
+
+ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files)
+ => CalloutRequesterPackable.Unpack(data, this, files);
+
+ #endregion
+
+ private CalloutCoordinator _coordinator;
+ private int _requestId;
+
+ private void OnEnable()
+ {
+ var request = new CalloutRequest(CalloutAsset, Priority, MaxQueueTime);
+ _coordinator = GetComponentInParent();
+ _coordinator.EnqueueCallout(request, out _requestId);
+ }
+
+ private void OnDisable()
+ {
+ _coordinator.DequeueCallout(_requestId);
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutRequester.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutRequester.cs.meta
new file mode 100644
index 000000000..db06aa620
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/CalloutRequester.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: c716757e0309b354b92dfe38da29afe5
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2a19a503f9b847f42bfcd456ec56f4b9, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/CoilSoundComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/CoilSoundComponent.cs
index 10177f4cd..c7c66eb06 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Sound/CoilSoundComponent.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/CoilSoundComponent.cs
@@ -1,5 +1,5 @@
// Visual Pinball Engine
-// Copyright (C) 2023 freezy and VPE Team
+// Copyright (C) 2025 freezy and VPE Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
@@ -16,8 +16,8 @@
// ReSharper disable InconsistentNaming
-using UnityEngine;
using System;
+using UnityEngine;
using UnityEngine.Serialization;
namespace VisualPinball.Unity
@@ -29,7 +29,6 @@ namespace VisualPinball.Unity
[AddComponentMenu("Pinball/Sound/Coil Sound")]
public class CoilSoundComponent : BinaryEventSoundComponent, IPackable
{
- [FormerlySerializedAs("_coilName")]
[HideInInspector]
public string CoilName;
@@ -39,7 +38,8 @@ protected override bool TryFindEventSource(out IApiCoil coil)
{
coil = null;
var player = GetComponentInParent();
- if (player == null) {
+ if (player == null)
+ {
return false;
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/DefaultVpeAudioMixer.mixer b/VisualPinball.Unity/VisualPinball.Unity/Sound/DefaultVpeAudioMixer.mixer
new file mode 100644
index 000000000..9cd8b7832
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/DefaultVpeAudioMixer.mixer
@@ -0,0 +1,286 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!244 &-8334109828346926041
+AudioMixerEffectController:
+ m_ObjectHideFlags: 3
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_EffectID: eff2428ece60eda48a80a6a8abc3ff22
+ m_EffectName: Duck Volume
+ m_MixLevel: 54f3073382b8eae4b8f3611ed6782f31
+ m_Parameters:
+ - m_ParameterName: Threshold
+ m_GUID: 791e6d41fd93e50498fe331c0f888638
+ - m_ParameterName: Ratio
+ m_GUID: 90682c3f69ae7d54f93dba39c472609f
+ - m_ParameterName: Attack Time
+ m_GUID: b7f39c2fa2fc4c44f8b8cad505929ef2
+ - m_ParameterName: Release Time
+ m_GUID: e07e994de202e2c44a70456afe9ab8cf
+ - m_ParameterName: Make-up Gain
+ m_GUID: 92d7353fcaa43214ab3a7a17a9cb1b3c
+ - m_ParameterName: Knee
+ m_GUID: 4fa0d37fae8be3046813cde49a55748b
+ - m_ParameterName: Sidechain Mix
+ m_GUID: 48ef743385cff1446bf6029dab3acc34
+ m_SendTarget: {fileID: 0}
+ m_EnableWetMix: 0
+ m_Bypass: 0
+--- !u!244 &-8116594623855882042
+AudioMixerEffectController:
+ m_ObjectHideFlags: 3
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_EffectID: b1314cd09a7eacb4fa6f674040de19f8
+ m_EffectName: Highpass Simple
+ m_MixLevel: cf405d8e3e744304e8886e4ff753f06a
+ m_Parameters:
+ - m_ParameterName: Cutoff freq
+ m_GUID: 21aa5fdf1126d754c85525d781f5911d
+ m_SendTarget: {fileID: 0}
+ m_EnableWetMix: 0
+ m_Bypass: 0
+--- !u!243 &-6565917931112018152
+AudioMixerGroupController:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: Effects
+ m_AudioMixer: {fileID: 24100000}
+ m_GroupID: 23f384cf7e198a748a52e42e210503a2
+ m_Children: []
+ m_Volume: 6a27e933c59229941b94877ddda2a0a4
+ m_Pitch: c71a46cd2225c4e41ba8320e29968bee
+ m_Send: 00000000000000000000000000000000
+ m_Effects:
+ - {fileID: -4998164517406045731}
+ - {fileID: -4979870985345707829}
+ m_UserColorIndex: 0
+ m_Mute: 0
+ m_Solo: 0
+ m_BypassEffects: 0
+--- !u!244 &-5853359535173080414
+AudioMixerEffectController:
+ m_ObjectHideFlags: 3
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_EffectID: 13131384fccd6914683e386f11912549
+ m_EffectName: Send
+ m_MixLevel: 638bcdf758f780744a31aef9bb9d62c4
+ m_Parameters: []
+ m_SendTarget: {fileID: -8334109828346926041}
+ m_EnableWetMix: 0
+ m_Bypass: 0
+--- !u!244 &-4998164517406045731
+AudioMixerEffectController:
+ m_ObjectHideFlags: 3
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_EffectID: 539fb8c6eb4f21c4392061ffbff689ab
+ m_EffectName: Attenuation
+ m_MixLevel: bb194f9c22956ef40bb4839d8a12a465
+ m_Parameters: []
+ m_SendTarget: {fileID: 0}
+ m_EnableWetMix: 0
+ m_Bypass: 0
+--- !u!244 &-4979870985345707829
+AudioMixerEffectController:
+ m_ObjectHideFlags: 3
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_EffectID: 9ab6dbb8d758fa648a4dbf13c0bc748a
+ m_EffectName: Duck Volume
+ m_MixLevel: 01710c5afdf94b042acf83df011e3871
+ m_Parameters:
+ - m_ParameterName: Threshold
+ m_GUID: bd53ab0324170e94b807b825561c37ce
+ - m_ParameterName: Ratio
+ m_GUID: 12b212671de5c914690c37d68ce06e2f
+ - m_ParameterName: Attack Time
+ m_GUID: 2dd6fb49c7b872d48b20a7619f23db15
+ - m_ParameterName: Release Time
+ m_GUID: d877af337bea54041ae4ce9539309da8
+ - m_ParameterName: Make-up Gain
+ m_GUID: 15774c43089dd1c44aef03587d2e13c2
+ - m_ParameterName: Knee
+ m_GUID: f644c84e4b97f424eba94d8d1e1429f2
+ - m_ParameterName: Sidechain Mix
+ m_GUID: d069a153acddcf245a0c83222444f4da
+ m_SendTarget: {fileID: 0}
+ m_EnableWetMix: 0
+ m_Bypass: 0
+--- !u!244 &-4784353138324999412
+AudioMixerEffectController:
+ m_ObjectHideFlags: 3
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_EffectID: c2828ff4de248fe4aa8aac8722518a03
+ m_EffectName: Send
+ m_MixLevel: c22ab7d1f1e5599489ea086ff4e8261f
+ m_Parameters: []
+ m_SendTarget: {fileID: -4979870985345707829}
+ m_EnableWetMix: 0
+ m_Bypass: 0
+--- !u!243 &-4147333927001089018
+AudioMixerGroupController:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: Callouts
+ m_AudioMixer: {fileID: 24100000}
+ m_GroupID: 5cc611b5ca08f754c9b4d902bb28443e
+ m_Children: []
+ m_Volume: fe7c035780bfe3e4ea62d42040674206
+ m_Pitch: c999539d98fbb5c4d932f7bbe01155a8
+ m_Send: 00000000000000000000000000000000
+ m_Effects:
+ - {fileID: 546393981669936499}
+ - {fileID: -4784353138324999412}
+ - {fileID: -5853359535173080414}
+ m_UserColorIndex: 0
+ m_Mute: 0
+ m_Solo: 0
+ m_BypassEffects: 0
+--- !u!243 &-3693297557024079217
+AudioMixerGroupController:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: Music
+ m_AudioMixer: {fileID: 24100000}
+ m_GroupID: 661f9122445d74944a32cdf2eabb0acb
+ m_Children: []
+ m_Volume: f60208e2070094348b24113d51660342
+ m_Pitch: e8ee71c35b660474e9f986d7e7d8835a
+ m_Send: 00000000000000000000000000000000
+ m_Effects:
+ - {fileID: 6348350702205789860}
+ - {fileID: -8334109828346926041}
+ m_UserColorIndex: 0
+ m_Mute: 0
+ m_Solo: 0
+ m_BypassEffects: 0
+--- !u!241 &24100000
+AudioMixerController:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: DefaultVpeAudioMixer
+ m_OutputGroup: {fileID: 0}
+ m_MasterGroup: {fileID: 24300002}
+ m_Snapshots:
+ - {fileID: 24500006}
+ m_StartSnapshot: {fileID: 24500006}
+ m_SuspendThreshold: -80
+ m_EnableSuspend: 1
+ m_UpdateMode: 0
+ m_ExposedParameters: []
+ m_AudioMixerGroupViews:
+ - guids:
+ - e9d33368c702da24e8c7ef89306a2be6
+ - 23f384cf7e198a748a52e42e210503a2
+ - 661f9122445d74944a32cdf2eabb0acb
+ - 5cc611b5ca08f754c9b4d902bb28443e
+ name: View
+ m_CurrentViewIndex: 0
+ m_TargetSnapshot: {fileID: 24500006}
+--- !u!243 &24300002
+AudioMixerGroupController:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: Master
+ m_AudioMixer: {fileID: 24100000}
+ m_GroupID: e9d33368c702da24e8c7ef89306a2be6
+ m_Children:
+ - {fileID: -6565917931112018152}
+ - {fileID: -3693297557024079217}
+ - {fileID: -4147333927001089018}
+ m_Volume: 718a4a34fcd499340903b310278e884f
+ m_Pitch: 54892ee14efaf92498a6ca9506fab7ed
+ m_Send: 00000000000000000000000000000000
+ m_Effects:
+ - {fileID: 24400004}
+ m_UserColorIndex: 0
+ m_Mute: 0
+ m_Solo: 0
+ m_BypassEffects: 0
+--- !u!244 &24400004
+AudioMixerEffectController:
+ m_ObjectHideFlags: 3
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_EffectID: 36a42f013a30d8646ada1b5ec4c7c50a
+ m_EffectName: Attenuation
+ m_MixLevel: ccc7dc24eeb7a9b43aac741d069d671f
+ m_Parameters: []
+ m_SendTarget: {fileID: 0}
+ m_EnableWetMix: 0
+ m_Bypass: 0
+--- !u!245 &24500006
+AudioMixerSnapshotController:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: Snapshot
+ m_AudioMixer: {fileID: 24100000}
+ m_SnapshotID: 6d896a4b315f9994d91e85396c144f61
+ m_FloatValues:
+ 791e6d41fd93e50498fe331c0f888638: -60
+ c22ab7d1f1e5599489ea086ff4e8261f: 0
+ bd53ab0324170e94b807b825561c37ce: -60
+ 638bcdf758f780744a31aef9bb9d62c4: 0
+ 2dd6fb49c7b872d48b20a7619f23db15: 0.3
+ f644c84e4b97f424eba94d8d1e1429f2: 30
+ b7f39c2fa2fc4c44f8b8cad505929ef2: 0.8
+ 90682c3f69ae7d54f93dba39c472609f: 2
+ 4fa0d37fae8be3046813cde49a55748b: 30
+ m_TransitionOverrides: {}
+--- !u!244 &546393981669936499
+AudioMixerEffectController:
+ m_ObjectHideFlags: 3
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_EffectID: d251172e994b9d84ab4ff04543649f1a
+ m_EffectName: Attenuation
+ m_MixLevel: b54aaff025b8094428f62d49d1b8850f
+ m_Parameters: []
+ m_SendTarget: {fileID: 0}
+ m_EnableWetMix: 0
+ m_Bypass: 0
+--- !u!244 &6348350702205789860
+AudioMixerEffectController:
+ m_ObjectHideFlags: 3
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_EffectID: a9694ec9f4fdbad458656ad4b7632674
+ m_EffectName: Attenuation
+ m_MixLevel: 714dabe95f9ce1d4a94e3162f032ea9f
+ m_Parameters: []
+ m_SendTarget: {fileID: 0}
+ m_EnableWetMix: 0
+ m_Bypass: 0
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/DefaultVpeAudioMixer.mixer.meta b/VisualPinball.Unity/VisualPinball.Unity/Sound/DefaultVpeAudioMixer.mixer.meta
new file mode 100644
index 000000000..e200b01b2
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/DefaultVpeAudioMixer.mixer.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 9784dda595e200a4dbef54316a45f16e
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 24100000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/EventSoundComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/EventSoundComponent.cs
index f00b5a3e5..b22797ef4 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Sound/EventSoundComponent.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/EventSoundComponent.cs
@@ -1,5 +1,5 @@
// Visual Pinball Engine
-// Copyright (C) 2023 freezy and VPE Team
+// Copyright (C) 2025 freezy and VPE Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
@@ -17,23 +17,30 @@
namespace VisualPinball.Unity
{
///
- /// Start and or stop a sound when an event occurs.
+ /// Start a sound when an event occurs.
///
- public abstract class EventSoundComponent : SoundComponent where TEventSource : class
+ public abstract class EventSoundComponent : SoundComponent
+ where TEventSource : class
{
private TEventSource _eventSource;
+ public override bool SupportsLoopingSoundAssets() => false;
+
protected abstract bool TryFindEventSource(out TEventSource eventSource);
- protected abstract void OnEvent(object sender, TEventArgs e);
protected abstract void Subscribe(TEventSource eventSource);
protected abstract void Unsubscribe(TEventSource eventSource);
+ protected virtual void OnEvent(object sender, TEventArgs e) => StartSound();
+
protected override void OnEnableAfterAfterAwake()
{
base.OnEnableAfterAfterAwake();
- if (TryFindEventSource(out _eventSource)) {
+ if (TryFindEventSource(out _eventSource))
+ {
Subscribe(_eventSource);
- } else {
+ }
+ else
+ {
Logger.Warn(
$"Could not find sound event source of type {typeof(TEventSource).Name} on "
+ $"game object '{name}.' Make sure an appropriate component is attached."
@@ -44,7 +51,8 @@ protected override void OnEnableAfterAfterAwake()
protected override void OnDisable()
{
base.OnDisable();
- if (_eventSource != null) {
+ if (_eventSource != null)
+ {
Unsubscribe(_eventSource);
_eventSource = null;
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/HitSoundComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/HitSoundComponent.cs
index c8524331e..eaa3d810d 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Sound/HitSoundComponent.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/HitSoundComponent.cs
@@ -1,5 +1,5 @@
// Visual Pinball Engine
-// Copyright (C) 2023 freezy and VPE Team
+// Copyright (C) 2025 freezy and VPE Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
@@ -26,27 +26,25 @@ namespace VisualPinball.Unity
public class HitSoundComponent : EventSoundComponent
{
public override bool SupportsLoopingSoundAssets() => false;
+
public override Type GetRequiredType() => typeof(ItemComponent);
protected override bool TryFindEventSource(out IApiHittable hittable)
{
hittable = null;
var player = GetComponentInParent();
- if (player == null) {
+ if (player == null)
return false;
- }
- foreach (var component in GetComponents()) {
+ foreach (var component in GetComponents())
+ {
hittable = player.TableApi.Hittable(component);
- if (hittable != null) {
+ if (hittable != null)
return true;
- }
}
return false;
}
- protected override async void OnEvent(object sender, HitEventArgs e) => await Play();
-
protected override void Subscribe(IApiHittable eventSource) => eventSource.Hit += OnEvent;
protected override void Unsubscribe(IApiHittable eventSource) => eventSource.Hit -= OnEvent;
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicAsset.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicAsset.cs
new file mode 100644
index 000000000..e4ad0020c
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicAsset.cs
@@ -0,0 +1,45 @@
+// Visual Pinball Engine
+// Copyright (C) 2025 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System;
+using UnityEngine;
+
+namespace VisualPinball.Unity
+{
+ [CreateAssetMenu(
+ fileName = "MusicAsset",
+ menuName = "Pinball/Sound/MusicAsset",
+ order = 102
+ )]
+ [PackAs("MusicAsset")]
+ [PackWith(typeof(SoundAssetPacker))]
+ public class MusicAsset : SoundAsset
+ {
+ public float Volume => _volume;
+
+ public override bool Loop => true;
+
+ [SerializeField]
+ [Range(0f, 1f)]
+ private float _volume = 1f;
+
+ public override void ConfigureAudioSource(AudioSource audioSource)
+ {
+ base.ConfigureAudioSource(audioSource);
+ audioSource.volume = _volume;
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicAsset.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicAsset.cs.meta
new file mode 100644
index 000000000..38b82c8cb
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicAsset.cs.meta
@@ -0,0 +1,13 @@
+fileFormatVersion: 2
+guid: 3da6cef59ec67434e894090e6eda4c04
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences:
+ - _audioMixerGroup: {fileID: -3693297557024079217, guid: 9784dda595e200a4dbef54316a45f16e,
+ type: 2}
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 22fa0f3254800a748abe0d6b46930179, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicCoordinator.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicCoordinator.cs
new file mode 100644
index 000000000..3c274acf2
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicCoordinator.cs
@@ -0,0 +1,156 @@
+// Visual Pinball Engine
+// Copyright (C) 2025 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System.Collections.Generic;
+using System.Linq;
+using NLog;
+using UnityEngine;
+using Logger = NLog.Logger;
+
+namespace VisualPinball.Unity
+{
+ public enum MusicRequestStatus
+ {
+ UnknownId,
+ Waiting,
+ Playing,
+ Finished,
+ }
+
+ ///
+ /// Manages music playback using a stack. Other scripts can add requests to the stack.
+ /// The stack is sorted based on priority and age of the requests. The topmost request is
+ /// played. Music fades out when stopped. New music fades in if other music is playing.
+ ///
+ [PackAs("MusicCoordinator")]
+ public class MusicCoordinator : MonoBehaviour, IPackable
+ {
+ #region Data
+
+ [Tooltip("How many seconds should transitions between songs take?")]
+ [Range(0f, 10f)]
+ public float FadeDuration = 3f;
+
+ #endregion
+
+ #region Packaging
+
+ public byte[] Pack() => MusicCoordinatorPackable.Pack(this);
+
+ public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) => null;
+
+ public void Unpack(byte[] bytes) => MusicCoordinatorPackable.Unpack(bytes, this);
+
+ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files) { }
+
+ #endregion
+
+ private readonly List _players = new();
+ private readonly List _requestStack = new();
+ private int _requestCounter;
+
+ private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
+
+ ///
+ /// Insert a request into the request stack
+ ///
+ ///
+ /// A unique identifier that can be used to remove the request later
+ public void AddRequest(MusicRequest request, out int requestId)
+ {
+ request.Index = _requestCounter;
+ requestId = request.Index;
+ _requestCounter++;
+ _requestStack.Add(request);
+
+ EvaluateRequestStack();
+ }
+
+ ///
+ /// Remove a request that was previously added using AddRequest
+ ///
+ ///
+ public void RemoveRequest(int requestId)
+ {
+ var i = _requestStack.FindIndex(x => x.Index == requestId);
+ if (i != -1)
+ _requestStack.RemoveAt(i);
+ else
+ Logger.Error(
+ $"Can't remove music request with id '{requestId}' because there is no such "
+ + "request in the stack."
+ );
+
+ EvaluateRequestStack();
+ }
+
+ public MusicRequestStatus GetRequestStatus(int requestId)
+ {
+ if (requestId < 0 || requestId >= _requestCounter)
+ return MusicRequestStatus.UnknownId;
+ if (_requestStack.Any(x => x.Index == requestId))
+ return MusicRequestStatus.Waiting;
+ if (_requestStack.Count > 0 && requestId == _requestStack[0].Index)
+ return MusicRequestStatus.Playing;
+ return MusicRequestStatus.Finished;
+ }
+
+ private void EvaluateRequestStack()
+ {
+ _players.RemoveAll(x => x == null);
+ if (_requestStack.Count > 0)
+ {
+ _requestStack.Sort();
+ var requestToPlay = _requestStack[0];
+ var musicToPlay = requestToPlay.MusicAsset;
+ var playerToPlay = _players.FirstOrDefault(x => x.MusicAsset == musicToPlay);
+ if (playerToPlay == default)
+ {
+ var musicGo = GetMusicGameObject(musicToPlay.name);
+ playerToPlay = musicGo.AddComponent();
+ playerToPlay.Init(
+ musicToPlay,
+ FadeDuration,
+ MusicPlayer.AfterStopAction.DeleteGameObject
+ );
+ _players.Add(playerToPlay);
+ }
+
+ // No need to fade in if nothing else is playing
+ playerToPlay.StartAtFullVolume = !_players.Any(x => x.IsPlaying);
+ playerToPlay.RequestVolume = requestToPlay.Volume;
+ _players.ForEach(x => x.ShouldPlay = x == playerToPlay);
+ }
+ else
+ _players.ForEach(x => x.ShouldPlay = false);
+ }
+
+ private GameObject GetMusicGameObject(string musicName)
+ {
+ const string musicGoName = "Music";
+ var musicParent = transform.Find(musicGoName);
+ if (musicParent == null)
+ {
+ musicParent = new GameObject(musicGoName).transform;
+ musicParent.SetParent(transform, false);
+ }
+
+ var musicGo = new GameObject(musicName);
+ musicGo.transform.SetParent(musicParent, false);
+ return musicGo;
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicCoordinator.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicCoordinator.cs.meta
new file mode 100644
index 000000000..95b38ce27
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicCoordinator.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 24ce1835440b2a54f9c0886a734eb8f4
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 22fa0f3254800a748abe0d6b46930179, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicPlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicPlayer.cs
new file mode 100644
index 000000000..0a89d01fa
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicPlayer.cs
@@ -0,0 +1,129 @@
+// Visual Pinball Engine
+// Copyright (C) 2025 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using UnityEngine;
+#if UNITY_EDITOR
+using UnityEditor;
+#endif
+
+namespace VisualPinball.Unity
+{
+ ///
+ /// Manages the playback of a single music asset according to the ShouldPlay property
+ /// controlled by MusicCoordinator . Always fades towards the desired state, unless
+ /// StartAtFullVolume is true when starting.
+ ///
+ public class MusicPlayer : MonoBehaviour
+ {
+ public enum AfterStopAction
+ {
+ None,
+ DeleteSelf,
+ DeleteGameObject,
+ }
+
+ public bool ShouldPlay { get; set; }
+ public bool IsPlaying => _audioSource != null && _audioSource.isPlaying;
+ public bool StartAtFullVolume { get; set; }
+
+ ///
+ /// The volume specified in the MusicRequest received by MusicCoordinator
+ ///
+ public float RequestVolume { get; set; } = 1f;
+ public MusicAsset MusicAsset { get; private set; }
+
+ private AudioSource _audioSource;
+ private float _fadeDuration;
+ private AfterStopAction _afterStopAction;
+
+ public void Init(MusicAsset musicAsset, float fadeDuration, AfterStopAction afterStopAction)
+ {
+ MusicAsset = musicAsset;
+ _fadeDuration = fadeDuration;
+ _afterStopAction = afterStopAction;
+ }
+
+ private void Start()
+ {
+ _audioSource = gameObject.AddComponent();
+ _audioSource.volume = 0f;
+ }
+
+ private void Update()
+ {
+ if (_audioSource == null)
+ {
+ Destroy(this);
+ return;
+ }
+
+ var canPlay = _audioSource.isActiveAndEnabled;
+#if UNITY_EDITOR
+ canPlay &= EditorApplication.isFocused;
+#else
+ canPlay &= Application.isFocused;
+#endif
+ if (ShouldPlay && canPlay && !_audioSource.isPlaying)
+ {
+ var oldVolume = _audioSource.volume;
+ MusicAsset.ConfigureAudioSource(_audioSource);
+ _audioSource.Play();
+ _audioSource.volume = StartAtFullVolume
+ ? MusicAsset.Volume * RequestVolume
+ : oldVolume;
+ }
+ else if (!ShouldPlay && _audioSource.isPlaying && _audioSource.volume == 0f)
+ {
+ _audioSource.Stop();
+ switch (_afterStopAction)
+ {
+ case AfterStopAction.None:
+ break;
+ case AfterStopAction.DeleteSelf:
+ Destroy(this);
+ break;
+ case AfterStopAction.DeleteGameObject:
+ Destroy(gameObject);
+ break;
+ }
+ return;
+ }
+
+ var targetVolume = ShouldPlay ? MusicAsset.Volume * RequestVolume : 0f;
+ if (_audioSource.volume != targetVolume)
+ {
+ if (_fadeDuration == 0f)
+ {
+ _audioSource.volume = targetVolume;
+ }
+ else
+ {
+ if (_audioSource.volume < targetVolume)
+ _audioSource.volume += 1 / _fadeDuration * Time.deltaTime;
+ else
+ _audioSource.volume -= 1 / _fadeDuration * Time.deltaTime;
+ _audioSource.volume = Mathf.Clamp(_audioSource.volume, 0f, MusicAsset.Volume);
+ }
+ }
+ }
+
+ private void OnDestroy()
+ {
+ if (_audioSource != null)
+ Destroy(_audioSource);
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicPlayer.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicPlayer.cs.meta
new file mode 100644
index 000000000..95f7c5121
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicPlayer.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 94ff9bae4534d3341a33ee4a9127d64a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 22fa0f3254800a748abe0d6b46930179, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicRequest.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicRequest.cs
new file mode 100644
index 000000000..9b68c704a
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicRequest.cs
@@ -0,0 +1,59 @@
+// Visual Pinball Engine
+// Copyright (C) 2025 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System;
+
+namespace VisualPinball.Unity
+{
+ ///
+ /// Used to request from MusicCoordinator to play a MusicAsset . Supports sorting
+ /// to decide which request is played first.
+ ///
+ public struct MusicRequest : IComparable
+ {
+ public MusicRequest(
+ MusicAsset musicAsset,
+ SoundPriority priority = SoundPriority.Medium,
+ float volume = 1f
+ )
+ {
+ MusicAsset = musicAsset;
+ Priority = priority;
+ Index = -1;
+ Volume = volume;
+ }
+
+ public readonly MusicAsset MusicAsset;
+ public readonly SoundPriority Priority;
+ public readonly float Volume;
+
+ ///
+ /// The MusicCoordinator sets the Index of the n th request it receives
+ /// to n . This allows sorting requests by receive order and uniquely identifies each
+ /// request so it can be removed later using its Index .
+ ///
+ public int Index { get; set; }
+
+ // Used to sort the request stack to determine which request to play
+ public readonly int CompareTo(MusicRequest other)
+ {
+ if (Priority != other.Priority)
+ return other.Priority.CompareTo(Priority);
+ // If priority is the same, favor newer requests
+ return other.Index.CompareTo(Index);
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundUtils.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicRequest.cs.meta
similarity index 83%
rename from VisualPinball.Unity/VisualPinball.Unity/Sound/SoundUtils.cs.meta
rename to VisualPinball.Unity/VisualPinball.Unity/Sound/MusicRequest.cs.meta
index aba75f0dd..41238309c 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundUtils.cs.meta
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicRequest.cs.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 39c1161b40f535947ad995908584d38a
+guid: 4332fea2301a25948bd8b7976b93af53
MonoImporter:
externalObjects: {}
serializedVersion: 2
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicRequester.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicRequester.cs
new file mode 100644
index 000000000..a420f46fd
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicRequester.cs
@@ -0,0 +1,67 @@
+// Visual Pinball Engine
+// Copyright (C) 2025 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+// ReSharper disable InconsistentNaming
+
+using UnityEngine;
+
+namespace VisualPinball.Unity
+{
+ ///
+ /// Requests music from the music coordinator while enabled. Intended for testing.
+ ///
+ [PackAs("MusicRequester")]
+ public class MusicRequester : MonoBehaviour, IPackable
+ {
+ #region Data
+
+ public MusicAsset MusicAsset;
+ public SoundPriority Priority = SoundPriority.Medium;
+ public float Volume = 1f;
+
+ #endregion
+
+ #region Packaging
+
+ public byte[] Pack() => PackageApi.Packer.Empty;
+
+ public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files)
+ => MusicRequesterPackable.Pack(this, files);
+
+ public void Unpack(byte[] bytes) { }
+
+ public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files)
+ => MusicRequesterPackable.Unpack(data, this, files);
+
+ #endregion
+
+ private int requestId;
+
+ private MusicCoordinator _coordinator;
+
+ private void OnEnable()
+ {
+ var request = new MusicRequest(MusicAsset, Priority, Volume);
+ _coordinator = GetComponentInParent();
+ _coordinator.AddRequest(request, out requestId);
+ }
+
+ private void OnDisable()
+ {
+ _coordinator.RemoveRequest(requestId);
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicRequester.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicRequester.cs.meta
new file mode 100644
index 000000000..f773612b1
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/MusicRequester.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 6a3f0b8ca7954c04baffe0e10ea16eef
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 22fa0f3254800a748abe0d6b46930179, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundAsset.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundAsset.cs
index 81796dac6..ebdd00942 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundAsset.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundAsset.cs
@@ -1,127 +1,138 @@
-// Visual Pinball Engine
-// Copyright (C) 2023 freezy and VPE Team
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with this program. If not, see .
-
-// ReSharper disable InconsistentNaming
-
-using System;
-using Newtonsoft.Json;
-using UnityEngine;
-using UnityEngine.Audio;
-using UnityEngine.Serialization;
-using Random = UnityEngine.Random;
-
-namespace VisualPinball.Unity
-{
- ///
- /// Represents a reusable collection of similar sounds, for example different samples of a
- /// flipper mechanism getting triggered. Supports multiple techniques to introduce variation
- /// for frequently used sounds. Instances of this class can be stored in the project files or
- /// in an asset library.
- ///
- [PackAs("SoundAsset")]
- [CreateAssetMenu(fileName = "Sound", menuName = "Pinball/Sound", order = 102)]
- public class SoundAsset : ScriptableObject
- {
- public enum SelectionMethod
- {
- RoundRobin,
- Random
- }
-
- [FormerlySerializedAs("_description")]
- public string Description;
-
- [JsonIgnore]
- [FormerlySerializedAs("_clips")]
- public AudioClip[] Clips;
-
- [FormerlySerializedAs("_clipSelectionMethod")]
- public SelectionMethod ClipSelectionMethod;
-
- [FormerlySerializedAs("_volumeRange")]
- public Vector2 VolumeRange = new(1f, 1f);
-
- [FormerlySerializedAs("_pitchRange")]
- public Vector2 PitchRange = new(1f, 1f);
-
- [FormerlySerializedAs("_loop")]
- public bool Loop;
-
- [FormerlySerializedAs("_fadeInTime")]
- [SerializeField, Range(0, 10f)]
- public float FadeInTime;
-
- [FormerlySerializedAs("_fadeOutTime")]
- [SerializeField, Range(0, 10f)]
- public float FadeOutTime;
-
- [FormerlySerializedAs("_isSpatial")]
- [Tooltip("Should the sound appear to come from the position of the emitter?")]
- public bool IsSpatial = true;
-
- [SerializeField]
- private AudioMixerGroup _audioMixerGroup;
-
- private int _roundRobinIndex = 0;
-
- public void ConfigureAudioSource(AudioSource audioSource, float volume = 1)
- {
- audioSource.volume = volume * Random.Range(VolumeRange.x, VolumeRange.y);
- audioSource.pitch = Random.Range(PitchRange.x, PitchRange.y);
- audioSource.loop = Loop;
- audioSource.clip = GetClip();
- audioSource.spatialBlend = IsSpatial ? 0f : 1f;
- audioSource.outputAudioMixerGroup = _audioMixerGroup;
- }
-
- public bool IsValid()
- {
- if (Clips == null) {
- return false;
- }
-
- foreach (var clip in Clips) {
- if (clip != null) {
- return true;
- }
- }
-
- return false;
- }
-
- private AudioClip GetClip()
- {
- if (Clips.Length == 0) {
- throw new InvalidOperationException($"The sound asset '{name}' has no audio clips to play.");
- }
-
- switch (ClipSelectionMethod) {
-
- case SelectionMethod.RoundRobin:
- _roundRobinIndex %= Clips.Length;
- var clip = Clips[_roundRobinIndex];
- _roundRobinIndex++;
- return clip;
-
- case SelectionMethod.Random:
- return Clips[Random.Range(0, Clips.Length)];
-
- default:
- throw new NotImplementedException("Selection method not implemented.");
- }
- }
- }
-}
+// Visual Pinball Engine
+// Copyright (C) 2025 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.Audio;
+using Random = UnityEngine.Random;
+
+namespace VisualPinball.Unity
+{
+ public enum SoundPriority
+ {
+ Lowest,
+ Low,
+ Medium,
+ High,
+ Highest,
+ }
+
+ ///
+ /// Common base class for sound effect assets, callout assets, and music assets. Multiple audio
+ /// clips can be assigned for variation. Instances of this class are Unity assets and can
+ /// therefore be stored in the project files or in an asset library for reuse across tables.
+ ///
+ [CreateAssetMenu(fileName = "Sound", menuName = "Pinball/Sound", order = 102)]
+ [PackWith(typeof(SoundAssetPacker))]
+ public abstract class SoundAsset : ScriptableObject
+ {
+ public enum SelectionMethod
+ {
+ RoundRobin,
+ Random,
+ }
+
+ [SerializeField]
+ private string _description;
+
+ [JsonIgnore]
+ public AudioClip[] Clips;
+
+ [SerializeField]
+ private SelectionMethod _clipSelectionMethod;
+
+ [SerializeField]
+ private AudioMixerGroup _audioMixerGroup;
+
+ [NonSerialized]
+ private int _roundRobinIndex = 0;
+
+ public abstract bool Loop { get; }
+
+ public virtual void ConfigureAudioSource(AudioSource audioSource)
+ {
+ audioSource.clip = GetClip();
+ audioSource.outputAudioMixerGroup = _audioMixerGroup;
+ audioSource.playOnAwake = false;
+ }
+
+ public bool IsValid()
+ {
+ if (Clips == null)
+ return false;
+
+ foreach (var clip in Clips) {
+ if (clip != null)
+ return true;
+ }
+
+ return false;
+ }
+
+ private AudioClip GetClip()
+ {
+ if (Clips.Length == 0) {
+ throw new InvalidOperationException($"The sound asset '{name}' has no audio clips to play.");
+ }
+
+ switch (_clipSelectionMethod) {
+
+ case SelectionMethod.RoundRobin:
+ _roundRobinIndex %= Clips.Length;
+ var clip = Clips[_roundRobinIndex];
+ _roundRobinIndex++;
+ return clip;
+ case SelectionMethod.Random:
+ return Clips[Random.Range(0, Clips.Length)];
+
+ default:
+ throw new NotImplementedException("Selection method not implemented.");
+ }
+ }
+
+ ///
+ /// AudioSource.isPlaying returns false if the window loses focus, so that
+ /// needs to be checked as well to make sure an audio source that was playing actually has
+ /// stopped.
+ ///
+ public static bool HasStopped(AudioSource audioSource)
+ {
+ if (audioSource == null)
+ return true;
+ if (audioSource.isPlaying)
+ return false;
+#if UNITY_EDITOR
+ return EditorApplication.isFocused;
+#else
+ return Application.isFocused;
+#endif
+ }
+
+ public static async Task WaitUntilAudioStops(AudioSource audioSource, CancellationToken ct)
+ {
+ ct.ThrowIfCancellationRequested();
+ while (!HasStopped(audioSource))
+ {
+ await Task.Yield();
+ ct.ThrowIfCancellationRequested();
+ }
+ }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundAsset.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundAsset.cs.meta
index 69d638dc9..e67af3d04 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundAsset.cs.meta
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundAsset.cs.meta
@@ -3,7 +3,8 @@ guid: 8a6287696a5efed489f573445fda4418
MonoImporter:
externalObjects: {}
serializedVersion: 2
- defaultReferences: []
+ defaultReferences:
+ - _audioMixerGroup: {fileID: 24300002, guid: 9784dda595e200a4dbef54316a45f16e, type: 2}
executionOrder: 0
icon: {fileID: 2800000, guid: 22fa0f3254800a748abe0d6b46930179, type: 3}
userData:
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundAssetPacker.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundAssetPacker.cs
new file mode 100644
index 000000000..17b1b6816
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundAssetPacker.cs
@@ -0,0 +1,48 @@
+// Visual Pinball Engine
+// Copyright (C) 2025 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System;
+using System.Linq;
+
+namespace VisualPinball.Unity
+{
+ public class SoundAssetPacker : IPacker
+ {
+ public MetaPackable Pack(int instanceId, SoundAsset soundAsset, PackagedFiles files)
+ {
+ var clipRefs = soundAsset.Clips != null
+ ? soundAsset.Clips.Select(files.Add).ToArray()
+ : Array.Empty();
+
+ return new SoundAssetMetaPackable {
+ ClipRefs = clipRefs,
+ InstanceId = instanceId
+ };
+ }
+
+ public MetaPackable Unpack(byte[] bytes, SoundAsset soundAsset, PackagedFiles files)
+ {
+ var data = PackageApi.Packer.Unpack(bytes);
+ soundAsset.Clips = data.ClipRefs.Select(files.GetAudioClip).ToArray();
+ return data;
+ }
+ }
+
+ public class SoundAssetMetaPackable : MetaPackable
+ {
+ public string[] ClipRefs;
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundAssetPacker.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundAssetPacker.cs.meta
new file mode 100644
index 000000000..0d3f7b93f
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundAssetPacker.cs.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 69ab2b1b679f486fa819f5b2734cc29f
+timeCreated: 1741528397
\ No newline at end of file
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundComponent.cs
index c1a92856c..d687a3ef6 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundComponent.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundComponent.cs
@@ -1,5 +1,5 @@
// Visual Pinball Engine
-// Copyright (C) 2023 freezy and VPE Team
+// Copyright (C) 2025 freezy and VPE Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
@@ -14,18 +14,24 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
-// ReSharper disable InconsistentNaming
-
using System;
+using System.Collections.Generic;
+using System.Linq;
using NLog;
-using Logger = NLog.Logger;
-using System.Threading;
-using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Serialization;
+using Logger = NLog.Logger;
namespace VisualPinball.Unity
{
+ public enum MultiPlayMode
+ {
+ PlayInParallel,
+ DoNotPlay,
+ FadeOutPrevious,
+ StopPrevious,
+ }
+
///
/// Base component for playing a SoundAsset using the public methods Play and Stop .
///
@@ -33,22 +39,23 @@ namespace VisualPinball.Unity
[AddComponentMenu("Pinball/Sound/Sound")]
public class SoundComponent : EnableAfterAwakeComponent, IPackable
{
- [FormerlySerializedAs("_soundAsset")]
public SoundAsset SoundAsset;
+ public MultiPlayMode MultiPlayMode;
+ [Range(0f, 1f)] public float Volume = 1f;
+ public SoundPriority Priority = SoundPriority.Medium;
+ public float CalloutMaxQueueTime = -1;
- [FormerlySerializedAs("_interrupt")]
- [Tooltip("Should the sound be interrupted if it is triggered again while already playing?")]
- public bool Interrupt;
+ private CalloutCoordinator _calloutCoordinator;
+ private MusicCoordinator _musicCoordinator;
+ private readonly List _soundPlayers = new();
- [FormerlySerializedAs("_volume")]
- [SerializeField, Range(0f, 1f)]
- public float Volume = 1f;
-
- private CancellationTokenSource _instantCts;
- private CancellationTokenSource _allowFadeCts;
- private float _lastPlayStartTime = float.NegativeInfinity;
protected static readonly Logger Logger = LogManager.GetCurrentClassLogger();
+ public bool IsPlayingOrRequestingSound()
+ {
+ return _soundPlayers.Any(x => x.IsPlayingOrRequestingSound());
+ }
+
#region Packaging
public byte[] Pack() => SoundPackable.Pack(this);
@@ -58,69 +65,129 @@ public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles fi
public void Unpack(byte[] bytes) => SoundPackable.Unpack(bytes, this);
- public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files)
- => SoundReferencesPackable.Unpack(data, this, files);
+ public void UnpackReferences(
+ byte[] data,
+ Transform root,
+ PackagedRefs refs,
+ PackagedFiles files
+ ) => SoundReferencesPackable.Unpack(data, this, files);
#endregion
protected override void OnEnableAfterAfterAwake()
{
base.OnEnableAfterAfterAwake();
- _instantCts = new CancellationTokenSource();
- _allowFadeCts = new CancellationTokenSource();
+ _calloutCoordinator = GetComponentInParent();
+ if (_calloutCoordinator == null)
+ Logger.Error("No callout coordinator found in parents. Callouts will not work!");
+ _musicCoordinator = GetComponentInParent();
+ if (_musicCoordinator == null)
+ Logger.Error("No music coordinator found in parents. Music will not work!");
+ }
+
+ protected void Update()
+ {
+ for (int i = _soundPlayers.Count - 1; i >= 0; i--)
+ {
+ if (!_soundPlayers[i].IsPlayingOrRequestingSound())
+ {
+ _soundPlayers[i].Dispose();
+ _soundPlayers.RemoveAt(i);
+ }
+ }
}
protected virtual void OnDisable()
{
- _allowFadeCts?.Dispose();
- _allowFadeCts = null;
- _instantCts?.Cancel();
- _instantCts?.Dispose();
- _instantCts = null;
+ StopAllSounds(allowFade: true);
}
- public async Task Play(float volume = 1f)
+ protected virtual void OnDestroy()
{
- if (!isActiveAndEnabled) {
+ StopAllSounds(allowFade: false);
+ _soundPlayers.ForEach(x => x.Dispose());
+ }
+
+ protected void StartSound()
+ {
+ if (!isActiveAndEnabled)
+ {
Logger.Warn("Cannot play a disabled sound component.");
return;
}
- if (SoundAsset == null) {
+
+ if (SoundAsset == null)
+ {
Logger.Warn("Cannot play without sound asset. Assign it in the inspector.");
return;
}
- float timeSinceLastPlay = Time.unscaledTime - _lastPlayStartTime;
- if (timeSinceLastPlay < 0.01f) {
- Logger.Warn($"Sound spam protection engaged. Time since last play was less than " +
- $"0.01 seconds ({timeSinceLastPlay}). There is probably something wrong with " +
- $"the calling code.");
- return;
- }
- if (Interrupt) {
- Stop(allowFade: true);
+ if (IsPlayingOrRequestingSound())
+ {
+ if (SoundAsset is MusicAsset)
+ {
+ // We never want to have multiple active music requests. Makes no sense.
+ StopAllSounds(allowFade: true);
+ }
+ else
+ {
+ switch (MultiPlayMode)
+ {
+ case MultiPlayMode.PlayInParallel:
+ // Don't need to do anything.
+ break;
+ case MultiPlayMode.DoNotPlay:
+ return;
+ case MultiPlayMode.FadeOutPrevious:
+ StopAllSounds(allowFade: true);
+ break;
+ case MultiPlayMode.StopPrevious:
+ StopAllSounds(allowFade: false);
+ break;
+ }
+ }
}
- try {
- var combinedVol = Volume * volume;
- _lastPlayStartTime = Time.unscaledTime;
- await SoundUtils.Play(SoundAsset, gameObject, _allowFadeCts.Token, _instantCts.Token, combinedVol);
- } catch (OperationCanceledException) { }
+
+ var player = CreateSoundPlayer();
+ player.StartSound(Volume);
+ _soundPlayers.Add(player);
}
- public void Stop(bool allowFade)
+ protected void StopAllSounds(bool allowFade)
{
- if (!isActiveAndEnabled) {
- return;
+ _soundPlayers.ForEach(x => x.StopSound(allowFade));
+ }
+
+ private ISoundComponentSoundPlayer CreateSoundPlayer()
+ {
+ if (SoundAsset is SoundEffectAsset)
+ {
+ return new SoundComponentSoundEffectPlayer(
+ (SoundEffectAsset)SoundAsset,
+ gameObject
+ );
}
- if (allowFade) {
- _allowFadeCts?.Cancel();
- _allowFadeCts?.Dispose();
- _allowFadeCts = new CancellationTokenSource();
- } else {
- _instantCts?.Cancel();
- _instantCts?.Dispose();
- _instantCts = new CancellationTokenSource();
+
+ if (SoundAsset is CalloutAsset)
+ {
+ var request = new CalloutRequest(
+ (CalloutAsset)SoundAsset,
+ Priority,
+ CalloutMaxQueueTime,
+ Volume
+ );
+ return new SoundComponentCalloutPlayer(request, _calloutCoordinator);
}
+
+ if (SoundAsset is MusicAsset)
+ {
+ var request = new MusicRequest((MusicAsset)SoundAsset, Priority, Volume);
+ return new SoundComponentMusicPlayer(request, _musicCoordinator);
+ }
+
+ throw new NotImplementedException(
+ $"Unknown type of sound asset '{SoundAsset.GetType()}'"
+ );
}
public virtual bool SupportsLoopingSoundAssets() => true;
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundComponentSoundPlayer.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundComponentSoundPlayer.cs
new file mode 100644
index 000000000..0f6e143d0
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundComponentSoundPlayer.cs
@@ -0,0 +1,167 @@
+// Visual Pinball Engine
+// Copyright (C) 2025 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using UnityEngine;
+
+namespace VisualPinball.Unity
+{
+ ///
+ /// Single use object to manage the playback of a music, callout, or sound effect asset in the
+ /// context of a SoundComponent
+ ///
+ public interface ISoundComponentSoundPlayer : IDisposable
+ {
+ public void StartSound(float volume = 10f);
+ public void StopSound(bool allowFade);
+ public bool IsPlayingOrRequestingSound();
+ }
+
+ ///
+ /// Single use object to manage the playback of a sound effect asset in the context of a
+ /// SoundComponent
+ ///
+ public class SoundComponentSoundEffectPlayer : ISoundComponentSoundPlayer
+ {
+ private SoundEffectAsset _soundEffectAsset;
+ private GameObject _audioSourceGo;
+ private CancellationTokenSource _allowFadeCts;
+ private CancellationTokenSource _instantFadeCts;
+ private Task _playTask;
+
+ public SoundComponentSoundEffectPlayer(
+ SoundEffectAsset soundEffectAsset,
+ GameObject audioSourceGo
+ )
+ {
+ _soundEffectAsset = soundEffectAsset;
+ _audioSourceGo = audioSourceGo;
+ _allowFadeCts = new CancellationTokenSource();
+ _instantFadeCts = new CancellationTokenSource();
+ }
+
+ public async void StartSound(float volume)
+ {
+ _playTask = _soundEffectAsset.Play(
+ _audioSourceGo,
+ _allowFadeCts.Token,
+ _instantFadeCts.Token,
+ volume
+ );
+
+ try
+ {
+ await _playTask;
+ }
+ catch (OperationCanceledException) { }
+ }
+
+ public void StopSound(bool allowFade)
+ {
+ var ctsToCancel = allowFade ? _allowFadeCts : _instantFadeCts;
+ ctsToCancel.Cancel();
+ }
+
+ public bool IsPlayingOrRequestingSound()
+ {
+ return _playTask != null && !_playTask.IsCompleted;
+ }
+
+ public void Dispose()
+ {
+ _allowFadeCts.Dispose();
+ _instantFadeCts.Dispose();
+ }
+ }
+
+ ///
+ /// Single use object to manage the playback of a music asset in the context of a
+ /// SoundComponent
+ ///
+ public class SoundComponentMusicPlayer : ISoundComponentSoundPlayer
+ {
+ private MusicRequest _request;
+ private MusicCoordinator _coordinator;
+ private int _requestId = -1;
+
+ public SoundComponentMusicPlayer(MusicRequest request, MusicCoordinator coordinator)
+ {
+ _request = request;
+ _coordinator = coordinator;
+ }
+
+ public void StartSound(float volume)
+ {
+ _coordinator.AddRequest(_request, out _requestId);
+ }
+
+ public bool IsPlayingOrRequestingSound()
+ {
+ return _requestId != -1
+ && _coordinator.GetRequestStatus(_requestId)
+ is MusicRequestStatus.Waiting
+ or MusicRequestStatus.Playing;
+ }
+
+ public void StopSound(bool allowFade)
+ {
+ if (_coordinator != null && IsPlayingOrRequestingSound())
+ _coordinator.RemoveRequest(_requestId);
+ }
+
+ public void Dispose() { }
+ }
+
+ ///
+ /// Single use object to manage the playback of a callout asset in the context of a
+ /// SoundComponent
+ ///
+ public class SoundComponentCalloutPlayer : ISoundComponentSoundPlayer
+ {
+ private CalloutRequest _request;
+ private CalloutCoordinator _coordinator;
+ private int _requestId = -1;
+
+ public SoundComponentCalloutPlayer(CalloutRequest request, CalloutCoordinator coordinator)
+ {
+ _request = request;
+ _coordinator = coordinator;
+ }
+
+ public void StartSound(float volume = 10)
+ {
+ _coordinator.EnqueueCallout(_request, out _requestId);
+ }
+
+ public void StopSound(bool allowFade)
+ {
+ if (_coordinator != null && IsPlayingOrRequestingSound())
+ _coordinator.DequeueCallout(_requestId);
+ }
+
+ public bool IsPlayingOrRequestingSound()
+ {
+ return _requestId != -1
+ && _coordinator.GetRequestStatus(_requestId)
+ is CalloutRequestStatus.Queued
+ or CalloutRequestStatus.Playing;
+ }
+
+ public void Dispose() { }
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CoilSoundComponentInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundComponentSoundPlayer.cs.meta
similarity index 52%
rename from VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CoilSoundComponentInspector.cs.meta
rename to VisualPinball.Unity/VisualPinball.Unity/Sound/SoundComponentSoundPlayer.cs.meta
index 515550dac..f568fde6a 100644
--- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Sound/CoilSoundComponentInspector.cs.meta
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundComponentSoundPlayer.cs.meta
@@ -1,11 +1,9 @@
fileFormatVersion: 2
-guid: efd14260f42063e4aadba8b265bad210
+guid: d6d34227b5cb9c243b5fe62262cd505c
MonoImporter:
externalObjects: {}
serializedVersion: 2
- defaultReferences:
- - inspectorXml: {fileID: 9197481963319205126, guid: 2e43810c575be6d46ae19bed7c1b38bd,
- type: 3}
+ defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundEffectAsset.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundEffectAsset.cs
new file mode 100644
index 000000000..518fb1c39
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundEffectAsset.cs
@@ -0,0 +1,249 @@
+// Visual Pinball Engine
+// Copyright (C) 2025 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+// ReSharper disable InconsistentNaming
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using NLog;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+using Logger = NLog.Logger;
+#if UNITY_EDITOR
+using UnityEditor;
+using UnityEditor.SceneManagement;
+#endif
+
+namespace VisualPinball.Unity
+{
+ public enum SoundEffectType
+ {
+ Mechanical,
+ Synthetic,
+ }
+
+ ///
+ /// Represents a reusable collection of similar sounds, for example different samples of a
+ /// flipper mechanism getting triggered. Supports randomizing pitch and volume to introduce
+ /// variation. Supports looping sounds with optional fade.
+ ///
+ [CreateAssetMenu(
+ fileName = "SoundEffect",
+ menuName = "Pinball/Sound/SoundEffectAsset",
+ order = 102
+ )]
+ [PackAs("SoundEffectAsset")]
+ public class SoundEffectAsset : SoundAsset
+ {
+ public override bool Loop => _loop;
+
+ [SerializeField]
+ private Vector2 _volumeRange = new(1f, 1f);
+
+ [SerializeField]
+ private Vector2 _pitchRange = new(1f, 1f);
+
+ [SerializeField]
+ private SoundEffectType _type;
+
+ [SerializeField]
+ private bool _loop;
+
+ [SerializeField, Range(0, 10f)]
+ private float _fadeInTime;
+
+ [SerializeField, Range(0, 10f)]
+ private float _fadeOutTime;
+
+ [SerializeField, Range(0, 0.2f)]
+ private float _cooldown = 0.02f;
+
+ [NonSerialized]
+ private float _lastPlayStartTime = -1f;
+
+ protected static readonly Logger Logger = LogManager.GetCurrentClassLogger();
+
+ public override void ConfigureAudioSource(AudioSource audioSource)
+ {
+ base.ConfigureAudioSource(audioSource);
+ audioSource.spatialBlend = _type == SoundEffectType.Mechanical ? 1f : 0f;
+ audioSource.volume = UnityEngine.Random.Range(_volumeRange.x, _volumeRange.y);
+ audioSource.pitch = UnityEngine.Random.Range(_pitchRange.x, _pitchRange.y);
+ audioSource.loop = _loop;
+ }
+
+ public async Task Play(
+ GameObject audioObj,
+ CancellationToken allowFadeOutCt,
+ CancellationToken instantCt,
+ float volume = 1f
+ )
+ {
+ float timeSinceLastPlay = Time.unscaledTime - _lastPlayStartTime;
+ if (timeSinceLastPlay < _cooldown)
+ {
+ Logger.Warn(
+ $"Will not play sound effect '{name}' because the time since last play is "
+ + $"{timeSinceLastPlay} seconds, which is less than the cooldown of "
+ + $"{_cooldown} seconds. If this is not intended by the table author, they "
+ + $"should lower the '{nameof(_cooldown)}' parameter in the sound effect "
+ + "asset inspector."
+ );
+ return;
+ }
+ _lastPlayStartTime = Time.unscaledTime;
+
+ var audioSource = audioObj.AddComponent();
+
+ try
+ {
+ ConfigureAudioSource(audioSource);
+ audioSource.volume *= volume;
+ audioSource.Play();
+ using var eitherCts = CancellationTokenSource.CreateLinkedTokenSource(
+ allowFadeOutCt,
+ instantCt
+ );
+
+ // Fade in
+ if (_loop && _fadeInTime > 0f)
+ {
+ try
+ {
+ await FadeOrFinish(
+ audioSource,
+ 0f,
+ audioSource.volume,
+ _fadeInTime,
+ eitherCts.Token
+ );
+ }
+ catch (OperationCanceledException)
+ {
+ instantCt.ThrowIfCancellationRequested();
+ }
+ }
+
+ // Play until sound stops or cancellation is requested
+ try
+ {
+ await WaitUntilAudioStops(audioSource, eitherCts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ instantCt.ThrowIfCancellationRequested();
+ // Fade out
+ if (_loop && _fadeOutTime > 0f)
+ {
+ await FadeOrFinish(
+ audioSource,
+ audioSource.volume,
+ 0f,
+ _fadeOutTime,
+ instantCt
+ );
+ }
+ allowFadeOutCt.ThrowIfCancellationRequested();
+ }
+ }
+ finally
+ {
+ if (audioSource != null)
+ {
+ if (Application.isPlaying)
+ Destroy(audioSource);
+ else
+ DestroyImmediate(audioSource);
+ }
+ }
+ }
+
+ public static async Task FadeOrFinish(
+ AudioSource audioSource,
+ float fromVolume,
+ float toVolume,
+ float duration,
+ CancellationToken ct
+ )
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+
+ var waitUntilStopTask = WaitUntilAudioStops(audioSource, cts.Token);
+ var fadeTask = Fade(audioSource, fromVolume, toVolume, duration, cts.Token);
+
+ var completedTask = await Task.WhenAny(waitUntilStopTask, fadeTask);
+ cts.Cancel();
+ try
+ {
+ await Task.WhenAll(waitUntilStopTask, fadeTask);
+ }
+ catch (OperationCanceledException)
+ {
+ ct.ThrowIfCancellationRequested();
+ }
+ }
+
+ public static async Task Fade(
+ AudioSource audioSource,
+ float fromVolume,
+ float toVolume,
+ float duration,
+ CancellationToken ct
+ )
+ {
+ float progress = 0f;
+#if UNITY_EDITOR
+ // Time.deltaTime doesn't really work in the editor outside play mode
+ var lastTime = EditorApplication.timeSinceStartup;
+#endif
+ while (progress < 1f && audioSource != null)
+ {
+ ct.ThrowIfCancellationRequested();
+ audioSource.volume = Mathf.Lerp(fromVolume, toVolume, progress);
+#if UNITY_EDITOR
+ var deltaTime = (float)(EditorApplication.timeSinceStartup - lastTime);
+ lastTime = EditorApplication.timeSinceStartup;
+#else
+ var deltaTime = Time.deltaTime;
+#endif
+ progress += 1f / duration * deltaTime;
+ await Task.Yield();
+ }
+ }
+
+#if UNITY_EDITOR
+ public async Task PlayInEditorPreviewScene(
+ CancellationToken allowFadeOutCt,
+ CancellationToken instantCt
+ )
+ {
+ Scene previewScene = EditorSceneManager.NewPreviewScene();
+ try
+ {
+ previewScene.name = "VPE Audio Preview";
+ var audioObj = new GameObject("Audio Preview");
+ SceneManager.MoveGameObjectToScene(audioObj, previewScene);
+ await Play(audioObj, allowFadeOutCt, instantCt);
+ }
+ finally
+ {
+ EditorSceneManager.ClosePreviewScene(previewScene);
+ }
+ }
+#endif
+ }
+}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundEffectAsset.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundEffectAsset.cs.meta
new file mode 100644
index 000000000..69ae5d81e
--- /dev/null
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundEffectAsset.cs.meta
@@ -0,0 +1,13 @@
+fileFormatVersion: 2
+guid: 6e0ad37c5157a844b9026bfcfe140656
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences:
+ - _audioMixerGroup: {fileID: -6565917931112018152, guid: 9784dda595e200a4dbef54316a45f16e,
+ type: 2}
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 22fa0f3254800a748abe0d6b46930179, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundPackable.cs
index 3df836b1a..fcef6e35f 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundPackable.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundPackable.cs
@@ -17,25 +17,30 @@
// ReSharper disable MemberCanBePrivate.Global
using System;
-using System.Linq;
namespace VisualPinball.Unity
{
- public class SoundPackable {
-
- public bool Interrupt;
+ public class SoundPackable
+ {
+ public MultiPlayMode MultiPlayMode;
public float Volume;
+ public SoundPriority Priority;
+ public float CalloutMaxQueueTime;
public static byte[] Pack(SoundComponent comp) => PackageApi.Packer.Pack(new SoundPackable {
- Interrupt = comp.Interrupt,
+ MultiPlayMode = comp.MultiPlayMode,
Volume = comp.Volume,
+ Priority = comp.Priority,
+ CalloutMaxQueueTime = comp.CalloutMaxQueueTime,
});
public static void Unpack(byte[] bytes, SoundComponent comp)
{
var data = PackageApi.Packer.Unpack(bytes);
- comp.Interrupt = data.Interrupt;
+ comp.MultiPlayMode = data.MultiPlayMode;
comp.Volume = data.Volume;
+ comp.Priority = data.Priority;
+ comp.CalloutMaxQueueTime = data.CalloutMaxQueueTime;
}
}
@@ -51,8 +56,10 @@ public class SwitchSoundPackable : BinaryEventSoundPackable
public static byte[] Pack(SwitchSoundComponent comp)
=> PackageApi.Packer.Pack(new SwitchSoundPackable {
- Interrupt = comp.Interrupt,
+ MultiPlayMode = comp.MultiPlayMode,
Volume = comp.Volume,
+ Priority = comp.Priority,
+ CalloutMaxQueueTime = comp.CalloutMaxQueueTime,
StartWhen = comp.StartWhen,
StopWhen = comp.StopWhen,
SwitchName = comp.SwitchName
@@ -61,8 +68,10 @@ public static byte[] Pack(SwitchSoundComponent comp)
public static void Unpack(byte[] bytes, SwitchSoundComponent comp)
{
var data = PackageApi.Packer.Unpack(bytes);
- comp.Interrupt = data.Interrupt;
+ comp.MultiPlayMode = data.MultiPlayMode;
comp.Volume = data.Volume;
+ comp.Priority = data.Priority;
+ comp.CalloutMaxQueueTime = data.CalloutMaxQueueTime;
comp.StartWhen = data.StartWhen;
comp.StopWhen = data.StopWhen;
comp.SwitchName = data.SwitchName;
@@ -75,8 +84,10 @@ public class CoilSoundPackable : BinaryEventSoundPackable
public static byte[] Pack(CoilSoundComponent comp)
=> PackageApi.Packer.Pack(new CoilSoundPackable {
- Interrupt = comp.Interrupt,
+ MultiPlayMode = comp.MultiPlayMode,
Volume = comp.Volume,
+ Priority = comp.Priority,
+ CalloutMaxQueueTime = comp.CalloutMaxQueueTime,
StartWhen = comp.StartWhen,
StopWhen = comp.StopWhen,
CoilName = comp.CoilName
@@ -85,8 +96,10 @@ public static byte[] Pack(CoilSoundComponent comp)
public static void Unpack(byte[] bytes, CoilSoundComponent comp)
{
var data = PackageApi.Packer.Unpack(bytes);
- comp.Interrupt = data.Interrupt;
+ comp.MultiPlayMode = data.MultiPlayMode;
comp.Volume = data.Volume;
+ comp.Priority = data.Priority;
+ comp.CalloutMaxQueueTime = data.CalloutMaxQueueTime;
comp.StartWhen = data.StartWhen;
comp.StopWhen = data.StopWhen;
comp.CoilName = data.CoilName;
@@ -96,7 +109,6 @@ public static void Unpack(byte[] bytes, CoilSoundComponent comp)
public struct SoundReferencesPackable {
public int SoundAssetRef;
- public string[] ClipRefs;
public static byte[] PackReferences(SoundComponent comp, PackagedFiles files)
{
@@ -104,17 +116,8 @@ public static byte[] PackReferences(SoundComponent comp, PackagedFiles files)
return Array.Empty();
}
- // pack asset
- var assetRef = files.AddAsset(comp.SoundAsset);
-
- // pack sound files
- var clipRefs = comp.SoundAsset.Clips != null
- ? comp.SoundAsset.Clips.Select(files.Add).ToArray()
- : Array.Empty();
-
return PackageApi.Packer.Pack(new SoundReferencesPackable {
- SoundAssetRef = assetRef,
- ClipRefs = clipRefs
+ SoundAssetRef = files.AddAsset(comp.SoundAsset),
});
}
@@ -122,7 +125,6 @@ public static void Unpack(byte[] bytes, SoundComponent comp, PackagedFiles files
{
var data = PackageApi.Packer.Unpack(bytes);
comp.SoundAsset = files.GetAsset(data.SoundAssetRef);
- comp.SoundAsset.Clips = data.ClipRefs.Select(files.GetAudioClip).ToArray();
}
}
@@ -131,4 +133,80 @@ public struct SoundMetaPackable
public string Guid;
// will probably get more data in here
}
+
+ public struct CalloutCoordinatorPackable
+ {
+ public float PauseDuration;
+
+ public static byte[] Pack(CalloutCoordinator comp)
+ => PackageApi.Packer.Pack(new CalloutCoordinatorPackable {
+ PauseDuration = comp.PauseDuration
+ });
+
+ public static void Unpack(byte[] bytes, CalloutCoordinator comp)
+ {
+ var data = PackageApi.Packer.Unpack(bytes);
+ comp.PauseDuration = data.PauseDuration;
+ }
+ }
+
+ public struct CalloutRequesterPackable
+ {
+ public int CalloutAssetRef;
+ public SoundPriority Priority;
+ public float MaxQueueTime;
+
+ public static byte[] Pack(CalloutRequester comp, PackagedFiles files)
+ => PackageApi.Packer.Pack(new CalloutRequesterPackable {
+ CalloutAssetRef = files.AddAsset(comp.CalloutAsset),
+ Priority = comp.Priority,
+ MaxQueueTime = comp.MaxQueueTime
+ });
+
+ public static void Unpack(byte[] bytes, CalloutRequester comp, PackagedFiles files)
+ {
+ var data = PackageApi.Packer.Unpack(bytes);
+ comp.CalloutAsset = files.GetAsset(data.CalloutAssetRef);
+ comp.Priority = data.Priority;
+ comp.MaxQueueTime = data.MaxQueueTime;
+ }
+ }
+
+ public struct MusicRequesterPackable
+ {
+ public int MusicAssetRef;
+ public SoundPriority Priority;
+ public float Volume;
+
+ public static byte[] Pack(MusicRequester comp, PackagedFiles files)
+ => PackageApi.Packer.Pack(new MusicRequesterPackable {
+ MusicAssetRef = files.AddAsset(comp.MusicAsset),
+ Priority = comp.Priority,
+ Volume = comp.Volume
+ });
+
+ public static void Unpack(byte[] bytes, MusicRequester comp, PackagedFiles files)
+ {
+ var data = PackageApi.Packer.Unpack(bytes);
+ comp.MusicAsset = files.GetAsset(data.MusicAssetRef);
+ comp.Priority = data.Priority;
+ comp.Volume = data.Volume;
+ }
+ }
+
+ public struct MusicCoordinatorPackable
+ {
+ public float FadeDuration;
+
+ public static byte[] Pack(MusicCoordinator comp)
+ => PackageApi.Packer.Pack(new MusicCoordinatorPackable {
+ FadeDuration = comp.FadeDuration
+ });
+
+ public static void Unpack(byte[] bytes, MusicCoordinator comp)
+ {
+ var data = PackageApi.Packer.Unpack(bytes);
+ comp.FadeDuration = data.FadeDuration;
+ }
+ }
}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundUtils.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundUtils.cs
deleted file mode 100644
index 12c8c83d7..000000000
--- a/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundUtils.cs
+++ /dev/null
@@ -1,116 +0,0 @@
-// Visual Pinball Engine
-// Copyright (C) 2023 freezy and VPE Team
-//
-// This program is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// This program is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with this program. If not, see .
-
-using UnityEngine;
-using System.Threading;
-using System.Threading.Tasks;
-using System;
-using UnityEngine.SceneManagement;
-#if UNITY_EDITOR
-using UnityEditor;
-using UnityEditor.SceneManagement;
-#endif
-
-namespace VisualPinball.Unity
-{
- ///
- /// Provides utility functions for playing sounds at runtime and in the editor with support for fading the volume in and out.
- ///
- public static class SoundUtils
- {
- public static async Task Fade(AudioSource audioSource, float fromVolume, float toVolume, float duration, CancellationToken ct)
- {
- float progress = 0f;
-#if UNITY_EDITOR
- // Time.deltaTime doesn't really work in the editor outside play mode
- var lastTime = EditorApplication.timeSinceStartup;
-#endif
- while (progress < 1f) {
- ct.ThrowIfCancellationRequested();
- audioSource.volume = Mathf.Lerp(fromVolume, toVolume, progress);
-#if UNITY_EDITOR
- var deltaTime = (float)(EditorApplication.timeSinceStartup - lastTime);
- lastTime = EditorApplication.timeSinceStartup;
-#else
- var deltaTime = Time.deltaTime;
-#endif
- progress += (1f / duration) * deltaTime;
- await Task.Yield();
- }
- }
-
-#if UNITY_EDITOR
- public static async Task PlayInEditorPreviewScene(SoundAsset sound, CancellationToken allowFadeOutCt, CancellationToken instantCt)
- {
- Scene previewScene = EditorSceneManager.NewPreviewScene();
- try {
- previewScene.name = "VPE Audio Preview";
- var audioObj = new GameObject("Audio Preview");
- SceneManager.MoveGameObjectToScene(audioObj, previewScene);
- await Play(sound, audioObj, allowFadeOutCt, instantCt);
- } finally {
- EditorSceneManager.ClosePreviewScene(previewScene);
- }
- }
-#endif
-
- public static async Task Play(SoundAsset sound, GameObject audioObj, CancellationToken allowFadeOutCt, CancellationToken instantCt, float volume = 1f)
- {
- var audioSource = audioObj.AddComponent();
-
- try {
- sound.ConfigureAudioSource(audioSource);
- audioSource.volume *= volume;
- audioSource.Play();
- using var eitherCts = CancellationTokenSource.CreateLinkedTokenSource(allowFadeOutCt, instantCt);
-
- // Fade in
- if (sound.Loop && sound.FadeInTime > 0f) {
- try {
- await Fade(audioSource, 0f, audioSource.volume, sound.FadeInTime, eitherCts.Token);
- } catch (OperationCanceledException) { }
- }
- instantCt.ThrowIfCancellationRequested();
-
- // Play until sound stops or cancellation is requested
- try {
- await WaitUntilAudioStops(audioSource, eitherCts.Token);
- } catch (OperationCanceledException) { }
- instantCt.ThrowIfCancellationRequested();
-
- // Fade out
- if (audioSource.isPlaying && sound.Loop && sound.FadeOutTime > 0f) {
- await Fade(audioSource, audioSource.volume, 0f, sound.FadeOutTime, instantCt);
- }
- } finally {
- if (audioSource != null) {
- if (Application.isPlaying)
- UnityEngine.Object.Destroy(audioSource);
- else
- UnityEngine.Object.DestroyImmediate(audioSource);
- }
- }
- }
-
- public static async Task WaitUntilAudioStops(AudioSource audioSource, CancellationToken ct)
- {
- while (audioSource != null && (audioSource.isPlaying || (Application.isPlaying && !Application.isFocused))) {
- await Task.Yield();
- ct.ThrowIfCancellationRequested();
- }
- }
- }
-}
diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/SwitchSoundComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/SwitchSoundComponent.cs
index 1aa95c6d4..74242d951 100644
--- a/VisualPinball.Unity/VisualPinball.Unity/Sound/SwitchSoundComponent.cs
+++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/SwitchSoundComponent.cs
@@ -1,5 +1,5 @@
// Visual Pinball Engine
-// Copyright (C) 2023 freezy and VPE Team
+// Copyright (C) 2025 freezy and VPE Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
@@ -29,7 +29,6 @@ namespace VisualPinball.Unity
[AddComponentMenu("Pinball/Sound/Switch Sound")]
public class SwitchSoundComponent : BinaryEventSoundComponent, IPackable
{
- [FormerlySerializedAs("_switchName")]
[HideInInspector]
public string SwitchName;
@@ -39,22 +38,21 @@ protected override bool TryFindEventSource(out IApiSwitch @switch)
{
@switch = null;
var player = GetComponentInParent();
- if (player == null) {
+ if (player == null)
return false;
- }
foreach (var component in GetComponents()) {
@switch = player.Switch(component, SwitchName);
- if (@switch != null) {
+ if (@switch != null)
return true;
- }
}
return false;
}
protected override void Subscribe(IApiSwitch eventSource) => eventSource.Switch += OnEvent;
- protected override void Unsubscribe(IApiSwitch eventSource) => eventSource.Switch -= OnEvent;
+ protected override void Unsubscribe(IApiSwitch eventSource) =>
+ eventSource.Switch -= OnEvent;
protected override bool InterpretAsBinary(SwitchEventArgs e) => e.IsEnabled;