nonemm-recorder is a separate program (src/Nonemm.Recorder.App) over a library (src/Nonemm.Recorder). Each row in its list is one channel of one device, and each row becomes one channel in an Ogg Opus file: 60 ms frames, complexity 9, 32 kbps per channel by default. - A device that is not connected keeps its row, in red, and writes silence. It is recorded from the moment it returns, also during a recording. - Output devices are recorded as loopback: WASAPI on Windows, the "Monitor of" sources on PulseAudio and PipeWire. - A headphone button plays one channel, in mono, on the default device. - The rows, bitrate and folder are saved when the window closes. SoundFlow 1.4.2 numbers its backend enum one higher than the miniaudio it ships, so asking for PulseAudio gave ALSA and Wasapi gave DirectSound. SoundFlowBackend lowers the value by one and checks which backend started. Output devices and live capture are not tested on real hardware. The tests use a fake sound system and decode the written file. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
141 lines
4.8 KiB
C#
141 lines
4.8 KiB
C#
namespace Nonemm.Recorder.Tests;
|
|
|
|
public class RecorderEngineTests : IDisposable
|
|
{
|
|
private readonly string path = Path.Combine(Path.GetTempPath(), $"recorder-{Guid.NewGuid():N}.opus");
|
|
private readonly FakeBackend backend = new();
|
|
private readonly RecorderEngine engine;
|
|
|
|
public RecorderEngineTests()
|
|
{
|
|
engine = new RecorderEngine(backend);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
engine.Dispose();
|
|
File.Delete(path);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_channel_of_a_missing_device_stays_in_the_list_as_unavailable()
|
|
{
|
|
RecorderChannel channel = engine.AddChannel(new ChannelSource("Gone", false, 0, 1));
|
|
|
|
Assert.False(channel.IsAvailable);
|
|
Assert.Single(engine.Channels);
|
|
Assert.Equal("not connected", channel.Problem);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_device_that_appears_is_opened_and_its_channel_becomes_available()
|
|
{
|
|
RecorderChannel channel = engine.AddChannel(new ChannelSource("Mic", false, 0, 1));
|
|
|
|
backend.Devices.Add(new AudioDeviceInfo("Mic", false, 1));
|
|
engine.Refresh();
|
|
|
|
Assert.True(channel.IsAvailable);
|
|
Assert.True(backend.IsOpen("Mic"));
|
|
}
|
|
|
|
[Fact]
|
|
public void A_device_that_is_unplugged_is_closed_and_its_channel_is_kept()
|
|
{
|
|
backend.Devices.Add(new AudioDeviceInfo("Mic", false, 1));
|
|
RecorderChannel channel = engine.AddChannel(new ChannelSource("Mic", false, 0, 1));
|
|
|
|
backend.Devices.Clear();
|
|
engine.Refresh();
|
|
|
|
Assert.False(channel.IsAvailable);
|
|
Assert.False(backend.IsOpen("Mic"));
|
|
Assert.Single(engine.Channels);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_channel_past_the_end_of_the_device_is_unavailable()
|
|
{
|
|
backend.Devices.Add(new AudioDeviceInfo("Line In", false, 2));
|
|
|
|
RecorderChannel channel = engine.AddChannel(new ChannelSource("Line In", false, 5, 8));
|
|
|
|
Assert.False(channel.IsAvailable);
|
|
}
|
|
|
|
[Fact]
|
|
public void The_two_sides_of_a_stereo_device_are_two_channels()
|
|
{
|
|
backend.Devices.Add(new AudioDeviceInfo("Line In", false, 2));
|
|
RecorderChannel left = engine.AddChannel(new ChannelSource("Line In", false, 0, 2));
|
|
RecorderChannel right = engine.AddChannel(new ChannelSource("Line In", false, 1, 2));
|
|
|
|
backend.Push("Line In", 2, 480, channel: 1, value: 0.5f);
|
|
|
|
Assert.Equal(0f, left.TakePeak());
|
|
Assert.Equal(0.5f, right.TakePeak());
|
|
}
|
|
|
|
[Fact]
|
|
public void An_unavailable_channel_is_recorded_as_silence_and_picks_up_when_it_appears()
|
|
{
|
|
backend.Devices.Add(new AudioDeviceInfo("Mic", false, 1));
|
|
engine.AddChannel(new ChannelSource("Mic", false, 0, 1));
|
|
engine.AddChannel(new ChannelSource("Radio", false, 0, 1));
|
|
|
|
engine.StartRecording(path, 32);
|
|
Feed(seconds: 1, "Mic");
|
|
backend.Devices.Add(new AudioDeviceInfo("Radio", false, 1));
|
|
engine.Refresh();
|
|
Feed(seconds: 1, "Mic", "Radio");
|
|
engine.StopRecording();
|
|
|
|
OggOpusReader file = OggOpusReader.Read(path);
|
|
Assert.Equal(2, file.Channels);
|
|
int second = RecorderEngine.SampleRate;
|
|
Assert.True(OggOpusReader.Rms(file.Pcm[0], second / 4, second / 2) > 0.2f);
|
|
Assert.True(OggOpusReader.Rms(file.Pcm[1], second / 4, second / 2) < 0.01f);
|
|
Assert.True(OggOpusReader.Rms(file.Pcm[1], second + second / 4, second / 2) > 0.2f);
|
|
}
|
|
|
|
[Fact]
|
|
public void The_channels_cannot_change_during_a_recording()
|
|
{
|
|
backend.Devices.Add(new AudioDeviceInfo("Mic", false, 1));
|
|
engine.AddChannel(new ChannelSource("Mic", false, 0, 1));
|
|
engine.StartRecording(path, 32);
|
|
|
|
Assert.Throws<InvalidOperationException>(() => engine.AddChannel(new ChannelSource("Mic", false, 0, 1)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Monitoring_opens_the_playback_device_once_and_closes_it_with_the_last_channel()
|
|
{
|
|
backend.Devices.Add(new AudioDeviceInfo("Mic", false, 1));
|
|
RecorderChannel first = engine.AddChannel(new ChannelSource("Mic", false, 0, 1));
|
|
RecorderChannel second = engine.AddChannel(new ChannelSource("Mic", false, 0, 1));
|
|
|
|
engine.SetMonitored(first, true);
|
|
engine.SetMonitored(second, true);
|
|
engine.SetMonitored(first, false);
|
|
|
|
Assert.Equal(1, backend.PlaybackOpened);
|
|
engine.SetMonitored(second, false);
|
|
engine.SetMonitored(first, true);
|
|
Assert.Equal(2, backend.PlaybackOpened);
|
|
}
|
|
|
|
// a tone in real time, 10 ms at a time, as a device delivers it
|
|
private void Feed(int seconds, params string[] names)
|
|
{
|
|
for (int step = 0; step < seconds * 100; step++)
|
|
{
|
|
foreach (string name in names)
|
|
{
|
|
backend.Push(name, 1, 480, channel: 0, value: step % 2 == 0 ? 0.4f : -0.4f);
|
|
}
|
|
Thread.Sleep(10);
|
|
}
|
|
}
|
|
}
|