4 Commits
Author SHA1 Message Date
mollusk c2e27c3367 Merge release 0.5.1: version bump + CHANGELOG
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (push) Has been cancelled
2026-06-27 22:57:03 -04:00
molluskandClaude Opus 4.8 3c678afaf7 chore(release): bump to 0.5.1 + add CHANGELOG
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:57:03 -04:00
mollusk 8cfb5beff9 Merge feat/clip-volume: per-clip + universal volume for inline audio 2026-06-27 22:24:17 -04:00
molluskandClaude Opus 4.8 f21a027e78 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>
2026-06-27 22:24:10 -04:00
6 changed files with 146 additions and 8 deletions
+6
View File
@@ -2,6 +2,11 @@
All notable changes to PeerSpeak are documented here. All notable changes to PeerSpeak are documented here.
## [0.5.1] — 2026-06-27
### Added
- **Volume control for inline chat audio clips.** A master volume slider plus a **Universal volume** toggle now sit in the Chat panel header, and every audio attachment gets its own 🔊 slider on its play row. With Universal volume on (the default), one level applies to all clips and persists across sessions; turn it off to give each clip its own independent level.
## [0.5.0] — 2026-06-27 ## [0.5.0] — 2026-06-27
### Added ### Added
@@ -22,6 +27,7 @@ All notable changes to PeerSpeak are documented here.
- Added Debian/Ubuntu `.deb` packaging (cargo-deb metadata); the official `.deb` is now built on **Debian 12 (bookworm)** for wide compatibility. - Added Debian/Ubuntu `.deb` packaging (cargo-deb metadata); the official `.deb` is now built on **Debian 12 (bookworm)** for wide compatibility.
- Arch `PKGBUILD` clones over anonymous HTTPS. - Arch `PKGBUILD` clones over anonymous HTTPS.
[0.5.1]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.5.1
[0.5.0]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.5.0 [0.5.0]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.5.0
Earlier releases: see git tags `v0.4.0`, `v0.3.0`, `v0.2.0`. Earlier releases: see git tags `v0.4.0`, `v0.3.0`, `v0.2.0`.
Generated
+1 -1
View File
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]] [[package]]
name = "peerspeak" name = "peerspeak"
version = "0.5.0" version = "0.5.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "peerspeak" name = "peerspeak"
version = "0.5.0" version = "0.5.1"
edition = "2024" edition = "2024"
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)" description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
# Application crate, not a crates.io library — refuse `cargo publish` and let # Application crate, not a crates.io library — refuse `cargo publish` and let
+90 -2
View File
@@ -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,
] ]
+29 -4
View File
@@ -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) => {}
} }
+19
View File
@@ -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]