feat(chat): volume control for inline audio clips

Add a per-clip volume slider plus a master volume control with a
"Universal volume" toggle in the chat header.

- ClipPlayer gains a SetVolume command; the worker remembers gain across
  clips and reapplies it to each freshly connected player.
- New config.clip_volume (universal level) and config.clip_volume_universal
  (mode toggle, default on), both persisted; old configs load at unity in
  universal mode.
- Universal on: master and per-clip sliders drive one shared level applied
  to every clip. Universal off: each clip keeps its own in-memory level and
  the master slider is inert.
- play_ready_audio applies the resolved effective gain right after Play.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 22:24:10 -04:00
co-authored by Claude Opus 4.8
parent 663956deaa
commit f21a027e78
3 changed files with 138 additions and 6 deletions
+29 -4
View File
@@ -40,6 +40,7 @@ enum ClipCommand {
Resume,
Seek(Duration),
Stop,
SetVolume(f32),
}
/// Cheap, `Send` command handle for the dedicated playback thread.
@@ -51,13 +52,16 @@ pub struct ClipPlayer {
impl ClipPlayer {
/// Start the playback worker. The system output device is opened lazily on
/// first Play, so merely launching PeerSpeak never claims another stream.
pub fn new() -> (Self, SharedClipStatus) {
///
/// `initial_volume` is the universal gain (`1.0` = unity) applied to every
/// clip, restored from config so the level persists across sessions.
pub fn new(initial_volume: f32) -> (Self, SharedClipStatus) {
let (command_tx, command_rx) = mpsc::channel();
let status = Arc::new(Mutex::new(ClipStatus::default()));
let worker_status = Arc::clone(&status);
std::thread::Builder::new()
.name("peerspeak-clip-player".to_string())
.spawn(move || playback_worker(command_rx, worker_status))
.spawn(move || playback_worker(command_rx, worker_status, initial_volume))
.expect("failed to spawn clip playback thread");
(
Self {
@@ -94,11 +98,24 @@ impl ClipPlayer {
pub fn stop(&self) {
let _ = self.command_tx.send(ClipCommand::Stop);
}
/// Set the universal playback gain (`1.0` = unity). Applies to the current
/// clip immediately and to every clip played afterwards.
pub fn set_volume(&self, volume: f32) {
let _ = self.command_tx.send(ClipCommand::SetVolume(volume));
}
}
fn playback_worker(command_rx: mpsc::Receiver<ClipCommand>, status: SharedClipStatus) {
fn playback_worker(
command_rx: mpsc::Receiver<ClipCommand>,
status: SharedClipStatus,
initial_volume: f32,
) {
let mut output: Option<MixerDeviceSink> = None;
let mut player: Option<Player> = None;
// Universal gain remembered across clips so a level set on one upload
// carries to the next; reapplied to each freshly connected player.
let mut volume = initial_volume.max(0.0);
loop {
match command_rx.recv_timeout(Duration::from_millis(100)) {
@@ -124,7 +141,9 @@ fn playback_worker(command_rx: mpsc::Receiver<ClipCommand>, status: SharedClipSt
if output.is_none() {
match DeviceSinkBuilder::open_default_sink() {
Ok(sink) => {
player = Some(Player::connect_new(sink.mixer()));
let new_player = Player::connect_new(sink.mixer());
new_player.set_volume(volume);
player = Some(new_player);
output = Some(sink);
}
Err(error) => {
@@ -177,6 +196,12 @@ fn playback_worker(command_rx: mpsc::Receiver<ClipCommand>, status: SharedClipSt
}
reset(&status);
}
Ok(ClipCommand::SetVolume(level)) => {
volume = level.max(0.0);
if let Some(player) = player.as_ref() {
player.set_volume(volume);
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
Err(mpsc::RecvTimeoutError::Timeout) => {}
}