Skip to content

Instantly share code, notes, and snippets.

@AldeRoberge
Created March 26, 2026 01:29
Show Gist options
  • Select an option

  • Save AldeRoberge/ff11db0415ee6bb1e02d006dcac151f4 to your computer and use it in GitHub Desktop.

Select an option

Save AldeRoberge/ff11db0415ee6bb1e02d006dcac151f4 to your computer and use it in GitHub Desktop.
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;
}
}
}
@AldeRoberge

AldeRoberge commented Mar 26, 2026

Copy link
Copy Markdown
Author
using ADG.Scripts.Runtime.Utilities;
using FMOD.Studio;
using FMODUnity;

namespace ADG.Scripts.Runtime.AudioSystem
{
    /// <summary>
    /// Singleton wrapper around FMOD RuntimeManager that provides enhanced control for OneShot audio playback.
    /// Extends FMOD's basic functionality by returning event instances for runtime manipulation.
    /// </summary>
    public class AudioManager : Singleton<AudioManager>
    {
        /// <summary>
        /// Plays a one-shot audio event and returns the instance for runtime control.
        /// Unlike FMOD's basic PlayOneShot, this allows the caller to manipulate the audio instance
        /// (e.g., stop, pause, adjust parameters) after playback has started.
        /// </summary>
        /// <param name="eventReference">FMOD event reference to play</param>
        /// <returns>FMOD EventInstance that can be controlled during playback</returns>
        public EventInstance PlayOneShot(EventReference eventReference)
        {
            var instance = RuntimeManager.CreateInstance(eventReference);
            instance.start();
            return instance;
        }
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment