From b691178d6528fc0d256618e0df53377fa3a8c370 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:35:25 -0400 Subject: [PATCH] output control TUI --- tui/CMakeLists.txt | 1 + tui/src/cli.cpp | 21 +- tui/src/main.cpp | 7 +- tui/src/output_selection.cpp | 66 +++++++ tui/src/output_selection.h | 23 +++ tui/src/tui_runtime.cpp | 368 +++++++++++++++++++++++++++++++++-- tui/src/tui_runtime.h | 6 +- tui/test/tui_tests.cpp | 87 ++++++++- 8 files changed, 543 insertions(+), 36 deletions(-) create mode 100644 tui/src/output_selection.cpp create mode 100644 tui/src/output_selection.h diff --git a/tui/CMakeLists.txt b/tui/CMakeLists.txt index 0159493..1de5473 100644 --- a/tui/CMakeLists.txt +++ b/tui/CMakeLists.txt @@ -34,6 +34,7 @@ add_library(prism_tui_analysis STATIC src/dashboard_layout.cpp src/display_model.cpp src/meter_display_model.cpp + src/output_selection.cpp src/profile_library.cpp src/scrolling_history.cpp src/scope_plot_model.cpp diff --git a/tui/src/cli.cpp b/tui/src/cli.cpp index 6b78875..919f6f2 100644 --- a/tui/src/cli.cpp +++ b/tui/src/cli.cpp @@ -22,20 +22,20 @@ ParseResult parseArguments(const std::vector& arguments) { result.options.command = Command::Version; continue; } - if (argument == "--list-devices") { + if (argument == "--list-devices" || argument == "--list-outputs") { if (arguments.size() != 1) { - return {false, {}, "--list-devices cannot be combined with other arguments."}; + return {false, {}, argument + " cannot be combined with other arguments."}; } result.options.command = Command::ListDevices; continue; } - if (argument == "--device") { + if (argument == "--device" || argument == "--output") { if (index + 1 >= arguments.size() || arguments[index + 1].empty() || arguments[index + 1][0] == '-') { - return {false, {}, "--device requires a non-empty device ID."}; + return {false, {}, argument + " requires a non-empty output ID."}; } if (!result.options.deviceId.empty()) { - return {false, {}, "--device may only be specified once."}; + return {false, {}, "An output may only be specified once."}; } result.options.deviceId = arguments[++index]; continue; @@ -48,18 +48,21 @@ ParseResult parseArguments(const std::vector& arguments) { std::string usageText() { return - "Usage: prism-tui [--device ]\n" - " prism-tui --list-devices\n" + "Usage: prism-tui [--output ]\n" + " prism-tui --list-outputs\n" " prism-tui --help\n" " prism-tui --version\n\n" "Options:\n" - " --device Capture a specific system output device.\n" - " --list-devices List available system output devices.\n" + " --output Capture a specific system output device.\n" + " --list-outputs List available system output devices.\n" + " --device Alias for --output.\n" + " --list-devices Alias for --list-outputs.\n" " -h, --help Show this help.\n" " -V, --version Show the Prism TUI version.\n\n" "Controls:\n" " Tab / Shift-Tab Focus the next or previous panel.\n" " Enter Expand the focused panel or restore the dashboard.\n" + " o Choose the system output.\n" " p Open profiles to load, save, or overwrite setups.\n" " s Open settings.\n" " l Edit the scope rack layout.\n" diff --git a/tui/src/main.cpp b/tui/src/main.cpp index 8963244..af23bdf 100644 --- a/tui/src/main.cpp +++ b/tui/src/main.cpp @@ -58,6 +58,7 @@ int run(const std::vector& arguments) { return 1; } + const auto outputDevices = capture->listOutputDevices(); Prism::Capture::StartResult started; std::string errorMessage; if (!capture->start(parsed.options.deviceId, &started, &errorMessage)) { @@ -67,7 +68,11 @@ int run(const std::vector& arguments) { return 1; } - return Prism::Tui::runInteractive(std::move(capture), started); + return Prism::Tui::runInteractive( + std::move(capture), + started, + parsed.options.deviceId, + outputDevices); } } // namespace diff --git a/tui/src/output_selection.cpp b/tui/src/output_selection.cpp new file mode 100644 index 0000000..0ec588e --- /dev/null +++ b/tui/src/output_selection.cpp @@ -0,0 +1,66 @@ +#include "output_selection.h" + +#include + +namespace Prism::Tui { + +OutputSwitchOutcome switchOutputCapture( + Prism::Capture::SystemAudioCapture& capture, + const std::string& currentRequestedDeviceId, + const Prism::Capture::StartResult& currentStarted, + const std::string& nextRequestedDeviceId) { + if (nextRequestedDeviceId == currentRequestedDeviceId) { + return { + true, + true, + currentStarted, + currentRequestedDeviceId, + {}, + }; + } + + capture.stop(); + + Prism::Capture::StartResult nextStarted; + std::string switchError; + if (capture.start(nextRequestedDeviceId, &nextStarted, &switchError)) { + return { + true, + true, + std::move(nextStarted), + nextRequestedDeviceId, + {}, + }; + } + + Prism::Capture::StartResult restoredStarted; + std::string restoreError; + if (capture.start( + currentRequestedDeviceId, &restoredStarted, &restoreError)) { + return { + false, + true, + std::move(restoredStarted), + currentRequestedDeviceId, + switchError.empty() + ? "The selected output could not be opened; the previous output was restored." + : switchError + " The previous output was restored.", + }; + } + + std::string error = switchError.empty() + ? "The selected output could not be opened." + : switchError; + error += restoreError.empty() + ? " The previous output could not be restored." + : " The previous output could not be restored: " + restoreError; + return { + false, + false, + currentStarted, + currentRequestedDeviceId, + std::move(error), + }; +} + +} // namespace Prism::Tui diff --git a/tui/src/output_selection.h b/tui/src/output_selection.h new file mode 100644 index 0000000..d1f44ba --- /dev/null +++ b/tui/src/output_selection.h @@ -0,0 +1,23 @@ +#pragma once + +#include "system_audio_capture.h" + +#include + +namespace Prism::Tui { + +struct OutputSwitchOutcome { + bool success = false; + bool captureRunning = false; + Prism::Capture::StartResult started; + std::string requestedDeviceId; + std::string error; +}; + +OutputSwitchOutcome switchOutputCapture( + Prism::Capture::SystemAudioCapture& capture, + const std::string& currentRequestedDeviceId, + const Prism::Capture::StartResult& currentStarted, + const std::string& nextRequestedDeviceId); + +} // namespace Prism::Tui diff --git a/tui/src/tui_runtime.cpp b/tui/src/tui_runtime.cpp index d14044d..bafa609 100644 --- a/tui/src/tui_runtime.cpp +++ b/tui/src/tui_runtime.cpp @@ -4,6 +4,7 @@ #include "dashboard_layout.h" #include "display_model.h" #include "meter_display_model.h" +#include "output_selection.h" #include "profile_library.h" #include "scope_plot_model.h" #include "snapshot_store.h" @@ -28,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -129,6 +131,25 @@ struct DisplayFrame { bool captureOverrun = false; }; +struct OutputSwitchRequest { + uint64_t serial = 0; + std::string requestedDeviceId; +}; + +struct OutputSwitchNotice { + uint64_t serial = 0; + bool complete = false; + bool success = false; + std::string activeRequestedDeviceId; + Prism::Capture::StartResult started; + std::string error; +}; + +struct OutputListNotice { + uint64_t serial = 0; + std::vector devices; +}; + enum class LayoutOverlay { None, AddScope, @@ -168,6 +189,17 @@ struct InterfaceState { std::string profileStatus; bool profileStatusError = false; std::string pendingProfileId; + bool outputsOpen = false; + std::vector outputDevices; + size_t outputSelection = 0; + std::string activeRequestedDeviceId; + std::string outputStatus; + bool outputStatusError = false; + bool outputSwitching = false; + uint64_t outputSwitchSerial = 0; + uint64_t appliedOutputSwitchSerial = 0; + uint64_t outputListSerial = 0; + uint64_t appliedOutputListSerial = 0; }; const TuiTheme* renderTheme = nullptr; @@ -1668,12 +1700,12 @@ ftxui::Element renderFooter(const DisplayFrame& frame, } const std::string enterAction = state.expandedPanel ? "restore" : "expand"; const std::string controls = minimal - ? "Tab • Enter • p • s • q" + ? "Tab • Enter • o • p • s • q" : compact ? "Tab focus • Enter " + enterAction + - " • p profiles • s settings • q quit" + " • o outputs • p profiles • s settings • q quit" : "Tab focus • Enter " + enterAction + - " • p profiles • s settings • l edit layout • r reset • q quit"; + " • o outputs • p profiles • s settings • l edit layout • r reset • q quit"; auto status = text(makeCaptureStatus(frame, state.settings, compact)) | dim; if (frame.captureOverrun) { status = status | color(terminalColor(palette().danger)); @@ -1881,6 +1913,104 @@ ftxui::Element renderSettings(const InterfaceState& state, size(HEIGHT, EQUAL, height); } +std::string outputFormat(const Prism::Capture::OutputDevice& device) { + std::ostringstream result; + const double kilohertz = device.sampleRate / 1000.0; + result << std::fixed << std::setprecision( + std::abs(kilohertz - std::round(kilohertz)) < 0.01 ? 0 : 1) + << kilohertz << " kHz • " << device.channelCount << " ch"; + if (device.isDefault) result << " • default"; + return result.str(); +} + +ftxui::Element renderOutputs(const DisplayFrame& frame, + const InterfaceState& state, + int width, + int height) { + using namespace ftxui; + const int contentWidth = std::max(1, width - 2); + const int contentHeight = std::max(1, height - 2); + const size_t choiceCount = state.outputDevices.size() + 1; + const size_t selected = std::min( + state.outputSelection, choiceCount - 1); + const size_t maximumVisibleRows = static_cast( + std::max(1, contentHeight - 9)); + const size_t firstVisible = selected >= maximumVisibleRows + ? selected - maximumVisibleRows + 1 + : 0; + const size_t lastVisible = std::min( + choiceCount, firstVisible + maximumVisibleRows); + + Elements rows; + for (size_t index = firstVisible; index < lastVisible; ++index) { + const bool isSelected = index == selected; + const bool followsDefault = index == 0; + const bool isActive = followsDefault + ? state.activeRequestedDeviceId.empty() + : state.activeRequestedDeviceId == state.outputDevices[index - 1].id; + const std::string label = followsDefault + ? "Follow system default" + : state.outputDevices[index - 1].label; + const std::string format = followsDefault + ? "automatic" + : outputFormat(state.outputDevices[index - 1]); + auto row = hbox({ + text(isSelected ? " › " : " "), + text(isActive ? "● " : " ") | + color(terminalColor( + isActive ? palette().accent : palette().muted)), + text(label) | (isSelected ? bold : dim) | flex, + text(" " + format + " ") | dim, + }); + rows.push_back(isSelected + ? std::move(row) | + bgcolor(terminalColor(palette().selection)) | + color(terminalColor(palette().accent)) + : std::move(row)); + } + + std::string selectionDescription; + if (selected == 0) { + selectionDescription = + "Uses whichever output the operating system currently considers default."; + } else { + const auto& device = state.outputDevices[selected - 1]; + selectionDescription = contentWidth < 76 + ? device.id + : "Output ID: " + device.id; + } + const std::string active = "Active: " + + (frame.device.empty() ? std::string("unknown output") : frame.device) + + (state.activeRequestedDeviceId.empty() + ? " • following system default" + : " • explicitly selected"); + + auto content = vbox({ + text(" PRISM / OUTPUTS") | + color(terminalColor(palette().accent)) | bold, + separator(), + text(active) | color(terminalColor(palette().text)), + separatorEmpty(), + vbox(std::move(rows)), + filler(), + text(selectionDescription) | color(terminalColor(palette().muted)), + state.outputStatus.empty() + ? emptyElement() + : text(state.outputStatus) | color(terminalColor( + state.outputStatusError + ? palette().danger + : palette().accent)), + separator(), + text(state.outputSwitching + ? "Switching output… • Esc keeps this screen open until complete" + : "↑↓ select • Enter switch • r refresh • o/Esc dashboard") | dim, + }) | size(WIDTH, EQUAL, contentWidth) | + size(HEIGHT, EQUAL, contentHeight); + return std::move(content) | borderRounded | + size(WIDTH, EQUAL, width) | + size(HEIGHT, EQUAL, height); +} + ftxui::Element renderProfiles(const InterfaceState& state, int width, int height) { @@ -2074,6 +2204,15 @@ ftxui::Element renderFrame(const DisplayFrame& frame, renderLayoutNode(layout.root, layout, frame, state), renderFooter(frame, state, width) | size(HEIGHT, EQUAL, 1), }); + if (state.outputsOpen) { + const int outputsWidth = std::min(96, std::max(44, width - 4)); + const int outputsHeight = std::min(20, std::max(12, height - 2)); + return dbox({ + std::move(dashboard) | dim, + renderOutputs(frame, state, outputsWidth, outputsHeight) | + borderEmpty | clear_under | center, + }); + } if (state.profilesOpen) { const int profilesWidth = std::min(92, std::max(44, width - 4)); const int profilesHeight = std::min(18, std::max(12, height - 2)); @@ -2120,7 +2259,9 @@ bool stdinAndStdoutAreTerminals() { } int runInteractive(std::unique_ptr capture, - const Prism::Capture::StartResult& started) { + const Prism::Capture::StartResult& started, + std::string requestedDeviceId, + std::vector outputDevices) { using namespace ftxui; signalRequested = 0; SignalHandlerGuard signalHandlerGuard; @@ -2128,7 +2269,12 @@ int runInteractive(std::unique_ptr capture, ScreenInteractive screen = ScreenInteractive::Fullscreen(); SnapshotStore frameStore; SnapshotStore settingsStore; + SnapshotStore outputSwitchRequestStore; + SnapshotStore outputSwitchNoticeStore; + SnapshotStore outputListNoticeStore; InterfaceState interfaceState; + interfaceState.outputDevices = std::move(outputDevices); + interfaceState.activeRequestedDeviceId = requestedDeviceId; const std::filesystem::path settingsPath = defaultSettingsPath(); interfaceState.settings = loadSettings(settingsPath); IroThemeLibrary themeLibrary(defaultIroThemeDirectory()); @@ -2165,18 +2311,29 @@ int runInteractive(std::unique_ptr capture, std::atomic running{true}; std::atomic resetRequested{false}; std::atomic redrawQueued{false}; + std::atomic outputListRequested{0}; std::exception_ptr workerError; auto exitLoop = screen.ExitLoopClosure(); std::thread worker([&]() { try { - AnalysisPipeline pipeline(static_cast(started.sampleRate)); TuiSettings appliedSettings = settingsStore.read(); - pipeline.setInputTrimDb(appliedSettings.inputTrimDb); - pipeline.setSpectrumTilt(appliedSettings.spectrumTiltDbPerOctave); - pipeline.setOscilloscopePitchLock(appliedSettings.oscilloscopePitchLock); - applySpectrogramSettings(pipeline, appliedSettings); - applyWaveformSettings(pipeline, appliedSettings); + const auto makePipeline = [&](double sampleRate) { + auto next = std::make_unique( + static_cast(sampleRate)); + next->setInputTrimDb(appliedSettings.inputTrimDb); + next->setSpectrumTilt(appliedSettings.spectrumTiltDbPerOctave); + next->setOscilloscopePitchLock( + appliedSettings.oscilloscopePitchLock); + applySpectrogramSettings(*next, appliedSettings); + applyWaveformSettings(*next, appliedSettings); + return next; + }; + auto pipeline = makePipeline(started.sampleRate); + Prism::Capture::StartResult activeStarted = started; + std::string activeRequestedDeviceId = requestedDeviceId; + uint64_t handledOutputSwitchSerial = 0; + uint64_t handledOutputListSerial = 0; bool captureOverrun = false; auto nextFrameAt = std::chrono::steady_clock::now(); @@ -2186,8 +2343,52 @@ int runInteractive(std::unique_ptr capture, exitLoop(); break; } + const uint64_t requestedOutputListSerial = + outputListRequested.load(); + if (requestedOutputListSerial > handledOutputListSerial) { + handledOutputListSerial = requestedOutputListSerial; + outputListNoticeStore.publish({ + requestedOutputListSerial, + capture->listOutputDevices(), + }); + if (running.load() && !redrawQueued.exchange(true)) { + screen.PostEvent(Event::Custom); + } + } + + const OutputSwitchRequest outputSwitchRequest = + outputSwitchRequestStore.read(); + if (outputSwitchRequest.serial > handledOutputSwitchSerial) { + handledOutputSwitchSerial = outputSwitchRequest.serial; + const auto outcome = switchOutputCapture( + *capture, + activeRequestedDeviceId, + activeStarted, + outputSwitchRequest.requestedDeviceId); + if (outcome.captureRunning) { + activeStarted = outcome.started; + activeRequestedDeviceId = outcome.requestedDeviceId; + pipeline = makePipeline(activeStarted.sampleRate); + captureOverrun = false; + nextFrameAt = std::chrono::steady_clock::now(); + } + outputSwitchNoticeStore.publish({ + outputSwitchRequest.serial, + true, + outcome.success, + outcome.requestedDeviceId, + outcome.started, + outcome.error, + }); + if (running.load() && !redrawQueued.exchange(true)) { + screen.PostEvent(Event::Custom); + } + if (!outcome.captureRunning) { + throw std::runtime_error(outcome.error); + } + } if (resetRequested.exchange(false)) { - pipeline.reset(); + pipeline->reset(); captureOverrun = false; } @@ -2195,10 +2396,10 @@ int runInteractive(std::unique_ptr capture, if (requestedSettings != appliedSettings) { const bool refreshChanged = requestedSettings.refreshRate != appliedSettings.refreshRate; - pipeline.setInputTrimDb(requestedSettings.inputTrimDb); - pipeline.setSpectrumTilt( + pipeline->setInputTrimDb(requestedSettings.inputTrimDb); + pipeline->setSpectrumTilt( requestedSettings.spectrumTiltDbPerOctave); - pipeline.setOscilloscopePitchLock( + pipeline->setOscilloscopePitchLock( requestedSettings.oscilloscopePitchLock); const bool spectrogramAnalysisChanged = requestedSettings.spectrogramClarity != appliedSettings.spectrogramClarity || @@ -2208,11 +2409,11 @@ int runInteractive(std::unique_ptr capture, requestedSettings.spectrogramContrast != appliedSettings.spectrogramContrast || requestedSettings.spectrogramTiltDbPerOctave != appliedSettings.spectrogramTiltDbPerOctave; if (spectrogramAnalysisChanged) { - applySpectrogramSettings(pipeline, requestedSettings); + applySpectrogramSettings(*pipeline, requestedSettings); } if (requestedSettings.waveformMode != appliedSettings.waveformMode || requestedSettings.waveformScrollSpeed != appliedSettings.waveformScrollSpeed) { - applyWaveformSettings(pipeline, requestedSettings); + applyWaveformSettings(*pipeline, requestedSettings); } appliedSettings = requestedSettings; if (refreshChanged) { @@ -2220,12 +2421,12 @@ int runInteractive(std::unique_ptr capture, } } - drainCapture(*capture, pipeline, captureOverrun); + drainCapture(*capture, *pipeline, captureOverrun); const auto now = std::chrono::steady_clock::now(); if (now >= nextFrameAt) { DisplayFrame next; - auto analyzed = pipeline.snapshot(); + auto analyzed = pipeline->snapshot(); next.magnitudes = std::move(analyzed.magnitudes); next.spectrumPeak = std::move(analyzed.spectrumPeak); next.vu = analyzed.vu; @@ -2234,9 +2435,11 @@ int runInteractive(std::unique_ptr capture, next.vectorscope = std::move(analyzed.vectorscope); next.spectrogram = std::move(analyzed.spectrogram); next.waveform = std::move(analyzed.waveform); - next.sampleRate = started.sampleRate; + next.sampleRate = activeStarted.sampleRate; next.backend = capture->backendName(); - next.device = started.deviceLabel.empty() ? started.deviceId : started.deviceLabel; + next.device = activeStarted.deviceLabel.empty() + ? activeStarted.deviceId + : activeStarted.deviceLabel; next.captureOverrun = captureOverrun; frameStore.publish(std::move(next)); if (running.load() && !redrawQueued.exchange(true)) { @@ -2365,9 +2568,83 @@ int runInteractive(std::unique_ptr capture, ? "" : " • hidden at this terminal size"); }; + const auto selectedOutputRequest = [&]() { + if (interfaceState.outputSelection == 0 || + interfaceState.outputDevices.empty()) { + return std::string{}; + } + const size_t index = std::min( + interfaceState.outputSelection - 1, + interfaceState.outputDevices.size() - 1); + return interfaceState.outputDevices[index].id; + }; + const auto selectOutputRequest = [&](const std::string& id) { + if (id.empty()) { + interfaceState.outputSelection = 0; + return; + } + const auto found = std::find_if( + interfaceState.outputDevices.begin(), + interfaceState.outputDevices.end(), + [&](const auto& device) { return device.id == id; }); + interfaceState.outputSelection = found == interfaceState.outputDevices.end() + ? 0 + : static_cast( + std::distance(interfaceState.outputDevices.begin(), found)) + 1; + }; + const auto requestOutputList = [&]() { + interfaceState.outputStatus = "Refreshing available outputs…"; + interfaceState.outputStatusError = false; + outputListRequested.store(++interfaceState.outputListSerial); + }; + selectOutputRequest(interfaceState.activeRequestedDeviceId); auto component = CatchEvent(renderer, [&](Event event) { if (event == Event::Custom) { redrawQueued.store(false); + const OutputListNotice outputList = outputListNoticeStore.read(); + if (outputList.serial > interfaceState.appliedOutputListSerial) { + const bool selectedDefault = + interfaceState.outputSelection == 0; + const std::string selectedRequest = selectedOutputRequest(); + interfaceState.appliedOutputListSerial = outputList.serial; + if (outputList.devices.empty()) { + interfaceState.outputStatus = + "No outputs were reported; current capture is unchanged."; + interfaceState.outputStatusError = true; + } else { + interfaceState.outputDevices = outputList.devices; + selectOutputRequest(selectedDefault + ? std::string{} + : selectedRequest); + if (!interfaceState.outputSwitching) { + interfaceState.outputStatus = + std::to_string(interfaceState.outputDevices.size()) + + (interfaceState.outputDevices.size() == 1 + ? " output available." + : " outputs available."); + interfaceState.outputStatusError = false; + } + } + } + const OutputSwitchNotice outputSwitch = + outputSwitchNoticeStore.read(); + if (outputSwitch.complete && + outputSwitch.serial > interfaceState.appliedOutputSwitchSerial) { + interfaceState.appliedOutputSwitchSerial = outputSwitch.serial; + interfaceState.outputSwitching = false; + interfaceState.activeRequestedDeviceId = + outputSwitch.activeRequestedDeviceId; + selectOutputRequest(interfaceState.activeRequestedDeviceId); + interfaceState.outputStatusError = !outputSwitch.success; + if (outputSwitch.success) { + const std::string label = outputSwitch.started.deviceLabel.empty() + ? outputSwitch.started.deviceId + : outputSwitch.started.deviceLabel; + interfaceState.outputStatus = "Now using " + label + "."; + } else { + interfaceState.outputStatus = outputSwitch.error; + } + } return false; } if (event == Event::CtrlC) { @@ -2375,6 +2652,50 @@ int runInteractive(std::unique_ptr capture, exitLoop(); return true; } + if (interfaceState.outputsOpen) { + if ((event == Event::Escape || event == Event::Character('o')) && + !interfaceState.outputSwitching) { + interfaceState.outputsOpen = false; + interfaceState.outputStatus.clear(); + return true; + } + if (interfaceState.outputSwitching) { + return true; + } + const size_t choiceCount = interfaceState.outputDevices.size() + 1; + if (event == Event::ArrowUp || event == Event::ArrowDown) { + const int direction = event == Event::ArrowDown ? 1 : -1; + interfaceState.outputSelection = static_cast( + (static_cast(interfaceState.outputSelection) + + direction + static_cast(choiceCount)) % + static_cast(choiceCount)); + interfaceState.outputStatus.clear(); + interfaceState.outputStatusError = false; + return true; + } + if (event == Event::Character('r')) { + requestOutputList(); + return true; + } + if (event == Event::Return) { + const std::string nextOutput = selectedOutputRequest(); + if (nextOutput == interfaceState.activeRequestedDeviceId) { + interfaceState.outputStatus = + "That output selection is already active."; + interfaceState.outputStatusError = false; + } else { + interfaceState.outputSwitching = true; + interfaceState.outputStatus = "Switching output…"; + interfaceState.outputStatusError = false; + outputSwitchRequestStore.publish({ + ++interfaceState.outputSwitchSerial, + nextOutput, + }); + } + return true; + } + return true; + } if (interfaceState.profilesOpen) { const auto selectedProfile = [&]() -> const TuiProfile* { if (interfaceState.profiles.empty()) return nullptr; @@ -2641,6 +2962,13 @@ int runInteractive(std::unique_ptr capture, } return true; } + if (event == Event::Character('o') && + !interfaceState.settingsOpen && !interfaceState.layoutEditing) { + interfaceState.outputsOpen = true; + selectOutputRequest(interfaceState.activeRequestedDeviceId); + requestOutputList(); + return true; + } if (event == Event::Character('q')) { running.store(false); exitLoop(); diff --git a/tui/src/tui_runtime.h b/tui/src/tui_runtime.h index f1a5a89..3addbdd 100644 --- a/tui/src/tui_runtime.h +++ b/tui/src/tui_runtime.h @@ -3,11 +3,15 @@ #include "system_audio_capture.h" #include +#include +#include namespace Prism::Tui { bool stdinAndStdoutAreTerminals(); int runInteractive(std::unique_ptr capture, - const Prism::Capture::StartResult& started); + const Prism::Capture::StartResult& started, + std::string requestedDeviceId, + std::vector outputDevices); } // namespace Prism::Tui diff --git a/tui/test/tui_tests.cpp b/tui/test/tui_tests.cpp index be66a9f..a9b621e 100644 --- a/tui/test/tui_tests.cpp +++ b/tui/test/tui_tests.cpp @@ -3,6 +3,7 @@ #include "dashboard_layout.h" #include "display_model.h" #include "meter_display_model.h" +#include "output_selection.h" #include "profile_library.h" #include "scope_plot_model.h" #include "scrolling_history.h" @@ -70,16 +71,36 @@ class FakeCapture final : public Prism::Capture::SystemAudioCapture { public: Prism::Capture::Support getSupport() const override { return {true, {}}; } std::vector listOutputDevices() override { - return {{"fake", "Fake Output", 48000.0, 2, true}}; + return { + {"fake", "Fake Output", 48000.0, 2, true}, + {"alternate", "Alternate Output", 44100.0, 2, false}, + }; } bool start(const std::string& requested, Prism::Capture::StartResult* result, - std::string*) override { - if (!requested.empty() && requested != "fake") return false; - if (result) *result = {48000.0, 2, "fake", "Fake Output"}; + std::string* error) override { + startRequests.push_back(requested); + const std::string selected = requested.empty() ? "fake" : requested; + if (failAllStarts || selected == failedDeviceId || + (selected != "fake" && selected != "alternate")) { + if (error) *error = "Fake output failed to start."; + return false; + } + stopped = false; + activeDeviceId = selected; + if (result) { + *result = selected == "alternate" + ? Prism::Capture::StartResult{ + 44100.0, 2, "alternate", "Alternate Output"} + : Prism::Capture::StartResult{ + 48000.0, 2, "fake", "Fake Output"}; + } return true; } - void stop() override { stopped = true; } + void stop() override { + stopped = true; + ++stopCount; + } Prism::Capture::DrainResult drain(size_t maxChunks) override { Prism::Capture::DrainResult result; const size_t count = std::min(maxChunks, chunks.size()); @@ -98,6 +119,11 @@ public: std::deque chunks; uint64_t nextOverwriteCount = 0; bool stopped = false; + bool failAllStarts = false; + std::string failedDeviceId; + std::string activeDeviceId; + std::vector startRequests; + size_t stopCount = 0; }; void testCli() { @@ -107,12 +133,19 @@ void testCli() { require(parsed.options.deviceId == "device-id", "device ID should be retained"); require(Prism::Tui::parseArguments({"--list-devices"}).options.command == Prism::Tui::Command::ListDevices, "list command should parse"); + auto output = Prism::Tui::parseArguments({"--output", "output-id"}); + require(output.ok && output.options.deviceId == "output-id", + "the output alias should retain its output ID"); + require(Prism::Tui::parseArguments({"--list-outputs"}).options.command == + Prism::Tui::Command::ListDevices, "the output-list alias should parse"); require(!Prism::Tui::parseArguments({"--device"}).ok, "missing device ID should fail"); require(!Prism::Tui::parseArguments({"--device", "--help"}).ok, "an option should not be accepted as a device ID"); require(!Prism::Tui::parseArguments({"--wat"}).ok, "unknown option should fail"); require(!Prism::Tui::parseArguments({"--device", "fake", "--device", "fake"}).ok, "duplicate device options should fail"); + require(!Prism::Tui::parseArguments({"--device", "fake", "--output", "fake"}).ok, + "mixed output aliases should still be rejected as duplicates"); require(!Prism::Tui::parseArguments({"--help", "--version"}).ok, "exclusive commands should not combine"); require(Prism::Tui::usageText().find("Tab / Shift-Tab") != std::string::npos, @@ -121,6 +154,49 @@ void testCli() { "help should not advertise the removed vectorscope shortcut"); require(Prism::Tui::usageText().find("Open profiles") != std::string::npos, "help should describe the profile library shortcut"); + require(Prism::Tui::usageText().find("--list-outputs") != std::string::npos && + Prism::Tui::usageText().find("Choose the system output") != std::string::npos, + "help should describe output aliases and the in-app picker"); +} + +void testOutputSwitching() { + FakeCapture capture; + Prism::Capture::StartResult started; + std::string error; + require(capture.start({}, &started, &error), + "fake output should start before switching"); + + const auto switched = Prism::Tui::switchOutputCapture( + capture, {}, started, "alternate"); + require(switched.success && switched.captureRunning && + switched.requestedDeviceId == "alternate" && + switched.started.deviceId == "alternate" && + switched.started.sampleRate == 44100.0, + "a live output switch should publish the new capture format"); + require(capture.stopCount == 1 && capture.activeDeviceId == "alternate", + "a live switch should stop the old capture before starting the new one"); + + capture.failedDeviceId = "fake"; + const auto restored = Prism::Tui::switchOutputCapture( + capture, + switched.requestedDeviceId, + switched.started, + "fake"); + require(!restored.success && restored.captureRunning && + restored.requestedDeviceId == "alternate" && + restored.started.deviceId == "alternate" && + restored.error.find("restored") != std::string::npos, + "a failed output switch should restore the previous capture"); + + capture.failAllStarts = true; + const auto failed = Prism::Tui::switchOutputCapture( + capture, + restored.requestedDeviceId, + restored.started, + "fake"); + require(!failed.success && !failed.captureRunning && + failed.error.find("could not be restored") != std::string::npos, + "an unrecoverable output switch should report that capture stopped"); } void testProjectionAndLayout() { @@ -960,6 +1036,7 @@ void testThreadSafeSnapshots() { int main() { testCli(); + testOutputSwitching(); testProjectionAndLayout(); testMeterDisplayModels(); testSpectrumPeakModel();