Files
ytLlive/ytLive.Tests/AudioMixerTests.cs
T

375 lines
10 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);
}
}
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);
}
}