Files
tooll3--t3/Core/Audio/WasapiAudioInput.cs
2026-07-13 13:13:17 +08:00

574 lines
22 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public static class WasapiAudioInput
{
/// <summary>
/// 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 (<see cref="BeginRecording"/>), regardless of the
/// <see cref="CompositionSettings.PlaybackConfig.AudioSource"/> setting — recording
/// and FFT analysis are independent capture consumers.
/// </summary>
/// <param name="settings">The playback settings containing audio configuration. If null, no action is taken.</param>
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;
}
/// <summary>
/// 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"
/// (<see cref="CoreSettings.ConfigData.LocalAudioInputDeviceName"/>), which keeps shared
/// projects portable. A non-empty name is an explicit per-project override.
/// </summary>
public static string ResolveInputDeviceName(string projectDeviceName)
{
return string.IsNullOrEmpty(projectDeviceName)
? CoreSettings.Config.LocalAudioInputDeviceName ?? string.Empty
: projectDeviceName;
}
/// <summary>
/// Gets the list of available WASAPI input devices.
/// </summary>
/// <value>
/// A list of <see cref="WasapiInputDevice"/> instances representing available audio input devices.
/// The list is lazily initialized on first access.
/// </value>
public static List<WasapiInputDevice> InputDevices
{
get
{
if (_inputDevices == null)
InitializeInputDeviceList();
return _inputDevices;
}
}
/// <summary>
/// Initializes and starts WASAPI audio capture for the specified device.
/// </summary>
/// <param name="device">
/// The WASAPI input device to capture from. If null, attempts to use the default input device.
/// </param>
/// <remarks>
/// Configures WASAPI with the device's native mix frequency and minimum update period.
/// It ensures BASS is initialized before WASAPI setup and registers <see cref="ProcessDataCallback"/>
/// for asynchronous audio data processing.
/// </remarks>
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();
}
/// <summary>
/// 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.
/// </summary>
private static void Stop()
{
//Log.Debug("Wasapi.Stop()");
if (_activeRecording != null)
{
EndRecording();
}
BassWasapi.Stop();
BassWasapi.Free();
ActiveInputDeviceName = null;
}
/// <summary>
/// Starts writing the live WASAPI capture to an uncompressed 16-bit PCM WAV file in
/// <see cref="RecordingPaths.TempRecordingsDirectory"/>.
/// </summary>
/// <param name="suffix">Optional source identifier appended to the filename
/// (e.g. <c>mic1</c>). Sanitised before use.</param>
/// <returns>The absolute path of the WAV file, or <c>null</c> if recording could not start.</returns>
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;
}
}
/// <summary>
/// Stops the active WAV recording started by <see cref="BeginRecording"/> and
/// finalises the file header so the WAV is readable.
/// </summary>
/// <returns>The path of the finalised file, or <c>null</c> if no recording was active.</returns>
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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>True while a WAV recording session is active.</summary>
public static bool IsRecording => _activeRecording != null;
/// <summary>Path of the WAV file currently being written, or <c>null</c> if not recording.</summary>
public static string ActiveRecordingPath => _activeRecording?.Path;
/// <summary>
/// Flag indicating whether a warning about missing device name has been logged.
/// Used to prevent repeated log spam.
/// </summary>
private static bool _complainedOnce;
/// <summary>
/// True while a recording session needs WASAPI capture running, regardless of the
/// composition's <see cref="CompositionSettings.PlaybackConfig.AudioSource"/>. Set by
/// <see cref="BeginRecording"/>, cleared by <see cref="EndRecording"/>. 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.
/// </summary>
private static bool _isCaptureNeededForRecording;
/// <summary>
/// Enumerates and initializes the list of available WASAPI input devices.
/// </summary>
/// <remarks>
/// Populates <see cref="_inputDevices"/> with all enabled input and loopback devices.
/// Requires BASS to be initialized before enumeration.
/// </remarks>
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,
});
}
}
/// <summary>
/// Callback invoked asynchronously by WASAPI when new audio data arrives.
/// Processes FFT data, audio levels, and triggers audio analysis updates.
/// </summary>
/// <param name="buffer">Pointer to the audio sample buffer.</param>
/// <param name="length">The length of the buffer in bytes.</param>
/// <param name="user">User data pointer (unused).</param>
/// <returns>The length parameter, indicating all data was processed.</returns>
/// <remarks>
/// This callback may be invoked multiple times per frame. It handles:
/// <list type="bullet">
/// <item>Waveform sample buffer updates (skipped during file export)</item>
/// <item>FFT gain buffer population for audio analysis</item>
/// <item>Stereo level calculation for metering</item>
/// <item>Beat synchronization updates when enabled</item>
/// </list>
/// </remarks>
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;
}
/// <summary>
/// Flag indicating whether the last FFT data fetch from WASAPI failed.
/// Used to trigger capture restart on the next frame.
/// </summary>
private static bool _failedToGetLastFffData;
/// <summary>
/// Represents a WASAPI audio input device with its associated device information.
/// </summary>
public sealed class WasapiInputDevice
{
/// <summary>
/// The internal WASAPI device index used for initialization.
/// </summary>
internal int WasapiDeviceIndex;
/// <summary>
/// Contains detailed device information including name, mix frequency, and capabilities.
/// </summary>
public WasapiDeviceInfo DeviceInfo;
}
/// <summary>
/// Internal cache of enumerated WASAPI input devices.
/// </summary>
private static List<WasapiInputDevice> _inputDevices;
/// <summary>
/// The time in seconds since the last audio data update callback.
/// </summary>
internal static double TimeSinceLastUpdate;
/// <summary>
/// The playback time in seconds when the last audio update occurred.
/// </summary>
internal static double LastUpdateTime;
/// <summary>
/// The sample rate in Hz of the currently active audio capture device.
/// Defaults to 48000 Hz if device sample rate cannot be determined.
/// </summary>
internal static int SampleRate;
/// <summary>
/// Initializes the sample rate from the audio system.
/// </summary>
/// <remarks>
/// Attempts to get the device sample rate from <see cref="AudioMixerManager"/>.
/// Falls back to 48000 Hz if initialization fails.
/// </remarks>
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;
}
}
/// <summary>
/// Gets the name of the currently active WASAPI input device.
/// </summary>
/// <value>
/// The device name string if capture is active; otherwise, <c>null</c>.
/// </value>
public static string ActiveInputDeviceName { get; private set; }
/// <summary>
/// The raw audio level value from the last WASAPI level measurement.
/// </summary>
private static float _lastAudioLevel;
/// <summary>
/// Active WAV writer. Set by <see cref="BeginRecording"/>, cleared by
/// <see cref="EndRecording"/> and <see cref="Stop"/>. Read on the BASS capture thread
/// inside the callback.
/// </summary>
private static WavFileWriter _activeRecording;
/// <summary>
/// Channel count of the active WASAPI capture, captured after BASS_WASAPI_Init.
/// Used as the channel count for new WAV recordings.
/// </summary>
private static int _activeChannelCount;
/// <summary>
/// Gets a time-decayed audio level value suitable for visual metering. (gain meter in playback settings)
/// </summary>
/// <value>
/// A float value representing the decaying audio level, calculated from the last
/// measured level divided by the elapsed time since measurement.
/// </value>
/// <remarks>
/// 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.
/// </remarks>
public static float DecayingAudioLevel => (float)(_lastAudioLevel / Math.Max(1, (Playback.RunTimeInSecs - LastUpdateTime) * 100));
}