refactor(audio): migrate UI sound playback to pw_stream + dr_wav

This commit is contained in:
Lysec
2026-04-22 14:23:13 +02:00
parent eec885b1b7
commit 6648b789c2
19 changed files with 9500 additions and 316 deletions
+9 -28
View File
@@ -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.
---
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+2 -2
View File
@@ -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;
+12 -29
View File
@@ -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]
+4 -20
View File
@@ -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; }
+2
View File
@@ -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;
};
+1
View File
@@ -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; }
+234
View File
@@ -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;
});
}
+56
View File
@@ -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;
};
+17 -4
View File
@@ -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);
}
}
+3
View File
@@ -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;
};
-143
View File
@@ -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));
}
}
-39
View File
@@ -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;
};
+2
View File
@@ -0,0 +1,2 @@
#define DR_WAV_IMPLEMENTATION
#include "dr_wav.h"
+9075
View File
File diff suppressed because it is too large Load Diff