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:
@@ -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) => {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user