using ytLive.Services; namespace ytLive.Services.Audio; /// /// Owns the mic + desktop/game capture sources (TASK 4 ship step 4) and drives /// the footer meters; since TASK 9 it is also the live audio path into the /// encoder. Capture runs for the app's lifetime so both bars stay live in /// preview: mic samples are downmixed to mono, resampled to 48 kHz, put /// through the voice chain (bass → treble → noise gate → compressor), THEN /// level-metered and queued; loopback samples are resampled to 48 kHz stereo /// and queued. While live (), a 10 ms loop drains both /// queues, applies the honest gains (mic = MicVolume, loopback = GameAudioVolume /// × auto-duck when the mic is hot), mixes them to stereo float and writes the /// chunk to the encoder's audio pipe. /// public sealed class AudioMixer : IDisposable { public const int OutputSampleRate = 48000; private static readonly TimeSpan DefaultMixInterval = TimeSpan.FromMilliseconds(10); private readonly IAudioSource _mic; private readonly IAudioSource _loopback; private readonly AudioLevelMeter _meter; private readonly AudioLevelMeter _loopbackMeter; private readonly Action? _log; private readonly Func? _micGain; private readonly Func? _loopbackGain; private readonly TimeSpan _mixInterval; private bool _started; // Live path (TASK 9): per-source resampling → voice chain on the mic → // thread-safe queues → the ducker + gain/mix loop → the encoder's pipe. private readonly AudioRingBuffer _micBuffer; private readonly AudioRingBuffer _loopbackBuffer; private readonly VoiceFilterChain _voiceChain; private readonly AutoDucker _ducker; private TinyResampler? _micResampler; private TinyResampler? _loopbackResampler; private CancellationTokenSource? _liveCts; private NamedPipeAudioWriter _pipe = new(); private float[]? _micChunk; private float[]? _loopbackChunk; private float[]? _mixBuffer; public AudioMixer( IAudioSource mic, IAudioSource loopback, Action? log = null, Func? micGain = null, Func? loopbackGain = null, TimeSpan? mixInterval = null) { _mic = mic; _loopback = loopback; _meter = new AudioLevelMeter(); _loopbackMeter = new AudioLevelMeter(); _log = log; _micGain = micGain; _loopbackGain = loopbackGain; _mixInterval = mixInterval ?? DefaultMixInterval; var seconds = _mixInterval.TotalSeconds; var framesPerTick = Math.Max(1, (int)Math.Round(OutputSampleRate * seconds)); _micBuffer = new AudioRingBuffer(OutputSampleRate * 2); _loopbackBuffer = new AudioRingBuffer(OutputSampleRate * 2 * 2); _voiceChain = new VoiceFilterChain(OutputSampleRate); _ducker = new AutoDucker(); _micChunk = new float[framesPerTick]; _loopbackChunk = new float[framesPerTick * 2]; _mixBuffer = new float[framesPerTick * 2]; _mic.Started += OnMicStarted; _mic.SampleReady += OnMicSample; _loopback.SampleReady += OnLoopbackSample; _mic.Failed += OnMicFailed; _loopback.Failed += OnLoopbackFailed; } /// Current smoothed mic level (0..1), post voice chain. public float MicLevel => _meter.Level; /// Current smoothed desktop/game level (0..1). public float LoopbackLevel => _loopbackMeter.Level; /// Raised whenever the smoothed mic level changes. public event Action? MicLevelChanged; /// Raised whenever the smoothed desktop/game level changes. public event Action? LoopbackLevelChanged; /// Raised when the mic capture comes up (the status dot goes green). public event Action? MicConnected; /// Raised when the mic capture fails or dies (the status dot goes yellow). public event Action? MicFailed; public void Start() { if (_started) return; _started = true; _meter.Reset(); _loopback.Start(); _mic.Start(); } /// Swaps the mic source without touching loopback — used when the /// creator picks a different device mid-session. The level resets and the /// new source raises or . public void RestartMic() { _mic.Stop(); _meter.Reset(); _micBuffer.Clear(); _micResampler?.Reset(); _voiceChain.Reset(); MicLevelChanged?.Invoke(0); _mic.Start(); } public void Stop() { if (!_started) return; _started = false; StopLive(); _mic.Stop(); _loopback.Stop(); _meter.Reset(); _loopbackMeter.Reset(); MicLevelChanged?.Invoke(0); LoopbackLevelChanged?.Invoke(0); } /// Begins the live mix loop: drains the capture queues, applies the /// honest gains + auto-duck, and streams stereo float into the named audio /// pipe. Idempotent — safe to call once per go-live. public void StartLive(string pipeName) { if (_liveCts != null) return; var cts = new CancellationTokenSource(); _liveCts = cts; _pipe = new NamedPipeAudioWriter(); _pipe.Start(pipeName); _ = Task.Run(() => LiveLoopAsync(_pipe, cts.Token)); } /// Ends the live mix loop and closes the audio pipe — ffmpeg sees /// EOF on the audio input. Safe when not live. public void StopLive() { _liveCts?.Cancel(); _liveCts = null; _pipe.Stop(); } public void Dispose() { Stop(); _mic.Started -= OnMicStarted; _mic.SampleReady -= OnMicSample; _loopback.SampleReady -= OnLoopbackSample; _mic.Failed -= OnMicFailed; _loopback.Failed -= OnLoopbackFailed; _mic.Dispose(); _loopback.Dispose(); } private void OnMicStarted() { MicConnected?.Invoke(); } private void OnMicSample(AudioSample sample) { var mono = DownmixToMono(sample); mono = ResampleMic(mono, sample.SampleRate); for (var i = 0; i < mono.Length; i++) mono[i] = _voiceChain.Process(mono[i]); _micBuffer.Write(mono); // Push unconditionally: the ?. on the event would otherwise skip the // argument (and the meter update) when nothing is subscribed yet. The // level is the POST-filtered signal — the meter shows what the stream // will carry. var level = _meter.Push(new AudioSample(mono, OutputSampleRate, 1)); MicLevelChanged?.Invoke(level); } private void OnLoopbackSample(AudioSample sample) { var data = ResampleLoopback(sample.Samples, sample.SampleRate); _loopbackBuffer.Write(data); var level = _loopbackMeter.Push(new AudioSample(data, OutputSampleRate, sample.Channels)); LoopbackLevelChanged?.Invoke(level); } private void OnMicFailed(Exception ex) { _log?.Invoke($"Mic capture failed: {ex.Message}"); MicLevelChanged?.Invoke(0); MicFailed?.Invoke(ex); } private void OnLoopbackFailed(Exception ex) { _log?.Invoke($"Desktop audio capture failed: {ex.Message}"); } private static float[] DownmixToMono(AudioSample sample) { if (sample.Channels <= 1) return sample.Samples; var frames = sample.Samples.Length / sample.Channels; var mono = new float[frames]; for (var i = 0; i < frames; i++) { var sum = 0f; for (var c = 0; c < sample.Channels; c++) sum += sample.Samples[i * sample.Channels + c]; mono[i] = sum / sample.Channels; } return mono; } private float[] ResampleMic(float[] mono, int inputRate) { if (inputRate == OutputSampleRate) return mono; _micResampler ??= new TinyResampler(inputRate, OutputSampleRate); if (_micResampler.NeedsResampling) return _micResampler.Process(mono); return mono; } private float[] ResampleLoopback(float[] interleaved, int inputRate) { if (inputRate == OutputSampleRate) return interleaved; _loopbackResampler ??= new TinyResampler(inputRate, OutputSampleRate); if (_loopbackResampler.NeedsResampling) return _loopbackResampler.Process(interleaved); return interleaved; } private async Task LiveLoopAsync(IAudioPipeWriter pipe, CancellationToken cancellationToken) { var mixBuffer = _mixBuffer!; var micChunk = _micChunk!; var loopbackChunk = _loopbackChunk!; while (!cancellationToken.IsCancellationRequested) { var nextTick = DateTime.UtcNow + _mixInterval; try { FillAndMix(micChunk, loopbackChunk, mixBuffer); await pipe.WriteAsync(mixBuffer, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; } catch (Exception ex) { _log?.Invoke($"Audio live loop error: {ex.Message}"); } var delay = nextTick - DateTime.UtcNow; if (delay > TimeSpan.Zero) { try { await Task.Delay(delay, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { break; } } } } /// Drains one tick's worth of mic + loopback, silence-fills any /// underrun, applies the ducker and the honest gains, and mixes to stereo. private float FillAndMix(float[] micChunk, float[] loopbackChunk, float[] mix) { var micCount = _micBuffer.Read(micChunk, micChunk.Length); var loopCount = _loopbackBuffer.Read(loopbackChunk, loopbackChunk.Length); float micRms = 0; if (micCount == 0) { Array.Clear(micChunk, 0, micChunk.Length); } else { double sumSquares = 0; for (var i = 0; i < micCount; i++) sumSquares += micChunk[i] * micChunk[i]; micRms = (float)Math.Sqrt(sumSquares / micCount); if (micCount < micChunk.Length) Array.Clear(micChunk, micCount, micChunk.Length - micCount); } if (loopCount == 0) { Array.Clear(loopbackChunk, 0, loopbackChunk.Length); } else if (loopCount < loopbackChunk.Length) { Array.Clear(loopbackChunk, loopCount, loopbackChunk.Length - loopCount); } var duck = _ducker.Update(micRms); var micGain = (float)(_micGain?.Invoke() ?? 1.0); var loopGain = (float)(_loopbackGain?.Invoke() ?? 1.0) * duck; var frames = micChunk.Length; for (var i = 0; i < frames; i++) { var m = micChunk[i] * micGain; mix[i * 2] = m + loopbackChunk[i * 2] * loopGain; mix[i * 2 + 1] = m + loopbackChunk[i * 2 + 1] * loopGain; } return micRms; } }