using NAudio.Wave; using Xunit; using ytLive.Services.Audio; namespace ytLive.Tests; /// /// TASK 4 ship step 4: WASAPI audio capture behind the /// 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. /// 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? SampleReady; public event Action? 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(), 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); } }