using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using ManagedBass;
using ManagedBass.Wasapi;
using T3.Core.Animation;
using T3.Core.IO;
using T3.Core.Logging;
using T3.Core.Settings;
namespace T3.Core.Audio;
///
/// Provides Windows Audio Session API (WASAPI) audio input handling for real-time audio capture.
/// Uses the WASAPI audio API to get audio reaction from devices like speakers and microphones.
/// Supports both loopback (system audio) and input device capture.
///
///
/// This class manages the lifecycle of WASAPI audio capture, including device enumeration,
/// initialization, and frame-by-frame audio processing. It integrates with the playback system
/// to provide FFT analysis data and audio level metering.
///
public static class WasapiAudioInput
{
///
/// Processes audio input at the start of each frame.
/// Handles device switching, capture restart on failure, and stops capture when not
/// using external audio. Capture is also kept running while an audio recording
/// session is active (), regardless of the
/// setting — recording
/// and FFT analysis are independent capture consumers.
///
/// The playback settings containing audio configuration. If null, no action is taken.
public static void StartFrame(CompositionSettings settings)
{
if (settings == null)
return;
var wantsCaptureForFft = settings.Playback.AudioSource == CompositionSettings.AudioSources.ExternalDevice;
var wantsCaptureForRecording = _isCaptureNeededForRecording;
if (!wantsCaptureForFft && !wantsCaptureForRecording)
{
if (!string.IsNullOrEmpty(ActiveInputDeviceName))
{
Stop();
}
return;
}
var deviceName = ResolveInputDeviceName(settings.Playback.AudioInputDeviceName);
if (ActiveInputDeviceName == deviceName)
{
// Try to restart capture
if(!_failedToGetLastFffData)
return;
Log.Debug("Trying to restart WASAPI...");
_failedToGetLastFffData = false;
}
// No configured device name. For FFT capture this is an error (user has
// ExternalDevice mode but no device picked). For recording-only capture
// (AudioSource is ProjectSoundtrack but a record session is active), silently
// fall back to the system default input so the recording still gets audio.
if (string.IsNullOrEmpty(deviceName))
{
if (wantsCaptureForFft)
{
if (_complainedOnce)
return;
Log.Warning("Can't switch to WASAPI device without a name");
_complainedOnce = true;
return;
}
// Recording-only path: pick the first available input device.
if (InputDevices.Count == 0)
return;
StartInputCapture(InputDevices[0]);
_complainedOnce = false;
return;
}
var device = InputDevices.FirstOrDefault(d => d.DeviceInfo.Name == deviceName);
if (device == null)
{
Log.Warning($"Can't find input device {deviceName}");
_complainedOnce = true;
return;
}
StartInputCapture(device);
_complainedOnce = false;
}
///
/// Resolves the device a composition asks for into the actual device name to capture from.
/// An empty project-level name means "use this machine's configured default input"
/// (), which keeps shared
/// projects portable. A non-empty name is an explicit per-project override.
///
public static string ResolveInputDeviceName(string projectDeviceName)
{
return string.IsNullOrEmpty(projectDeviceName)
? CoreSettings.Config.LocalAudioInputDeviceName ?? string.Empty
: projectDeviceName;
}
///
/// Gets the list of available WASAPI input devices.
///
///
/// A list of instances representing available audio input devices.
/// The list is lazily initialized on first access.
///
public static List InputDevices
{
get
{
if (_inputDevices == null)
InitializeInputDeviceList();
return _inputDevices;
}
}
///
/// Initializes and starts WASAPI audio capture for the specified device.
///
///
/// The WASAPI input device to capture from. If null, attempts to use the default input device.
///
///
/// Configures WASAPI with the device's native mix frequency and minimum update period.
/// It ensures BASS is initialized before WASAPI setup and registers
/// for asynchronous audio data processing.
///
private static void StartInputCapture(WasapiInputDevice device)
{
// Ensure BASS is initialized before WASAPI
// WASAPI requires a valid BASS device to work properly
AudioMixerManager.Initialize();
var inputDeviceIndex = BassWasapi.DefaultInputDevice;
if (device == null)
{
if (_inputDevices.Count == 0)
{
Log.Error("No wasapi input devices found");
return;
}
Log.Error($"Attempting default input {BassWasapi.DefaultInputDevice}.");
device = _inputDevices[0];
}
else
{
Log.Info($"Initializing WASAPI audio input for {device.DeviceInfo.Name}... ");
inputDeviceIndex = device.WasapiDeviceIndex;
}
SampleRate = device.DeviceInfo.MixFrequency;
BassWasapi.Stop();
BassWasapi.Free();
if (!BassWasapi.Init(inputDeviceIndex,
Frequency: device.DeviceInfo.MixFrequency,
Channels: 0,
//Flags: WasapiInitFlags.Buffer | WasapiInitFlags.Exclusive,
Flags: WasapiInitFlags.Buffer,
Buffer: (float)device.DeviceInfo.MinimumUpdatePeriod*4,
Period: (float)device.DeviceInfo.MinimumUpdatePeriod,
Procedure: ProcessDataCallback,
User: IntPtr.Zero))
{
Log.Error("Can't initialize WASAPI:" + Bass.LastError);
return;
}
ActiveInputDeviceName = device.DeviceInfo.Name;
// Record the channel count BASS settled on, so an active WavFileWriter knows
// the correct interleave layout. Falls back to stereo if Info isn't populated yet.
var info = BassWasapi.Info;
_activeChannelCount = info.Channels > 0 ? info.Channels : 2;
BassWasapi.Start();
}
///
/// Stops the WASAPI audio capture and releases associated resources.
/// Any active recording session is finalised first so the WAV file isn't left
/// with a placeholder header.
///
private static void Stop()
{
//Log.Debug("Wasapi.Stop()");
if (_activeRecording != null)
{
EndRecording();
}
BassWasapi.Stop();
BassWasapi.Free();
ActiveInputDeviceName = null;
}
///
/// Starts writing the live WASAPI capture to an uncompressed 16-bit PCM WAV file in
/// .
///
/// Optional source identifier appended to the filename
/// (e.g. mic1). Sanitised before use.
/// The absolute path of the WAV file, or null if recording could not start.
public static string BeginRecording(int sessionIndex = -1, string suffix = null)
{
if (_activeRecording != null)
{
Log.Warning($"WAV recording already active at '{_activeRecording.Path}'. Call EndRecording first.");
return null;
}
// Mark capture as needed for recording so StartFrame won't tear it down even if
// the composition's AudioSource is something other than ExternalDevice. If WASAPI
// isn't running yet, bring it up on demand using whatever input device is
// configured — or fall back to the system default.
_isCaptureNeededForRecording = true;
if (string.IsNullOrEmpty(ActiveInputDeviceName))
{
EnsureCaptureRunningForRecording();
}
if (string.IsNullOrEmpty(ActiveInputDeviceName))
{
_isCaptureNeededForRecording = false;
Log.Warning("Cannot start WAV recording: no WASAPI input device available.");
return null;
}
var directory = RecordingPaths.TempRecordingsDirectory;
try
{
Directory.CreateDirectory(directory);
}
catch (Exception e)
{
Log.Warning($"Cannot create recordings directory '{directory}': {e.Message}");
return null;
}
// -1 = compute independently; the recording session passes a shared index so the audio + data
// files of one session line up (AudioRec-007 / DataRec-007).
if (sessionIndex < 0)
sessionIndex = RecordingPaths.NextSessionIndex(directory);
var fileName = RecordingPaths.BuildFileName(RecordingPaths.AudioRecordingPrefix, sessionIndex, ".wav", suffix);
var path = Path.Combine(directory, fileName);
var sampleRate = SampleRate > 0 ? SampleRate : 48000;
var channels = _activeChannelCount > 0 ? _activeChannelCount : 2;
try
{
// Assign last so the capture callback only sees a fully constructed writer.
var writer = new WavFileWriter(path, sampleRate, channels);
_activeRecording = writer;
Log.Debug($"WAV recording started: {path} ({sampleRate} Hz, {channels} ch)");
return path;
}
catch (Exception e)
{
Log.Warning($"Failed to start WAV recording at '{path}': {e.Message}");
return null;
}
}
///
/// Stops the active WAV recording started by and
/// finalises the file header so the WAV is readable.
///
/// The path of the finalised file, or null if no recording was active.
public static string EndRecording()
{
var writer = _activeRecording;
if (writer == null)
return null;
// Clear the field before disposing so the callback stops feeding the writer
// the moment it next observes the change. The writer's own lock guards against
// a callback that may already be inside AppendFloat32Samples.
_activeRecording = null;
writer.Dispose();
// Recording is done; let StartFrame tear capture down again on the next tick
// (it will, if AudioSource isn't ExternalDevice). Holding capture alive until the
// next frame avoids a stutter if BeginRecording is called again immediately.
_isCaptureNeededForRecording = false;
Log.Debug($"WAV recording stopped: {writer.Path} ({writer.DurationSeconds:F1} s, {writer.BytesWritten} bytes)");
return writer.Path;
}
///
/// Brings WASAPI capture up on demand when a recording is requested but no capture
/// is currently active (because the composition's AudioSource isn't ExternalDevice).
/// Picks the configured input device if one is set, else the first available.
///
private static void EnsureCaptureRunningForRecording()
{
var configuredDeviceName = ResolveInputDeviceName(Playback.Current?.Settings?.Playback.AudioInputDeviceName);
WasapiInputDevice device = null;
if (!string.IsNullOrEmpty(configuredDeviceName))
device = InputDevices.FirstOrDefault(d => d.DeviceInfo.Name == configuredDeviceName);
if (device == null && InputDevices.Count > 0)
device = InputDevices[0];
if (device == null)
{
Log.Warning("Cannot start WASAPI capture for recording: no input devices available.");
return;
}
StartInputCapture(device);
}
/// True while a WAV recording session is active.
public static bool IsRecording => _activeRecording != null;
/// Path of the WAV file currently being written, or null if not recording.
public static string ActiveRecordingPath => _activeRecording?.Path;
///
/// Flag indicating whether a warning about missing device name has been logged.
/// Used to prevent repeated log spam.
///
private static bool _complainedOnce;
///
/// True while a recording session needs WASAPI capture running, regardless of the
/// composition's . Set by
/// , cleared by . Lets the
/// FFT-input and recording paths use the WASAPI input independently — the OS allows
/// simultaneous render-and-capture, so there's no hardware reason to couple them.
///
private static bool _isCaptureNeededForRecording;
///
/// Enumerates and initializes the list of available WASAPI input devices.
///
///
/// Populates with all enabled input and loopback devices.
/// Requires BASS to be initialized before enumeration.
///
private static void InitializeInputDeviceList()
{
_inputDevices = [];
// Ensure BASS is initialized before enumerating WASAPI devices
AudioMixerManager.Initialize();
// Keep in local variable to avoid double evaluation
var deviceCount = BassWasapi.DeviceCount;
for (var deviceIndex = 0; deviceIndex < deviceCount; deviceIndex++)
{
var deviceInfo = BassWasapi.GetDeviceInfo(deviceIndex);
var isValidInputDevice = deviceInfo.IsEnabled && (deviceInfo.IsLoopback || deviceInfo.IsInput);
if (!isValidInputDevice)
continue;
Log.Debug($"Found Wasapi input ID:{_inputDevices.Count} {deviceInfo.Name} LoopBack:{deviceInfo.IsLoopback} IsInput:{deviceInfo.IsInput} (at {deviceIndex})");
_inputDevices.Add(new WasapiInputDevice()
{
WasapiDeviceIndex = deviceIndex,
DeviceInfo = deviceInfo,
});
}
}
///
/// Callback invoked asynchronously by WASAPI when new audio data arrives.
/// Processes FFT data, audio levels, and triggers audio analysis updates.
///
/// Pointer to the audio sample buffer.
/// The length of the buffer in bytes.
/// User data pointer (unused).
/// The length parameter, indicating all data was processed.
///
/// This callback may be invoked multiple times per frame. It handles:
///
/// - Waveform sample buffer updates (skipped during file export)
/// - FFT gain buffer population for audio analysis
/// - Stereo level calculation for metering
/// - Beat synchronization updates when enabled
///
///
private static int ProcessDataCallback(IntPtr buffer, int length, IntPtr user)
{
// Skip all WASAPI processing during export - AudioRendering handles FFT/waveform
if (Playback.Current.IsRenderingToFile)
return length;
// Stream the raw captured samples to the active WAV recording, if any.
// Read directly from the callback's buffer instead of issuing another GetData()
// call — GetData consumes BASS's internal queue and would compete with the
// analysis paths below for the same samples.
var writer = _activeRecording;
if (writer != null && buffer != IntPtr.Zero && length > 0)
{
writer.AppendFloat32Samples(buffer, length);
}
var time = Playback.RunTimeInSecs; // Keep because timer is still running
TimeSinceLastUpdate = time - LastUpdateTime;
LastUpdateTime = time;
// Skip waveform buffer updates during export - AudioRendering handles this
if (WaveFormProcessing.RequestedOnce && !Playback.Current.IsRenderingToFile)
{
var sizeInBytes = AudioConfig.WaveformSampleCount << 2 << 1;
WaveFormProcessing.LastFetchResultCode = BassWasapi.GetData(WaveFormProcessing.InterleavenSampleBuffer,
sizeInBytes);
}
var resultCode = BassWasapi.GetData(AudioAnalysis.FftGainBuffer, (int)(AudioAnalysis.BassFlagForFftBufferSize | DataFlags.FFTRemoveDC));
_failedToGetLastFffData = resultCode < 0;
if (_failedToGetLastFffData)
{
Log.Debug($"Can't get Wasapi FFT-Data: {Bass.LastError}");
return length;
}
// level is an int32 carrying per-channel level, such as: "0xRRRRLLLL"
// convert to M/S, and scale it.
// more info : https://documentation.help/BASSWASAPI/BASS_WASAPI_GetLevel.html
var level = BassWasapi.GetLevel();
if (level != -1) // exactly -1 is a capture error, do not measure it
{
var left = level & 0xffff;
var right = (level >> 16) & 0xffff;
_lastAudioLevel = (left + right + Math.Abs(left-right)) * short.MaxValue * 0.00001f;
}
var playbackSettings = Playback.Current?.Settings;
if (playbackSettings == null)
return length;
AudioAnalysis.ProcessUpdate(playbackSettings.Playback.AudioGainFactor,
playbackSettings.Playback.AudioDecayFactor);
if (playbackSettings.Playback.EnableAudioBeatLocking)
{
BeatSynchronizer.UpdateBeatTimer();
}
return length;
}
///
/// Flag indicating whether the last FFT data fetch from WASAPI failed.
/// Used to trigger capture restart on the next frame.
///
private static bool _failedToGetLastFffData;
///
/// Represents a WASAPI audio input device with its associated device information.
///
public sealed class WasapiInputDevice
{
///
/// The internal WASAPI device index used for initialization.
///
internal int WasapiDeviceIndex;
///
/// Contains detailed device information including name, mix frequency, and capabilities.
///
public WasapiDeviceInfo DeviceInfo;
}
///
/// Internal cache of enumerated WASAPI input devices.
///
private static List _inputDevices;
///
/// The time in seconds since the last audio data update callback.
///
internal static double TimeSinceLastUpdate;
///
/// The playback time in seconds when the last audio update occurred.
///
internal static double LastUpdateTime;
///
/// The sample rate in Hz of the currently active audio capture device.
/// Defaults to 48000 Hz if device sample rate cannot be determined.
///
internal static int SampleRate;
///
/// Initializes the sample rate from the audio system.
///
///
/// Attempts to get the device sample rate from .
/// Falls back to 48000 Hz if initialization fails.
///
static WasapiAudioInput()
{
try
{
// Ensure AudioMixerManager has a chance to query the device sample rate.
AudioMixerManager.Initialize();
var deviceRate = AudioConfig.MixerFrequency; // AudioMixerManager sets this during Initialize
SampleRate = deviceRate > 0 ? deviceRate : 48000; //Set to device rate if valid, fallback to 48kHz if not
}
catch (Exception ex)
{
Log.Debug($"WasapiAudioInput: Failed to initialize sample rate from AudioMixerManager: {ex.Message}");
SampleRate = 48000;
}
}
///
/// Gets the name of the currently active WASAPI input device.
///
///
/// The device name string if capture is active; otherwise, null.
///
public static string ActiveInputDeviceName { get; private set; }
///
/// The raw audio level value from the last WASAPI level measurement.
///
private static float _lastAudioLevel;
///
/// Active WAV writer. Set by , cleared by
/// and . Read on the BASS capture thread
/// inside the callback.
///
private static WavFileWriter _activeRecording;
///
/// Channel count of the active WASAPI capture, captured after BASS_WASAPI_Init.
/// Used as the channel count for new WAV recordings.
///
private static int _activeChannelCount;
///
/// Gets a time-decayed audio level value suitable for visual metering. (gain meter in playback settings)
///
///
/// A float value representing the decaying audio level, calculated from the last
/// measured level divided by the elapsed time since measurement.
///
///
/// This property is primarily used for the gain meter display in the playback settings dialog.
/// The decay creates a smooth falloff effect for visual representation.
///
public static float DecayingAudioLevel => (float)(_lastAudioLevel / Math.Max(1, (Playback.RunTimeInSecs - LastUpdateTime) * 100));
}