Created
March 26, 2026 01:29
-
-
Save AldeRoberge/ff11db0415ee6bb1e02d006dcac151f4 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System; | |
| using System.Collections; | |
| using System.IO; | |
| using System.Runtime.InteropServices; | |
| using FMOD; | |
| using FMOD.Studio; | |
| using FMODUnity; | |
| using JetBrains.Annotations; | |
| using UnityEngine; | |
| using Debug = UnityEngine.Debug; | |
| using INITFLAGS = FMOD.INITFLAGS; | |
| namespace ADG.Scripts.Runtime.CoolStuff | |
| { | |
| // This class demonstrates how to play an audio file from an absolute path | |
| // using a Programmer Instrument (in Play mode) and a custom Core API–based system in Edit mode. | |
| public static class FMODPlayFromFilesystem | |
| { | |
| // Flag to ensure the FMOD Core system is initialized only once. | |
| private static bool _hasBeenInit = false; | |
| private static FMOD.System _coreSystem; | |
| // Callback for programmer instrument events (used only in Play mode). | |
| private static EVENT_CALLBACK dialogueCallback; | |
| // These are the two event paths defined in FMOD Studio. | |
| public const string OverPhone = "event:/Programmer Instrument Phone"; | |
| public const string Default = "event:/Programmer Instrument Default"; | |
| // Studio EventInstances (only used in Play mode). | |
| private static EventInstance _overPhone = default; | |
| private static EventInstance _default = default; | |
| [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] | |
| private static void Init() | |
| { | |
| _coreSystem = default; | |
| _hasBeenInit = false; | |
| // Set up the callback (used when playing via the Studio system in Play mode). | |
| dialogueCallback = DialogueEventCallback; | |
| // In Play mode we initialize the event instances. | |
| if (Application.isPlaying) | |
| { | |
| InitOverPhone(); | |
| InitDefault(); | |
| } | |
| Application.quitting += OnApplicationQuit; | |
| } | |
| private static void OnApplicationQuit() | |
| { | |
| _coreSystem = default; | |
| _hasBeenInit = false; | |
| if (_overPhone.isValid()) | |
| { | |
| _overPhone.release(); | |
| _overPhone = default; | |
| } | |
| if (_default.isValid()) | |
| { | |
| _default.release(); | |
| _default = default; | |
| } | |
| Application.quitting -= OnApplicationQuit; | |
| } | |
| /// <summary> | |
| /// Public method to play an audio file from an absolute path. | |
| /// </summary> | |
| /// <param name="absolutePath">Absolute path to the audio file (.mp3, .wav, etc.)</param> | |
| /// <param name="finished">Optional callback when playback finishes</param> | |
| /// <param name="isOverPhone">If true, use the OverPhone event; otherwise use the Default event (only in Play mode)</param> | |
| public static void Play(string absolutePath, Action? finished = null, bool isOverPhone = false) | |
| { | |
| if (string.IsNullOrEmpty(absolutePath)) | |
| throw new ArgumentException("Path cannot be null or empty", nameof(absolutePath)); | |
| if (!File.Exists(absolutePath)) | |
| throw new ArgumentException($"Audio file '{absolutePath}' does not exist.", nameof(absolutePath)); | |
| CoroutineRunner.Instance.StartCoroutine(PlayAudioFromPath(absolutePath, finished, isOverPhone)); | |
| } | |
| private static IEnumerator PlayAudioFromPath( | |
| string absolutePath, | |
| Action? finished = null, | |
| bool isOverPhone = false | |
| ) | |
| { | |
| // Validate the file path. | |
| if (string.IsNullOrEmpty(absolutePath)) | |
| throw new ArgumentException("Path cannot be null or empty", nameof(absolutePath)); | |
| if (!File.Exists(absolutePath)) | |
| throw new ArgumentException("File does not exist", nameof(absolutePath)); | |
| // Initialize the FMOD Core system if needed. | |
| if (!_hasBeenInit) | |
| { | |
| if (Application.isEditor && !Application.isPlaying) | |
| { | |
| Factory.System_Create(out var system); | |
| _coreSystem = system; | |
| _coreSystem.init(32, INITFLAGS.NORMAL, IntPtr.Zero); | |
| _hasBeenInit = true; | |
| } | |
| else | |
| { | |
| _coreSystem = RuntimeManager.CoreSystem; | |
| _hasBeenInit = true; | |
| } | |
| } | |
| // --- Edit Mode Playback with Manual Fade-Out --- | |
| if (!Application.isPlaying) | |
| { | |
| RESULT result = _coreSystem.createSound(absolutePath, MODE.CREATESTREAM, out Sound sound); | |
| if (result != RESULT.OK) | |
| { | |
| Debug.LogError("Failed to create sound in Edit mode: " + result); | |
| yield break; | |
| } | |
| // Get length and set up fade variables | |
| sound.getLength(out uint lengthMs, TIMEUNIT.MS); | |
| bool startedFade = false; | |
| double fadeStartTime = 0; | |
| const float maxFadeDuration = 5f; | |
| float fadeDuration = Mathf.Min(maxFadeDuration, lengthMs / 1000f); | |
| _coreSystem.getMasterChannelGroup(out ChannelGroup masterGroup); | |
| _coreSystem.playSound(sound, masterGroup, false, out Channel channel); | |
| bool isPlaying = true; | |
| while (isPlaying) | |
| { | |
| _coreSystem.update(); | |
| channel.isPlaying(out isPlaying); | |
| /* | |
| channel.getPosition(out uint positionMs, TIMEUNIT.MS); | |
| double currentTime = Time.realtimeSinceStartup; | |
| if (!startedFade && positionMs >= lengthMs - fadeDuration * 1000) | |
| { | |
| startedFade = true; | |
| fadeStartTime = currentTime; | |
| } | |
| if (startedFade) | |
| { | |
| float t = (float)((currentTime - fadeStartTime) / fadeDuration); | |
| float vol = Mathf.Clamp01(1f - t); | |
| channel.setVolume(vol); | |
| Debug.Log($"Fading out audio: {Path.GetFileName(absolutePath)} at {positionMs}ms, " + | |
| $"total length {lengthMs}ms (remaining: {lengthMs - positionMs}ms), " + | |
| $"fade duration: {fadeDuration}s, fade start time: {fadeStartTime}s, " + | |
| $"current time: {currentTime}s, " + | |
| $"fade progress: {(currentTime - fadeStartTime) / fadeDuration}s, " + | |
| $"volume: {vol}"); | |
| if (t >= 1f) | |
| { | |
| channel.stop(); | |
| } | |
| }*/ | |
| yield return null; | |
| } | |
| Debug.Log("Audio finished playing for track: " + Path.GetFileName(absolutePath)); | |
| sound.release(); | |
| finished?.Invoke(); | |
| yield break; | |
| } | |
| // --- Play Mode Playback --- | |
| EventInstance eventInstance = isOverPhone ? (_overPhone = InitOverPhone()) : (_default = InitDefault()); | |
| eventInstance.setCallback(dialogueCallback, EVENT_CALLBACK_TYPE.CREATE_PROGRAMMER_SOUND | EVENT_CALLBACK_TYPE.DESTROY_PROGRAMMER_SOUND); | |
| var handle = GCHandle.Alloc(absolutePath); | |
| eventInstance.setUserData(GCHandle.ToIntPtr(handle)); | |
| // Cross-fade stop using FMOD Studio envelope | |
| eventInstance.getDescription(out EventDescription desc); | |
| desc.getLength(out int totalMs); | |
| bool fadeTriggered = false; | |
| eventInstance.start(); | |
| while (true) | |
| { | |
| eventInstance.getPlaybackState(out PLAYBACK_STATE state); | |
| if (!fadeTriggered && state == PLAYBACK_STATE.PLAYING) | |
| { | |
| eventInstance.getTimelinePosition(out int posMs); | |
| Debug.Log($"Current playback state: {state}, position: {posMs}ms, total length: {totalMs}ms"); | |
| if (posMs >= totalMs - 250) | |
| { | |
| Debug.Log($"Triggering fade-out for audio: {Path.GetFileName(absolutePath)} at {posMs}ms, " + | |
| $"total length {totalMs}ms, fade duration: 5s"); | |
| eventInstance.stop(FMOD.Studio.STOP_MODE.ALLOWFADEOUT); | |
| fadeTriggered = true; | |
| } | |
| } | |
| if (state == PLAYBACK_STATE.STOPPED) | |
| { | |
| Debug.Log("Audio finished playing for track: " + Path.GetFileName(absolutePath)); | |
| finished?.Invoke(); | |
| break; | |
| } | |
| _coreSystem.update(); | |
| yield return null; | |
| } | |
| handle.Free(); | |
| eventInstance.release(); | |
| yield return null; | |
| } | |
| /// <summary> | |
| /// Initializes the OverPhone event instance (only used in Play mode). | |
| /// </summary> | |
| private static EventInstance InitOverPhone() | |
| { | |
| if (!Application.isPlaying) | |
| return default; // In Edit mode, we use the Core API path. | |
| if (_overPhone.isValid()) return _overPhone; | |
| Debug.Log("Creating new instance of OverPhone event"); | |
| _overPhone = RuntimeManager.CreateInstance(OverPhone); | |
| _overPhone.start(); | |
| return _overPhone; | |
| } | |
| /// <summary> | |
| /// Initializes the Default event instance (only used in Play mode). | |
| /// </summary> | |
| private static EventInstance InitDefault() | |
| { | |
| if (!Application.isPlaying) | |
| return default; // In Edit mode, we use the Core API path. | |
| if (_default.isValid()) return _default; | |
| Debug.Log("Creating new instance of Default event"); | |
| _default = RuntimeManager.CreateInstance(Default); | |
| _default.start(); | |
| return _default; | |
| } | |
| /// <summary> | |
| /// Callback used for the Programmer Instrument in Play mode. | |
| /// It creates (or destroys) a sound from the file path stored in user data. | |
| /// </summary> | |
| [AOT.MonoPInvokeCallback(typeof(EVENT_CALLBACK))] | |
| private static RESULT DialogueEventCallback(EVENT_CALLBACK_TYPE type, IntPtr instancePtr, IntPtr parameterPtr) | |
| { | |
| EventInstance instance = new EventInstance(instancePtr); | |
| // Retrieve the user data (the absolute file path). | |
| instance.getUserData(out IntPtr stringPtr); | |
| GCHandle stringHandle = GCHandle.FromIntPtr(stringPtr); | |
| string key = stringHandle.Target as string; | |
| switch (type) | |
| { | |
| case EVENT_CALLBACK_TYPE.CREATE_PROGRAMMER_SOUND: | |
| { | |
| // Use a streaming mode to reduce loading delays. | |
| MODE soundMode = MODE.CREATESTREAM; | |
| var parameter = (PROGRAMMER_SOUND_PROPERTIES)Marshal.PtrToStructure(parameterPtr, typeof(PROGRAMMER_SOUND_PROPERTIES)); | |
| RESULT result = _coreSystem.createSound(key, soundMode, out var sound); | |
| if (result != RESULT.OK) | |
| { | |
| Debug.LogError($"Failed to create sound from path: {key}, Error: {result}"); | |
| return result; | |
| } | |
| parameter.sound = sound.handle; | |
| parameter.subsoundIndex = -1; | |
| Marshal.StructureToPtr(parameter, parameterPtr, false); | |
| break; | |
| } | |
| case EVENT_CALLBACK_TYPE.DESTROY_PROGRAMMER_SOUND: | |
| { | |
| var parameter = (PROGRAMMER_SOUND_PROPERTIES)Marshal.PtrToStructure(parameterPtr, typeof(PROGRAMMER_SOUND_PROPERTIES)); | |
| var sound = new Sound(parameter.sound); | |
| sound.release(); | |
| break; | |
| } | |
| } | |
| return RESULT.OK; | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.