Skip to content

Commit 7ec6e5d

Browse files
committed
GUI - tell the user when the sound moves to a different output device
1 parent 3973df4 commit 7ec6e5d

8 files changed

Lines changed: 131 additions & 2 deletions

File tree

‎app/api/include/api/sonicpi_api.h‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,9 @@ struct IAPIClient
508508
// AudioSwitchOutcome. Default no-op so non-GUI consumers don't
509509
// need to react.
510510
virtual void AudioSwitchDone(const AudioSwitchOutcome& /*outcome*/) {}
511+
// The engine's state and why it changed (/clockwork/statechange: "boot",
512+
// "rate-change", "snapshot", "swap-failed-rollback"). Default no-op.
513+
virtual void AudioStateChanged(const std::string& /*state*/, const std::string& /*reason*/) {}
511514
// Immediate accept/reject for /clockwork/devices/reopen (the engine
512515
// debounces: in-flight or <3s cooldown → rejected). Default no-op.
513516
virtual void AudioDeviceReopenReply(bool /*accepted*/, const std::string& /*reason*/) {}

‎app/api/src/osc/osc_handler.cpp‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,7 @@ void OscHandler::oscMessage(std::vector<char> buffer)
462462
std::string state, reason;
463463
msg->arg().popStr(state).popStr(reason);
464464
LOG(INFO, "/clockwork/statechange: " << state << " (" << reason << ")");
465+
m_pClient->AudioStateChanged(state, reason);
465466
if (state == "restarting") {
466467
// Show "switching" status in GUI while device change is in progress
467468
ScsynthInfo info;

‎app/gui-tests/audiodevicepolicy.test.cpp‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,3 +451,64 @@ TEST_CASE("input pick: an unknown driver on either side is left to the engine",
451451
noInputDriver.engineDriver = "DirectSound";
452452
REQUIRE(audioInputPickPlan(noInputDriver).send);
453453
}
454+
455+
// ── 4. Telling the user ────────────────────────────────────────────────────
456+
//
457+
// On 2026-10-06 the MOTU was absent at boot: the restore asked for it, the
458+
// engine refused, the pref was cleared to "follow system" (rightly, so a dead
459+
// device is not replayed every boot) — and nothing on screen said so. The next
460+
// boot followed the system default to a streamer in another room, again
461+
// silently. The decision now says what the user must hear.
462+
463+
TEST_CASE("notice: a failed request names the output that isn't available", "[audio][notice]")
464+
{
465+
SonicPi::AudioSwitchRequest request;
466+
request.output = "motu-xaero";
467+
SonicPi::AudioSwitchOutcome outcome;
468+
outcome.success = false;
469+
outcome.error = "No such device: motu-xaero";
470+
const auto d = SonicPi::audioPrefsDecision(request, outcome);
471+
CHECK(d.notice == SonicPi::AudioNotice::OutputUnavailable);
472+
CHECK(d.noticeDevice == "motu-xaero");
473+
}
474+
475+
TEST_CASE("notice: following the system default names the device it resolved to", "[audio][notice]")
476+
{
477+
SonicPi::AudioSwitchRequest request;
478+
request.output = SonicPi::kAudioSystemOutput;
479+
SonicPi::AudioSwitchOutcome outcome;
480+
outcome.success = true;
481+
outcome.actualOutput = "DMP-A6(Kitchen)";
482+
const auto d = SonicPi::audioPrefsDecision(request, outcome);
483+
CHECK(d.notice == SonicPi::AudioNotice::FollowingSystemDefault);
484+
CHECK(d.noticeDevice == "DMP-A6(Kitchen)");
485+
486+
SonicPi::AudioSwitchRequest byRow;
487+
byRow.output = "Some Default Row";
488+
byRow.outputFollowsDefault = true;
489+
const auto r = SonicPi::audioPrefsDecision(byRow, outcome);
490+
CHECK(r.notice == SonicPi::AudioNotice::FollowingSystemDefault);
491+
}
492+
493+
TEST_CASE("notice: a device the user named and got raises none, nor does an engine-initiated swap", "[audio][notice]")
494+
{
495+
SonicPi::AudioSwitchRequest request;
496+
request.output = "motu-xaero";
497+
SonicPi::AudioSwitchOutcome outcome;
498+
outcome.success = true;
499+
outcome.actualOutput = "motu-xaero";
500+
CHECK(SonicPi::audioPrefsDecision(request, outcome).notice == SonicPi::AudioNotice::None);
501+
SonicPi::AudioSwitchRequest engineInitiated; // nothing asked: the engine's own reopen
502+
CHECK(SonicPi::audioPrefsDecision(engineInitiated, outcome).notice == SonicPi::AudioNotice::None);
503+
}
504+
505+
TEST_CASE("notice: the engine falling back on its own, mid-session, is a lost device", "[audio][notice]")
506+
{
507+
// The engine's own swap rolled back (a device unplugged, a reopen refused):
508+
// the next device-list push names where the sound went, and the user hears it.
509+
CHECK(SonicPi::audioStateLostDevice("running", "swap-failed-rollback"));
510+
CHECK_FALSE(SonicPi::audioStateLostDevice("running", "rate-change")); // a swap the user asked for
511+
CHECK_FALSE(SonicPi::audioStateLostDevice("running", "boot"));
512+
CHECK_FALSE(SonicPi::audioStateLostDevice("running", "snapshot"));
513+
CHECK_FALSE(SonicPi::audioStateLostDevice("restarting", "swap-failed-rollback")); // not until it is running again
514+
}

‎app/gui/mainwindow.cpp‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -847,6 +847,8 @@ void MainWindow::setupWindowStructure()
847847
this, &MainWindow::onMixerSettings);
848848
connect(m_spClient.get(), &SonicPi::QtAPIClient::AudioSwitchDoneReceived,
849849
this, &MainWindow::onAudioSwitchDone);
850+
connect(m_spClient.get(), &SonicPi::QtAPIClient::AudioStateChangedReceived,
851+
this, &MainWindow::onAudioStateChanged, Qt::QueuedConnection);
850852
connect(m_spClient.get(), &SonicPi::QtAPIClient::AudioDeviceReopenReplyReceived,
851853
this, &MainWindow::onAudioDeviceReopenReply);
852854

