diff --git a/CMakeLists.txt b/CMakeLists.txt index 7c077c3..19636ca 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -501,6 +501,7 @@ if(SENDSPIN_CLI_BUILD_TESTS) tests/pcm_volume_test.cpp tests/pipewire_sink_test.cpp tests/player_listener_test.cpp + tests/portaudio_device_test.cpp tests/sink_recovery_test.cpp tests/state_store_test.cpp tests/supported_formats_test.cpp diff --git a/docs/wiki/Advanced-Usage.md b/docs/wiki/Advanced-Usage.md index 5769c01..c785a25 100644 --- a/docs/wiki/Advanced-Usage.md +++ b/docs/wiki/Advanced-Usage.md @@ -48,6 +48,10 @@ pair split on the first colon (`coreaudio:2`, `portaudio:2`, `pulse:`, `pipewire:`), or an ALSA PCM name such as `hw:1,0`, `plughw:1,0`, or `default`. `plughw:` lets ALSA convert rate and format for a device that refuses the stream as it arrives. +A PortAudio name listed under several host APIs, as every device is on Windows, is refused +on its own; put `-l`'s host API column in front of it, e.g. +`portaudio:directsound:Speakers (USB DAC)`. + `default` follows the host's normal audio configuration. Under a system service, name a hardware device such as `hw:1,0` instead; the service does not have a logged-in desktop audio session. @@ -88,7 +92,9 @@ argument the way `-o` does: - On macOS, `coreaudio` for the system default input, which is followed if it moves, or `coreaudio:` for one device from `-l`'s list. - `portaudio` for PortAudio's default input as of startup, or `portaudio:` - for one device from `-l`'s list, in a build with the PortAudio backend. + for one device from `-l`'s list, in a build with the PortAudio backend. A name listed + under several host APIs takes the one from `-l`'s host API column in front of it: + `portaudio:directsound:`. - `loopback` or `loopback:` for what this host plays; see [Stream what this host plays](#stream-what-this-host-plays). - `tone` and `null`, which need no sound card: `tone` streams a 440 Hz test tone, which diff --git a/docs/wiki/Getting-Started-on-Windows.md b/docs/wiki/Getting-Started-on-Windows.md index 25bccf6..2aa646a 100644 --- a/docs/wiki/Getting-Started-on-Windows.md +++ b/docs/wiki/Getting-Started-on-Windows.md @@ -173,8 +173,10 @@ Get-ChildItem .\sendspin-cli-0.5.0-windows-x86_64 | Unblock-File .\sendspin-cli-0.5.0-windows-x86_64\sendspin-cli.exe ``` -`-l` lists what it can play through; `-o portaudio:` picks one. Ctrl+C and closing -the window stop it cleanly, and it handles logging off and shutting down the same way. +`-l` lists what it can play through. Windows lists each device once per host API, so pick one +by that column and its name, e.g. `-o "portaudio:directsound:Speakers (USB DAC)"`; an index +works too, but shifts as devices come and go. Ctrl+C and closing the window stop it cleanly, +and it handles logging off and shutting down the same way. ## Next diff --git a/src/audio_sink.cpp b/src/audio_sink.cpp index 3ab85e9..d0de2ca 100644 --- a/src/audio_sink.cpp +++ b/src/audio_sink.cpp @@ -488,14 +488,18 @@ void print_audio_devices(std::FILE* out) { #endif #ifdef SENDSPIN_CLI_HAVE_PORTAUDIO - std::fprintf(out, "\nPortAudio output devices on this host (-o portaudio:):\n"); + std::fprintf(out, "\nPortAudio output devices on this host " + "(-o portaudio:):\n"); PortAudioSink::list_devices(out); std::fprintf(out, "\nThe name is the form worth writing down. PortAudio numbers devices as it\n" "walks each host API, so an index shifts as devices come and go; a name is\n" "matched in full and case-insensitively, and one that matches more than one\n" - "device is refused rather than guessed at. -o portaudio with no device at all\n" - "follows this host's default output, resolved afresh at every stream.\n" + "device is refused rather than guessed at. Where a device is listed under\n" + "several host APIs, as every one is on Windows, put the host API column in\n" + "front of the name, e.g. -o portaudio:directsound:. -o portaudio with no\n" + "device at all follows this host's default output, resolved afresh at every\n" + "stream.\n" "\nInput-only devices are left out, since -o cannot play through them. The\n" "rate on the device's own line is its default; the rates below it are what it\n" "will actually take, asked for the same way a stream would ask. Only the four\n" diff --git a/src/audio_source.cpp b/src/audio_source.cpp index cfbda6c..ae4f59e 100644 --- a/src/audio_source.cpp +++ b/src/audio_source.cpp @@ -565,7 +565,8 @@ void print_capture_devices(std::FILE* out) { "the first time --input opens a device.\n"); #endif #ifdef SENDSPIN_CLI_HAVE_PORTAUDIO - std::fprintf(out, "\nPortAudio input devices on this host (--input portaudio:):\n"); + std::fprintf(out, "\nPortAudio input devices on this host " + "(--input portaudio:):\n"); PortAudioSource::list_devices(out); std::fprintf(out, "\nA capture stream runs at one format for the whole run: 48000 Hz where the\n" diff --git a/src/portaudio_device.cpp b/src/portaudio_device.cpp index daa4d18..9b8b54a 100644 --- a/src/portaudio_device.cpp +++ b/src/portaudio_device.cpp @@ -114,6 +114,107 @@ bool pa_has_channels(const PaDeviceInfo* info, PaDirection direction) { 0; } +const char* pa_host_api_prefix(PaHostApiTypeId type) { + switch (type) { + case paDirectSound: + return "directsound"; + case paMME: + return "mme"; + case paASIO: + return "asio"; + case paCoreAudio: + return "coreaudio"; + case paOSS: + return "oss"; + case paALSA: + return "alsa"; + case paWDMKS: + return "wdmks"; + case paJACK: + return "jack"; + case paWASAPI: + return "wasapi"; + default: + return nullptr; + } +} + +const char* pa_host_api_label(PaHostApiIndex host_api) { + const PaHostApiInfo* host = Pa_GetHostApiInfo(host_api); + if (host == nullptr) { + return "(unknown host API)"; + } + const char* prefix = pa_host_api_prefix(host->type); + return (prefix != nullptr) ? prefix : host->name; +} + +bool match_pa_device_name(const std::string& device, const std::vector& entries, + PaDirection direction, PaDeviceIndex& out, std::string& error) { + const std::string flag = flag_of(direction); + const std::string spec = flag + " portaudio:" + device; + std::vector matches; + for (const PaDeviceEntry& entry : entries) { + if (iequals(device, entry.name.c_str())) { + matches.push_back(&entry); + } + } + + // Read as : only when no device has the whole name, so none that resolved + // before resolves differently now. + const size_t colon = device.find(':'); + if (matches.empty() && colon != std::string::npos) { + const std::string host_api = device.substr(0, colon); + const std::string name = device.substr(colon + 1); + for (const PaDeviceEntry& entry : entries) { + if (iequals(host_api, entry.host_api) && iequals(name, entry.name.c_str())) { + matches.push_back(&entry); + } + } + } + + const std::string noun = noun_of(direction); + if (matches.empty()) { + error = spec + ": no " + noun + " device by that name -- run with -l to list them"; + return false; + } + if (matches.size() > 1) { + // A prefix only tells them apart if each sits under a different, nameable host API. + bool by_host_api = true; + std::string listed; + for (size_t i = 0; i < matches.size(); ++i) { + const char* host_api = matches[i]->host_api; + for (size_t j = 0; j < i; ++j) { + const char* earlier = matches[j]->host_api; + if (host_api != nullptr && earlier != nullptr && + std::strcmp(host_api, earlier) == 0) { + by_host_api = false; + } + } + if (host_api == nullptr) { + by_host_api = false; + } + listed += (listed.empty() ? "" : ", ") + std::to_string(matches[i]->index); + if (host_api != nullptr) { + listed += std::string(" ") + host_api; + } + } + error = spec + ": " + std::to_string(matches.size()) + " " + noun + + " devices share that name (" + listed + ") -- "; + if (by_host_api) { + // Quoted, since device names carry spaces and parentheses. + error += "put its host API in front to pick one, e.g. " + flag + + " \"portaudio:" + matches.front()->host_api + ":" + matches.front()->name + + "\""; + } else { + error += "name the one you mean by index instead"; + } + return false; + } + + out = matches.front()->index; + return true; +} + bool resolve_pa_device(const std::string& device, PaDirection direction, PaDeviceIndex& out, std::string& error) { const PaDeviceIndex count = Pa_GetDeviceCount(); @@ -162,33 +263,16 @@ bool resolve_pa_device(const std::string& device, PaDirection direction, PaDevic return true; } - std::vector matches; + std::vector entries; for (PaDeviceIndex i = 0; i < count; ++i) { const PaDeviceInfo* info = Pa_GetDeviceInfo(i); - if (pa_has_channels(info, direction) && iequals(device, info->name)) { - matches.push_back(i); - } - } - if (matches.empty()) { - error = spec + ": no " + noun + " device by that name -- run with -l to list them"; - return false; - } - if (matches.size() > 1) { - std::string indices; - for (const PaDeviceIndex index : matches) { - if (!indices.empty()) { - indices += ", "; - } - indices += std::to_string(index); + if (pa_has_channels(info, direction) && info->name != nullptr) { + const PaHostApiInfo* host = Pa_GetHostApiInfo(info->hostApi); + entries.push_back( + {i, info->name, (host != nullptr) ? pa_host_api_prefix(host->type) : nullptr}); } - error = spec + ": " + std::to_string(matches.size()) + " " + noun + - " devices share that name (indices " + indices + - ") -- name the one you mean by index instead"; - return false; } - - out = matches.front(); - return true; + return match_pa_device_name(device, entries, direction, out, error); } } // namespace sendspin_cli diff --git a/src/portaudio_device.h b/src/portaudio_device.h index ea3bc00..3bf06a2 100644 --- a/src/portaudio_device.h +++ b/src/portaudio_device.h @@ -24,6 +24,7 @@ #include #include #include +#include namespace sendspin_cli { @@ -69,8 +70,26 @@ bool pa_format_for(uint8_t bits_per_sample, PaSampleFormat& format); /// True if the device has channels on that side. bool pa_has_channels(const PaDeviceInfo* info, PaDirection direction); -/// Resolves a PortAudio device spec: empty is the default, digits an index, else a unique name. -/// PortAudio must be initialized. +/// The prefix a device spec names a host API by, e.g. "wasapi"; nullptr for one it cannot name. +const char* pa_host_api_prefix(PaHostApiTypeId type); + +/// The host API column -l prints: its prefix, else PortAudio's own name for it. +const char* pa_host_api_label(PaHostApiIndex host_api); + +/// One device as the name lookup sees it. +struct PaDeviceEntry { + PaDeviceIndex index; + std::string name; + const char* host_api; ///< pa_host_api_prefix() of its host API, or nullptr +}; + +/// Picks the one device `device` names, whole or as :, out of the devices on +/// that side. +bool match_pa_device_name(const std::string& device, const std::vector& entries, + PaDirection direction, PaDeviceIndex& out, std::string& error); + +/// Resolves a PortAudio device spec: empty is the default, digits an index, else a unique name, +/// optionally behind its host API prefix. PortAudio must be initialized. bool resolve_pa_device(const std::string& device, PaDirection direction, PaDeviceIndex& out, std::string& error); diff --git a/src/portaudio_sink.cpp b/src/portaudio_sink.cpp index 220dd7f..32590e0 100644 --- a/src/portaudio_sink.cpp +++ b/src/portaudio_sink.cpp @@ -237,11 +237,9 @@ void PortAudioSink::list_devices(std::FILE* out) { std::fprintf(out, " idx name host API " "out ch default rate\n"); } - const PaHostApiInfo* host = Pa_GetHostApiInfo(info->hostApi); std::fprintf(out, " %3d %-38s %-12s %2d ch %6.0f Hz%s\n", static_cast(i), - info->name, (host != nullptr) ? host->name : "(unknown host API)", - info->maxOutputChannels, info->defaultSampleRate, - (i == fallback) ? " (system default)" : ""); + info->name, pa_host_api_label(info->hostApi), info->maxOutputChannels, + info->defaultSampleRate, (i == fallback) ? " (system default)" : ""); print_device_capabilities(out, i); ++listed; } diff --git a/src/portaudio_source.cpp b/src/portaudio_source.cpp index 7059a30..2e0f1db 100644 --- a/src/portaudio_source.cpp +++ b/src/portaudio_source.cpp @@ -96,11 +96,9 @@ void PortAudioSource::list_devices(std::FILE* out) { std::fprintf(out, " idx name host API " " in ch default rate\n"); } - const PaHostApiInfo* host = Pa_GetHostApiInfo(info->hostApi); std::fprintf(out, " %3d %-38s %-12s %2d ch %6.0f Hz%s\n", static_cast(i), - info->name, (host != nullptr) ? host->name : "(unknown host API)", - info->maxInputChannels, info->defaultSampleRate, - (i == fallback) ? " (system default)" : ""); + info->name, pa_host_api_label(info->hostApi), info->maxInputChannels, + info->defaultSampleRate, (i == fallback) ? " (system default)" : ""); ++listed; } diff --git a/tests/portaudio_device_test.cpp b/tests/portaudio_device_test.cpp new file mode 100644 index 0000000..7f11332 --- /dev/null +++ b/tests/portaudio_device_test.cpp @@ -0,0 +1,135 @@ +// Copyright 2026 sendspin-cpp-cli Contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// How -o portaudio: picks a device by name; built only with the PortAudio backend. + +#ifdef SENDSPIN_CLI_HAVE_PORTAUDIO + +#include "portaudio_device.h" + +#include + +#include +#include + +namespace sendspin_cli { +namespace { + +/// One output device listed under three host APIs, as Windows lists every one. +const std::vector WINDOWS{ + {4, "Speakers (7- Schiit Modi+)", "mme"}, + {5, "Odyssey G95NC (NVIDIA High Defi", "mme"}, + {12, "Speakers (7- Schiit Modi+)", "directsound"}, + {13, "Odyssey G95NC (NVIDIA High Definition Audio)", "directsound"}, + {19, "Speakers (7- Schiit Modi+)", "wasapi"}, + {16, "Odyssey G95NC (NVIDIA High Definition Audio)", "wasapi"}, +}; + +/// Matches `device`, requiring success, and returns the index it picked. +PaDeviceIndex matched(const std::string& device, const std::vector& entries) { + PaDeviceIndex out = paNoDevice; + std::string error; + EXPECT_TRUE(match_pa_device_name(device, entries, PaDirection::Output, out, error)) + << device << ": " << error; + return out; +} + +/// Matches `device`, requiring failure, and returns the reason given. +std::string refused(const std::string& device, const std::vector& entries) { + PaDeviceIndex out = paNoDevice; + std::string error; + EXPECT_FALSE(match_pa_device_name(device, entries, PaDirection::Output, out, error)) + << "matched '" << device << "' to " << out; + EXPECT_FALSE(error.empty()) << "no reason given for '" << device << "'"; + return error; +} + +TEST(PaHostApiPrefix, NamesTheHostApisASpecCanSay) { + EXPECT_STREQ(pa_host_api_prefix(paMME), "mme"); + EXPECT_STREQ(pa_host_api_prefix(paDirectSound), "directsound"); + EXPECT_STREQ(pa_host_api_prefix(paWASAPI), "wasapi"); + EXPECT_STREQ(pa_host_api_prefix(paWDMKS), "wdmks"); + EXPECT_STREQ(pa_host_api_prefix(paALSA), "alsa"); + EXPECT_STREQ(pa_host_api_prefix(paJACK), "jack"); + EXPECT_EQ(pa_host_api_prefix(paInDevelopment), nullptr); +} + +TEST(MatchPaDeviceName, AUniqueNameResolvesWithoutAPrefix) { + const std::vector entries{{0, "Built-in Output", "coreaudio"}, + {1, "USB DAC", "coreaudio"}}; + EXPECT_EQ(matched("USB DAC", entries), 1); + EXPECT_EQ(matched("usb dac", entries), 1); + // MME's truncated copy is the only device with that exact name. + EXPECT_EQ(matched("Odyssey G95NC (NVIDIA High Defi", WINDOWS), 5); +} + +TEST(MatchPaDeviceName, ANameUnderSeveralHostApisIsRefusedWithThePrefixedForm) { + const std::string error = refused("Speakers (7- Schiit Modi+)", WINDOWS); + EXPECT_NE(error.find("3 output devices share that name (4 mme, 12 directsound, 19 wasapi)"), + std::string::npos) + << error; + EXPECT_NE(error.find("-o \"portaudio:mme:Speakers (7- Schiit Modi+)\""), std::string::npos) + << error; +} + +TEST(MatchPaDeviceName, AHostApiPrefixPicksThatOne) { + EXPECT_EQ(matched("mme:Speakers (7- Schiit Modi+)", WINDOWS), 4); + EXPECT_EQ(matched("directsound:Speakers (7- Schiit Modi+)", WINDOWS), 12); + EXPECT_EQ(matched("wasapi:Speakers (7- Schiit Modi+)", WINDOWS), 19); + EXPECT_EQ(matched("DirectSound:odyssey g95nc (nvidia high definition audio)", WINDOWS), 13); +} + +TEST(MatchPaDeviceName, AWholeNameWinsOverReadingItAsAPrefix) { + const std::vector entries{{0, "wasapi:Den", "mme"}, {1, "Den", "wasapi"}}; + EXPECT_EQ(matched("wasapi:Den", entries), 0); +} + +TEST(MatchPaDeviceName, ANameWithAColonResolvesBareOrPrefixed) { + const std::vector entries{{0, "HDA Intel PCH: ALC892 Analog (hw:0,0)", "alsa"}, + {1, "pulse", "alsa"}}; + EXPECT_EQ(matched("HDA Intel PCH: ALC892 Analog (hw:0,0)", entries), 0); + EXPECT_EQ(matched("alsa:HDA Intel PCH: ALC892 Analog (hw:0,0)", entries), 0); +} + +TEST(MatchPaDeviceName, AnUnknownOrAbsentPrefixIsJustANameThatMatchesNothing) { + EXPECT_NE(refused("kitchen:Speakers (7- Schiit Modi+)", WINDOWS) + .find("no output device by that name"), + std::string::npos); + EXPECT_NE( + refused("asio:Speakers (7- Schiit Modi+)", WINDOWS).find("no output device by that name"), + std::string::npos); +} + +TEST(MatchPaDeviceName, TwinsUnderOneHostApiAreNamedByIndex) { + const std::vector entries{{7, "USB Audio", "wasapi"}, + {9, "USB Audio", "wasapi"}}; + for (const char* device : {"USB Audio", "wasapi:USB Audio"}) { + const std::string error = refused(device, entries); + EXPECT_NE(error.find("(7 wasapi, 9 wasapi)"), std::string::npos) << error; + EXPECT_NE(error.find("by index instead"), std::string::npos) << error; + EXPECT_EQ(error.find("in front"), std::string::npos) << error; + } +} + +TEST(MatchPaDeviceName, AnUnnameableHostApiFallsBackToTheIndex) { + const std::vector entries{{2, "Line Out", nullptr}, {3, "Line Out", "mme"}}; + const std::string error = refused("Line Out", entries); + EXPECT_NE(error.find("(2, 3 mme)"), std::string::npos) << error; + EXPECT_NE(error.find("by index instead"), std::string::npos) << error; +} + +} // namespace +} // namespace sendspin_cli + +#endif // SENDSPIN_CLI_HAVE_PORTAUDIO