410 lines
17 KiB
C#
410 lines
17 KiB
C#
#nullable enable
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using ManagedBass;
|
|
using ManagedBass.Mix;
|
|
using T3.Core.Animation;
|
|
using T3.Core.IO;
|
|
using T3.Core.Logging;
|
|
using T3.Core.Operator;
|
|
using T3.Core.Settings;
|
|
|
|
namespace T3.Core.Audio;
|
|
|
|
/// <summary>
|
|
/// Handles audio rendering/export functionality.
|
|
/// For export, we temporarily remove soundtrack streams from the mixer and read directly from them.
|
|
/// </summary>
|
|
public static class AudioRendering
|
|
{
|
|
private static bool _isRecording;
|
|
private static readonly ExportState _exportState = new();
|
|
private static int _frameCount;
|
|
|
|
// Reusable buffers to reduce per-frame allocations during export
|
|
private static float[] _mixBuffer = Array.Empty<float>();
|
|
private static float[] _operatorBuffer = Array.Empty<float>();
|
|
private static float[] _spatialStreamBuffer = Array.Empty<float>();
|
|
|
|
// Export mixer handle - BASS handles resampling and mixing for us
|
|
private static int _exportMixerHandle;
|
|
private static bool _exportMixerInitialized;
|
|
|
|
/// <summary>
|
|
/// Ensures the buffer has at least the required capacity, reallocating if necessary.
|
|
/// Clears the buffer up to the required length before returning.
|
|
/// </summary>
|
|
private static float[] EnsureBuffer(ref float[] buffer, int requiredLength)
|
|
{
|
|
if (buffer.Length < requiredLength)
|
|
buffer = new float[requiredLength];
|
|
Array.Clear(buffer, 0, requiredLength);
|
|
return buffer;
|
|
}
|
|
|
|
public static void PrepareRecording(Playback playback, double fps)
|
|
{
|
|
if (_isRecording) return;
|
|
|
|
_isRecording = true;
|
|
_frameCount = 0;
|
|
|
|
_exportState.SaveState();
|
|
AudioExportSourceRegistry.Clear();
|
|
|
|
// Reset audio analysis state for clean export - ensures both modes start from same state
|
|
AudioAnalysisContext.Default.Reset();
|
|
WaveFormProcessing.ResetExportBuffer();
|
|
|
|
Bass.ChannelPause(AudioMixerManager.GlobalMixerHandle);
|
|
Log.Gated.AudioRender("[AudioRendering] GlobalMixer PAUSED for export");
|
|
|
|
AudioEngine.ResetAllOperatorStreamsForExport();
|
|
|
|
// Create export mixer - BASS handles resampling when adding streams
|
|
// This replaces manual ResampleAndMix with BASS's optimized resampler
|
|
_exportMixerHandle = BassMix.CreateMixerStream(
|
|
AudioConfig.MixerFrequency,
|
|
2, // stereo output
|
|
BassFlags.Decode | BassFlags.Float | BassFlags.MixerNonStop);
|
|
|
|
if (_exportMixerHandle == 0)
|
|
{
|
|
Log.Error($"[AudioRendering] Failed to create export mixer: {Bass.LastError}");
|
|
_exportMixerInitialized = false;
|
|
}
|
|
else
|
|
{
|
|
_exportMixerInitialized = true;
|
|
Log.Gated.AudioRender($"[AudioRendering] Export mixer created: Handle={_exportMixerHandle}, Freq={AudioConfig.MixerFrequency}Hz");
|
|
}
|
|
|
|
// Remove soundtrack streams from live mixer and add to export mixer
|
|
foreach (var (handle, clipStream) in AudioEngine.SoundtrackClipStreams)
|
|
{
|
|
BassMix.MixerRemoveChannel(clipStream.StreamHandle);
|
|
|
|
// Reset stream attributes for export
|
|
float nativeFrequency = clipStream.GetDefaultFrequency();
|
|
Bass.ChannelSetAttribute(clipStream.StreamHandle, ChannelAttribute.Frequency, nativeFrequency);
|
|
Bass.ChannelSetAttribute(clipStream.StreamHandle, ChannelAttribute.NoRamp, 1);
|
|
|
|
if (_exportMixerInitialized)
|
|
{
|
|
// Add to export mixer - BASS will handle resampling from native frequency to mixer frequency
|
|
// Use MixerChanNoRamping for sample-accurate seeking during export
|
|
if (!BassMix.MixerAddChannel(_exportMixerHandle, clipStream.StreamHandle, BassFlags.MixerChanNoRampin | BassFlags.MixerChanPause))
|
|
{
|
|
Log.Warning($"[AudioRendering] Failed to add soundtrack to export mixer: {Bass.LastError}");
|
|
}
|
|
else
|
|
{
|
|
Log.Gated.AudioRender($"[AudioRendering] Soundtrack '{handle.Clip.AssetPath}' added to export mixer");
|
|
}
|
|
}
|
|
}
|
|
|
|
Log.Gated.AudioRender($"[AudioRendering] PrepareRecording: fps={fps}");
|
|
}
|
|
|
|
public static void EndRecording(Playback playback, double fps)
|
|
{
|
|
if (!_isRecording) return;
|
|
|
|
_isRecording = false;
|
|
Log.Gated.AudioRender($"[AudioRendering] EndRecording: Exported {_frameCount} frames");
|
|
|
|
// Remove soundtrack streams from export mixer and re-add to live mixer
|
|
foreach (var (_, clipStream) in AudioEngine.SoundtrackClipStreams)
|
|
{
|
|
if (_exportMixerInitialized)
|
|
{
|
|
BassMix.MixerRemoveChannel(clipStream.StreamHandle);
|
|
}
|
|
|
|
if (!BassMix.MixerAddChannel(AudioMixerManager.SoundtrackMixerHandle, clipStream.StreamHandle, BassFlags.MixerChanPause))
|
|
{
|
|
Log.Warning($"[AudioRendering] Failed to re-add soundtrack: {Bass.LastError}");
|
|
}
|
|
clipStream.UpdateTimeWhileRecording(playback, fps, true);
|
|
}
|
|
|
|
// Clean up export mixer
|
|
if (_exportMixerInitialized && _exportMixerHandle != 0)
|
|
{
|
|
Bass.StreamFree(_exportMixerHandle);
|
|
_exportMixerHandle = 0;
|
|
_exportMixerInitialized = false;
|
|
Log.Gated.AudioRender("[AudioRendering] Export mixer freed");
|
|
}
|
|
|
|
_exportState.RestoreState();
|
|
AudioEngine.RestoreOperatorAudioStreams();
|
|
}
|
|
|
|
internal static void ExportAudioFrame(Playback playback, double frameDurationInSeconds, SoundtrackClipStream clipStream)
|
|
{
|
|
try
|
|
{
|
|
AudioEngine.UpdateFftBufferFromSoundtrack(playback);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Error($"ExportAudioFrame error: {ex}", typeof(AudioRendering));
|
|
}
|
|
}
|
|
|
|
public static float[] GetFullMixDownBuffer(double frameDurationInSeconds)
|
|
{
|
|
_frameCount++;
|
|
|
|
// Update stale states - this marks which operators were active in the previous frame.
|
|
// Streams that weren't updated will be marked stale, but during export they won't be paused
|
|
// (handled in SetStale). We use the stale flag to know which streams to include in the mix.
|
|
AudioEngine.UpdateStaleStatesForExport();
|
|
|
|
int sampleCount = (int)Math.Max(Math.Round(frameDurationInSeconds * AudioConfig.MixerFrequency), 1);
|
|
int floatCount = sampleCount * 2; // stereo
|
|
var mixBuffer = EnsureBuffer(ref _mixBuffer, floatCount);
|
|
double currentTime = Playback.Current.TimeInSecs;
|
|
|
|
// Mix soundtrack streams using BASS export mixer (handles resampling automatically)
|
|
if (_exportMixerInitialized)
|
|
{
|
|
MixSoundtracksFromExportMixer(mixBuffer, floatCount, currentTime);
|
|
}
|
|
|
|
// Mix operator audio (always included)
|
|
MixOperatorAudio(mixBuffer, floatCount, currentTime, frameDurationInSeconds);
|
|
|
|
LogMixStats(mixBuffer, floatCount, currentTime);
|
|
UpdateOperatorMetering();
|
|
|
|
// Populate waveform buffers from export mixdown (maintains rolling window like live playback)
|
|
// This ensures AudioWaveform, PlaybackFFT, AudioReaction, etc. work correctly during rendering
|
|
WaveFormProcessing.PopulateFromExportBuffer(mixBuffer);
|
|
|
|
// Compute FFT from the accumulated waveform buffer (same as live playback uses)
|
|
var context = AudioAnalysisContext.Default;
|
|
AudioAnalysis.ComputeFftFromBuffer(context.InterleavedSampleBuffer, context);
|
|
|
|
// Process FFT data to compute frequency bands, peaks, and attacks for AudioReaction
|
|
// Use the same gain/decay factors from playback settings as used during normal playback
|
|
var settings = Playback.Current.Settings;
|
|
float gainFactor = settings?.Playback.AudioGainFactor ?? 1f;
|
|
float decayFactor = settings?.Playback.AudioDecayFactor ?? 0.9f;
|
|
AudioAnalysis.ProcessUpdate(gainFactor, decayFactor);
|
|
|
|
return mixBuffer;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mixes all soundtrack clips using the BASS export mixer.
|
|
/// BASS handles resampling from each clip's native frequency to the mixer frequency.
|
|
/// </summary>
|
|
private static void MixSoundtracksFromExportMixer(float[] mixBuffer, int floatCount, double currentTime)
|
|
{
|
|
// Position each soundtrack clip and set its volume based on whether it should be active
|
|
foreach (var (handle, clipStream) in AudioEngine.SoundtrackClipStreams)
|
|
{
|
|
var clip = handle.Clip;
|
|
double clipStart = Playback.Current.SecondsFromBars(clip.TimeRange.Start);
|
|
double elapsedInClip = currentTime - clipStart;
|
|
double targetSourcePos = clip.SourceOffsetSecs + elapsedInClip;
|
|
double sourceEnd = clip.SourceDurationSecs > 0
|
|
? clip.SourceOffsetSecs + clip.SourceDurationSecs
|
|
: clip.LengthInSeconds;
|
|
|
|
// Check if clip is active at this time
|
|
bool isActive = elapsedInClip >= 0 && targetSourcePos < sourceEnd;
|
|
|
|
if (isActive)
|
|
{
|
|
// Position the stream at the correct time in the source file
|
|
long targetBytes = Bass.ChannelSeconds2Bytes(clipStream.StreamHandle, targetSourcePos);
|
|
Bass.ChannelSetPosition(clipStream.StreamHandle, targetBytes);
|
|
|
|
// Apply volume: clip.Volume * SoundtrackVolume * AppVolume
|
|
float effectiveVolume = handle.Clip.Volume
|
|
* CompositionSettings.Current.Audio.SoundtrackVolume
|
|
* CoreSettings.Config.AppVolume;
|
|
Bass.ChannelSetAttribute(clipStream.StreamHandle, ChannelAttribute.Volume, effectiveVolume);
|
|
|
|
// Unpause for this frame
|
|
BassMix.ChannelFlags(clipStream.StreamHandle, 0, BassFlags.MixerChanPause);
|
|
}
|
|
else
|
|
{
|
|
// Pause clips that shouldn't be playing
|
|
BassMix.ChannelFlags(clipStream.StreamHandle, BassFlags.MixerChanPause, BassFlags.MixerChanPause);
|
|
}
|
|
}
|
|
|
|
// Read mixed audio from export mixer - BASS handles all resampling
|
|
int bytesRead = Bass.ChannelGetData(_exportMixerHandle, mixBuffer, floatCount * sizeof(float));
|
|
|
|
if (bytesRead < 0)
|
|
{
|
|
var error = Bass.LastError;
|
|
if (error != Errors.OK && error != Errors.Ended)
|
|
{
|
|
Log.Gated.AudioRender($"[AudioRendering] Export mixer read error: {error}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void MixOperatorAudio(float[] mixBuffer, int floatCount, double currentTime, double frameDuration)
|
|
{
|
|
// Mix all operator audio from the operator mixer
|
|
// This includes:
|
|
// - Procedural/callback streams (e.g., AudioToneGenerator)
|
|
// - File-based stereo streams (e.g., AudioPlayer) - these are NOT paused during export
|
|
// The OperatorMixer is a decode-only mixer, so it doesn't output to soundcard
|
|
var operatorBuffer = EnsureBuffer(ref _operatorBuffer, floatCount);
|
|
int bytesRead = Bass.ChannelGetData(AudioMixerManager.OperatorMixerHandle, operatorBuffer, floatCount * sizeof(float));
|
|
|
|
if (bytesRead > 0)
|
|
{
|
|
int samplesRead = bytesRead / sizeof(float);
|
|
for (int i = 0; i < Math.Min(samplesRead, mixBuffer.Length); i++)
|
|
{
|
|
if (!float.IsNaN(operatorBuffer[i]))
|
|
mixBuffer[i] += operatorBuffer[i];
|
|
}
|
|
}
|
|
|
|
// Mix spatial operator audio (these are not in the mixer - they use native 3D)
|
|
// We need to render each spatial stream separately with manual 3D processing
|
|
MixSpatialOperatorAudio(mixBuffer, currentTime, frameDuration);
|
|
}
|
|
|
|
private static void MixSpatialOperatorAudio(float[] mixBuffer, double currentTime, double frameDuration)
|
|
{
|
|
foreach (var kvp in AudioEngine.GetAllSpatialOperatorStates())
|
|
{
|
|
var stream = kvp.Value.Stream;
|
|
if (stream == null || !stream.IsPlaying || stream.IsPaused || kvp.Value.IsStale)
|
|
continue;
|
|
|
|
// Use reusable buffer for this stream's contribution
|
|
var streamBuffer = EnsureBuffer(ref _spatialStreamBuffer, mixBuffer.Length);
|
|
|
|
// RenderAudio handles 3D attenuation and panning for export
|
|
stream.RenderAudio(currentTime, frameDuration, streamBuffer, AudioConfig.MixerFrequency, 2);
|
|
|
|
// Mix into the main buffer
|
|
for (int i = 0; i < mixBuffer.Length; i++)
|
|
{
|
|
if (!float.IsNaN(streamBuffer[i]))
|
|
mixBuffer[i] += streamBuffer[i];
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void LogMixStats(float[] mixBuffer, int floatCount, double currentTime)
|
|
{
|
|
if (_frameCount <= 3 || _frameCount % 60 == 0)
|
|
{
|
|
float peak = 0;
|
|
for (int i = 0; i < floatCount; i++)
|
|
if (!float.IsNaN(mixBuffer[i]))
|
|
peak = Math.Max(peak, Math.Abs(mixBuffer[i]));
|
|
|
|
Log.Gated.AudioRender($"[AudioRendering] Frame {_frameCount}: peak={peak:F4}, time={currentTime:F3}s");
|
|
}
|
|
}
|
|
|
|
|
|
private static void UpdateOperatorMetering()
|
|
{
|
|
UpdateStereoMeteringForStates(AudioEngine.GetAllStereoOperatorStates());
|
|
UpdateSpatialMeteringForStates(AudioEngine.GetAllSpatialOperatorStates());
|
|
}
|
|
|
|
private static void UpdateStereoMeteringForStates(IEnumerable<KeyValuePair<Guid, (StereoOperatorAudioStream? Stream, bool IsStale)>> states)
|
|
{
|
|
foreach (var kvp in states)
|
|
{
|
|
var stream = kvp.Value.Stream;
|
|
if (stream == null || !stream.IsPlaying || stream.IsPaused || kvp.Value.IsStale)
|
|
continue;
|
|
|
|
var level = BassMix.ChannelGetLevel(stream.StreamHandle);
|
|
if (level != -1)
|
|
{
|
|
float left = (level & 0xFFFF) / 32768f;
|
|
float right = ((level >> 16) & 0xFFFF) / 32768f;
|
|
stream.UpdateFromBuffer([left, right]);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void UpdateSpatialMeteringForStates(IEnumerable<KeyValuePair<Guid, (SpatialOperatorAudioStream? Stream, bool IsStale)>> states)
|
|
{
|
|
foreach (var kvp in states)
|
|
{
|
|
var stream = kvp.Value.Stream;
|
|
if (stream == null || !stream.IsPlaying || stream.IsPaused || kvp.Value.IsStale)
|
|
continue;
|
|
|
|
// For spatial streams during export, use Bass.ChannelGetLevel directly (not through mixer)
|
|
var level = Bass.ChannelGetLevel(stream.StreamHandle);
|
|
if (level != -1)
|
|
{
|
|
float left = (level & 0xFFFF) / 32768f;
|
|
float right = ((level >> 16) & 0xFFFF) / 32768f;
|
|
stream.UpdateFromBuffer([left, right]);
|
|
}
|
|
}
|
|
}
|
|
|
|
public static void GetLastMixDownBuffer(double frameDurationInSeconds)
|
|
{
|
|
AudioEngine.UpdateFftBufferFromSoundtrack(Playback.Current);
|
|
}
|
|
|
|
public static void EvaluateAllAudioMeteringOutputs(double localFxTime, float[]? audioBuffer = null)
|
|
{
|
|
var context = new EvaluationContext { LocalFxTime = localFxTime };
|
|
|
|
foreach (var source in AudioExportSourceRegistry.Sources)
|
|
{
|
|
if (source is not Instance operatorInstance) continue;
|
|
|
|
foreach (var input in operatorInstance.Inputs)
|
|
input.DirtyFlag.ForceInvalidate();
|
|
|
|
foreach (var output in operatorInstance.Outputs)
|
|
{
|
|
try { output.Update(context); }
|
|
catch (Exception ex)
|
|
{
|
|
Log.Gated.AudioRender($"Failed to evaluate output: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class ExportState
|
|
{
|
|
private float _savedVolume;
|
|
private bool _wasPlaying;
|
|
|
|
public void SaveState()
|
|
{
|
|
Bass.ChannelGetAttribute(AudioMixerManager.GlobalMixerHandle, ChannelAttribute.Volume, out _savedVolume);
|
|
if (_savedVolume <= 0) _savedVolume = 1.0f;
|
|
_wasPlaying = Bass.ChannelIsActive(AudioMixerManager.GlobalMixerHandle) == PlaybackState.Playing;
|
|
}
|
|
|
|
public void RestoreState()
|
|
{
|
|
Bass.ChannelSetAttribute(AudioMixerManager.GlobalMixerHandle, ChannelAttribute.Volume, _savedVolume);
|
|
if (_wasPlaying)
|
|
{
|
|
Bass.ChannelPlay(AudioMixerManager.GlobalMixerHandle);
|
|
Log.Gated.AudioRender("[AudioRendering] GlobalMixer RESUMED");
|
|
}
|
|
}
|
|
}
|
|
} |