@@ -9349,9 +9351,23 @@ void MainWindow::updateAudioDevices(const SonicPi::AudioDevicesInfo& devicesInfo
93499351
m_lastAudioDevices = devicesInfo;
93509352
m_audioDevicesSeen = true;
93519353
settingsWidget->updateAudioDevices(devicesInfo);
9354+
// The engine lost its device and fell back on its own: this push names
9355+
// where the sound went (audiodevicepolicy.h, audioStateLostDevice).
9356+
if (m_announceDeviceAfterRollback)
9357+
{
9358+
m_announceDeviceAfterRollback = false;
9359+
showStatusAndAnnounce(tr("Audio device lost — output is now %1")
9360+
.arg(QString::fromStdString(devicesInfo.currentDevice)), 8000);
9361+
}
93529362
maybeRestoreAudioIntent();
93539363
}
93549364

9365+
void MainWindow::onAudioStateChanged(const QString& state, const QString& reason)
9366+
{
9367+
if (SonicPi::audioStateLostDevice(state.toStdString(), reason.toStdString()))
9368+
m_announceDeviceAfterRollback = true;
9369+
}
9370+
93559371
void MainWindow::updateAudioInputDevices(const SonicPi::AudioInputDevicesInfo& devicesInfo)
93569372
{
93579373
m_lastAudioInputDevices = devicesInfo;
@@ -9659,6 +9675,22 @@ void MainWindow::onAudioSwitchDone(const SonicPi::AudioSwitchOutcome& outcome)
96599675
gui_settings->setValue("prefs/audio-buffer-size", decision.bufferSizeValue);
96609676
}
96619677

9678+
// Where the sound now goes, when it is not where the user pointed: on
9679+
// the status bar and to a screen reader, whether or not the preferences
9680+
// are open (audiodevicepolicy.h, AudioNotice).
9681+
switch (decision.notice) {
9682+
case SonicPi::AudioNotice::OutputUnavailable:
9683+
showStatusAndAnnounce(tr("Audio output '%1' isn't available — following the system default")
9684+
.arg(QString::fromStdString(decision.noticeDevice)), 8000);
9685+
break;
9686+
case SonicPi::AudioNotice::FollowingSystemDefault:
9687+
showStatusAndAnnounce(tr("Audio output: %1 (the system default)")
9688+
.arg(QString::fromStdString(decision.noticeDevice)), 6000);
9689+
break;
9690+
case SonicPi::AudioNotice::None:
9691+
break;
9692+
}
9693+
96629694
// Two failure shapes from the engine. Surface both as a modal
96639695
// carrying the verbatim engine/JUCE error — no diagnosis, no
96649696
// enrichment. Revert the affected dropdown.

‎app/gui/mainwindow.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -632,6 +632,8 @@ private slots:
632632
#if defined(Q_OS_MAC) || defined(Q_OS_WIN)
633633
// A+V session-recorder branch of toggleRecording. Write to a temp
634634
// file; rename or delete it once the user picks a save location.
635+
void onAudioStateChanged(const QString& state, const QString& reason); // the engine's own fallback: say where the sound went
636+
bool m_announceDeviceAfterRollback = false;
635637
void startSessionRecordingFlow();
636638
void stopSessionRecordingFlow();
637639
void recordingFailed(const QString& reason); // the recorder could not start or died: Record goes back

