mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
refactor(audio): migrate UI sound playback to pw_stream + dr_wav
This commit is contained in:
+9
-28
@@ -1,7 +1,6 @@
|
||||
# Services
|
||||
|
||||
- [Audio](#audio)
|
||||
- [Sound](#sound)
|
||||
- [Brightness](#brightness)
|
||||
- [Night Light](#night-light)
|
||||
- [Weather](#weather)
|
||||
@@ -14,35 +13,17 @@
|
||||
|
||||
```toml
|
||||
[audio]
|
||||
enable_overdrive = false # allow volume sliders above 100% (up to 150%)
|
||||
enable_overdrive = false # allow volume sliders above 100% (up to 150%)
|
||||
enable_sounds = true # master toggle for UI sounds
|
||||
sound_volume = 1.0 # global sound volume (0.0 - 1.0)
|
||||
volume_change_sound = "" # empty = bundled default sounds/volume-change.wav
|
||||
notification_sound = "" # empty = bundled default sounds/notification.wav
|
||||
```
|
||||
|
||||
When `enable_overdrive = false`, the Control Center output and microphone sliders clamp to 100%. When `true`, they allow up to 150%.
|
||||
|
||||
---
|
||||
|
||||
## Sound
|
||||
|
||||
```toml
|
||||
[sound.notification]
|
||||
enabled = false
|
||||
sound = "sounds/notification-generic.wav" # bundled relative path, or absolute path like "/home/me/sounds/ping.wav"
|
||||
volume = 1.25 # playback gain (0.0 - 3.0)
|
||||
|
||||
[sound.volume]
|
||||
enabled = false
|
||||
sound = "sounds/volume-change.wav"
|
||||
volume = 1.25
|
||||
```
|
||||
|
||||
- `sound.notification` controls the notification-added cue.
|
||||
- `sound.volume` controls playback when default sink/source volume or mute state changes.
|
||||
- When `enabled = false`, that cue is disabled.
|
||||
- `sound` may point to any file path (absolute paths are recommended for custom files outside the repo).
|
||||
- `~` home paths are supported (for example `~/Music/noctalia/notify.wav`).
|
||||
- Relative paths still support bundled assets like `sounds/notification-generic.wav`.
|
||||
- `volume` is clamped to `0.0`-`3.0` before playback.
|
||||
- Values above `1.0` are allowed and useful for quiet samples (for example `1.25` or `1.5`).
|
||||
- `enable_overdrive = false` clamps Control Center output/microphone sliders to 100%; `true` allows up to 150%.
|
||||
- `enable_sounds = false` disables all UI sound playback.
|
||||
- `sound_volume` is the global playback gain for all UI sounds.
|
||||
- `volume_change_sound` and `notification_sound` accept absolute paths, `~`-prefixed home paths, or asset-relative paths.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -78,6 +78,10 @@ unit = "celsius" # celsius | fahrenheit
|
||||
|
||||
[audio]
|
||||
enable_overdrive = false # allow volume above 100% (up to 150%)
|
||||
enable_sounds = true # master toggle for UI feedback sounds
|
||||
sound_volume = 1.0 # 0.0 - 1.0
|
||||
volume_change_sound = "" # empty = bundled sounds/volume-change.wav
|
||||
notification_sound = "" # empty = bundled sounds/notification.wav
|
||||
|
||||
# ── Brightness ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
+12
-1
@@ -59,6 +59,16 @@ tinyexpr_dep = declare_dependency(
|
||||
include_directories: include_directories('third_party/tinyexpr', is_system: true),
|
||||
)
|
||||
|
||||
# ── Vendored: dr_wav (single-file WAV decoder) ───────────────────────────────
|
||||
_drwav_lib = static_library('dr_wav',
|
||||
'third_party/dr_wav/dr_wav.c',
|
||||
override_options: ['warning_level=0'],
|
||||
)
|
||||
drwav_dep = declare_dependency(
|
||||
link_with: _drwav_lib,
|
||||
include_directories: include_directories('third_party/dr_wav', is_system: true),
|
||||
)
|
||||
|
||||
# ── Vendored: Material Color Utilities (Google) ───────────────────────────────
|
||||
# Upstream uses #include "cpp/..." prefixes, so the include root is the
|
||||
# material_color_utilities/ dir itself. Built as a quiet static library.
|
||||
@@ -317,6 +327,7 @@ _noctalia_sources = files(
|
||||
'src/notification/notification_manager.cpp',
|
||||
'src/pipewire/pipewire_service.cpp',
|
||||
'src/pipewire/pipewire_spectrum.cpp',
|
||||
'src/pipewire/sound_player.cpp',
|
||||
'src/render/animation/animation.cpp',
|
||||
'src/render/animation/animation_manager.cpp',
|
||||
'src/render/animation/motion_service.cpp',
|
||||
@@ -440,7 +451,6 @@ _noctalia_sources = files(
|
||||
'src/system/icon_resolver.cpp',
|
||||
'src/system/internal_app_metadata.cpp',
|
||||
'src/system/night_light_manager.cpp',
|
||||
'src/system/sound_service.cpp',
|
||||
'src/system/system_monitor_service.cpp',
|
||||
'src/system/weather_service.cpp',
|
||||
'src/time/time_service.cpp',
|
||||
@@ -545,6 +555,7 @@ executable('noctalia',
|
||||
curl_dep,
|
||||
pam_dep,
|
||||
tinyexpr_dep,
|
||||
drwav_dep,
|
||||
mcu_dep,
|
||||
stb_dep,
|
||||
libwebp_dep,
|
||||
|
||||
+61
-50
@@ -4,6 +4,7 @@
|
||||
#include "core/deferred_call.h"
|
||||
#include "core/log.h"
|
||||
#include "core/process.h"
|
||||
#include "core/resource_paths.h"
|
||||
#include "i18n/i18n_service.h"
|
||||
#include "ipc/ipc_arg_parse.h"
|
||||
#include "launcher/app_provider.h"
|
||||
@@ -28,6 +29,7 @@
|
||||
#include <cmath>
|
||||
#include <csignal>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <malloc.h>
|
||||
#include <stdexcept>
|
||||
#include <string_view>
|
||||
@@ -39,6 +41,23 @@ namespace {
|
||||
|
||||
constexpr Logger kLog("app");
|
||||
|
||||
std::filesystem::path expandUserPath(const std::string& path) {
|
||||
if (!path.starts_with('~')) {
|
||||
return std::filesystem::path(path);
|
||||
}
|
||||
const char* home = std::getenv("HOME");
|
||||
if (home == nullptr || home[0] == '\0') {
|
||||
return std::filesystem::path(path);
|
||||
}
|
||||
if (path.size() == 1) {
|
||||
return std::filesystem::path(home);
|
||||
}
|
||||
if (path[1] == '/') {
|
||||
return std::filesystem::path(home) / path.substr(2);
|
||||
}
|
||||
return std::filesystem::path(path);
|
||||
}
|
||||
|
||||
template <typename Factory>
|
||||
auto makeWithStartupBackoff(std::string_view label, Factory&& factory) -> decltype(factory()) {
|
||||
using namespace std::chrono_literals;
|
||||
@@ -97,11 +116,6 @@ Application::Application() : m_weatherService(m_configService, m_httpClient) {
|
||||
const char* origin = (n.origin == NotificationOrigin::Internal) ? "internal" : "external";
|
||||
kLog.debug("notification {} id={} origin={}", kind, n.id, origin);
|
||||
|
||||
const auto& notificationSound = m_configService.config().sound.notification;
|
||||
if (event == NotificationEvent::Added && !m_notificationManager.doNotDisturb() && notificationSound.enabled) {
|
||||
m_soundService.play(SoundId::NotificationGeneric, notificationSound.sound, notificationSound.volume);
|
||||
}
|
||||
|
||||
// Keep bar widgets in sync with notification state changes.
|
||||
m_bar.refresh();
|
||||
if (shouldRefreshControlCenter()) {
|
||||
@@ -156,6 +170,7 @@ Application::~Application() {
|
||||
m_pipewireSpectrumPollSource.reset();
|
||||
m_pipewireSpectrum.reset();
|
||||
m_pipewirePollSource.reset();
|
||||
m_soundPlayer.reset();
|
||||
m_pipewireService.reset();
|
||||
|
||||
// MainLoop will be destroyed next, then SessionBus
|
||||
@@ -538,8 +553,35 @@ void Application::initServices() {
|
||||
try {
|
||||
m_pipewireService = std::make_unique<PipeWireService>();
|
||||
m_pipewireSpectrum = std::make_unique<PipeWireSpectrum>(*m_pipewireService);
|
||||
m_soundPlayer = std::make_unique<SoundPlayer>(m_pipewireService->loop());
|
||||
|
||||
auto applySoundConfig = [this]() {
|
||||
if (m_soundPlayer == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& audio = m_configService.config().audio;
|
||||
m_soundPlayer->setVolume(audio.enableSounds ? audio.soundVolume : 0.0f);
|
||||
|
||||
auto resolveSoundPath = [](const std::string& configured, std::string_view bundledRelative) {
|
||||
if (configured.empty()) {
|
||||
return paths::assetPath(bundledRelative);
|
||||
}
|
||||
const std::filesystem::path expanded = expandUserPath(configured);
|
||||
if (expanded.is_absolute()) {
|
||||
return expanded;
|
||||
}
|
||||
return paths::assetPath(expanded.string());
|
||||
};
|
||||
|
||||
(void)m_soundPlayer->load("volume-change", resolveSoundPath(audio.volumeChangeSound, "sounds/volume-change.wav"));
|
||||
(void)m_soundPlayer->load("notification", resolveSoundPath(audio.notificationSound, "sounds/notification.wav"));
|
||||
};
|
||||
applySoundConfig();
|
||||
m_configService.addReloadCallback(applySoundConfig);
|
||||
} catch (const std::exception& e) {
|
||||
kLog.warn("pipewire disabled: {}", e.what());
|
||||
m_soundPlayer.reset();
|
||||
m_pipewireSpectrum.reset();
|
||||
m_pipewireService.reset();
|
||||
}
|
||||
@@ -724,9 +766,11 @@ void Application::initUi() {
|
||||
|
||||
m_notificationToast.initialize(m_wayland, &m_configService, &m_notificationManager, &m_renderContext, &m_httpClient);
|
||||
m_configService.setNotificationManager(&m_notificationManager);
|
||||
m_notificationManager.setSoundPlayer(m_soundPlayer.get());
|
||||
|
||||
m_osdOverlay.initialize(m_wayland, &m_configService, &m_renderContext);
|
||||
m_audioOsd.bindOverlay(m_osdOverlay);
|
||||
m_audioOsd.setSoundPlayer(m_soundPlayer.get());
|
||||
if (m_pipewireService != nullptr) {
|
||||
m_audioOsd.primeFromService(*m_pipewireService);
|
||||
}
|
||||
@@ -804,51 +848,18 @@ void Application::initUi() {
|
||||
|
||||
if (m_pipewireService != nullptr) {
|
||||
m_audioOsd.suppressFor(std::chrono::milliseconds(2000));
|
||||
const AudioNode* initialSink = m_pipewireService->defaultSink();
|
||||
const AudioNode* initialSource = m_pipewireService->defaultSource();
|
||||
m_pipewireService->setChangeCallback(
|
||||
[this, shouldRefreshControlCenter,
|
||||
lastSoundSink = initialSink != nullptr ? std::optional<AudioNode>(*initialSink) : std::nullopt,
|
||||
lastSoundSource = initialSource != nullptr ? std::optional<AudioNode>(*initialSource) : std::nullopt,
|
||||
soundStatePrimed = true,
|
||||
soundEnableAfter = std::chrono::steady_clock::now() + std::chrono::milliseconds(2500)]() mutable {
|
||||
auto soundNodeChanged = [](const std::optional<AudioNode>& previous, const AudioNode* current) {
|
||||
if (!previous.has_value()) {
|
||||
return false;
|
||||
}
|
||||
if (current == nullptr) {
|
||||
return false;
|
||||
}
|
||||
return previous->id != current->id || previous->muted != current->muted ||
|
||||
std::abs(previous->volume - current->volume) >= 0.0005f;
|
||||
};
|
||||
|
||||
if (m_pipewireService != nullptr) {
|
||||
const AudioNode* sink = m_pipewireService->defaultSink();
|
||||
const AudioNode* source = m_pipewireService->defaultSource();
|
||||
const bool volumeChanged = soundStatePrimed && (soundNodeChanged(lastSoundSink, sink) ||
|
||||
soundNodeChanged(lastSoundSource, source));
|
||||
lastSoundSink = sink != nullptr ? std::optional<AudioNode>(*sink) : std::nullopt;
|
||||
lastSoundSource = source != nullptr ? std::optional<AudioNode>(*source) : std::nullopt;
|
||||
soundStatePrimed = true;
|
||||
|
||||
const auto& volumeSound = m_configService.config().sound.volume;
|
||||
if (volumeChanged && volumeSound.enabled && std::chrono::steady_clock::now() >= soundEnableAfter) {
|
||||
m_soundService.play(SoundId::VolumeChange, volumeSound.sound, volumeSound.volume);
|
||||
}
|
||||
}
|
||||
|
||||
if (m_pipewireSpectrum != nullptr) {
|
||||
m_pipewireSpectrum->handleAudioStateChanged();
|
||||
}
|
||||
m_bar.refresh();
|
||||
if (shouldRefreshControlCenter()) {
|
||||
m_panelManager.refresh();
|
||||
}
|
||||
if (m_pipewireService != nullptr) {
|
||||
m_audioOsd.onAudioStateChanged(*m_pipewireService);
|
||||
}
|
||||
});
|
||||
m_pipewireService->setChangeCallback([this, shouldRefreshControlCenter]() {
|
||||
if (m_pipewireSpectrum != nullptr) {
|
||||
m_pipewireSpectrum->handleAudioStateChanged();
|
||||
}
|
||||
m_bar.refresh();
|
||||
if (shouldRefreshControlCenter()) {
|
||||
m_panelManager.refresh();
|
||||
}
|
||||
if (m_pipewireService != nullptr) {
|
||||
m_audioOsd.onAudioStateChanged(*m_pipewireService);
|
||||
}
|
||||
});
|
||||
m_pipewireService->setVolumePreviewCallback([this](bool isInput, std::uint32_t id, float volume, bool muted) {
|
||||
if (isInput) {
|
||||
m_audioOsd.showInput(id, volume, muted);
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
#include "pipewire/pipewire_service.h"
|
||||
#include "pipewire/pipewire_spectrum.h"
|
||||
#include "pipewire/pipewire_spectrum_poll_source.h"
|
||||
#include "pipewire/sound_player.h"
|
||||
#include "render/core/async_texture_cache.h"
|
||||
#include "render/core/shared_texture_cache.h"
|
||||
#include "render/core/thumbnail_service.h"
|
||||
@@ -59,7 +60,6 @@
|
||||
#include "system/brightness_service.h"
|
||||
#include "system/desktop_entry_poll_source.h"
|
||||
#include "system/night_light_manager.h"
|
||||
#include "system/sound_service.h"
|
||||
#include "system/system_monitor_service.h"
|
||||
#include "system/weather_poll_source.h"
|
||||
#include "system/weather_service.h"
|
||||
@@ -121,7 +121,6 @@ private:
|
||||
IdleManager m_idleManager;
|
||||
HookManager m_hookManager;
|
||||
NightLightManager m_nightLightManager;
|
||||
SoundService m_soundService;
|
||||
std::unique_ptr<MprisService> m_mprisService;
|
||||
std::unique_ptr<PowerProfilesService> m_powerProfilesService;
|
||||
std::unique_ptr<NetworkService> m_networkService;
|
||||
@@ -139,6 +138,7 @@ private:
|
||||
std::unique_ptr<NotificationService> m_notificationDbus;
|
||||
std::unique_ptr<PipeWireService> m_pipewireService;
|
||||
std::unique_ptr<PipeWireSpectrum> m_pipewireSpectrum;
|
||||
std::unique_ptr<SoundPlayer> m_soundPlayer;
|
||||
|
||||
GlSharedContext m_glShared;
|
||||
SharedTextureCache m_sharedTextureCache;
|
||||
|
||||
@@ -1558,35 +1558,18 @@ void ConfigService::parseTable(const toml::table& tbl) {
|
||||
if (auto v = (*audioTbl)["enable_overdrive"].value<bool>()) {
|
||||
audio.enableOverdrive = *v;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse [sound.notification] and [sound.volume]
|
||||
if (auto* soundTbl = tbl["sound"].as_table()) {
|
||||
auto parseSoundEvent = [](const toml::table& root, const char* key, SoundEventConfig& target) {
|
||||
auto* eventTbl = root[key].as_table();
|
||||
if (eventTbl == nullptr) {
|
||||
return;
|
||||
}
|
||||
if (auto enabledValue = (*eventTbl)["enabled"].value<bool>()) {
|
||||
target.enabled = *enabledValue;
|
||||
} else if (auto legacyEnabledValue = (*eventTbl)["sound_enabled"].value<bool>()) {
|
||||
target.enabled = *legacyEnabledValue;
|
||||
}
|
||||
if (auto soundValue = (*eventTbl)["sound"].value<std::string>()) {
|
||||
target.sound = *soundValue;
|
||||
} else if (auto legacySoundPath = (*eventTbl)["sound_path"].value<std::string>()) {
|
||||
target.sound = *legacySoundPath;
|
||||
}
|
||||
if (auto volumeValue = (*eventTbl)["volume"].value<double>()) {
|
||||
target.volume = std::clamp(static_cast<float>(*volumeValue), 0.0f, 3.0f);
|
||||
} else if (auto legacySoundVolume = (*eventTbl)["sound_volume"].value<double>()) {
|
||||
target.volume = std::clamp(static_cast<float>(*legacySoundVolume), 0.0f, 3.0f);
|
||||
}
|
||||
};
|
||||
|
||||
auto& sound = m_config.sound;
|
||||
parseSoundEvent(*soundTbl, "notification", sound.notification);
|
||||
parseSoundEvent(*soundTbl, "volume", sound.volume);
|
||||
if (auto v = (*audioTbl)["enable_sounds"].value<bool>()) {
|
||||
audio.enableSounds = *v;
|
||||
}
|
||||
if (auto v = (*audioTbl)["sound_volume"].value<double>()) {
|
||||
audio.soundVolume = std::clamp(static_cast<float>(*v), 0.0f, 1.0f);
|
||||
}
|
||||
if (auto v = (*audioTbl)["volume_change_sound"].value<std::string>()) {
|
||||
audio.volumeChangeSound = *v;
|
||||
}
|
||||
if (auto v = (*audioTbl)["notification_sound"].value<std::string>()) {
|
||||
audio.notificationSound = *v;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse [brightness]
|
||||
|
||||
@@ -273,25 +273,10 @@ struct WeatherConfig {
|
||||
|
||||
struct AudioConfig {
|
||||
bool enableOverdrive = false;
|
||||
};
|
||||
|
||||
struct SoundEventConfig {
|
||||
bool enabled = false;
|
||||
std::string sound;
|
||||
float volume = 1.0f;
|
||||
};
|
||||
|
||||
struct SoundConfig {
|
||||
SoundEventConfig notification{
|
||||
.enabled = false,
|
||||
.sound = "sounds/notification-generic.wav",
|
||||
.volume = 1.0f,
|
||||
};
|
||||
SoundEventConfig volume{
|
||||
.enabled = false,
|
||||
.sound = "sounds/volume-change.wav",
|
||||
.volume = 1.0f,
|
||||
};
|
||||
bool enableSounds = true;
|
||||
float soundVolume = 1.0f;
|
||||
std::string volumeChangeSound;
|
||||
std::string notificationSound;
|
||||
};
|
||||
|
||||
enum class BrightnessBackendPreference : std::uint8_t {
|
||||
@@ -433,7 +418,6 @@ struct Config {
|
||||
NotificationConfig notification;
|
||||
WeatherConfig weather;
|
||||
AudioConfig audio;
|
||||
SoundConfig sound;
|
||||
BrightnessConfig brightness;
|
||||
KeybindsConfig keybinds;
|
||||
NightLightConfig nightlight;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "notification_manager.h"
|
||||
|
||||
#include "core/log.h"
|
||||
#include "pipewire/sound_player.h"
|
||||
|
||||
#include <string_view>
|
||||
|
||||
@@ -149,6 +150,9 @@ uint32_t NotificationManager::addOrReplace(uint32_t replaces_id, std::string app
|
||||
for (auto& [token, cb] : m_eventCallbacks) {
|
||||
cb(n, NotificationEvent::Added);
|
||||
}
|
||||
if (!m_doNotDisturb && m_soundPlayer != nullptr) {
|
||||
m_soundPlayer->play("notification");
|
||||
}
|
||||
|
||||
return n.id;
|
||||
}
|
||||
@@ -320,3 +324,5 @@ bool NotificationManager::toggleDoNotDisturb() {
|
||||
}
|
||||
|
||||
void NotificationManager::setStateCallback(StateCallback callback) { m_stateCallback = std::move(callback); }
|
||||
|
||||
void NotificationManager::setSoundPlayer(SoundPlayer* soundPlayer) { m_soundPlayer = soundPlayer; }
|
||||
|
||||
@@ -84,6 +84,7 @@ public:
|
||||
[[nodiscard]] bool doNotDisturb() const noexcept;
|
||||
[[nodiscard]] bool toggleDoNotDisturb();
|
||||
void setStateCallback(StateCallback callback);
|
||||
void setSoundPlayer(class SoundPlayer* soundPlayer);
|
||||
|
||||
private:
|
||||
void upsertHistory(const Notification& notification, bool active, std::optional<CloseReason> closeReason);
|
||||
@@ -100,4 +101,5 @@ private:
|
||||
uint32_t m_nextId{1};
|
||||
std::uint64_t m_changeSerial{0};
|
||||
bool m_doNotDisturb = false;
|
||||
class SoundPlayer* m_soundPlayer = nullptr;
|
||||
};
|
||||
|
||||
@@ -56,6 +56,7 @@ public:
|
||||
[[nodiscard]] int fd() const noexcept;
|
||||
void dispatch();
|
||||
[[nodiscard]] pw_core* coreHandle() const noexcept { return m_core; }
|
||||
[[nodiscard]] pw_loop* loop() const noexcept { return m_loop; }
|
||||
|
||||
// State
|
||||
[[nodiscard]] const AudioState& state() const noexcept { return m_state; }
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
#include "pipewire/sound_player.h"
|
||||
|
||||
#include "core/log.h"
|
||||
#include "dr_wav.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <pipewire/pipewire.h>
|
||||
#include <spa/param/audio/format-utils.h>
|
||||
#include <spa/param/param.h>
|
||||
#include <spa/utils/result.h>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr Logger kLog("sound");
|
||||
|
||||
const pw_stream_events kStreamEvents = [] {
|
||||
pw_stream_events events{};
|
||||
events.version = PW_VERSION_STREAM_EVENTS;
|
||||
events.state_changed = SoundPlayer::onStreamStateChanged;
|
||||
events.drained = SoundPlayer::onDrained;
|
||||
events.process = SoundPlayer::onProcess;
|
||||
return events;
|
||||
}();
|
||||
|
||||
} // namespace
|
||||
|
||||
SoundPlayer::SoundPlayer(pw_loop* loop) : m_loop(loop) {}
|
||||
|
||||
SoundPlayer::~SoundPlayer() {
|
||||
for (auto& active : m_active) {
|
||||
if (active->listener != nullptr) {
|
||||
spa_hook_remove(active->listener);
|
||||
delete active->listener;
|
||||
active->listener = nullptr;
|
||||
}
|
||||
if (active->stream != nullptr) {
|
||||
pw_stream_disconnect(active->stream);
|
||||
pw_stream_destroy(active->stream);
|
||||
active->stream = nullptr;
|
||||
}
|
||||
}
|
||||
m_active.clear();
|
||||
}
|
||||
|
||||
bool SoundPlayer::load(const std::string& name, const std::filesystem::path& path) {
|
||||
if (name.empty() || path.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
drwav wav{};
|
||||
if (drwav_init_file(&wav, path.string().c_str(), nullptr) == DRWAV_FALSE) {
|
||||
kLog.warn("failed to load sound \"{}\" from {}", name, path.string());
|
||||
return false;
|
||||
}
|
||||
|
||||
SoundBuffer buffer;
|
||||
buffer.sampleRate = wav.sampleRate;
|
||||
buffer.channels = std::max<std::uint32_t>(1, wav.channels);
|
||||
const std::uint64_t totalFrames = wav.totalPCMFrameCount;
|
||||
const std::size_t totalSamples = static_cast<std::size_t>(totalFrames) * static_cast<std::size_t>(buffer.channels);
|
||||
buffer.samples.resize(totalSamples);
|
||||
|
||||
const std::uint64_t readFrames = drwav_read_pcm_frames_f32(&wav, totalFrames, buffer.samples.data());
|
||||
drwav_uninit(&wav);
|
||||
|
||||
const std::size_t readSamples = static_cast<std::size_t>(readFrames) * static_cast<std::size_t>(buffer.channels);
|
||||
if (readSamples == 0) {
|
||||
kLog.warn("sound \"{}\" from {} has no samples", name, path.string());
|
||||
return false;
|
||||
}
|
||||
buffer.samples.resize(readSamples);
|
||||
|
||||
m_buffers[name] = std::move(buffer);
|
||||
kLog.info("loaded sound \"{}\" from {}", name, path.string());
|
||||
return true;
|
||||
}
|
||||
|
||||
void SoundPlayer::play(const std::string& name) {
|
||||
if (m_loop == nullptr || m_volume <= 0.0f) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto it = m_buffers.find(name);
|
||||
if (it == m_buffers.end()) {
|
||||
return;
|
||||
}
|
||||
const SoundBuffer* buffer = &it->second;
|
||||
if (buffer->samples.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeFinished();
|
||||
|
||||
auto active = std::make_unique<ActiveStream>();
|
||||
active->owner = this;
|
||||
active->buffer = buffer;
|
||||
active->listener = new spa_hook{};
|
||||
spa_zero(*active->listener);
|
||||
|
||||
pw_properties* props = pw_properties_new(PW_KEY_MEDIA_TYPE, "Audio", PW_KEY_MEDIA_CATEGORY, "Playback",
|
||||
PW_KEY_MEDIA_ROLE, "Notification", PW_KEY_APP_NAME, "Noctalia", nullptr);
|
||||
active->stream = pw_stream_new_simple(m_loop, "noctalia-sound", props, &kStreamEvents, active.get());
|
||||
if (active->stream == nullptr) {
|
||||
delete active->listener;
|
||||
kLog.warn("failed to create stream for sound \"{}\"", name);
|
||||
return;
|
||||
}
|
||||
|
||||
pw_stream_add_listener(active->stream, active->listener, &kStreamEvents, active.get());
|
||||
|
||||
std::uint8_t formatBuffer[1024];
|
||||
spa_pod_builder builder{};
|
||||
spa_pod_builder_init(&builder, formatBuffer, sizeof(formatBuffer));
|
||||
spa_audio_info_raw audioInfo{};
|
||||
audioInfo.format = SPA_AUDIO_FORMAT_F32;
|
||||
audioInfo.rate = buffer->sampleRate;
|
||||
audioInfo.channels = buffer->channels;
|
||||
const spa_pod* params[1];
|
||||
params[0] = reinterpret_cast<spa_pod*>(spa_format_audio_raw_build(&builder, SPA_PARAM_EnumFormat, &audioInfo));
|
||||
|
||||
const int rc = pw_stream_connect(
|
||||
active->stream, PW_DIRECTION_OUTPUT, PW_ID_ANY,
|
||||
static_cast<pw_stream_flags>(PW_STREAM_FLAG_AUTOCONNECT | PW_STREAM_FLAG_MAP_BUFFERS), params, 1);
|
||||
if (rc < 0) {
|
||||
kLog.warn("failed to connect stream for sound \"{}\": {}", name, spa_strerror(rc));
|
||||
spa_hook_remove(active->listener);
|
||||
delete active->listener;
|
||||
pw_stream_destroy(active->stream);
|
||||
return;
|
||||
}
|
||||
|
||||
m_active.push_back(std::move(active));
|
||||
}
|
||||
|
||||
void SoundPlayer::setVolume(float volume) { m_volume = std::clamp(volume, 0.0f, 1.0f); }
|
||||
|
||||
void SoundPlayer::onProcess(void* userdata) {
|
||||
auto* streamState = static_cast<ActiveStream*>(userdata);
|
||||
if (streamState == nullptr || streamState->owner == nullptr) {
|
||||
return;
|
||||
}
|
||||
streamState->owner->processStream(*streamState);
|
||||
}
|
||||
|
||||
void SoundPlayer::onStreamStateChanged(void* userdata, pw_stream_state /*oldState*/, pw_stream_state state,
|
||||
const char* error) {
|
||||
auto* streamState = static_cast<ActiveStream*>(userdata);
|
||||
if (streamState == nullptr || streamState->owner == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == PW_STREAM_STATE_ERROR) {
|
||||
kLog.warn("sound stream error: {}", error != nullptr ? error : "unknown");
|
||||
streamState->owner->markFinished(*streamState);
|
||||
}
|
||||
if (state == PW_STREAM_STATE_UNCONNECTED) {
|
||||
streamState->owner->markFinished(*streamState);
|
||||
}
|
||||
}
|
||||
|
||||
void SoundPlayer::onDrained(void* userdata) {
|
||||
auto* streamState = static_cast<ActiveStream*>(userdata);
|
||||
if (streamState == nullptr || streamState->owner == nullptr) {
|
||||
return;
|
||||
}
|
||||
streamState->owner->markFinished(*streamState);
|
||||
}
|
||||
|
||||
void SoundPlayer::processStream(ActiveStream& streamState) {
|
||||
if (streamState.stream == nullptr || streamState.buffer == nullptr || streamState.finished) {
|
||||
markFinished(streamState);
|
||||
return;
|
||||
}
|
||||
|
||||
pw_buffer* pwBuffer = pw_stream_dequeue_buffer(streamState.stream);
|
||||
if (pwBuffer == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
spa_buffer* spaBuffer = pwBuffer->buffer;
|
||||
spa_data& data = spaBuffer->datas[0];
|
||||
if (data.data == nullptr || data.maxsize < sizeof(float)) {
|
||||
pw_stream_queue_buffer(streamState.stream, pwBuffer);
|
||||
return;
|
||||
}
|
||||
|
||||
const auto* src = streamState.buffer->samples.data();
|
||||
const std::size_t sampleCount = streamState.buffer->samples.size();
|
||||
float* dst = static_cast<float*>(data.data);
|
||||
const std::size_t capacitySamples = data.maxsize / sizeof(float);
|
||||
const std::size_t remaining =
|
||||
(streamState.cursor < sampleCount && !streamState.draining) ? (sampleCount - streamState.cursor) : 0;
|
||||
const std::size_t copySamples = std::min(capacitySamples, remaining);
|
||||
|
||||
for (std::size_t i = 0; i < copySamples; ++i) {
|
||||
dst[i] = src[streamState.cursor + i] * m_volume;
|
||||
}
|
||||
|
||||
if (copySamples < capacitySamples) {
|
||||
std::memset(dst + copySamples, 0, (capacitySamples - copySamples) * sizeof(float));
|
||||
}
|
||||
|
||||
streamState.cursor += copySamples;
|
||||
data.chunk->offset = 0;
|
||||
data.chunk->size = static_cast<std::uint32_t>(copySamples * sizeof(float));
|
||||
data.chunk->stride = static_cast<std::int32_t>(streamState.buffer->channels * sizeof(float));
|
||||
pw_stream_queue_buffer(streamState.stream, pwBuffer);
|
||||
|
||||
if (streamState.cursor >= sampleCount && !streamState.draining) {
|
||||
streamState.draining = true;
|
||||
(void)pw_stream_flush(streamState.stream, true);
|
||||
}
|
||||
}
|
||||
|
||||
void SoundPlayer::markFinished(ActiveStream& streamState) { streamState.finished = true; }
|
||||
|
||||
void SoundPlayer::removeFinished() {
|
||||
std::erase_if(m_active, [](const std::unique_ptr<ActiveStream>& active) {
|
||||
if (!active->finished) {
|
||||
return false;
|
||||
}
|
||||
if (active->listener != nullptr) {
|
||||
spa_hook_remove(active->listener);
|
||||
delete active->listener;
|
||||
active->listener = nullptr;
|
||||
}
|
||||
if (active->stream != nullptr) {
|
||||
pw_stream_destroy(active->stream);
|
||||
active->stream = nullptr;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <pipewire/pipewire.h>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
struct pw_loop;
|
||||
struct pw_stream;
|
||||
struct spa_hook;
|
||||
|
||||
class SoundPlayer {
|
||||
public:
|
||||
explicit SoundPlayer(pw_loop* loop);
|
||||
~SoundPlayer();
|
||||
|
||||
SoundPlayer(const SoundPlayer&) = delete;
|
||||
SoundPlayer& operator=(const SoundPlayer&) = delete;
|
||||
|
||||
bool load(const std::string& name, const std::filesystem::path& path);
|
||||
void play(const std::string& name);
|
||||
void setVolume(float volume);
|
||||
|
||||
static void onProcess(void* userdata);
|
||||
static void onStreamStateChanged(void* userdata, pw_stream_state oldState, pw_stream_state state, const char* error);
|
||||
static void onDrained(void* userdata);
|
||||
|
||||
private:
|
||||
struct SoundBuffer {
|
||||
std::vector<float> samples;
|
||||
std::uint32_t sampleRate = 48000;
|
||||
std::uint32_t channels = 2;
|
||||
};
|
||||
|
||||
struct ActiveStream {
|
||||
SoundPlayer* owner = nullptr;
|
||||
pw_stream* stream = nullptr;
|
||||
spa_hook* listener = nullptr;
|
||||
const SoundBuffer* buffer = nullptr;
|
||||
std::size_t cursor = 0;
|
||||
bool draining = false;
|
||||
bool finished = false;
|
||||
};
|
||||
|
||||
void processStream(ActiveStream& streamState);
|
||||
void markFinished(ActiveStream& streamState);
|
||||
void removeFinished();
|
||||
|
||||
pw_loop* m_loop = nullptr;
|
||||
float m_volume = 1.0f;
|
||||
std::unordered_map<std::string, SoundBuffer> m_buffers;
|
||||
std::vector<std::unique_ptr<ActiveStream>> m_active;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "shell/osd/audio_osd.h"
|
||||
|
||||
#include "pipewire/pipewire_service.h"
|
||||
#include "pipewire/sound_player.h"
|
||||
#include "shell/osd/osd_overlay.h"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -8,6 +9,7 @@
|
||||
#include <string>
|
||||
|
||||
namespace {
|
||||
constexpr auto kVolumeSoundCooldown = std::chrono::milliseconds(70);
|
||||
|
||||
const char* volumeIconName(float volume, bool muted) {
|
||||
if (muted || volume <= 0.0f) {
|
||||
@@ -40,6 +42,7 @@ namespace {
|
||||
} // namespace
|
||||
|
||||
void AudioOsd::bindOverlay(OsdOverlay& overlay) { m_overlay = &overlay; }
|
||||
void AudioOsd::setSoundPlayer(SoundPlayer* soundPlayer) { m_soundPlayer = soundPlayer; }
|
||||
|
||||
void AudioOsd::primeFromService(const PipeWireService& service) {
|
||||
if (const auto* sink = service.defaultSink(); sink != nullptr) {
|
||||
@@ -60,24 +63,34 @@ void AudioOsd::suppressFor(std::chrono::milliseconds duration) {
|
||||
}
|
||||
|
||||
void AudioOsd::showOutput(std::uint32_t sinkId, float volume, bool muted) {
|
||||
if (std::chrono::steady_clock::now() < m_suppressUntil) {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (now < m_suppressUntil) {
|
||||
return;
|
||||
}
|
||||
if (m_overlay != nullptr) {
|
||||
m_overlay->show(makeOutputContent(volume, muted));
|
||||
}
|
||||
if (m_soundPlayer != nullptr && now - m_lastSoundAt >= kVolumeSoundCooldown) {
|
||||
m_soundPlayer->play("volume-change");
|
||||
m_lastSoundAt = now;
|
||||
}
|
||||
m_lastSinkId = sinkId;
|
||||
m_lastSinkPercent = static_cast<int>(std::round(std::max(0.0f, volume) * 100.0f));
|
||||
m_lastSinkMuted = muted;
|
||||
}
|
||||
|
||||
void AudioOsd::showInput(std::uint32_t sourceId, float volume, bool muted) {
|
||||
if (std::chrono::steady_clock::now() < m_suppressUntil) {
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
if (now < m_suppressUntil) {
|
||||
return;
|
||||
}
|
||||
if (m_overlay != nullptr) {
|
||||
m_overlay->show(makeInputContent(volume, muted));
|
||||
}
|
||||
if (m_soundPlayer != nullptr && now - m_lastSoundAt >= kVolumeSoundCooldown) {
|
||||
m_soundPlayer->play("volume-change");
|
||||
m_lastSoundAt = now;
|
||||
}
|
||||
m_lastSourceId = sourceId;
|
||||
m_lastSourcePercent = static_cast<int>(std::round(std::max(0.0f, volume) * 100.0f));
|
||||
m_lastSourceMuted = muted;
|
||||
@@ -109,10 +122,10 @@ void AudioOsd::onAudioStateChanged(const PipeWireService& service) {
|
||||
if (m_overlay != nullptr) {
|
||||
if (sink != nullptr &&
|
||||
(sinkId != m_lastSinkId || sinkPercent != m_lastSinkPercent || sinkMuted != m_lastSinkMuted)) {
|
||||
m_overlay->show(makeOutputContent(sink->volume, sinkMuted));
|
||||
showOutput(sink->id, sink->volume, sinkMuted);
|
||||
} else if (source != nullptr && (sourceId != m_lastSourceId || sourcePercent != m_lastSourcePercent ||
|
||||
sourceMuted != m_lastSourceMuted)) {
|
||||
m_overlay->show(makeInputContent(source->volume, sourceMuted));
|
||||
showInput(source->id, source->volume, sourceMuted);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ class PipeWireService;
|
||||
class AudioOsd {
|
||||
public:
|
||||
void bindOverlay(OsdOverlay& overlay);
|
||||
void setSoundPlayer(class SoundPlayer* soundPlayer);
|
||||
void primeFromService(const PipeWireService& service);
|
||||
void suppressFor(std::chrono::milliseconds duration);
|
||||
void showOutput(std::uint32_t sinkId, float volume, bool muted);
|
||||
@@ -24,4 +25,6 @@ private:
|
||||
int m_lastSourcePercent = -1;
|
||||
bool m_lastSourceMuted = false;
|
||||
std::chrono::steady_clock::time_point m_suppressUntil{};
|
||||
std::chrono::steady_clock::time_point m_lastSoundAt{};
|
||||
class SoundPlayer* m_soundPlayer = nullptr;
|
||||
};
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
#include "system/sound_service.h"
|
||||
|
||||
#include "core/log.h"
|
||||
#include "core/process.h"
|
||||
#include "core/resource_paths.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <format>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr Logger kLog("sound");
|
||||
constexpr float kMaxSoundPlaybackVolume = 3.0f;
|
||||
|
||||
std::filesystem::path expandUserPath(std::string_view rawPath) {
|
||||
if (rawPath.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (rawPath[0] != '~') {
|
||||
return std::filesystem::path(std::string(rawPath));
|
||||
}
|
||||
|
||||
const char* home = std::getenv("HOME");
|
||||
if (home == nullptr || home[0] == '\0') {
|
||||
return std::filesystem::path(std::string(rawPath));
|
||||
}
|
||||
|
||||
if (rawPath.size() == 1) {
|
||||
return std::filesystem::path(home);
|
||||
}
|
||||
if (rawPath[1] == '/') {
|
||||
return std::filesystem::path(home) / std::string(rawPath.substr(2));
|
||||
}
|
||||
|
||||
// "~user" expansion is intentionally not supported.
|
||||
return std::filesystem::path(std::string(rawPath));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
SoundService::SoundService() : m_soundFiles(buildSoundTable()), m_backend(detectBackend()) {
|
||||
if (m_backend.has_value()) {
|
||||
kLog.info("sound service active via {}", backendName(*m_backend));
|
||||
} else {
|
||||
kLog.warn("sound service disabled: no supported playback backend found (pw-play/paplay/aplay)");
|
||||
}
|
||||
}
|
||||
|
||||
void SoundService::play(SoundId soundId, std::string_view overridePath, float volume) const {
|
||||
if (!m_backend.has_value()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const std::filesystem::path filePath = resolvePath(soundId, overridePath);
|
||||
if (filePath.empty() || !std::filesystem::exists(filePath)) {
|
||||
kLog.warn("sound file not found: {}", filePath.string());
|
||||
return;
|
||||
}
|
||||
|
||||
playWithBackend(*m_backend, filePath, std::clamp(volume, 0.0f, kMaxSoundPlaybackVolume));
|
||||
}
|
||||
|
||||
std::array<std::filesystem::path, static_cast<std::size_t>(SoundId::Count)> SoundService::buildSoundTable() {
|
||||
return {
|
||||
paths::assetPath("sounds/notification-generic.wav"),
|
||||
paths::assetPath("sounds/volume-change.wav"),
|
||||
paths::assetPath("sounds/alarm-beep.wav"),
|
||||
};
|
||||
}
|
||||
|
||||
std::optional<SoundService::Backend> SoundService::detectBackend() {
|
||||
if (process::commandExists("pw-play")) {
|
||||
return Backend::PwPlay;
|
||||
}
|
||||
if (process::commandExists("paplay")) {
|
||||
return Backend::Paplay;
|
||||
}
|
||||
if (process::commandExists("aplay")) {
|
||||
return Backend::Aplay;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::string_view SoundService::backendName(Backend backend) {
|
||||
switch (backend) {
|
||||
case Backend::PwPlay:
|
||||
return "pw-play";
|
||||
case Backend::Paplay:
|
||||
return "paplay";
|
||||
case Backend::Aplay:
|
||||
return "aplay";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
std::filesystem::path SoundService::resolvePath(SoundId soundId, std::string_view overridePath) const {
|
||||
if (!overridePath.empty()) {
|
||||
std::filesystem::path customPath = expandUserPath(overridePath);
|
||||
if (customPath.is_relative()) {
|
||||
const std::filesystem::path bundledPath = paths::assetsRoot() / customPath;
|
||||
if (std::filesystem::exists(bundledPath)) {
|
||||
return bundledPath;
|
||||
}
|
||||
}
|
||||
return customPath;
|
||||
}
|
||||
|
||||
const std::size_t index = static_cast<std::size_t>(soundId);
|
||||
if (index >= m_soundFiles.size()) {
|
||||
return {};
|
||||
}
|
||||
return m_soundFiles[index];
|
||||
}
|
||||
|
||||
void SoundService::playWithBackend(Backend backend, const std::filesystem::path& filePath, float volume) const {
|
||||
const std::string path = filePath.string();
|
||||
bool launched = false;
|
||||
switch (backend) {
|
||||
case Backend::PwPlay:
|
||||
launched = process::runAsync(
|
||||
std::vector<std::string>{"pw-play", "--volume", std::format("{:.3f}", static_cast<double>(volume)), path});
|
||||
break;
|
||||
case Backend::Paplay:
|
||||
launched = process::runAsync(std::vector<std::string>{
|
||||
"paplay",
|
||||
"--volume",
|
||||
std::to_string(static_cast<int>(std::lround(std::clamp(volume, 0.0f, kMaxSoundPlaybackVolume) * 65536.0f))),
|
||||
path,
|
||||
});
|
||||
break;
|
||||
case Backend::Aplay:
|
||||
launched = process::runAsync(std::vector<std::string>{"aplay", "-q", path});
|
||||
break;
|
||||
}
|
||||
if (!launched) {
|
||||
kLog.warn("failed to play sound via {}", backendName(backend));
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
|
||||
enum class SoundId : std::size_t {
|
||||
NotificationGeneric = 0,
|
||||
VolumeChange = 1,
|
||||
AlarmBeep = 2,
|
||||
Count,
|
||||
};
|
||||
|
||||
class SoundService {
|
||||
public:
|
||||
SoundService();
|
||||
|
||||
void play(SoundId soundId, std::string_view overridePath = {}, float volume = 1.0f) const;
|
||||
|
||||
private:
|
||||
enum class Backend : std::uint8_t {
|
||||
PwPlay,
|
||||
Paplay,
|
||||
Aplay,
|
||||
};
|
||||
|
||||
[[nodiscard]] static std::array<std::filesystem::path, static_cast<std::size_t>(SoundId::Count)> buildSoundTable();
|
||||
[[nodiscard]] static std::optional<Backend> detectBackend();
|
||||
[[nodiscard]] static std::string_view backendName(Backend backend);
|
||||
|
||||
[[nodiscard]] std::filesystem::path resolvePath(SoundId soundId, std::string_view overridePath) const;
|
||||
void playWithBackend(Backend backend, const std::filesystem::path& filePath, float volume) const;
|
||||
|
||||
std::array<std::filesystem::path, static_cast<std::size_t>(SoundId::Count)> m_soundFiles;
|
||||
std::optional<Backend> m_backend;
|
||||
};
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
#define DR_WAV_IMPLEMENTATION
|
||||
#include "dr_wav.h"
|
||||
Vendored
+9075
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user