336 lines
11 KiB
C#
336 lines
11 KiB
C#
using ytLive.Services;
|
||
|
||
namespace ytLive.Services.Audio;
|
||
|
||
/// <summary>
|
||
/// 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 (<see cref="StartLive"/>), 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.
|
||
/// </summary>
|
||
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<string>? _log;
|
||
private readonly Func<double>? _micGain;
|
||
private readonly Func<double>? _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<string>? log = null,
|
||
Func<double>? micGain = null,
|
||
Func<double>? 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;
|
||
}
|
||
|
||
/// <summary>Current smoothed mic level (0..1), post voice chain.</summary>
|
||
public float MicLevel => _meter.Level;
|
||
|
||
/// <summary>Current smoothed desktop/game level (0..1).</summary>
|
||
public float LoopbackLevel => _loopbackMeter.Level;
|
||
|
||
/// <summary>Raised whenever the smoothed mic level changes.</summary>
|
||
public event Action<float>? MicLevelChanged;
|
||
|
||
/// <summary>Raised whenever the smoothed desktop/game level changes.</summary>
|
||
public event Action<float>? LoopbackLevelChanged;
|
||
|
||
/// <summary>Raised when the mic capture comes up (the status dot goes green).</summary>
|
||
public event Action? MicConnected;
|
||
|
||
/// <summary>Raised when the mic capture fails or dies (the status dot goes yellow).</summary>
|
||
public event Action<Exception>? MicFailed;
|
||
|
||
public void Start()
|
||
{
|
||
if (_started)
|
||
return;
|
||
|
||
_started = true;
|
||
_meter.Reset();
|
||
_loopback.Start();
|
||
_mic.Start();
|
||
}
|
||
|
||
/// <summary>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 <see cref="MicConnected"/> or <see cref="MicFailed"/>.</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>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.</summary>
|
||
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));
|
||
}
|
||
|
||
/// <summary>Ends the live mix loop and closes the audio pipe — ffmpeg sees
|
||
/// EOF on the audio input. Safe when not live.</summary>
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Drains one tick's worth of mic + loopback, silence-fills any
|
||
/// underrun, applies the ducker and the honest gains, and mixes to stereo.</summary>
|
||
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;
|
||
}
|
||
}
|