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:
+90
-2
@@ -393,6 +393,15 @@ pub enum AppMessage {
|
|||||||
PauseAudio,
|
PauseAudio,
|
||||||
ResumeAudio,
|
ResumeAudio,
|
||||||
SeekAudio(crate::files::AttachmentId, f32),
|
SeekAudio(crate::files::AttachmentId, f32),
|
||||||
|
/// Adjust the universal inline-clip playback volume (`1.0` = unity) from the
|
||||||
|
/// master slider. Persisted to config; applied live only in universal mode.
|
||||||
|
SetClipVolume(f32),
|
||||||
|
/// Adjust volume from a clip's own row slider. In universal mode this drives
|
||||||
|
/// the shared level; otherwise it sets just that clip's in-memory level.
|
||||||
|
SetClipVolumeFor(crate::files::AttachmentId, f32),
|
||||||
|
/// Toggle whether one universal level governs every clip (checked) or each
|
||||||
|
/// clip keeps its own level (unchecked).
|
||||||
|
ToggleUniversalClipVolume(bool),
|
||||||
/// Redraw cadence while an inline clip is active.
|
/// Redraw cadence while an inline clip is active.
|
||||||
AudioTick,
|
AudioTick,
|
||||||
/// Send the current chat input line (Enter or the Send button).
|
/// Send the current chat input line (Enter or the Send button).
|
||||||
@@ -582,6 +591,10 @@ pub struct AppState {
|
|||||||
/// the call capture/mixer path.
|
/// the call capture/mixer path.
|
||||||
clip_player: ClipPlayer,
|
clip_player: ClipPlayer,
|
||||||
clip_status: SharedClipStatus,
|
clip_status: SharedClipStatus,
|
||||||
|
/// Per-clip playback gain used when universal clip volume is disabled
|
||||||
|
/// (`config.clip_volume_universal == false`). In-memory only; absent clips
|
||||||
|
/// default to unity. Universal mode ignores this and uses `config.clip_volume`.
|
||||||
|
clip_volumes: HashMap<crate::files::AttachmentId, f32>,
|
||||||
/// Last known window size, tracked so divider clamps stay valid on resize.
|
/// Last known window size, tracked so divider clamps stay valid on resize.
|
||||||
/// (The divider positions themselves are persisted in `config`.)
|
/// (The divider positions themselves are persisted in `config`.)
|
||||||
window_size: Size,
|
window_size: Size,
|
||||||
@@ -781,7 +794,7 @@ impl Default for AppState {
|
|||||||
let selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned();
|
let selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned();
|
||||||
|
|
||||||
let background_image = load_background_bytes(&config);
|
let background_image = load_background_bytes(&config);
|
||||||
let (clip_player, clip_status) = ClipPlayer::new();
|
let (clip_player, clip_status) = ClipPlayer::new(config.clip_volume);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
// Pre-fill the nickname with the last one used (or "Peer" by default).
|
// Pre-fill the nickname with the last one used (or "Peer" by default).
|
||||||
@@ -819,6 +832,7 @@ impl Default for AppState {
|
|||||||
invalid_audio: HashSet::new(),
|
invalid_audio: HashSet::new(),
|
||||||
clip_player,
|
clip_player,
|
||||||
clip_status,
|
clip_status,
|
||||||
|
clip_volumes: HashMap::new(),
|
||||||
chat_input: String::new(),
|
chat_input: String::new(),
|
||||||
window_size: Size::new(ww, wh),
|
window_size: Size::new(ww, wh),
|
||||||
layout_picker_open: false,
|
layout_picker_open: false,
|
||||||
@@ -2200,6 +2214,38 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
state.clip_player.seek(seek_target(fraction, total));
|
state.clip_player.seek(seek_target(fraction, total));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppMessage::SetClipVolume(volume) => {
|
||||||
|
// Master slider: always stores the universal level, but only the
|
||||||
|
// active player is nudged when universal mode is actually on.
|
||||||
|
let volume = volume.clamp(0.0, 2.0);
|
||||||
|
state.config.clip_volume = volume;
|
||||||
|
state.config.save();
|
||||||
|
if state.config.clip_volume_universal {
|
||||||
|
state.clip_player.set_volume(volume);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMessage::SetClipVolumeFor(id, volume) => {
|
||||||
|
let volume = volume.clamp(0.0, 2.0);
|
||||||
|
if state.config.clip_volume_universal {
|
||||||
|
state.config.clip_volume = volume;
|
||||||
|
state.config.save();
|
||||||
|
state.clip_player.set_volume(volume);
|
||||||
|
} else {
|
||||||
|
state.clip_volumes.insert(id, volume);
|
||||||
|
// Only the clip the user is dragging should react immediately.
|
||||||
|
if status_snapshot(&state.clip_status).playing_id == Some(id) {
|
||||||
|
state.clip_player.set_volume(volume);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AppMessage::ToggleUniversalClipVolume(on) => {
|
||||||
|
state.config.clip_volume_universal = on;
|
||||||
|
state.config.save();
|
||||||
|
// Reapply the now-effective level to whatever is currently playing.
|
||||||
|
if let Some(id) = status_snapshot(&state.clip_status).playing_id {
|
||||||
|
state.clip_player.set_volume(effective_clip_volume(state, id));
|
||||||
|
}
|
||||||
|
}
|
||||||
AppMessage::AudioTick => {
|
AppMessage::AudioTick => {
|
||||||
let clip = status_snapshot(&state.clip_status);
|
let clip = status_snapshot(&state.clip_status);
|
||||||
if let Some(failure) = clip.failure {
|
if let Some(failure) = clip.failure {
|
||||||
@@ -2512,6 +2558,16 @@ fn find_attachment_source(
|
|||||||
|
|
||||||
/// Validate cached bytes and hand them to the independent clip player. A false
|
/// Validate cached bytes and hand them to the independent clip player. A false
|
||||||
/// filename hint falls back to the generic file chip without reaching rodio.
|
/// filename hint falls back to the generic file chip without reaching rodio.
|
||||||
|
/// Resolve the gain to use for clip `id`: the shared universal level, or the
|
||||||
|
/// clip's own stored level (defaulting to unity) when universal mode is off.
|
||||||
|
fn effective_clip_volume(state: &AppState, id: crate::files::AttachmentId) -> f32 {
|
||||||
|
if state.config.clip_volume_universal {
|
||||||
|
state.config.clip_volume
|
||||||
|
} else {
|
||||||
|
state.clip_volumes.get(&id).copied().unwrap_or(1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn play_ready_audio(state: &mut AppState, key: AttachmentKey) {
|
fn play_ready_audio(state: &mut AppState, key: AttachmentKey) {
|
||||||
let Some(AttachmentState::Ready(data)) = state.attachments.get(&key) else {
|
let Some(AttachmentState::Ready(data)) = state.attachments.get(&key) else {
|
||||||
return;
|
return;
|
||||||
@@ -2521,6 +2577,9 @@ fn play_ready_audio(state: &mut AppState, key: AttachmentKey) {
|
|||||||
let bytes = data.clone();
|
let bytes = data.clone();
|
||||||
state.invalid_audio.remove(&id);
|
state.invalid_audio.remove(&id);
|
||||||
state.clip_player.play(id, bytes);
|
state.clip_player.play(id, bytes);
|
||||||
|
// Apply this clip's effective gain; the command lands after Play so it
|
||||||
|
// takes effect on the freshly connected player.
|
||||||
|
state.clip_player.set_volume(effective_clip_volume(state, id));
|
||||||
} else {
|
} else {
|
||||||
state.invalid_audio.insert(id);
|
state.invalid_audio.insert(id);
|
||||||
state.status_message = "This attachment is not valid supported audio.".to_string();
|
state.status_message = "This attachment is not valid supported audio.".to_string();
|
||||||
@@ -4805,6 +4864,17 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
text(format!("{elapsed} / {duration}"))
|
text(format!("{elapsed} / {duration}"))
|
||||||
.size(11)
|
.size(11)
|
||||||
.color(color_subtext),
|
.color(color_subtext),
|
||||||
|
// Per-clip volume. In universal mode this shows
|
||||||
|
// and drives the shared level; otherwise it is
|
||||||
|
// this clip's own remembered level.
|
||||||
|
text("🔊").size(12).color(color_subtext),
|
||||||
|
slider(
|
||||||
|
0.0..=2.0,
|
||||||
|
effective_clip_volume(state, att.id),
|
||||||
|
move |v| AppMessage::SetClipVolumeFor(att.id, v),
|
||||||
|
)
|
||||||
|
.step(0.01)
|
||||||
|
.width(iced::Length::Fixed(80.0)),
|
||||||
]
|
]
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
.align_y(iced::alignment::Vertical::Center),
|
.align_y(iced::alignment::Vertical::Center),
|
||||||
@@ -4868,8 +4938,26 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
]
|
]
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
.align_y(iced::alignment::Vertical::Center);
|
.align_y(iced::alignment::Vertical::Center);
|
||||||
let chat_inner = column![
|
// Chat header: title on the left, the universal-volume control on the
|
||||||
|
// right. The master slider drives every clip when "Universal" is checked;
|
||||||
|
// when unchecked each clip keeps its own level and this slider is inert.
|
||||||
|
let universal = state.config.clip_volume_universal;
|
||||||
|
let chat_header = row![
|
||||||
text("Chat").size(16).color(color_blue),
|
text("Chat").size(16).color(color_blue),
|
||||||
|
horizontal_space(),
|
||||||
|
checkbox(universal)
|
||||||
|
.label("Universal volume")
|
||||||
|
.text_size(12)
|
||||||
|
.on_toggle(AppMessage::ToggleUniversalClipVolume),
|
||||||
|
text("🔊").size(13).color(color_subtext),
|
||||||
|
slider(0.0..=2.0, state.config.clip_volume, AppMessage::SetClipVolume)
|
||||||
|
.step(0.01)
|
||||||
|
.width(iced::Length::Fixed(110.0)),
|
||||||
|
]
|
||||||
|
.spacing(10)
|
||||||
|
.align_y(iced::alignment::Vertical::Center);
|
||||||
|
let chat_inner = column![
|
||||||
|
chat_header,
|
||||||
chat_scroll,
|
chat_scroll,
|
||||||
chat_input_row,
|
chat_input_row,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ enum ClipCommand {
|
|||||||
Resume,
|
Resume,
|
||||||
Seek(Duration),
|
Seek(Duration),
|
||||||
Stop,
|
Stop,
|
||||||
|
SetVolume(f32),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cheap, `Send` command handle for the dedicated playback thread.
|
/// Cheap, `Send` command handle for the dedicated playback thread.
|
||||||
@@ -51,13 +52,16 @@ pub struct ClipPlayer {
|
|||||||
impl ClipPlayer {
|
impl ClipPlayer {
|
||||||
/// Start the playback worker. The system output device is opened lazily on
|
/// Start the playback worker. The system output device is opened lazily on
|
||||||
/// first Play, so merely launching PeerSpeak never claims another stream.
|
/// 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 (command_tx, command_rx) = mpsc::channel();
|
||||||
let status = Arc::new(Mutex::new(ClipStatus::default()));
|
let status = Arc::new(Mutex::new(ClipStatus::default()));
|
||||||
let worker_status = Arc::clone(&status);
|
let worker_status = Arc::clone(&status);
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("peerspeak-clip-player".to_string())
|
.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");
|
.expect("failed to spawn clip playback thread");
|
||||||
(
|
(
|
||||||
Self {
|
Self {
|
||||||
@@ -94,11 +98,24 @@ impl ClipPlayer {
|
|||||||
pub fn stop(&self) {
|
pub fn stop(&self) {
|
||||||
let _ = self.command_tx.send(ClipCommand::Stop);
|
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 output: Option<MixerDeviceSink> = None;
|
||||||
let mut player: Option<Player> = 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 {
|
loop {
|
||||||
match command_rx.recv_timeout(Duration::from_millis(100)) {
|
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() {
|
if output.is_none() {
|
||||||
match DeviceSinkBuilder::open_default_sink() {
|
match DeviceSinkBuilder::open_default_sink() {
|
||||||
Ok(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);
|
output = Some(sink);
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -177,6 +196,12 @@ fn playback_worker(command_rx: mpsc::Receiver<ClipCommand>, status: SharedClipSt
|
|||||||
}
|
}
|
||||||
reset(&status);
|
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::Disconnected) => break,
|
||||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,14 @@ pub struct AppConfig {
|
|||||||
/// App-internal playback gain applied to the mixed output (1.0 = unity).
|
/// App-internal playback gain applied to the mixed output (1.0 = unity).
|
||||||
#[serde(default = "default_volume")]
|
#[serde(default = "default_volume")]
|
||||||
pub output_volume: f32,
|
pub output_volume: f32,
|
||||||
|
/// Universal playback gain for inline chat audio clips (1.0 = unity). One
|
||||||
|
/// level shared by every uploaded clip so the slider sticks across plays.
|
||||||
|
#[serde(default = "default_volume")]
|
||||||
|
pub clip_volume: f32,
|
||||||
|
/// When true, `clip_volume` governs every clip. When false, each clip keeps
|
||||||
|
/// its own (in-memory) level and the universal slider is inactive.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub clip_volume_universal: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub network_mode: NetworkMode,
|
pub network_mode: NetworkMode,
|
||||||
/// Presence posture for the friends idle listener (W7): invisible / normal /
|
/// Presence posture for the friends idle listener (W7): invisible / normal /
|
||||||
@@ -312,6 +320,8 @@ impl Default for AppConfig {
|
|||||||
noise_gate_threshold: 0.01,
|
noise_gate_threshold: 0.01,
|
||||||
input_volume: 1.0,
|
input_volume: 1.0,
|
||||||
output_volume: 1.0,
|
output_volume: 1.0,
|
||||||
|
clip_volume: 1.0,
|
||||||
|
clip_volume_universal: true,
|
||||||
network_mode: NetworkMode::default(),
|
network_mode: NetworkMode::default(),
|
||||||
presence_mode: crate::presence::PresenceMode::default(),
|
presence_mode: crate::presence::PresenceMode::default(),
|
||||||
echo_cancellation_enabled: false,
|
echo_cancellation_enabled: false,
|
||||||
@@ -657,23 +667,32 @@ mod tests {
|
|||||||
let def = AppConfig::default();
|
let def = AppConfig::default();
|
||||||
assert_eq!(def.input_volume, 1.0);
|
assert_eq!(def.input_volume, 1.0);
|
||||||
assert_eq!(def.output_volume, 1.0);
|
assert_eq!(def.output_volume, 1.0);
|
||||||
|
assert_eq!(def.clip_volume, 1.0);
|
||||||
|
assert!(def.clip_volume_universal);
|
||||||
|
|
||||||
// Missing in JSON → unity (serde default).
|
// Missing in JSON → unity (serde default).
|
||||||
let missing = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
let missing = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
||||||
let cfg_missing: AppConfig = serde_json::from_str(missing).unwrap();
|
let cfg_missing: AppConfig = serde_json::from_str(missing).unwrap();
|
||||||
assert_eq!(cfg_missing.input_volume, 1.0);
|
assert_eq!(cfg_missing.input_volume, 1.0);
|
||||||
assert_eq!(cfg_missing.output_volume, 1.0);
|
assert_eq!(cfg_missing.output_volume, 1.0);
|
||||||
|
assert_eq!(cfg_missing.clip_volume, 1.0);
|
||||||
|
// Configs predating the toggle default to universal mode.
|
||||||
|
assert!(cfg_missing.clip_volume_universal);
|
||||||
|
|
||||||
// Explicit non-unity values are preserved across a round-trip.
|
// Explicit non-unity values are preserved across a round-trip.
|
||||||
let cfg = AppConfig {
|
let cfg = AppConfig {
|
||||||
input_volume: 1.5,
|
input_volume: 1.5,
|
||||||
output_volume: 0.25,
|
output_volume: 0.25,
|
||||||
|
clip_volume: 0.7,
|
||||||
|
clip_volume_universal: false,
|
||||||
..AppConfig::default()
|
..AppConfig::default()
|
||||||
};
|
};
|
||||||
let round_tripped: AppConfig =
|
let round_tripped: AppConfig =
|
||||||
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
||||||
assert_eq!(round_tripped.input_volume, 1.5);
|
assert_eq!(round_tripped.input_volume, 1.5);
|
||||||
assert_eq!(round_tripped.output_volume, 0.25);
|
assert_eq!(round_tripped.output_volume, 0.25);
|
||||||
|
assert_eq!(round_tripped.clip_volume, 0.7);
|
||||||
|
assert!(!round_tripped.clip_volume_universal);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user