From a39077e59f82eaaa42ec683756153a3af6d52310 Mon Sep 17 00:00:00 2001 From: Johann Date: Tue, 15 Sep 2026 23:04:40 +0800 Subject: [PATCH] Prevent alert bursts after Mac sleep Observe macOS system sleep and wake events. Stop active sounds before sleep, rebuild the audio engine on wake, and discard background chimes for five seconds while timelines catch up. Block new sounds immediately when power commands arrive, including while older timeline callbacks are still queued. Keep engine access on the core loop and keep the gate closed until all pending power commands finish. Route automatic timeline and notification chimes through a shared policy that allows one background chime at a time, at least one second apart. Action sounds such as boosting, liking, and posting remain independent and can still overlap incoming-post chimes after wake recovery. Add regression coverage for wake suppression, repeated power transitions, short notification bursts, and long soundpack clips. Register the tests in the test runner and Windows build, and add the 0.5.9 changelog entry. Validation: macOS app and core builds succeeded; 1,439 checks passed. Actual sleep/wake behavior with live accounts still needs a manual run. Co-Authored-By: Codex --- apple/shared/AppState.swift | 4 ++ build.bat | 2 +- core/include/fastsm/sound/playback_policy.hpp | 43 ++++++++++++ core/include/fastsm/sound/sound_manager.hpp | 13 +++- core/src/session/core_session.cpp | 35 ++++++++-- core/src/sound/sound_manager.cpp | 57 +++++++++++++++- docs/changelog.txt | 1 + macos/src/AppDelegate.swift | 18 +++++ tests/main.cpp | 6 ++ tests/test_sound.cpp | 68 +++++++++++++++++++ 10 files changed, 238 insertions(+), 9 deletions(-) create mode 100644 core/include/fastsm/sound/playback_policy.hpp create mode 100644 tests/test_sound.cpp diff --git a/apple/shared/AppState.swift b/apple/shared/AppState.swift index 5615082..0cc1add 100644 --- a/apple/shared/AppState.swift +++ b/apple/shared/AppState.swift @@ -320,6 +320,10 @@ final class AppState { /// waiting on its idle timer, since we may be suspended before it fires. func pause() { client.send("pause") } + /// System sleep/wake, separate from ordinary app activation. + func suspendAudio() { client.send("suspend_audio") } + func resetAudio() { client.send("reset_audio") } + // Settings: mutate the full object and echo it back so the core keeps every // field (it re-applies defaults for anything missing). func updateSettings(_ mutate: (inout [String: Any]) -> Void) { diff --git a/build.bat b/build.bat index 29c15f1..9d9fa5f 100644 --- a/build.bat +++ b/build.bat @@ -202,7 +202,7 @@ if defined ISCC ( REM ---- 3) optional: tests ---- if "%RUN_TESTS%"=="1" ( echo Compiling tests... - cl %CFLAGS% %COREINC% /I tests tests\main.cpp tests\test_models.cpp tests\test_util.cpp tests\test_mastodon_map.cpp tests\test_bluesky_map.cpp tests\test_bluesky_richtext.cpp tests\test_auth.cpp tests\test_store.cpp tests\test_presentation.cpp tests\test_speech.cpp tests\test_sse.cpp tests\test_capi.cpp tests\test_push.cpp tests\test_confirm.cpp tests\test_thread.cpp tests\test_keymap.cpp tests\test_update.cpp tests\test_filters.cpp tests\test_timeline_refresh.cpp "%BUILD%\fastsm_core.lib" /Fo"%OBJ%\test\\" /Fe"%BUILD%\fastsm_tests.exe" /link %LINKFLAGS% crypt32.lib + cl %CFLAGS% %COREINC% /I tests tests\main.cpp tests\test_models.cpp tests\test_util.cpp tests\test_mastodon_map.cpp tests\test_bluesky_map.cpp tests\test_bluesky_richtext.cpp tests\test_auth.cpp tests\test_store.cpp tests\test_presentation.cpp tests\test_speech.cpp tests\test_sse.cpp tests\test_capi.cpp tests\test_push.cpp tests\test_confirm.cpp tests\test_sound.cpp tests\test_thread.cpp tests\test_keymap.cpp tests\test_update.cpp tests\test_filters.cpp tests\test_timeline_refresh.cpp "%BUILD%\fastsm_core.lib" /Fo"%OBJ%\test\\" /Fe"%BUILD%\fastsm_tests.exe" /link %LINKFLAGS% crypt32.lib if errorlevel 1 goto error echo Running tests... "%BUILD%\fastsm_tests.exe" diff --git a/core/include/fastsm/sound/playback_policy.hpp b/core/include/fastsm/sound/playback_policy.hpp new file mode 100644 index 0000000..c64eaef --- /dev/null +++ b/core/include/fastsm/sound/playback_policy.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include +#include + +namespace fastsm::sound { + +// Admission only: rejected sounds are discarded, never queued for later. +// Time is supplied by the caller so sleep recovery can be tested without audio. +class PlaybackPolicy { +public: + using Clock = std::chrono::steady_clock; + + // The command dispatcher may call this from another thread. Block sounds + // immediately, even when timeline callbacks precede the power command. + void begin_power_transition() { pending_transitions_.fetch_add(1); } + void end_power_transition() { pending_transitions_.fetch_sub(1); } + + // All remaining methods run on the core loop. + void suspend() { suspended_ = true; } + void resume(Clock::time_point now) { + suspended_ = false; + background_after_ = now + std::chrono::seconds(5); + } + + bool allows(bool background, bool background_playing, Clock::time_point now) const { + if (suspended_ || pending_transitions_.load() != 0) { + return false; + } + return !background || (!background_playing && now >= background_after_); + } + + void background_started(Clock::time_point now) { + background_after_ = now + std::chrono::seconds(1); + } + +private: + std::atomic pending_transitions_{0}; + bool suspended_ = false; + Clock::time_point background_after_{}; +}; + +} // namespace fastsm::sound diff --git a/core/include/fastsm/sound/sound_manager.hpp b/core/include/fastsm/sound/sound_manager.hpp index 9b84de4..d9bd7d3 100644 --- a/core/include/fastsm/sound/sound_manager.hpp +++ b/core/include/fastsm/sound/sound_manager.hpp @@ -12,7 +12,7 @@ namespace fastsm::sound { // SILENT — like the Mac app, row movement is conveyed by the screen reader, not // an earcon. // Mirrors the Mac Earcon enum exactly. Per-timeline "new items" chimes are NOT -// here — they use SoundManager::play_named(source.new_items_sound_name()). +// here — they use SoundManager::play_background(source.new_items_sound_name()). enum class Earcon { Navigate, // silent — row movement is conveyed by the screen reader Boundary, // hit the top/bottom of a list @@ -76,6 +76,13 @@ class SoundManager { // live voices are recreated. Safe to call even if the engine never came up. void reinitialize(); + // Power commands block new sounds as soon as they are dispatched; engine + // teardown/recovery stays on the core loop. Pair begin/end even on failure. + void begin_power_transition(); + void end_power_transition(); + void suspend(); + void resume(); + // ["Default", ]. std::vector list_soundpacks() const; @@ -84,10 +91,14 @@ class SoundManager { // account's chime can sound in that account's pack. void play(Earcon e, const std::string& pack = {}); void play_named(const std::string& base, const std::string& pack = {}); + // Background timeline/notification chimes never overlap each other and are + // discarded during wake catch-up. Foreground action feedback stays separate. + void play_background(const std::string& base, const std::string& pack = {}); private: struct Impl; Impl* impl_; + void play_impl(const std::string& base, const std::string& pack, bool background); // Ordered pack directories to search for a sound (the named pack, then default). std::vector search_dirs(const std::string& pack) const; diff --git a/core/src/session/core_session.cpp b/core/src/session/core_session.cpp index e3b274a..5f183ee 100644 --- a/core/src/session/core_session.cpp +++ b/core/src/session/core_session.cpp @@ -371,7 +371,27 @@ void CoreSession::dispatch(const std::string& command_json) { } catch (...) { return; } - loop_.post([this, cmd = std::move(cmd)] { handle(cmd); }); + if (!cmd.is_object() || !cmd.contains("cmd") || !cmd["cmd"].is_string()) { + return; + } + const std::string name = cmd["cmd"].get(); + const bool power_transition = name == "suspend_audio" || name == "reset_audio"; + if (power_transition) { + sound_.begin_power_transition(); + } + loop_.post([this, cmd = std::move(cmd), power_transition] { + // Release the immediate sound gate even if the handler throws. Multiple + // pending power commands keep it closed until the last one finishes. + struct TransitionGuard { + sound::SoundManager* manager; + ~TransitionGuard() { + if (manager) { + manager->end_power_transition(); + } + } + } guard{power_transition ? &sound_ : nullptr}; + handle(cmd); + }); } void CoreSession::handle(const json& cmd) { @@ -512,6 +532,9 @@ void CoreSession::handle(const json& cmd) { cmd_play_earcon(cmd); else if (c == "reset_audio") cmd_reset_audio(); + else if (c == "suspend_audio") { + sound_.suspend(); + } else if (c == "get_action_catalog") cmd_get_action_catalog(); else if (c == "get_keymap") @@ -3367,10 +3390,10 @@ void CoreSession::cmd_play_earcon(const json& cmd) { void CoreSession::cmd_reset_audio() { // The front end saw the OS resume from sleep/hibernation; rebuild the audio - // device so earcons keep sounding. Runs on the core loop, like every other - // sound_ call, so there's no cross-thread access to the engine. + // device and discard catch-up chimes briefly. Engine access stays on the + // core loop; dispatch only closes the atomic sound gate. log::write("reset_audio: reinitializing the sound engine after resume"); - sound_.reinitialize(); + sound_.resume(); sound_devices_.clear(); // devices often change across a resume: enumerate afresh } @@ -4221,11 +4244,11 @@ std::unique_ptr CoreSession::make_controller(SocialAccount* // A direct message / direct mention gets the "messages" chime instead of // the usual mentions/notification sound (matches FastSM). if (has_direct && p->source().is_notification_timeline()) { - sound_.play_named("messages", pack); + sound_.play_background("messages", pack); return; } if (auto name = p->source().new_items_sound_name()) - sound_.play_named(*name, pack); + sound_.play_background(*name, pack); }; tc->on_new_items = [this, p](const std::vector& items) { if (!p->auto_read() || items.empty()) diff --git a/core/src/sound/sound_manager.cpp b/core/src/sound/sound_manager.cpp index 1831c69..f3ea56a 100644 --- a/core/src/sound/sound_manager.cpp +++ b/core/src/sound/sound_manager.cpp @@ -5,6 +5,7 @@ #include #endif +#include "fastsm/sound/playback_policy.hpp" #include "fastsm/sound/sound_manager.hpp" #include @@ -64,6 +65,7 @@ struct Voice { ma_sound sound{}; ma_audio_buffer_ref ref{}; bool has_ref = false; + bool background = false; }; } // namespace @@ -73,6 +75,7 @@ struct SoundManager::Impl { bool ok = false; std::unordered_map pcm_cache; // keyed by file path std::vector> voices; + PlaybackPolicy policy; void cleanup_finished() { voices.erase(std::remove_if(voices.begin(), voices.end(), @@ -213,6 +216,28 @@ void SoundManager::reinitialize() { impl_->ok = true; } +void SoundManager::begin_power_transition() { + impl_->policy.begin_power_transition(); +} + +void SoundManager::end_power_transition() { + impl_->policy.end_power_transition(); +} + +void SoundManager::suspend() { + impl_->policy.suspend(); + impl_->stop_all(); + if (impl_->ok) { + ma_engine_uninit(&impl_->engine); + impl_->ok = false; + } +} + +void SoundManager::resume() { + reinitialize(); + impl_->policy.resume(PlaybackPolicy::Clock::now()); +} + void SoundManager::set_output_device(const std::string& name) { if (name == output_device_) return; @@ -311,8 +336,21 @@ void SoundManager::play(Earcon e, const std::string& pack) { } void SoundManager::play_named(const std::string& base, const std::string& pack) { + play_impl(base, pack, false); +} + +void SoundManager::play_background(const std::string& base, const std::string& pack) { + play_impl(base, pack, true); +} + +void SoundManager::play_impl(const std::string& base, const std::string& pack, bool background) { if (!enabled_) return; + // Check before device recovery: sleep must not revive the engine or build + // a backlog of voices while its output callback is stopped. + if (!impl_->policy.allows(background, false, PlaybackPolicy::Clock::now())) { + return; + } // Self-heal a dead output device (audio service restarted, device unplugged or // re-routed) rather than going silent until the app is restarted. if (!impl_->device_running()) { @@ -328,7 +366,13 @@ void SoundManager::play_named(const std::string& base, const std::string& pack) return; impl_->cleanup_finished(); + const bool background_playing = std::any_of(impl_->voices.begin(), impl_->voices.end(), + [](const auto& voice) { return voice->background; }); + if (!impl_->policy.allows(background, background_playing, PlaybackPolicy::Clock::now())) { + return; + } auto voice = std::make_unique(); + voice->background = background; const std::string path_str = path.string(); const bool is_ogg = path.extension() == ".ogg" || path.extension() == ".OGG"; @@ -368,7 +412,18 @@ void SoundManager::play_named(const std::string& base, const std::string& pack) } ma_sound_set_volume(&voice->sound, volume_); - ma_sound_start(&voice->sound); + // A power command may have arrived while decoding/opening the file. + if (!impl_->policy.allows(background, background_playing, PlaybackPolicy::Clock::now()) || + ma_sound_start(&voice->sound) != MA_SUCCESS) { + ma_sound_uninit(&voice->sound); + if (voice->has_ref) { + ma_audio_buffer_ref_uninit(&voice->ref); + } + return; + } + if (background) { + impl_->policy.background_started(PlaybackPolicy::Clock::now()); + } impl_->voices.push_back(std::move(voice)); } diff --git a/docs/changelog.txt b/docs/changelog.txt index a46fb67..e02dcd2 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -4,6 +4,7 @@ FastSMRW changelog 0.5.9 ----- +- Fixed: waking the Mac no longer plays a burst of overlapping alert sounds, and background notification chimes no longer pile up across the apps. - New: the user profile dialog now shows the date the account joined. - New: FastSMRW can now ask before you follow or unfollow someone — turn either on in Settings, Confirmation. - New: on Android, mentions, boosts, favorites and each other kind of notification now have their own entry in Android's notification settings, so you can give each one its own sound, vibration or none at all. diff --git a/macos/src/AppDelegate.swift b/macos/src/AppDelegate.swift index 884562d..839d216 100644 --- a/macos/src/AppDelegate.swift +++ b/macos/src/AppDelegate.swift @@ -37,8 +37,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate { mainWindowController = controller state.start() + + let workspaceNotifications = NSWorkspace.shared.notificationCenter + workspaceNotifications.addObserver(self, selector: #selector(systemWillSleep(_:)), + name: NSWorkspace.willSleepNotification, object: nil) + workspaceNotifications.addObserver(self, selector: #selector(systemDidWake(_:)), + name: NSWorkspace.didWakeNotification, object: nil) } + @objc private func systemWillSleep(_ notification: Notification) { + state?.suspendAudio() + } + + @objc private func systemDidWake(_ notification: Notification) { + state?.resetAudio() + } + + func applicationWillTerminate(_ notification: Notification) { + NSWorkspace.shared.notificationCenter.removeObserver(self) + } + func applicationDidBecomeActive(_ notification: Notification) { // The initial activation is already covered by start(). On subsequent // activations (for example Command-Tabbing back), refresh and begin a diff --git a/tests/main.cpp b/tests/main.cpp index 064c1d4..4b4bffc 100644 --- a/tests/main.cpp +++ b/tests/main.cpp @@ -135,6 +135,10 @@ void test_marker_restore_reports_already_there(); void test_note_selection_same_row_is_not_a_move(); void test_restored_position_survives_default_edge_echo(); +// From test_sound.cpp +void test_sound_wake_recovery(); +void test_sound_background_burst(); + // From test_push.cpp void test_push_settings(); void test_push_requests(); @@ -159,6 +163,8 @@ static void test_http_header_lookup() { } int main() { + test_sound_wake_recovery(); + test_sound_background_burst(); test_version(); test_http_header_lookup(); test_status_roundtrip(); diff --git a/tests/test_sound.cpp b/tests/test_sound.cpp new file mode 100644 index 0000000..69bb206 --- /dev/null +++ b/tests/test_sound.cpp @@ -0,0 +1,68 @@ +#include "check.hpp" +#include "fastsm/sound/playback_policy.hpp" + +using fastsm::sound::PlaybackPolicy; +using namespace std::chrono_literals; + +void test_sound_wake_recovery() { + PlaybackPolicy policy; + const auto start = PlaybackPolicy::Clock::time_point{} + 100s; + CHECK(policy.allows(false, false, start)); + CHECK(policy.allows(true, false, start)); + + // Sleep blocks already-queued callbacks before the core handles teardown. + policy.begin_power_transition(); + CHECK(!policy.allows(false, false, start)); + CHECK(!policy.allows(true, false, start)); + policy.suspend(); + policy.end_power_transition(); + CHECK(!policy.allows(false, false, start + 1h)); + CHECK(!policy.allows(true, false, start + 1h)); + + const auto wake = start + 1h; + policy.begin_power_transition(); + policy.resume(wake); + CHECK(!policy.allows(false, false, wake)); + policy.end_power_transition(); + // Actions work immediately; catching-up timelines stay quiet for five seconds. + CHECK(policy.allows(false, false, wake)); + for (int i = 0; i < 500; ++i) { + CHECK(!policy.allows(true, false, wake + i * 10ms)); + } + CHECK(policy.allows(true, false, wake + 5s)); + + // A second pending sleep/wake command must not reopen the gate early. + policy.begin_power_transition(); + policy.begin_power_transition(); + policy.resume(wake + 10s); + policy.end_power_transition(); + CHECK(!policy.allows(false, false, wake + 10s)); + policy.suspend(); + policy.end_power_transition(); + CHECK(!policy.allows(false, false, wake + 20s)); + policy.begin_power_transition(); + policy.resume(wake + 20s); + policy.end_power_transition(); + CHECK(policy.allows(false, false, wake + 20s)); + CHECK(!policy.allows(true, false, wake + 24s)); + CHECK(policy.allows(true, false, wake + 25s)); +} + +void test_sound_background_burst() { + PlaybackPolicy policy; + const auto start = PlaybackPolicy::Clock::time_point{} + 100s; + CHECK(policy.allows(true, false, start)); + policy.background_started(start); + // Even very short chimes cannot turn a batch of arrivals into a sound burst. + for (int i = 0; i < 100; ++i) { + CHECK(!policy.allows(true, false, start + i * 10ms)); + } + CHECK(policy.allows(true, false, start + 1s)); + // Long custom soundpack clips cannot overlap after the cooldown expires. + CHECK(!policy.allows(true, true, start + 30s)); + CHECK(policy.allows(false, true, start + 30s)); + CHECK(policy.allows(true, false, start + 30s)); + policy.background_started(start + 30s); + CHECK(!policy.allows(true, false, start + 30500ms)); + CHECK(policy.allows(true, false, start + 31s)); +}