Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apple/shared/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion build.bat
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
43 changes: 43 additions & 0 deletions core/include/fastsm/sound/playback_policy.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#pragma once

#include <atomic>
#include <chrono>

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<unsigned> pending_transitions_{0};
bool suspended_ = false;
Clock::time_point background_after_{};
};

} // namespace fastsm::sound
13 changes: 12 additions & 1 deletion core/include/fastsm/sound/sound_manager.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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", <user packs sorted>].
std::vector<std::string> list_soundpacks() const;

Expand All @@ -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<std::filesystem::path> search_dirs(const std::string& pack) const;
Expand Down
35 changes: 29 additions & 6 deletions core/src/session/core_session.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::string>();
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) {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -4221,11 +4244,11 @@ std::unique_ptr<TimelineController> 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<TimelineItem>& items) {
if (!p->auto_read() || items.empty())
Expand Down
57 changes: 56 additions & 1 deletion core/src/sound/sound_manager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
#include <TargetConditionals.h>
#endif

#include "fastsm/sound/playback_policy.hpp"
#include "fastsm/sound/sound_manager.hpp"

#include <algorithm>
Expand Down Expand Up @@ -64,6 +65,7 @@ struct Voice {
ma_sound sound{};
ma_audio_buffer_ref ref{};
bool has_ref = false;
bool background = false;
};

} // namespace
Expand All @@ -73,6 +75,7 @@ struct SoundManager::Impl {
bool ok = false;
std::unordered_map<std::string, DecodedPcm> pcm_cache; // keyed by file path
std::vector<std::unique_ptr<Voice>> voices;
PlaybackPolicy policy;

void cleanup_finished() {
voices.erase(std::remove_if(voices.begin(), voices.end(),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()) {
Expand All @@ -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>();
voice->background = background;

const std::string path_str = path.string();
const bool is_ogg = path.extension() == ".ogg" || path.extension() == ".OGG";
Expand Down Expand Up @@ -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));
}

Expand Down
1 change: 1 addition & 0 deletions docs/changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions macos/src/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions tests/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand Down
Loading
Loading