406 lines
12 KiB
C#
406 lines
12 KiB
C#
using NAudio.Wave;
|
|
using Xunit;
|
|
using ytLive.Services.Audio;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// TASK 4 ship step 4: WASAPI audio capture behind the <see cref="IAudioSource"/>
|
|
/// seam. The units pin down the pure pieces — byte→float conversion, the level
|
|
/// meter math, and the mixer lifecycle/forwarding against fakes. No real audio
|
|
/// devices (NAudio device resolution is a thin wrapper left to a manual smoke
|
|
/// test), no timers.
|
|
/// </summary>
|
|
public class AudioMixerTests
|
|
{
|
|
private sealed class FakeSource : IAudioSource
|
|
{
|
|
public int StartCount { get; private set; }
|
|
public int StopCount { get; private set; }
|
|
public bool Disposed { get; private set; }
|
|
public event Action? Started;
|
|
public event Action<AudioSample>? SampleReady;
|
|
public event Action<Exception>? Failed;
|
|
|
|
public void Start() => StartCount++;
|
|
public void Stop() => StopCount++;
|
|
public void Dispose() => Disposed = true;
|
|
|
|
public void MarkStarted() => Started?.Invoke();
|
|
public void Emit(AudioSample sample) => SampleReady?.Invoke(sample);
|
|
public void Fail(Exception ex) => Failed?.Invoke(ex);
|
|
}
|
|
|
|
[Fact]
|
|
public void Start_StartsBothSources()
|
|
{
|
|
var mic = new FakeSource();
|
|
var loopback = new FakeSource();
|
|
var mixer = new AudioMixer(mic, loopback);
|
|
|
|
mixer.Start();
|
|
|
|
Assert.Equal(1, mic.StartCount);
|
|
Assert.Equal(1, loopback.StartCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Start_IsIdempotent()
|
|
{
|
|
var mic = new FakeSource();
|
|
var mixer = new AudioMixer(mic, new FakeSource());
|
|
|
|
mixer.Start();
|
|
mixer.Start();
|
|
|
|
Assert.Equal(1, mic.StartCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Stop_StopsBothAndResetsLevel()
|
|
{
|
|
var mic = new FakeSource();
|
|
var loopback = new FakeSource();
|
|
var mixer = new AudioMixer(mic, loopback);
|
|
mixer.Start();
|
|
mic.Emit(new AudioSample(new[] { 0.8f }, 48000, 1));
|
|
|
|
var last = -1f;
|
|
mixer.MicLevelChanged += l => last = l;
|
|
mixer.Stop();
|
|
|
|
Assert.Equal(1, mic.StopCount);
|
|
Assert.Equal(1, loopback.StopCount);
|
|
Assert.Equal(0f, last);
|
|
Assert.Equal(0f, mixer.MicLevel);
|
|
}
|
|
|
|
[Fact]
|
|
public void Stop_WithNoStart_DoesNothing()
|
|
{
|
|
var mic = new FakeSource();
|
|
var mixer = new AudioMixer(mic, new FakeSource());
|
|
|
|
mixer.Stop();
|
|
|
|
Assert.Equal(0, mic.StopCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void MicSamples_DriveMicLevelChanged()
|
|
{
|
|
var mic = new FakeSource();
|
|
var mixer = new AudioMixer(mic, new FakeSource());
|
|
var levels = new List<float>();
|
|
mixer.MicLevelChanged += l => levels.Add(l);
|
|
|
|
mixer.Start();
|
|
mic.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
|
mic.Emit(new AudioSample(new[] { -1f, -1f, -1f, -1f }, 48000, 1));
|
|
|
|
Assert.NotEmpty(levels);
|
|
Assert.All(levels, l => Assert.InRange(l, 0f, 1f));
|
|
}
|
|
|
|
[Fact]
|
|
public void LoopbackSamples_DoNotChangeMicLevel()
|
|
{
|
|
var loopback = new FakeSource();
|
|
var mixer = new AudioMixer(new FakeSource(), loopback);
|
|
var levels = new List<float>();
|
|
mixer.MicLevelChanged += l => levels.Add(l);
|
|
|
|
mixer.Start();
|
|
loopback.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 2));
|
|
|
|
Assert.Empty(levels);
|
|
Assert.Equal(0f, mixer.MicLevel);
|
|
}
|
|
|
|
[Fact]
|
|
public void MicSamples_DoNotChangeLoopbackLevel()
|
|
{
|
|
var mic = new FakeSource();
|
|
var mixer = new AudioMixer(mic, new FakeSource());
|
|
var levels = new List<float>();
|
|
mixer.LoopbackLevelChanged += l => levels.Add(l);
|
|
|
|
mixer.Start();
|
|
mic.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
|
|
|
Assert.Empty(levels);
|
|
Assert.Equal(0f, mixer.LoopbackLevel);
|
|
}
|
|
|
|
[Fact]
|
|
public void LoopbackSamples_DriveLoopbackLevelChanged()
|
|
{
|
|
var loopback = new FakeSource();
|
|
var mixer = new AudioMixer(new FakeSource(), loopback);
|
|
var levels = new List<float>();
|
|
mixer.LoopbackLevelChanged += l => levels.Add(l);
|
|
|
|
mixer.Start();
|
|
loopback.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 2));
|
|
loopback.Emit(new AudioSample(new[] { -1f, -1f, -1f, -1f }, 48000, 2));
|
|
|
|
Assert.NotEmpty(levels);
|
|
Assert.All(levels, l => Assert.InRange(l, 0f, 1f));
|
|
}
|
|
|
|
[Fact]
|
|
public void MicStarted_RaisesMicConnected()
|
|
{
|
|
var mic = new FakeSource();
|
|
var mixer = new AudioMixer(mic, new FakeSource());
|
|
var connected = 0;
|
|
mixer.MicConnected += () => connected++;
|
|
|
|
mixer.Start();
|
|
mic.MarkStarted();
|
|
|
|
Assert.Equal(1, connected);
|
|
}
|
|
|
|
[Fact]
|
|
public void MicFailure_RaisesMicFailed()
|
|
{
|
|
var mic = new FakeSource();
|
|
var mixer = new AudioMixer(mic, new FakeSource());
|
|
Exception? failed = null;
|
|
mixer.MicFailed += ex => failed = ex;
|
|
|
|
mixer.Start();
|
|
mic.Fail(new InvalidOperationException("boom"));
|
|
|
|
Assert.NotNull(failed);
|
|
Assert.Equal("boom", failed!.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void RestartMic_StopsAndRestartsMic_KeepsLoopbackRunning()
|
|
{
|
|
var mic = new FakeSource();
|
|
var loopback = new FakeSource();
|
|
var mixer = new AudioMixer(mic, loopback);
|
|
mixer.Start();
|
|
|
|
mixer.RestartMic();
|
|
|
|
Assert.Equal(2, mic.StartCount);
|
|
Assert.Equal(1, mic.StopCount);
|
|
Assert.Equal(1, loopback.StartCount);
|
|
Assert.Equal(0, loopback.StopCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void RestartMic_ResetsLevel()
|
|
{
|
|
var mic = new FakeSource();
|
|
var mixer = new AudioMixer(mic, new FakeSource());
|
|
mixer.Start();
|
|
mic.Emit(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
|
Assert.True(mixer.MicLevel > 0);
|
|
|
|
float? reset = null;
|
|
mixer.MicLevelChanged += l => reset = l;
|
|
mixer.RestartMic();
|
|
|
|
Assert.Equal(0f, reset);
|
|
}
|
|
|
|
[Fact]
|
|
public void MicFailure_LogsAndResetsLevel()
|
|
{
|
|
var mic = new FakeSource();
|
|
var logs = new List<string>();
|
|
var mixer = new AudioMixer(mic, new FakeSource(), m => logs.Add(m));
|
|
mixer.Start();
|
|
mic.Emit(new AudioSample(new[] { 0.5f }, 48000, 1));
|
|
|
|
var last = -1f;
|
|
mixer.MicLevelChanged += l => last = l;
|
|
mic.Fail(new InvalidOperationException("boom"));
|
|
|
|
Assert.Contains(logs, l => l.Contains("boom"));
|
|
Assert.Equal(0f, last);
|
|
}
|
|
|
|
[Fact]
|
|
public void LoopbackFailure_LogsButKeepsMic()
|
|
{
|
|
var loopback = new FakeSource();
|
|
var logs = new List<string>();
|
|
var mixer = new AudioMixer(new FakeSource(), loopback, m => logs.Add(m));
|
|
mixer.Start();
|
|
|
|
var last = -1f;
|
|
mixer.MicLevelChanged += l => last = l;
|
|
loopback.Fail(new InvalidOperationException("boom"));
|
|
|
|
Assert.Contains(logs, l => l.Contains("boom"));
|
|
Assert.Equal(-1f, last);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_StopsAndDisposesSources()
|
|
{
|
|
var mic = new FakeSource();
|
|
var loopback = new FakeSource();
|
|
var mixer = new AudioMixer(mic, loopback);
|
|
mixer.Start();
|
|
|
|
mixer.Dispose();
|
|
|
|
Assert.True(mic.Disposed);
|
|
Assert.True(loopback.Disposed);
|
|
Assert.Equal(1, mic.StopCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_UnsubscribesEvents()
|
|
{
|
|
var mic = new FakeSource();
|
|
var loopback = new FakeSource();
|
|
var mixer = new AudioMixer(mic, loopback);
|
|
mixer.Dispose();
|
|
|
|
var levels = new List<float>();
|
|
mixer.MicLevelChanged += l => levels.Add(l);
|
|
mic.Emit(new AudioSample(new[] { 1f }, 48000, 1));
|
|
|
|
Assert.Empty(levels);
|
|
}
|
|
}
|
|
|
|
public class AudioLevelMeterTests
|
|
{
|
|
[Fact]
|
|
public void ConstantSine_ConvergesToRms()
|
|
{
|
|
var meter = new AudioLevelMeter();
|
|
var sample = new AudioSample(new[] { 0.5f, -0.5f, 0.5f, -0.5f }, 48000, 1);
|
|
|
|
float level = 0;
|
|
for (var i = 0; i < 30; i++)
|
|
level = meter.Push(sample);
|
|
|
|
Assert.InRange(level, 0.45f, 0.55f);
|
|
}
|
|
|
|
[Fact]
|
|
public void Silence_DrivesTowardZero()
|
|
{
|
|
var meter = new AudioLevelMeter();
|
|
meter.Push(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
|
var samples = new float[1024];
|
|
var sample = new AudioSample(samples, 48000, 1);
|
|
|
|
for (var i = 0; i < 50; i++)
|
|
meter.Push(sample);
|
|
|
|
Assert.Equal(0f, meter.Level, 3);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmptySample_KeepsLevel()
|
|
{
|
|
var meter = new AudioLevelMeter();
|
|
meter.Push(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
|
var before = meter.Level;
|
|
|
|
var level = meter.Push(new AudioSample(Array.Empty<float>(), 48000, 1));
|
|
|
|
Assert.Equal(before, level);
|
|
}
|
|
|
|
[Fact]
|
|
public void Reset_ZerosLevel()
|
|
{
|
|
var meter = new AudioLevelMeter();
|
|
meter.Push(new AudioSample(new[] { 1f, 1f, 1f, 1f }, 48000, 1));
|
|
|
|
meter.Reset();
|
|
|
|
Assert.Equal(0f, meter.Level);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToDisplay_IsLogarithmic()
|
|
{
|
|
Assert.Equal(0f, AudioLevelMeter.ToDisplay(0f));
|
|
Assert.Equal(0f, AudioLevelMeter.ToDisplay(0.001f), 2);
|
|
|
|
// -60 dBFS floor → 0, 0 dBFS → 1, and +10 dB amplification: a decade
|
|
// (~-20 dB) is 2/3 up the bar.
|
|
Assert.Equal(1f, AudioLevelMeter.ToDisplay(1f), 3);
|
|
Assert.InRange(AudioLevelMeter.ToDisplay(0.0316f), 0.66f, 0.67f);
|
|
Assert.InRange(AudioLevelMeter.ToDisplay(0.1f), 0.83f, 0.84f);
|
|
Assert.InRange(AudioLevelMeter.ToDisplay(0.01f), 0.49f, 0.51f);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToDisplay_Pushes_Speech_Peaks_Into_Red_At_Maxed_Volume()
|
|
{
|
|
// ~0.2 linear RMS (-14 dBFS) is a loud speech peak; at maxed volume it
|
|
// must land in the red zone (0.8+) and normal speech (~0.05, -26 dBFS)
|
|
// in the yellow — the meter uses the whole bar proportionately.
|
|
Assert.InRange(AudioLevelMeter.ToDisplay(0.2f), 0.92f, 0.95f);
|
|
Assert.InRange(AudioLevelMeter.ToDisplay(0.05f), 0.72f, 0.75f);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToDisplay_ClampsAtFloor()
|
|
{
|
|
Assert.Equal(0f, AudioLevelMeter.ToDisplay(0.0001f));
|
|
Assert.Equal(0f, AudioLevelMeter.ToDisplay(-1f));
|
|
}
|
|
}
|
|
|
|
public class WaveToFloatTests
|
|
{
|
|
private static byte[] FloatSamples(params float[] values)
|
|
{
|
|
var bytes = new byte[values.Length * 4];
|
|
Buffer.BlockCopy(values, 0, bytes, 0, bytes.Length);
|
|
return bytes;
|
|
}
|
|
|
|
[Fact]
|
|
public void IeeeFloat32_PreservesValues()
|
|
{
|
|
var format = WaveFormat.CreateIeeeFloatWaveFormat(48000, 2);
|
|
var samples = WaveToFloat.Convert(FloatSamples(0.25f, -0.5f, 1f), 12, format);
|
|
|
|
Assert.Equal(3, samples.Length);
|
|
Assert.Equal(0.25f, samples[0], 4);
|
|
Assert.Equal(-0.5f, samples[1], 4);
|
|
Assert.Equal(1f, samples[2], 4);
|
|
}
|
|
|
|
[Fact]
|
|
public void Pcm16_NormalizesToUnitRange()
|
|
{
|
|
var format = WaveFormat.CreateCustomFormat(WaveFormatEncoding.Pcm, 48000, 1, 48000 * 2, 2, 16);
|
|
var bytes = new byte[] { 0x00, 0x00, 0xFF, 0x7F, 0x00, 0x80 };
|
|
var samples = WaveToFloat.Convert(bytes, 6, format);
|
|
|
|
Assert.Equal(3, samples.Length);
|
|
Assert.Equal(0f, samples[0], 4);
|
|
Assert.Equal(1f, samples[1], 4);
|
|
Assert.Equal(-1f, samples[2], 4);
|
|
}
|
|
|
|
[Fact]
|
|
public void TruncatedTrailingBytes_AreIgnored()
|
|
{
|
|
var format = WaveFormat.CreateIeeeFloatWaveFormat(48000, 2);
|
|
var bytes = new byte[] { 0, 0, 0x80, 0x3F, 1, 2, 3 }; // 1 float + 3 stray bytes
|
|
|
|
var samples = WaveToFloat.Convert(bytes, bytes.Length, format);
|
|
|
|
Assert.Single(samples);
|
|
Assert.Equal(1f, samples[0], 4);
|
|
}
|
|
}
|