Hi, sorry for the late reply. My previous posted version worked somehow, but sound was off or late very often anyway.
Here is an updated version that syncs with unity update and works well with variable/const framerate of Unity Recorder. One thing: you need to set Project Settings/Audio/Disable Unity Audio = false before entering playmode. Create GameObject with this component in runtime. Another catch is that migh be crashes, relaunch Unity will help. This is the most stable what i managed to get.
Here is the script:
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Threading;
using Sirenix.OdinInspector;
using UnityEngine;
namespace _Project.Recorder
{
internal sealed class FmodAudioRecorderDSP : MonoBehaviour
{
[Title("Diagnostics")]
[Tooltip("Print capture stats and mixer mode changes to the console. Errors are reported regardless")]
[SerializeField] private bool showLogs;
[Tooltip("Log a stats line every N seconds while capturing. 0 - only the final summary")]
[SerializeField] [MinValue(0f)] [ShowIf(nameof(showLogs))] private float statsLogInterval = 5f;
[Title("Non-realtime capture")]
[Tooltip("Switch FMOD to NOSOUND_NRT and advance its mixer by hand, one video frame worth of audio per rendered frame. " +
"FMOD goes silent while this is on - that is the point of offline rendering")]
[SerializeField] private bool driveMixerManually = true;
[Tooltip("Stay this many audio frames ahead of the video timeline so Unity never pulls an empty queue")]
[SerializeField] [MinValue(0)] private int aheadFrames = 1024;
[Tooltip("Safety cap on CoreSystem.update() calls per rendered frame")]
[SerializeField] [MinValue(1)] private int maxPumpsPerFrame = 64;
private FMOD.DSP_READ_CALLBACK mReadCallback;
private FMOD.DSP mCaptureDSP;
private GCHandle mObjHandle;
private int mFrontBufferPosition = 0;
private readonly Queue<float[]> mFullBufferQueue = new();
private readonly Queue<float[]> mEmptyBufferQueue = new();
private readonly object lockOb = new();
private static int nbChannelDetected = 0;
// Stats counters. Written from the FMOD mixer thread and from Unity's audio thread,
// read on the main thread - every access goes through Interlocked, and nothing here logs.
private long mProducedFrames;
private long mConsumedFrames;
private long mDeliveredFrames;
private long mFmodCallbacks;
private long mFilterReads;
// Main thread only.
private int mSampleRate;
private int mRenderedFrames;
private int mCaptureFrames;
private double mTimelineFrames;
private float mRealStartTime;
private float mNextStatsLogTime;
// Manual mixer pumping, main thread only.
private FMOD.OUTPUTTYPE mPreviousOutput;
private bool mOutputSwitched;
private long mPumpCalls;
private int mPumpCapHits;
// Start, not OnEnable: to buffer between object creation and dsp start
private void Start()
{
StartDSP();
}
private void OnDisable()
{
StopDSP();
}
private void StartDSP()
{
ResetStats();
var config = AudioSettings.GetConfiguration();
int unitySampleRate = config.sampleRate;
int unityChannels = config.speakerMode == AudioSpeakerMode.Stereo ? 2 : (int) config.speakerMode;
int fmodSampleRate;
FMOD.SPEAKERMODE fmodSpeakerMode;
FMODUnity.RuntimeManager.CoreSystem.getSoftwareFormat(out fmodSampleRate, out fmodSpeakerMode, out _);
int fmodChannels = fmodSpeakerMode == FMOD.SPEAKERMODE.STEREO ? 2 :
fmodSpeakerMode == FMOD.SPEAKERMODE.MONO ? 1 :
0; // Default to 0 for unsupported speaker modes(e.g. Surround)
string unityFormat = unityChannels == 1 ? "Mono" :
unityChannels == 2 ? "Stereo" : "Unsupported";
string fmodFormat = fmodChannels == 1 ? "Mono" :
fmodChannels == 2 ? "Stereo" : "Unsupported";
if (fmodSampleRate != unitySampleRate || fmodChannels != unityChannels)
{
Debug.LogError($"FMOD/Unity audio mismatch or unsupported channel format. Unity: {unitySampleRate}Hz/{unityFormat}, FMOD: {fmodSampleRate}Hz/{fmodFormat}\n" +
$"Please ensure FMOD and Unity use the same sample rate and channel layout (Mono or Stereo only).");
enabled = false;
return;
}
mSampleRate = unitySampleRate;
// Switch the output before the tap is attached: setOutput tears the output down and brings it back up.
EnterNonRealtimeMode();
mReadCallback = CaptureDSPReadCallback;
mObjHandle = GCHandle.Alloc(this);
var desc = new FMOD.DSP_DESCRIPTION
{
numinputbuffers = 1,
numoutputbuffers = 1,
read = mReadCallback,
userdata = GCHandle.ToIntPtr(mObjHandle),
};
// Attach custom DSP to master channel group
if (FMODUnity.RuntimeManager.CoreSystem.getMasterChannelGroup(out var masterCG) == FMOD.RESULT.OK)
{
if (FMODUnity.RuntimeManager.CoreSystem.createDSP(ref desc, out mCaptureDSP) == FMOD.RESULT.OK)
{
if (masterCG.addDSP(FMOD.CHANNELCONTROL_DSP_INDEX.TAIL, mCaptureDSP) == FMOD.RESULT.OK)
// channelmask устарел, FMOD его игнорирует и пишет об этом в консоль
mCaptureDSP.setChannelFormat(0, 2, FMOD.SPEAKERMODE.STEREO);
else
Debug.LogWarning("FMOD: Failed to add DSP to master channel group.");
}
else
{
Debug.LogWarning("FMOD: Failed to create DSP.");
}
}
else
{
Debug.LogWarning("FMOD: Failed to retrieve master channel group.");
}
}
private void StopDSP()
{
LogStats("final");
// Detach first, free the handle second. A live DSP whose userdata points at a freed
// GCHandle is a hard crash on the next mixer callback, so the tap must go down
// even when the master channel group cannot be reached.
if (mCaptureDSP.hasHandle())
{
if (FMODUnity.RuntimeManager.CoreSystem.getMasterChannelGroup(out var masterCG) == FMOD.RESULT.OK)
masterCG.removeDSP(mCaptureDSP);
else
Debug.LogError("FMOD: master channel group is unreachable, releasing the capture DSP anyway.");
mCaptureDSP.release();
mCaptureDSP = default;
}
if (mObjHandle.IsAllocated) mObjHandle.Free();
ExitNonRealtimeMode();
lock (lockOb)
{
mFullBufferQueue.Clear();
mEmptyBufferQueue.Clear();
}
}
#region Manual mixer pumping
private void EnterNonRealtimeMode()
{
if (!driveMixerManually) return;
var core = FMODUnity.RuntimeManager.CoreSystem;
var result = core.getOutput(out mPreviousOutput);
if (result != FMOD.RESULT.OK)
{
Debug.LogError($"FMOD: getOutput failed ({result}). Staying realtime - capture will drift on constant frame rate.");
return;
}
result = core.setOutput(FMOD.OUTPUTTYPE.NOSOUND_NRT);
if (result != FMOD.RESULT.OK)
{
Debug.LogError($"FMOD: setOutput(NOSOUND_NRT) failed ({result}). Staying realtime - capture will drift on constant frame rate.");
return;
}
mOutputSwitched = true;
if (showLogs) Debug.Log($"FMOD: mixer is driven manually, output {mPreviousOutput} -> NOSOUND_NRT.");
}
private void ExitNonRealtimeMode()
{
if (!mOutputSwitched) return;
mOutputSwitched = false;
var result = FMODUnity.RuntimeManager.CoreSystem.setOutput(mPreviousOutput);
if (result != FMOD.RESULT.OK)
{
Debug.LogError($"FMOD: failed to restore output {mPreviousOutput} ({result}). " +
"Sound will stay silent until Play Mode is restarted.");
return;
}
if (showLogs) Debug.Log($"FMOD: mixer is back on its own clock, output NOSOUND_NRT -> {mPreviousOutput}.");
}
// Runs after game logic has posted this frame's events, so the mixer renders what the frame actually asked for.
private void LateUpdate()
{
if (!mOutputSwitched) return;
if (mSampleRate <= 0) return;
long target = (long) mTimelineFrames + aheadFrames;
var core = FMODUnity.RuntimeManager.CoreSystem;
int pumps = 0;
while (Interlocked.Read(ref mProducedFrames) < target && pumps < maxPumpsPerFrame)
{
if (core.update() != FMOD.RESULT.OK) break;
pumps++;
}
mPumpCalls += pumps;
if (pumps >= maxPumpsPerFrame) mPumpCapHits++;
}
#endregion
private void OnAudioFilterRead(float[] data, int channels)
{
// Audio thread: count only, never log.
Interlocked.Increment(ref mFilterReads);
Interlocked.Add(ref mConsumedFrames, channels > 0 ? data.Length / channels : 0);
// Avoid leftover noise
Array.Clear(data, 0, data.Length);
lock (lockOb)
{
int offset = 0;
while (mFullBufferQueue.Count > 0 && offset < data.Length)
{
float[] front = mFullBufferQueue.Peek();
int remainingInFront = front.Length - mFrontBufferPosition;
if (remainingInFront <= 0)
{
mFullBufferQueue.Dequeue();
mFrontBufferPosition = 0;
continue;
}
int remainingInData = data.Length - offset;
int copyLength = Math.Min(remainingInFront, remainingInData);
Array.Copy(front, mFrontBufferPosition, data, offset, copyLength);
mFrontBufferPosition += copyLength;
offset += copyLength;
// If buffer fully consumed, recycle it
if (mFrontBufferPosition < front.Length) continue;
mFullBufferQueue.Dequeue();
mFrontBufferPosition = 0;
// Recycle consumed buffers, limit to 32 stored
if (mEmptyBufferQueue.Count < 32) mEmptyBufferQueue.Enqueue(front);
}
Interlocked.Add(ref mDeliveredFrames, channels > 0 ? offset / channels : 0);
}
}
[AOT.MonoPInvokeCallback(typeof(FMOD.DSP_READ_CALLBACK))]
private static FMOD.RESULT CaptureDSPReadCallback(ref FMOD.DSP_STATE dsp_state, IntPtr inbuffer, IntPtr outbuffer, uint length, int inchannels, ref int outchannels)
{
IntPtr userData;
dsp_state.functions.getuserdata(ref dsp_state, out userData);
var objHandle = GCHandle.FromIntPtr(userData);
var obj = objHandle.Target as FmodAudioRecorderDSP;
if (inchannels > nbChannelDetected)
nbChannelDetected = inchannels;
if (inchannels > 2)
{
Debug.LogError("Channels FMOD > 2!");
inchannels = 2;
}
// FMOD mixer thread: count only, never log.
Interlocked.Increment(ref obj.mFmodCallbacks);
Interlocked.Add(ref obj.mProducedFrames, length);
int lengthElements = (int) length * inchannels;
float[] buffer;
// Try to reuse a managed buffer of the exact size to reduce GC pressure.
lock (obj.lockOb)
{
if (obj.mEmptyBufferQueue.Count > 0)
{
float[] tmp = obj.mEmptyBufferQueue.Dequeue();
buffer = tmp.Length == lengthElements ? tmp : new float[lengthElements];
}
else
{
buffer = new float[lengthElements];
}
}
Marshal.Copy(inbuffer, buffer, 0, lengthElements);
lock (obj.lockOb)
{
obj.mFullBufferQueue.Enqueue(buffer);
}
// Pass through to FMOD downstream (so monitoring still works)
Marshal.Copy(buffer, 0, outbuffer, lengthElements);
outchannels = inchannels;
return FMOD.RESULT.OK;
}
#region Stats
private void Update()
{
mRenderedFrames++;
// The video timeline: a rendered frame is worth exactly one frame delta of audio.
float delta = Time.captureDeltaTime;
if (delta > 0f) mCaptureFrames++;
else delta = Time.unscaledDeltaTime;
mTimelineFrames += (double) delta * mSampleRate;
if (statsLogInterval <= 0f) return;
if (Time.realtimeSinceStartup < mNextStatsLogTime) return;
mNextStatsLogTime = Time.realtimeSinceStartup + statsLogInterval;
LogStats("progress");
}
// Explicit request, so it prints even with showLogs off.
[Button("Log stats now")]
private void LogStatsNow()
{
LogStats("manual", force: true);
}
private void ResetStats()
{
Interlocked.Exchange(ref mProducedFrames, 0L);
Interlocked.Exchange(ref mConsumedFrames, 0L);
Interlocked.Exchange(ref mDeliveredFrames, 0L);
Interlocked.Exchange(ref mFmodCallbacks, 0L);
Interlocked.Exchange(ref mFilterReads, 0L);
mRenderedFrames = 0;
mCaptureFrames = 0;
mTimelineFrames = 0d;
mPumpCalls = 0L;
mPumpCapHits = 0;
mRealStartTime = Time.realtimeSinceStartup;
mNextStatsLogTime = Time.realtimeSinceStartup + statsLogInterval;
}
// Main thread only - the mixer and audio threads never log, they only bump counters.
private void LogStats(string title, bool force = false)
{
if (!showLogs && !force) return;
if (Interlocked.Read(ref mFmodCallbacks) == 0L && Interlocked.Read(ref mFilterReads) == 0L) return;
Debug.Log(BuildStatsReport(title));
}
private string BuildStatsReport(string title)
{
long produced = Interlocked.Read(ref mProducedFrames);
long consumed = Interlocked.Read(ref mConsumedFrames);
long delivered = Interlocked.Read(ref mDeliveredFrames);
long fmodCallbacks = Interlocked.Read(ref mFmodCallbacks);
long filterReads = Interlocked.Read(ref mFilterReads);
long expected = (long) mTimelineFrames;
float timelineElapsed = FramesToSeconds(expected);
float realElapsed = Time.realtimeSinceStartup - mRealStartTime;
float clockRatio = timelineElapsed > 0f ? realElapsed / timelineElapsed : 0f;
float captureDelta = Time.captureDeltaTime;
string frameRateMode = captureDelta > 0f ? $"{1f / captureDelta:F1} fps, constant" : "variable frame rate";
string mixerMode = mOutputSwitched ? "NOSOUND_NRT, driven by hand" : "realtime, own clock";
int queuedBuffers;
lock (lockOb) queuedBuffers = mFullBufferQueue.Count;
return $"[FmodAudioRecorderDSP] stats ({title})\n" +
$" render : {mRenderedFrames} frames ({mCaptureFrames} captured), captureDeltaTime {captureDelta:F5}s ({frameRateMode})\n" +
$" clock : timeline {timelineElapsed:F2}s, real {realElapsed:F2}s, real/timeline {clockRatio:F2}\n" +
$" mixer : {mixerMode}, {mPumpCalls} pumps, {PumpsPerFrame():F2} per rendered frame, cap hits {mPumpCapHits}\n" +
$" produced : {produced} frames / {FramesToSeconds(produced):F2}s (FMOD mixer, {fmodCallbacks} callbacks)\n" +
$" consumed : {consumed} frames / {FramesToSeconds(consumed):F2}s (Unity pulled, {filterReads} reads)\n" +
$" delivered : {delivered} frames / {FramesToSeconds(delivered):F2}s, underrun {consumed - delivered} frames\n" +
$" expected : {expected} frames / {timelineElapsed:F2}s (video timeline x {mSampleRate}Hz)\n" +
$" drift : produced-expected {produced - expected} frames / {FramesToSeconds(produced - expected):F2}s\n" +
$" backlog : {produced - delivered} frames / {FramesToSeconds(produced - delivered):F2}s, queue {queuedBuffers} buffers";
}
private float FramesToSeconds(long frames)
{
return mSampleRate > 0 ? frames / (float) mSampleRate : 0f;
}
private float PumpsPerFrame()
{
return mRenderedFrames > 0 ? mPumpCalls / (float) mRenderedFrames : 0f;
}
#endregion
}
}