From a33d6df102071f6d29ef8c95996b2e89ef55cf2a Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 28 Oct 2026 07:29:18 -0500 Subject: [PATCH 1/2] Let a PortAudio device name carry its host API Windows lists every output once per host API (MME, DirectSound, WASAPI, WDM-KS) under the same name, so -o portaudio: was refused as ambiguous and only an index, which shifts as devices come and go, could pick one. : now names exactly one, using the prefix -l prints in its host API column. A spec is read that way only when no device has the whole string as its name, so every spec that resolved before resolves the same. An ambiguous name's error lists each copy's host API and the prefixed form. The matching is split from the PortAudio enumeration so it can be tested. --- CMakeLists.txt | 1 + docs/wiki/Advanced-Usage.md | 8 +- docs/wiki/Getting-Started-on-Windows.md | 6 +- src/audio_sink.cpp | 10 +- src/audio_source.cpp | 3 +- src/portaudio_device.cpp | 128 ++++++++++++++++++---- src/portaudio_device.h | 23 +++- src/portaudio_sink.cpp | 6 +- src/portaudio_source.cpp | 6 +- tests/portaudio_device_test.cpp | 135 ++++++++++++++++++++++++ 10 files changed, 286 insertions(+), 40 deletions(-) create mode 100644 tests/portaudio_device_test.cpp 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..ecd4d0f 100644 --- a/src/portaudio_device.cpp +++ b/src/portaudio_device.cpp @@ -114,6 +114,105 @@ 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) { + 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 +261,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..e0a14b8 --- /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 From 8ad102f4fd38805a8348c9e67633aed164fef227 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 28 Oct 2026 13:11:07 -0500 Subject: [PATCH 2/2] Quote the device in the suggested -o A Windows device name carries spaces and parentheses, so the suggestion only worked once quoted. --- src/portaudio_device.cpp | 4 +++- tests/portaudio_device_test.cpp | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/portaudio_device.cpp b/src/portaudio_device.cpp index ecd4d0f..9b8b54a 100644 --- a/src/portaudio_device.cpp +++ b/src/portaudio_device.cpp @@ -201,8 +201,10 @@ bool match_pa_device_name(const std::string& device, const std::vectorhost_api + ":" + matches.front()->name; + " \"portaudio:" + matches.front()->host_api + ":" + matches.front()->name + + "\""; } else { error += "name the one you mean by index instead"; } diff --git a/tests/portaudio_device_test.cpp b/tests/portaudio_device_test.cpp index e0a14b8..7f11332 100644 --- a/tests/portaudio_device_test.cpp +++ b/tests/portaudio_device_test.cpp @@ -79,7 +79,7 @@ TEST(MatchPaDeviceName, ANameUnderSeveralHostApisIsRefusedWithThePrefixedForm) { 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) + EXPECT_NE(error.find("-o \"portaudio:mme:Speakers (7- Schiit Modi+)\""), std::string::npos) << error; }