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)); }