‎app/gui/qt_api_client.cpp‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,11 @@ void QtAPIClient::AudioSwitchDone(const SonicPi::AudioSwitchOutcome& outcome)
534534
emit AudioSwitchDoneReceived(outcome);
535535
}
536536

537+
void QtAPIClient::AudioStateChanged(const std::string& state, const std::string& reason)
538+
{
539+
emit AudioStateChangedReceived(QString::fromStdString(state), QString::fromStdString(reason));
540+
}
541+
537542
void QtAPIClient::AudioDeviceReopenReply(bool accepted, const std::string& reason)
538543
{
539544
emit AudioDeviceReopenReplyReceived(accepted, QString::fromStdString(reason));

‎app/gui/qt_api_client.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ class QtAPIClient : public QObject, public SonicPi::IAPIClient
7676
virtual void SupersonicSetup(int sampleRate, int bufferSize) override;
7777
virtual void SpiderReady() override;
7878
virtual void AudioSwitchDone(const SonicPi::AudioSwitchOutcome& outcome) override;
79+
virtual void AudioStateChanged(const std::string& state, const std::string& reason) override;
7980
virtual void AudioDeviceReopenReply(bool accepted, const std::string& reason) override;
8081
virtual void RunStarted(int jobId, const std::string& workspace) override;
8182
virtual void RunEnded(int jobId) override;
@@ -95,6 +96,7 @@ class QtAPIClient : public QObject, public SonicPi::IAPIClient
9596
void SupersonicSetupReceived(int sampleRate, int bufferSize);
9697
void SpiderReadyReceived();
9798
void AudioSwitchDoneReceived(const SonicPi::AudioSwitchOutcome& outcome);
99+
void AudioStateChangedReceived(const QString& state, const QString& reason);
98100
void AudioDeviceReopenReplyReceived(bool accepted, const QString& reason);
99101
void RunStartedReceived(int jobId, const QString& workspace);
100102
void RunEndedReceived(int jobId);

‎app/gui/utils/audiodevicepolicy.h‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,16 @@ struct AudioSwitchRequest {
117117

118118
enum class PrefAction { Leave, Save, Clear };
119119

120+
// What the user must hear about the swap, on the status bar and to a screen
121+
// reader — not only inside the preferences pane, which may not be open:
122+
// the output they asked for could not be opened (its pref is dropped, so
123+
// the next boot follows the system default), or the system default was
124+
// followed and resolved to a particular device, which may be in another room.
125+
enum class AudioNotice { None, OutputUnavailable, FollowingSystemDefault };
126+
120127
struct AudioPrefsDecision {
128+
AudioNotice notice = AudioNotice::None;
129+
std::string noticeDevice; // the output asked for, or the one now followed
121130
PrefAction output = PrefAction::Leave;
122131
std::string outputValue;
123132
PrefAction input = PrefAction::Leave;
@@ -150,8 +159,12 @@ inline AudioPrefsDecision audioPrefsDecision(const AudioSwitchRequest& request,
150159

151160
if (!outcome.success) {
152161
// Drop the saved value for whatever was asked for, so a device that no
153-
// longer exists isn't replayed on every boot.
154-
if (!request.output.empty()) d.output = PrefAction::Clear;
162+
// longer exists isn't replayed on every boot — and say so.
163+
if (!request.output.empty()) {
164+
d.output = PrefAction::Clear;
165+
d.notice = AudioNotice::OutputUnavailable;
166+
d.noticeDevice = request.output;
167+
}
155168
if (!request.input.empty()) d.input = PrefAction::Clear;
156169
return d;
157170
}
@@ -163,6 +176,8 @@ inline AudioPrefsDecision audioPrefsDecision(const AudioSwitchRequest& request,
163176
if (request.output == kAudioSystemOutput || request.outputFollowsDefault) {
164177
d.output = PrefAction::Save;
165178
d.outputValue = kAudioSystemOutput;
179+
d.notice = AudioNotice::FollowingSystemDefault;
180+
d.noticeDevice = outcome.actualOutput;
166181
} else if (!outcome.actualOutput.empty()) {
167182
d.output = PrefAction::Save;
168183
d.outputValue = outcome.actualOutput;
@@ -196,6 +211,14 @@ inline AudioPrefsDecision audioPrefsDecision(const AudioSwitchRequest& request,
196211
return d;
197212
}
198213

214+
// The engine's own state change that means it lost the device it was on and
215+
// fell back by itself (no request from the GUI, so audioPrefsDecision never
216+
// sees it): the device-list push that follows names where the sound went.
217+
inline bool audioStateLostDevice(const std::string& state, const std::string& reason)
218+
{
219+
return state == "running" && reason == "swap-failed-rollback";
220+
}
221+
199222
// ── 3. Booting with saved prefs ──────────────────────────────────────────────
200223

201224
// Saved audio prefs are handed to the engine's first open. A pref that names

0 commit comments

Comments
 (0)