Skip to content

Commit 0c6be98

Browse files
committed
Add a live input-device test that streams a second of audio through the pipeline
1 parent 297a908 commit 0c6be98

3 files changed

Lines changed: 134 additions & 2 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,10 @@ jobs:
8282
if: matrix.app != ''
8383
run: ${{ matrix.app }} --version
8484

85-
# The runners have no audio hardware; this proves the console tool starts, brings
86-
# PortAudio up with the expected host APIs, and copes with an empty device list.
85+
# The Windows and Linux runners have no audio devices; the macOS runner exposes
86+
# virtual CoreAudio ones, which the [hardware] test above opens. This proves the
87+
# console tool starts, brings PortAudio up with the expected host APIs, and copes
88+
# with an empty device list.
8789
- name: Console tool
8890
run: |
8991
${{ matrix.cli }} --version

‎tests/CMakeLists.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ add_executable(takt4_tests
1212
audio/hop_meter_test.cpp
1313
audio/host_apis_test.cpp
1414
audio/input_pipeline_test.cpp
15+
audio/input_stream_test.cpp
1516
audio/resampler_test.cpp
1617
output/link_session_test.cpp
1718
output/midi_ports_test.cpp

‎tests/audio/input_stream_test.cpp‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
#include "core/audio/input_stream.hpp"
2+
3+
#include "core/audio/channel_picker.hpp"
4+
#include "core/audio/devices.hpp"
5+
#include "core/audio/hop_meter.hpp"
6+
#include "core/audio/portaudio_session.hpp"
7+
#include "core/audio/rates.hpp"
8+
9+
#include <catch2/catch_test_macros.hpp>
10+
#include <catch2/generators/catch_generators.hpp>
11+
12+
#include <chrono>
13+
#include <cstddef>
14+
#include <cstdint>
15+
#include <optional>
16+
#include <thread>
17+
#include <tuple>
18+
#include <vector>
19+
20+
using takt4::audio::ChannelSelection;
21+
using takt4::audio::HopLevel;
22+
using takt4::audio::HopMeter;
23+
using takt4::audio::InputDevice;
24+
using takt4::audio::InputStream;
25+
using takt4::audio::InputStreamOptions;
26+
using takt4::audio::kHopSize;
27+
using takt4::audio::kInternalSampleRate;
28+
using takt4::audio::PickMode;
29+
30+
namespace {
31+
32+
// The most interesting input this machine has: a host API with native channel selection
33+
// first (ASIO, CoreAudio), then the most inputs, then the platform's default input. On
34+
// the macOS CI runner that is the "Apple Virtual Sound Device"; here it is the MOTU over
35+
// ASIO. Loopback captures are not inputs in the HANDOFF §5.1 sense.
36+
std::optional<InputDevice> bestInputDevice(const takt4::audio::PortAudioSession& session) {
37+
std::optional<InputDevice> best;
38+
const auto rank = [](const InputDevice& d) {
39+
return std::make_tuple(takt4::audio::hasNativeChannelSelection(d.hostApi), d.maxInputChannels,
40+
d.isDefaultInput, -d.index);
41+
};
42+
for (const InputDevice& device : takt4::audio::listInputDevices(session)) {
43+
if (!device.isLoopback && (!best || rank(device) > rank(*best))) {
44+
best = device;
45+
}
46+
}
47+
return best;
48+
}
49+
50+
} // namespace
51+
52+
TEST_CASE("the input device streams hops end to end", "[audio][hardware]") {
53+
// Phase 1's live path on whatever this machine has: open a real device, pick its
54+
// last input, and check that a second of audio reaches the meter as hops, in order,
55+
// with nothing dropped. Native selection is used where the host API has it, so on
56+
// macOS this is the CoreAudio channel map at work; the second run forces the
57+
// software slice on the same device. The allocation guard is on, so any heap use in
58+
// the callback aborts the run. Machines without an input device (the Windows and
59+
// Linux CI runners) skip.
60+
const takt4::audio::PortAudioSession session;
61+
const std::optional<InputDevice> device = bestInputDevice(session);
62+
if (!device) {
63+
SKIP("no input device on this machine");
64+
}
65+
const bool forceSoftware = GENERATE(false, true);
66+
const int channel = device->maxInputChannels - 1;
67+
INFO(device->hostApiName << " / " << device->name << ", input " << channel + 1 << " of "
68+
<< device->maxInputChannels << (forceSoftware ? ", software slice" : ""));
69+
70+
HopMeter meter;
71+
InputStreamOptions options;
72+
options.forceSoftwareSlice = forceSoftware;
73+
InputStream stream(session, *device, ChannelSelection::single(channel), meter, options);
74+
const bool nativeExpected = !forceSoftware && takt4::audio::hasNativeChannelSelection(device->hostApi);
75+
CHECK(stream.picker().mode() == (nativeExpected ? PickMode::Native : PickMode::Software));
76+
INFO("opened at " << stream.reportedSampleRate() << " Hz, input latency "
77+
<< stream.inputLatencySeconds() * 1000.0 << " ms");
78+
79+
// Run until a second of audio has arrived, reading the meter as its consumer would.
80+
// The deadline only bounds a device that never delivers.
81+
const auto targetFrames = static_cast<std::uint64_t>(stream.sampleRate());
82+
std::vector<HopLevel> levels;
83+
const auto drain = [&] {
84+
HopLevel level;
85+
while (meter.pop(level)) {
86+
levels.push_back(level);
87+
}
88+
};
89+
stream.start();
90+
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10);
91+
while (stream.counters().framesIn < targetFrames && std::chrono::steady_clock::now() < deadline) {
92+
std::this_thread::sleep_for(std::chrono::milliseconds(10));
93+
drain();
94+
}
95+
stream.stop();
96+
REQUIRE_FALSE(stream.running());
97+
drain();
98+
99+
const auto counters = stream.counters();
100+
INFO(counters.callbacks << " callbacks, " << counters.framesIn << " frames, " << counters.hopsOut
101+
<< " hops, " << counters.inputOverflows << " overflows");
102+
CHECK(counters.callbacks > 0);
103+
CHECK(counters.framesIn >= targetFrames);
104+
if (counters.inputOverflows > 0) {
105+
WARN("the device reported " << counters.inputOverflows << " input overflows");
106+
}
107+
108+
// The same arithmetic as the synthetic pipeline test: every device frame past the
109+
// resampler's delay becomes internal-rate samples, kHopSize to a hop.
110+
const std::uint64_t delay = stream.resamplerDelayFrames();
111+
REQUIRE(counters.framesIn > delay);
112+
const std::uint64_t expectedHops =
113+
static_cast<std::uint64_t>(static_cast<double>(counters.framesIn - delay) * kInternalSampleRate /
114+
stream.sampleRate()) /
115+
kHopSize;
116+
CHECK(counters.hopsOut + 1 >= expectedHops);
117+
CHECK(counters.hopsOut <= expectedHops + 1);
118+
119+
// Every hop reached the reader, in order.
120+
CHECK(meter.dropped() == 0);
121+
REQUIRE(levels.size() == counters.hopsOut);
122+
std::size_t outOfOrder = 0;
123+
for (std::size_t i = 0; i < levels.size(); ++i) {
124+
if (levels[i].hopIndex != i) {
125+
++outOfOrder;
126+
}
127+
}
128+
CHECK(outOfOrder == 0);
129+
}

0 commit comments

Comments
 (0)