7 Commits
Author SHA1 Message Date
molluskandClaude Opus 4.8 0588d92537 release: 0.6.6
CI / check (push) Failing after 5m35s
The live-edge catch-up (8c4f4a0, b4a4c00) landed after the v0.6.5 tag, so
the 0.6.5 artifacts do not contain it — the same gap that left the fix out
of v0.6.4. Cut 0.6.6 so the published build actually carries it.

Local-only changes (no wire change; PROTO planes unchanged), so this is a
PATCH bump per VERSIONING.md.

601 lib tests green, clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:57:05 -04:00
molluskandClaude Opus 4.8 b4a4c00711 fix(screenshare): make live-edge catch-up actually recover
CI / check (push) Failing after 2m37s
The first cut used a fixed 1.05x drain, which measurement showed was too
gentle to matter: clearing a 6 s backlog would take two minutes, which a
viewer experiences as still broken.

Two changes, both measured on the netem satellite rig (loopback
impairment, gst -> ffmpeg HTTP relay -> mpv, matching the http:// URL
production actually serves):

1. Proportional drain. Speed now scales with buffer depth,
   1 + 0.05*(cache - 0.5), clamped to 1.15x, keeping the hysteresis band
   so it cannot oscillate. Deep backlogs recover in tens of seconds;
   small excursions still get an inaudible nudge.

2. Bound the byte cache in Low latency. The demuxer cache is a *byte*
   budget, so at a given bitrate it sets the worst-case backlog: 2 MiB
   held ~6 s of a 2.5 Mbps share. Capping Low latency at 1 MiB halved the
   standing buffer, 6.0 s -> 2.8 s, on its own. Smooth keeps the user's
   value, since a deep buffer is that posture's whole point.

Measured effect with both: playback consumes 11.6% faster than realtime
while behind (ratio 1.1157 vs 0.9988 with catch-up off), i.e. ~9 s of
backlog cleared in 80 s where before it recovered nothing at all and the
viewer stayed behind for the rest of the call.

Rig caveat: its upstream queues hold an unbounded backlog, so the cache
never drops back through the low mark and the return-to-1x transition is
only covered by unit tests, not the rig.

601 lib tests green, clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:47:32 -04:00
molluskandClaude Opus 4.8 8c4f4a0b8b feat(screenshare): drain a lagging viewer back to the live edge
CI / check (push) Failing after 2m12s
On a lossy link the reliable PixelPass transport turns every loss burst
into buffered latency that nothing trims back, so the viewer settles
seconds behind the host and stays there. Measured on a tc netem satellite
simulation: a viewer parks at a ~6 s standing buffer indefinitely.

--untimed (0.6.5) does NOT fix this and measured marginally worse (+1.38 s
vs +1.24 s): it only unpaces presentation, while audio still drains at 1x
the DAC rate, so an accumulated backlog never shrinks. Drop it.

Instead give mpv a JSON IPC socket in the Low latency posture and drive
playback slightly fast while the buffer is deep, returning to 1x once it
drains. Pitch correction keeps it inaudible and A/V sync is preserved,
because audio and video speed up together.

The control law and IPC message handling are pure functions with unit
tests; the only I/O is livesync::drive, which ends by itself when the
player exits. Smooth is deliberately excluded — its ~2 s readahead is the
point of that posture, and catch-up would fight it every poll.

Known limitation: 1.05x needs ~120 s to clear a 6 s backlog, so recovery
is slower than ideal. Tuning (a proportional law, or a seek-to-live for
large backlogs) is the follow-up.

598 lib tests green (+11), clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:36:06 -04:00
molluskandClaude Opus 4.8 76c4f68bb3 release: 0.6.5
CI / check (push) Successful in 2m54s
Local-only changes since 0.6.4 (no wire change; PROTO planes unchanged),
so this is a PATCH bump per VERSIONING.md.

Ships the low-latency screen-share live-edge fix (4bfc184), which landed
three hours after the v0.6.4 tag and was therefore never released.

Also adds the missing CHANGELOG entry for the participant "Advanced audio"
foldout (26d6600), which shipped without one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 14:45:16 -04:00
mollusk c427231858 feat(notifications): add chat and contact sounds
CI / check (push) Successful in 2m39s
2026-07-19 02:02:03 -04:00
mollusk 4bfc18463b fix(screenshare): keep low-latency playback live 2026-07-18 22:22:24 -04:00
mollusk 26d66007de ui: fold participant audio controls 2026-07-18 20:14:09 -04:00
14 changed files with 952 additions and 118 deletions
+47
View File
@@ -4,6 +4,53 @@ All notable changes to PeerSpeak are documented here.
## [Unreleased]
## [0.6.6] — 2026-07-19
### Fixed
- **A screen share that falls behind now catches back up.** On a lossy
connection (satellite links are the worst case) the share could settle several
seconds behind the host and simply stay there for the rest of the call. The
viewer now notices a deep buffer and plays imperceptibly fast until it is back
at the live edge — the audio stays in tune and in sync while it does. This
replaces the previous attempt at the problem, which measurement showed did not
help. Applies to the Low latency setting; Smooth intentionally keeps its
larger buffer.
### Changed
- **Low latency now keeps a tighter viewer buffer.** The screen-share cache
setting is a size in megabytes, which at a given bitrate quietly decides how
many *seconds* behind a viewer can drift — a 2 MB buffer turned out to hold
about six seconds of a typical share. Low latency now caps that buffer at 1 MB
regardless of the setting, which halved how far behind a share fell on a bad
connection before anything else kicked in. Smooth still honors the value you
choose, since a deep buffer is the point of that mode.
## [0.6.5] — 2026-07-19
### Added
- **Chat message sounds.** Successful outgoing messages and admitted incoming
messages now have distinct notification chimes, each with its own enable
toggle and optional custom WAV path in Notifications settings.
- **Contact presence sounds.** The home-screen contacts list now announces a
contact becoming online or offline. Initial online contacts are announced;
initial offline results stay silent. Both events have independent toggles and
optional custom WAV paths.
- **Notification sound browser.** Every notification event now has a native
Browse button for choosing a custom WAV instead of typing its path manually.
### Changed
- **Tidier per-participant audio controls.** The equalizer bands and noise gate
for each participant now live behind an **"Advanced audio"** foldout instead
of being expanded all the time, so a call with several people no longer fills
the panel with sliders. The controls themselves are unchanged.
### Fixed
- **Low-latency screen sharing stays near the live edge again.** mpv's
timestamp pacing could let stale frames accumulate across the reliable
PixelPass transport until a share was 710 seconds behind. Low-latency mode
now presents decoded frames immediately; Smooth mode retains timestamp pacing
when keeping shared-video audio and video synchronized matters more.
## [0.6.4] — 2026-07-18
### Added
Generated
+1 -1
View File
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "peerspeak"
version = "0.6.4"
version = "0.6.6"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "peerspeak"
version = "0.6.4"
version = "0.6.6"
edition = "2024"
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
license = "MIT"
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8
View File
@@ -76,6 +76,14 @@ CHIMES = {
"mic-toggle.wav": [(E5, 0.08)],
# Reconnect gave up: disappointing low two-note fall.
"reconnect-failed.wav": [(C5, 0.15), (349.23, 0.30)],
# Our chat message entered the room: a tiny bright acknowledgement.
"chat-sent.wav": [(1046.50, 0.06)],
# A peer message arrived: a soft two-note lift, distinct but unobtrusive.
"chat-received.wav": [(E5, 0.07), (G5, 0.11)],
# A saved contact came online: a light, higher two-note arrival.
"contact-online.wav": [(E5, 0.09), (880.00, 0.18)],
# A saved contact went offline: the same tonal family falling away.
"contact-offline.wav": [(E5, 0.09), (440.00, 0.18)],
}
+1 -1
View File
@@ -12,7 +12,7 @@
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
#define MyAppName "PeerSpeak"
#define MyAppVersion "0.6.4"
#define MyAppVersion "0.6.6"
#define MyAppPublisher "mollusk"
#define MyAppExeName "peerspeak.exe"
+363 -92
View File
@@ -700,6 +700,8 @@ pub enum AppMessage {
PeerPanChanged(EndpointId, f32),
PeerGateChanged(EndpointId, f32),
PeerEqChanged(EndpointId, EqBand, f32),
/// Show or hide the secondary audio controls on one participant card.
TogglePeerAdvancedAudio(EndpointId),
/// Toggle local mute of a peer (silence them just for us).
TogglePeerMute(EndpointId),
InputDeviceSelected(AudioDevice),
@@ -758,6 +760,10 @@ pub enum AppMessage {
ToggleNotifications(bool),
ToggleEchoCancellation(bool),
CustomSoundPathChanged(Sound, String),
/// Open a native WAV picker for one notification event.
BrowseCustomSound(Sound),
/// Result of the notification WAV picker (`None` = cancelled).
CustomSoundFilePicked(Sound, Option<std::path::PathBuf>),
/// Toggle the per-sound enable flag for a single chime (W6).
ToggleSoundEnabled(Sound, bool),
/// Open / cancel the "Regenerate identity?" confirm modal (W7).
@@ -1047,6 +1053,9 @@ pub struct AppState {
conn_stats: HashMap<EndpointId, crate::core::connstats::PeerConnInfo>,
/// Peers we've locally muted (their audio isn't mixed into our output).
locally_muted: HashSet<EndpointId>,
/// Participant cards whose volume/pan/gate/EQ foldout is open. Session-only:
/// a fresh room starts compact, regardless of the previous room's UI state.
peer_audio_expanded: HashSet<EndpointId>,
/// When we joined the current room, for the in-room call-duration timer.
call_started: Option<std::time::Instant>,
/// Whether a local call recording is in progress (confirmed by the core).
@@ -1240,6 +1249,7 @@ impl AppState {
self.audio_levels.clear();
self.conn_stats.clear();
self.locally_muted.clear();
self.peer_audio_expanded.clear();
self.chat_messages.clear();
// Unsent queue + retry bytes die with the room's transcript. The pacer
// and id counter deliberately survive: receivers' per-author buckets
@@ -1333,16 +1343,20 @@ impl AppState {
/// so Retry can re-dispatch, unless the entry is already gone (history
/// eviction / room reset), in which case the payload is dropped so its map
/// can't leak. Either way an id with no matching entry is a harmless no-op.
fn apply_send_result(&mut self, local_id: u64, error: Option<String>) {
/// Returns `true` only when a successful result matched a live local echo,
/// which is the boundary used for the outgoing-message notification.
fn apply_send_result(&mut self, local_id: u64, error: Option<String>) -> bool {
match error {
None => {
self.set_send_status(local_id, SendStatus::Broadcast);
let matched = self.set_send_status(local_id, SendStatus::Broadcast);
self.send_payloads.remove(&local_id);
matched
}
Some(e) => {
if !self.set_send_status(local_id, SendStatus::Failed(e)) {
self.send_payloads.remove(&local_id);
}
false
}
}
}
@@ -1373,9 +1387,30 @@ impl AppState {
Sound::SelfLeave => &self.config.custom_sound_self_leave,
Sound::MicToggle => &self.config.custom_sound_mic_toggle,
Sound::ReconnectFailed => &self.config.custom_sound_reconnect_failed,
Sound::ChatSent => &self.config.custom_sound_chat_sent,
Sound::ChatReceived => &self.config.custom_sound_chat_received,
Sound::ContactOnline => &self.config.custom_sound_contact_online,
Sound::ContactOffline => &self.config.custom_sound_contact_offline,
};
opt.as_deref().unwrap_or("")
}
fn set_custom_sound_path(&mut self, sound: Sound, path: Option<String>) {
match sound {
Sound::SelfJoin => self.config.custom_sound_self_join = path,
Sound::PeerJoin => self.config.custom_sound_peer_join = path,
Sound::PeerLeave => self.config.custom_sound_peer_leave = path,
Sound::ReconnectAttempt => self.config.custom_sound_reconnect_attempt = path,
Sound::Reconnected => self.config.custom_sound_reconnected = path,
Sound::SelfLeave => self.config.custom_sound_self_leave = path,
Sound::MicToggle => self.config.custom_sound_mic_toggle = path,
Sound::ReconnectFailed => self.config.custom_sound_reconnect_failed = path,
Sound::ChatSent => self.config.custom_sound_chat_sent = path,
Sound::ChatReceived => self.config.custom_sound_chat_received = path,
Sound::ContactOnline => self.config.custom_sound_contact_online = path,
Sound::ContactOffline => self.config.custom_sound_contact_offline = path,
}
}
}
impl Default for AppState {
@@ -1502,6 +1537,7 @@ impl Default for AppState {
audio_levels: HashMap::new(),
conn_stats: HashMap::new(),
locally_muted: HashSet::new(),
peer_audio_expanded: HashSet::new(),
call_started: None,
recording: false,
recording_started: None,
@@ -1864,6 +1900,47 @@ fn reconnected_chime(
was_reconnect.then_some(Sound::Reconnected)
}
/// Return the landing-page contact chime for one definitive presence update.
/// An initial online result is an arrival (so contacts already online at app
/// startup are announced), while an initial offline result is silent. Online
/// includes both plain `Online` and `InRoom`; moving between those two states is
/// not a connection transition. Updates continue to populate the presence map
/// off-home, but notification sounds are intentionally limited to the home page.
fn friend_presence_notification(
screen: Screen,
previous: Option<&crate::presence::FriendPresence>,
next: &crate::presence::FriendPresence,
) -> Option<Sound> {
if screen != Screen::Home {
return None;
}
let online = |presence: &crate::presence::FriendPresence| {
matches!(
presence,
crate::presence::FriendPresence::Online
| crate::presence::FriendPresence::InRoom { .. }
)
};
match (previous.map(online), online(next)) {
(None | Some(false), true) => Some(Sound::ContactOnline),
(Some(true), false) => Some(Sound::ContactOffline),
_ => None,
}
}
/// Convert a native picker result into the persisted notification path. The
/// dialog filter is advisory on some desktops, so enforce WAV here as well.
/// `None` (cancel) and a non-WAV selection leave the existing setting untouched.
fn selected_wav_path(picked: Option<std::path::PathBuf>) -> Option<String> {
let path = picked?;
let is_wav = path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("wav"));
is_wav.then(|| path.to_string_lossy().into_owned())
}
fn in_call(state: &AppState) -> bool {
!state.ticket.is_empty()
}
@@ -2217,6 +2294,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.peers.remove(&id);
state.audio_levels.remove(&id);
state.locally_muted.remove(&id);
state.peer_audio_expanded.remove(&id);
state.connecting.remove(&id);
state.ever_connected.remove(&id);
if state.music_listening_to == Some(id) {
@@ -2238,6 +2316,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.peers.remove(&id);
state.audio_levels.remove(&id);
state.locally_muted.remove(&id);
state.peer_audio_expanded.remove(&id);
state.connecting.remove(&id);
state.ever_connected.remove(&id);
notify::play(
@@ -2301,7 +2380,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
// failure it's retained for Retry — unless the entry is gone
// (history eviction / room reset), in which case drop it so
// the payload map can't leak.
state.apply_send_result(local_id, error);
if state.apply_send_result(local_id, error) {
notify::play(
Sound::ChatSent,
state.config.custom_sound_chat_sent.as_deref(),
);
}
}
UiEvent::ChatMessage {
from,
@@ -2334,6 +2418,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
local_send: None,
},
);
notify::play(
Sound::ChatReceived,
state.config.custom_sound_chat_received.as_deref(),
);
}
}
UiEvent::AttachmentReady { from, id, data } => {
@@ -2503,7 +2591,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.friends_read_only = read_only;
}
UiEvent::FriendPresence { id, presence } => {
let sound = friend_presence_notification(
state.current_screen,
state.friend_presence.get(&id),
&presence,
);
state.friend_presence.insert(id, presence);
if let Some(sound) = sound {
notify::play(sound, Some(state.custom_sound_path(sound)));
}
}
UiEvent::FriendsRescanned => {
// The manual pass finished. Stamp the time for the live "scanned
@@ -2588,6 +2684,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
let settings = set_peer_eq_config(&mut state.config, id, band, gain_db);
let _ = state.controller.send(CoreCommand::SetPeerEq(id, settings));
}
AppMessage::TogglePeerAdvancedAudio(id) => {
if !state.peer_audio_expanded.remove(&id) && state.peers.contains_key(&id) {
state.peer_audio_expanded.insert(id);
}
}
AppMessage::TogglePeerMute(id) => {
let now_muted = if state.locally_muted.contains(&id) {
state.locally_muted.remove(&id);
@@ -2873,15 +2974,36 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
} else {
Some(path)
};
match sound {
Sound::SelfJoin => state.config.custom_sound_self_join = path_opt,
Sound::PeerJoin => state.config.custom_sound_peer_join = path_opt,
Sound::PeerLeave => state.config.custom_sound_peer_leave = path_opt,
Sound::ReconnectAttempt => state.config.custom_sound_reconnect_attempt = path_opt,
Sound::Reconnected => state.config.custom_sound_reconnected = path_opt,
Sound::SelfLeave => state.config.custom_sound_self_leave = path_opt,
Sound::MicToggle => state.config.custom_sound_mic_toggle = path_opt,
Sound::ReconnectFailed => state.config.custom_sound_reconnect_failed = path_opt,
state.set_custom_sound_path(sound, path_opt);
}
AppMessage::BrowseCustomSound(sound) => {
let initial_dir = {
let current = state.custom_sound_path(sound);
(!current.trim().is_empty())
.then(|| notify::expand_tilde(current))
.and_then(|path| path.parent().map(std::path::Path::to_path_buf))
.filter(|path| path.is_dir())
};
return Task::perform(
async move {
let mut dialog = rfd::AsyncFileDialog::new()
.add_filter("WAV audio", &["wav"])
.set_title("Choose a notification sound");
if let Some(dir) = initial_dir {
dialog = dialog.set_directory(dir);
}
dialog
.pick_file()
.await
.map(|handle| handle.path().to_path_buf())
},
move |picked| AppMessage::CustomSoundFilePicked(sound, picked),
);
}
AppMessage::CustomSoundFilePicked(sound, picked) => {
if let Some(path) = selected_wav_path(picked) {
state.set_custom_sound_path(sound, Some(path));
state.config.save();
}
}
AppMessage::ToggleSoundEnabled(sound, enabled) => {
@@ -5268,10 +5390,19 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
]
.spacing(6)
.align_y(iced::alignment::Vertical::Center),
context_input("Default (embedded)...", path)
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
.style(t_style)
.padding(8)
row![
context_input("Default (embedded)...", path)
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
.style(t_style)
.padding(8)
.width(iced::Length::Fill),
button(text("Browse…").size(11))
.on_press(AppMessage::BrowseCustomSound(sound))
.style(b_style(color_surface, color_blue, color_text, 5.0))
.padding([8, 10]),
]
.spacing(6)
.width(iced::Length::Fill)
]
.spacing(4)
.width(iced::Length::Fill)
@@ -6021,6 +6152,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
path_field("Mic Toggle", Sound::MicToggle),
path_field("Reconnect Failed", Sound::ReconnectFailed),
].spacing(20).width(iced::Length::Fill),
row![
path_field("Chat Sent", Sound::ChatSent),
path_field("Chat Received", Sound::ChatReceived),
].spacing(20).width(iced::Length::Fill),
row![
path_field("Contact Online", Sound::ContactOnline),
path_field("Contact Offline", Sound::ContactOffline),
].spacing(20).width(iced::Length::Fill),
].spacing(8).width(iced::Length::Fill),
]
.spacing(10)
@@ -6748,15 +6887,48 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
]
.spacing(8);
// Peer volume slider
let current_vol = state
.config
.peer_volume
.get(&peer_id.to_string())
.copied()
.unwrap_or(1.0);
let advanced_audio_open = state.peer_audio_expanded.contains(peer_id);
let foldout_symbol = if advanced_audio_open { "" } else { "" };
card_content = card_content.push(
row![
button(
row![
text(foldout_symbol).size(13).color(color_subtext),
text("Advanced audio").size(12).color(color_text),
]
.spacing(6)
.align_y(iced::alignment::Vertical::Center),
)
.on_press(AppMessage::TogglePeerAdvancedAudio(peer_id_clone))
.style(b_style(color_surface, color_blue, color_text, 6.0))
.padding([6, 8])
.width(iced::Length::Fill),
);
if advanced_audio_open {
let peer_key = peer_id.to_string();
let current_vol = state
.config
.peer_volume
.get(&peer_key)
.copied()
.unwrap_or(1.0);
let current_pan = state.config.peer_pan.get(&peer_key).copied().unwrap_or(0.0);
let current_gate = state
.config
.peer_gate
.get(&peer_key)
.copied()
.unwrap_or(0.0);
let gate_label = if current_gate <= 0.0 {
"Off".to_string()
} else {
format!(
"{:.0}%",
(current_gate / METER_MAX * 100.0).clamp(0.0, 100.0)
)
};
let volume_row = row![
text("Vol:").size(12).color(color_subtext),
slider(0.0..=2.0, current_vol, move |v| {
AppMessage::PeerVolumeChanged(peer_id_clone, v)
@@ -6765,47 +6937,24 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.on_release(AppMessage::PersistConfig)
]
.spacing(8)
.align_y(iced::alignment::Vertical::Center),
);
.align_y(iced::alignment::Vertical::Center);
let peer_key = peer_id.to_string();
let current_pan = state.config.peer_pan.get(&peer_key).copied().unwrap_or(0.0);
card_content = card_content.push(
row![
let pan_row = row![
text("Pan:").size(12).color(color_subtext),
container(text(pan_label(current_pan)).size(11).color(color_subtext))
.width(iced::Length::Fixed(58.0)),
slider(
-1.0..=1.0,
current_pan,
move |v| AppMessage::PeerPanChanged(peer_id_clone, v)
)
slider(-1.0..=1.0, current_pan, move |v| {
AppMessage::PeerPanChanged(peer_id_clone, v)
})
.step(0.05)
.on_release(AppMessage::PersistConfig),
]
.spacing(8)
.align_y(iced::alignment::Vertical::Center),
);
.align_y(iced::alignment::Vertical::Center);
// Peer noise gate: suppress this peer's background noise on our end.
// Threshold is normalized RMS on the same 0..METER_MAX scale as the
// mic gate; 0 = off.
let current_gate = state
.config
.peer_gate
.get(&peer_key)
.copied()
.unwrap_or(0.0);
let gate_label = if current_gate <= 0.0 {
"Off".to_string()
} else {
format!(
"{:.0}%",
(current_gate / METER_MAX * 100.0).clamp(0.0, 100.0)
)
};
card_content = card_content.push(
row![
// Peer noise gate: suppress this peer's background noise on our
// end. Threshold is on the mic meter's 0..METER_MAX scale; 0 = off.
let gate_row = row![
text("Gate:").size(12).color(color_subtext),
container(text(gate_label).size(11).color(color_subtext))
.width(iced::Length::Fixed(58.0)),
@@ -6816,38 +6965,45 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.on_release(AppMessage::PersistConfig),
]
.spacing(8)
.align_y(iced::alignment::Vertical::Center),
);
.align_y(iced::alignment::Vertical::Center);
let eq = peer_eq_settings(&state.config, peer_id);
let eq_row =
|label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> {
row![
container(
text(format!("{label} {value:+.1} dB"))
.size(11)
.color(color_subtext)
)
.width(iced::Length::Fixed(86.0)),
slider(EQ_GAIN_DB_MIN..=EQ_GAIN_DB_MAX, value, move |v| {
AppMessage::PeerEqChanged(peer_id_clone, band, v)
})
.step(0.5)
.on_release(AppMessage::PersistConfig),
]
.spacing(8)
.align_y(iced::alignment::Vertical::Center)
.into()
};
card_content = card_content.push(
column![
let eq = peer_eq_settings(&state.config, peer_id);
let eq_row =
|label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> {
row![
container(
text(format!("{label} {value:+.1} dB"))
.size(11)
.color(color_subtext)
)
.width(iced::Length::Fixed(86.0)),
slider(EQ_GAIN_DB_MIN..=EQ_GAIN_DB_MAX, value, move |v| {
AppMessage::PeerEqChanged(peer_id_clone, band, v)
})
.step(0.5)
.on_release(AppMessage::PersistConfig),
]
.spacing(8)
.align_y(iced::alignment::Vertical::Center)
.into()
};
let advanced_audio = column![
volume_row,
pan_row,
gate_row,
text("EQ").size(11).color(color_subtext),
eq_row("Low", EqBand::Low, eq.low_gain_db),
eq_row("Mid", EqBand::Mid, eq.mid_gain_db),
eq_row("High", EqBand::High, eq.high_gain_db),
]
.spacing(4),
);
.spacing(6);
card_content = card_content.push(
container(advanced_audio)
.style(c_style(color_crust, color_surface, 6.0))
.padding(10)
.width(iced::Length::Fill),
);
}
let card = container(card_content)
.style(c_style(
@@ -9513,12 +9669,12 @@ mod tests {
use super::sendqueue::{self, LocalSend, SendStatus};
use super::{
AppConfig, AppMessage, AppState, AttachmentCache, AttachmentState,
CLOCK_SKEW_WARNING_VISIBLE_SECS, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX,
CLOCK_SKEW_WARNING_VISIBLE_SECS, ChatEntry, ClockSkewBanner, GateMeter, METER_MAX, Screen,
ScreenBounds, UiEvent, attachment_default_name, clamp_window_position,
clear_expired_clock_skew_warning, format_clock_skew_duration, format_duration,
format_relative_ago, initial_window_position, now_playing_label, reconnect_attempt_chime,
reconnected_chime, set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning,
update,
format_relative_ago, friend_presence_notification, initial_window_position,
now_playing_label, reconnect_attempt_chime, reconnected_chime, selected_wav_path,
set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update,
};
use iroh::SecretKey;
use std::collections::VecDeque;
@@ -9776,6 +9932,7 @@ mod tests {
);
state.audio_levels.insert(peer, 0.5);
state.locally_muted.insert(peer);
state.peer_audio_expanded.insert(peer);
state.chat_messages.push(ChatEntry {
name: "Peer".to_string(),
text: "old room".to_string(),
@@ -9837,6 +9994,7 @@ mod tests {
assert!(state.peers.is_empty());
assert!(state.audio_levels.is_empty());
assert!(state.locally_muted.is_empty());
assert!(state.peer_audio_expanded.is_empty());
assert!(state.chat_messages.is_empty());
assert!(state.chat_input.is_empty());
assert!(state.attachments.len() == 0);
@@ -9886,6 +10044,34 @@ mod tests {
panic!("clip player did not stop during room reset");
}
#[test]
fn peer_advanced_audio_toggle_is_per_peer_and_rejects_stale_ids() {
let mut state = AppState::default();
let peer = SecretKey::generate().public();
state.peers.insert(
peer,
crate::network::PeerState {
name: "Peer".to_string(),
is_muted: false,
addr: iroh::EndpointAddr::from(peer),
sharing: None,
avatar: crate::avatar::Avatar::default(),
game: None,
music: None,
},
);
let _ = update(&mut state, AppMessage::TogglePeerAdvancedAudio(peer));
assert!(state.peer_audio_expanded.contains(&peer));
let _ = update(&mut state, AppMessage::TogglePeerAdvancedAudio(peer));
assert!(!state.peer_audio_expanded.contains(&peer));
let stale = SecretKey::generate().public();
let _ = update(&mut state, AppMessage::TogglePeerAdvancedAudio(stale));
assert!(!state.peer_audio_expanded.contains(&stale));
}
#[test]
fn clock_skew_warning_shows_dismisses_and_expires() {
let mut state = AppState::default();
@@ -10579,6 +10765,91 @@ mod tests {
const W: f32 = 200.0;
#[test]
fn initial_contact_presence_announces_only_online() {
use crate::presence::FriendPresence;
assert_eq!(
friend_presence_notification(Screen::Home, None, &FriendPresence::Online),
Some(Sound::ContactOnline)
);
assert_eq!(
friend_presence_notification(Screen::Home, None, &FriendPresence::Offline),
None
);
}
#[test]
fn contact_presence_chimes_only_on_online_boundary() {
use crate::presence::FriendPresence;
let in_room = FriendPresence::InRoom {
name: "Game night".to_string(),
ticket: "ticket".to_string(),
};
assert_eq!(
friend_presence_notification(Screen::Home, Some(&FriendPresence::Offline), &in_room,),
Some(Sound::ContactOnline)
);
assert_eq!(
friend_presence_notification(
Screen::Home,
Some(&FriendPresence::Online),
&FriendPresence::Offline,
),
Some(Sound::ContactOffline)
);
assert_eq!(
friend_presence_notification(Screen::Home, Some(&FriendPresence::Online), &in_room,),
None
);
assert_eq!(
friend_presence_notification(
Screen::Home,
Some(&FriendPresence::Offline),
&FriendPresence::Offline,
),
None
);
}
#[test]
fn contact_presence_is_silent_away_from_landing_page() {
use crate::presence::FriendPresence;
assert_eq!(
friend_presence_notification(Screen::Room, None, &FriendPresence::Online),
None
);
assert_eq!(
friend_presence_notification(
Screen::Settings,
Some(&FriendPresence::Online),
&FriendPresence::Offline,
),
None
);
}
#[test]
fn selected_notification_sound_accepts_wav_and_preserves_cancel() {
use std::path::PathBuf;
assert_eq!(selected_wav_path(None), None);
assert_eq!(
selected_wav_path(Some(PathBuf::from("/tmp/notify.mp3"))),
None
);
assert_eq!(
selected_wav_path(Some(PathBuf::from("/tmp/notify.wav"))),
Some("/tmp/notify.wav".to_string())
);
assert_eq!(
selected_wav_path(Some(PathBuf::from("/tmp/notify.WAV"))),
Some("/tmp/notify.WAV".to_string())
);
}
#[test]
fn gate_drag_maps_left_edge_to_zero() {
assert_eq!(GateMeter::x_to_threshold(0.0, W), 0.0);
@@ -10862,7 +11133,7 @@ mod tests {
// Empty queue + a fresh full pacer → dispatched immediately.
assert_eq!(status_of(&state, id), Some(SendStatus::Pending));
assert!(state.send_payloads.contains_key(&id));
state.apply_send_result(id, None);
assert!(state.apply_send_result(id, None));
assert_eq!(status_of(&state, id), Some(SendStatus::Broadcast));
// A completed send releases its retry payload.
assert!(!state.send_payloads.contains_key(&id));
@@ -10873,7 +11144,7 @@ mod tests {
let mut state = AppState::default();
let id = push_own(&mut state, "yo");
state.submit_send(id, PendingSend::Text("yo".to_string()));
state.apply_send_result(id, Some("not in a room".to_string()));
assert!(!state.apply_send_result(id, Some("not in a room".to_string())));
assert_eq!(
status_of(&state, id),
Some(SendStatus::Failed("not in a room".to_string()))
@@ -10889,11 +11160,11 @@ mod tests {
state.submit_send(a, PendingSend::Text("a".to_string()));
let b = push_own(&mut state, "b");
state.submit_send(b, PendingSend::Text("b".to_string()));
state.apply_send_result(a, None);
assert!(state.apply_send_result(a, None));
assert_eq!(status_of(&state, a), Some(SendStatus::Broadcast));
assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
// A result for an id with no matching entry is a harmless no-op.
state.apply_send_result(9999, None);
assert!(!state.apply_send_result(9999, None));
assert_eq!(status_of(&state, b), Some(SendStatus::Pending));
}
@@ -10907,7 +11178,7 @@ mod tests {
state
.chat_messages
.retain(|m| m.local_send.as_ref().map(|s| s.id) != Some(id));
state.apply_send_result(id, Some("dead".to_string()));
assert!(!state.apply_send_result(id, Some("dead".to_string())));
// No entry to mark → the payload must not leak.
assert!(!state.send_payloads.contains_key(&id));
}
@@ -10922,7 +11193,7 @@ mod tests {
assert!(state.send_queue.is_empty());
assert!(state.send_payloads.is_empty());
// A late result for the pre-reset send touches nothing and adds no entry.
state.apply_send_result(id, None);
assert!(!state.apply_send_result(id, None));
assert!(state.chat_messages.is_empty());
assert!(state.send_payloads.is_empty());
}
+36
View File
@@ -478,6 +478,14 @@ pub struct AppConfig {
pub custom_sound_mic_toggle: Option<String>,
#[serde(default)]
pub custom_sound_reconnect_failed: Option<String>,
#[serde(default)]
pub custom_sound_chat_sent: Option<String>,
#[serde(default)]
pub custom_sound_chat_received: Option<String>,
#[serde(default)]
pub custom_sound_contact_online: Option<String>,
#[serde(default)]
pub custom_sound_contact_offline: Option<String>,
/// Per-sound enable flags (W6). The master `notifications_enabled` toggle
/// gates ALL chimes; these let the user silence individual events while the
/// master stays on. A chime plays only if the master AND its flag are true.
@@ -498,6 +506,14 @@ pub struct AppConfig {
pub sound_mic_toggle_enabled: bool,
#[serde(default = "default_true")]
pub sound_reconnect_failed_enabled: bool,
#[serde(default = "default_true")]
pub sound_chat_sent_enabled: bool,
#[serde(default = "default_true")]
pub sound_chat_received_enabled: bool,
#[serde(default = "default_true")]
pub sound_contact_online_enabled: bool,
#[serde(default = "default_true")]
pub sound_contact_offline_enabled: bool,
/// Optional override for the `pixelpass` binary location (screen share).
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
#[serde(default)]
@@ -601,6 +617,10 @@ impl Default for AppConfig {
custom_sound_self_leave: None,
custom_sound_mic_toggle: None,
custom_sound_reconnect_failed: None,
custom_sound_chat_sent: None,
custom_sound_chat_received: None,
custom_sound_contact_online: None,
custom_sound_contact_offline: None,
sound_self_join_enabled: true,
sound_peer_join_enabled: true,
sound_peer_leave_enabled: true,
@@ -609,6 +629,10 @@ impl Default for AppConfig {
sound_self_leave_enabled: true,
sound_mic_toggle_enabled: true,
sound_reconnect_failed_enabled: true,
sound_chat_sent_enabled: true,
sound_chat_received_enabled: true,
sound_contact_online_enabled: true,
sound_contact_offline_enabled: true,
pixelpass_path: None,
screen_share: ScreenShareSettings::default(),
recents: Vec::new(),
@@ -639,6 +663,10 @@ impl AppConfig {
Sound::SelfLeave => self.sound_self_leave_enabled,
Sound::MicToggle => self.sound_mic_toggle_enabled,
Sound::ReconnectFailed => self.sound_reconnect_failed_enabled,
Sound::ChatSent => self.sound_chat_sent_enabled,
Sound::ChatReceived => self.sound_chat_received_enabled,
Sound::ContactOnline => self.sound_contact_online_enabled,
Sound::ContactOffline => self.sound_contact_offline_enabled,
}
}
@@ -653,6 +681,10 @@ impl AppConfig {
Sound::SelfLeave => self.sound_self_leave_enabled = enabled,
Sound::MicToggle => self.sound_mic_toggle_enabled = enabled,
Sound::ReconnectFailed => self.sound_reconnect_failed_enabled = enabled,
Sound::ChatSent => self.sound_chat_sent_enabled = enabled,
Sound::ChatReceived => self.sound_chat_received_enabled = enabled,
Sound::ContactOnline => self.sound_contact_online_enabled = enabled,
Sound::ContactOffline => self.sound_contact_offline_enabled = enabled,
}
}
@@ -940,6 +972,10 @@ mod tests {
assert!(deserialized.custom_sound_self_leave.is_none());
assert!(deserialized.custom_sound_mic_toggle.is_none());
assert!(deserialized.custom_sound_reconnect_failed.is_none());
assert!(deserialized.custom_sound_chat_sent.is_none());
assert!(deserialized.custom_sound_chat_received.is_none());
assert!(deserialized.custom_sound_contact_online.is_none());
assert!(deserialized.custom_sound_contact_offline.is_none());
assert_eq!(deserialized.screen_share, ScreenShareSettings::default());
assert_eq!(deserialized.screen_share.quality, ShareQuality::Auto);
assert_eq!(deserialized.screen_share.player, SharePlayer::Mpv);
+25 -1
View File
@@ -80,6 +80,14 @@ pub enum Sound {
MicToggle,
/// Reconnect failed / peer evicted.
ReconnectFailed,
/// One of our chat messages was broadcast to the room.
ChatSent,
/// A chat message from another participant was admitted.
ChatReceived,
/// A saved contact was detected online on the home screen.
ContactOnline,
/// A saved contact previously seen online went offline on the home screen.
ContactOffline,
}
impl Sound {
@@ -93,10 +101,14 @@ impl Sound {
Sound::SelfLeave,
Sound::MicToggle,
Sound::ReconnectFailed,
Sound::ChatSent,
Sound::ChatReceived,
Sound::ContactOnline,
Sound::ContactOffline,
];
/// Number of distinct notification events.
pub const COUNT: usize = 8;
pub const COUNT: usize = 12;
/// Stable 0-based index into the per-sound flag array. Must match `ALL`.
fn index(self) -> usize {
@@ -109,6 +121,10 @@ impl Sound {
Sound::SelfLeave => 5,
Sound::MicToggle => 6,
Sound::ReconnectFailed => 7,
Sound::ChatSent => 8,
Sound::ChatReceived => 9,
Sound::ContactOnline => 10,
Sound::ContactOffline => 11,
}
}
@@ -123,6 +139,10 @@ impl Sound {
Sound::SelfLeave => include_bytes!("../assets/sounds/self-leave.wav"),
Sound::MicToggle => include_bytes!("../assets/sounds/mic-toggle.wav"),
Sound::ReconnectFailed => include_bytes!("../assets/sounds/reconnect-failed.wav"),
Sound::ChatSent => include_bytes!("../assets/sounds/chat-sent.wav"),
Sound::ChatReceived => include_bytes!("../assets/sounds/chat-received.wav"),
Sound::ContactOnline => include_bytes!("../assets/sounds/contact-online.wav"),
Sound::ContactOffline => include_bytes!("../assets/sounds/contact-offline.wav"),
}
}
@@ -137,6 +157,10 @@ impl Sound {
Sound::SelfLeave => "self-leave",
Sound::MicToggle => "mic-toggle",
Sound::ReconnectFailed => "reconnect-failed",
Sound::ChatSent => "chat-sent",
Sound::ChatReceived => "chat-received",
Sound::ContactOnline => "contact-online",
Sound::ContactOffline => "contact-offline",
}
}
}
+314
View File
@@ -0,0 +1,314 @@
//! Live-edge catch-up for the screen-share viewer.
//!
//! PixelPass carries the share as MPEG-TS over a reliable, ordered transport. On
//! a lossy link (satellite handovers are the pathological case) every loss burst
//! becomes retransmission plus head-of-line blocking, and the viewer absorbs the
//! stall as buffered latency. Nothing in the chain ever trims that buffer back,
//! so the picture ends up seconds behind the host and stays there.
//!
//! Measured on a `tc netem` rig that simulates a satellite link (40 ms +/- 20 ms
//! jitter, 0.5% loss, a 250 ms/30%-loss handover burst every 15 s): a viewer with
//! ordinary timestamp pacing settles ~1.24 s behind. mpv's `--untimed` does NOT
//! help (~1.38 s, marginally worse) because it only removes pacing at
//! *presentation* while audio still drains at 1x the DAC rate, so an accumulated
//! buffer never shrinks. Returning to the live edge requires consuming the
//! backlog faster than it arrives.
//!
//! So we nudge playback slightly faster than realtime while the buffer is deep,
//! and drop back to 1x once it has drained. mpv's default pitch correction
//! (`scaletempo2`) keeps a 5% speedup inaudible, and because audio and video are
//! sped up together A/V sync is preserved — unlike `--untimed`.
//!
//! The control law and the JSON-IPC message handling are pure functions with
//! tests; the only I/O is [`drive`], which talks to mpv's `--input-ipc-server`
//! socket.
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Buffer depth (seconds) above which we start draining.
pub const CACHE_HIGH_S: f64 = 1.0;
/// Buffer depth (seconds) below which we return to realtime.
pub const CACHE_LOW_S: f64 = 0.4;
/// The buffer depth we aim to sit at; the drain rate is proportional to how far
/// above this the buffer actually is.
pub const CACHE_TARGET_S: f64 = 0.5;
/// Extra playback rate per second of excess buffer.
pub const CATCHUP_GAIN: f64 = 0.05;
/// Hard ceiling on the drain rate. Beyond this the speedup stops being
/// unnoticeable, and a share that far behind is better served by the operator
/// restarting it than by a chipmunk impression.
pub const MAX_CATCHUP_SPEED: f64 = 1.15;
/// Normal realtime playback.
pub const NORMAL_SPEED: f64 = 1.0;
/// How often we sample the buffer depth.
pub const POLL_INTERVAL: Duration = Duration::from_millis(500);
/// Smallest rate change worth sending to the player.
pub const SPEED_EPSILON: f64 = 0.005;
/// The property we watch on the viewer.
const CACHE_PROPERTY: &str = "demuxer-cache-duration";
/// Decide the playback rate for the next interval.
///
/// Proportional, because a fixed small speedup cannot recover a large backlog in
/// any reasonable time: draining 6 s at 1.05x takes two minutes, which a viewer
/// experiences as "still broken". The drain rate instead scales with how deep
/// the buffer is, so a bad handover is cleared in tens of seconds while a small
/// excursion still gets only a gentle, inaudible nudge.
///
/// Deliberately hysteretic: between [`CACHE_LOW_S`] and [`CACHE_HIGH_S`] the
/// current rate is held, so a buffer hovering near a single threshold cannot
/// oscillate the speed (and with it the audio pitch) every poll. Pure.
///
/// A non-finite reading (mpv reports `null` before playback starts, and the
/// caller maps that to NaN) holds the current rate rather than guessing.
pub fn catchup_speed(cache_s: f64, current: f64) -> f64 {
if !cache_s.is_finite() {
return current;
}
if cache_s < CACHE_LOW_S {
return NORMAL_SPEED;
}
if cache_s <= CACHE_HIGH_S {
return current;
}
let excess = cache_s - CACHE_TARGET_S;
(NORMAL_SPEED + CATCHUP_GAIN * excess).clamp(NORMAL_SPEED, MAX_CATCHUP_SPEED)
}
/// Where mpv should create its IPC socket. Kept separate from the runtime
/// lookup so tests can pin a directory. Pure.
pub fn socket_path(dir: &Path, token: u64) -> PathBuf {
dir.join(format!("peerspeak-mpv-{token}.sock"))
}
/// The directory for the IPC socket: the XDG runtime dir when the session
/// provides one (tmpfs, user-private, cleaned at logout), else the temp dir.
pub fn socket_dir() -> PathBuf {
std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
}
/// A `get_property` request for the buffer depth. Pure.
pub fn get_cache_request(request_id: u64) -> String {
format!(r#"{{"command":["get_property","{CACHE_PROPERTY}"],"request_id":{request_id}}}"#)
}
/// A `set_property` request for the playback rate. Pure.
pub fn set_speed_request(request_id: u64, speed: f64) -> String {
format!(r#"{{"command":["set_property","speed",{speed}],"request_id":{request_id}}}"#)
}
/// Extract the buffer depth from one line of mpv's IPC output.
///
/// mpv interleaves unsolicited event lines with command replies, so a line is
/// only ours when it carries the matching `request_id`. Returns:
/// - `Some(Some(secs))` — our reply, with a usable number,
/// - `Some(None)` — our reply, but no number (mpv sends `"data":null` before
/// playback starts, and reports `error` while the demuxer has no cache yet),
/// - `None` — not our reply (an event, or another command's response).
///
/// Pure.
pub fn parse_cache_response(line: &str, request_id: u64) -> Option<Option<f64>> {
let value: serde_json::Value = serde_json::from_str(line.trim()).ok()?;
let id = value.get("request_id")?.as_u64()?;
if id != request_id {
return None;
}
if value.get("error").and_then(|e| e.as_str()) != Some("success") {
return Some(None);
}
Some(value.get("data").and_then(|d| d.as_f64()))
}
/// Drive one mpv viewer's playback rate over its JSON IPC socket.
///
/// Runs until mpv exits (the socket dies), so it is spawned detached alongside
/// the player and needs no shutdown signal. Every failure path just ends the
/// task: catch-up is an optimization, and a viewer that never gets it still
/// plays, exactly as before this existed.
#[cfg(unix)]
pub async fn drive(socket: PathBuf) {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
// mpv creates the socket a moment after exec, so the first connects race it.
let mut stream = None;
for _ in 0..40 {
match UnixStream::connect(&socket).await {
Ok(s) => {
stream = Some(s);
break;
}
Err(_) => tokio::time::sleep(Duration::from_millis(250)).await,
}
}
let Some(stream) = stream else {
crate::log_msg("livesync: mpv IPC socket never appeared; catch-up disabled");
return;
};
let (read_half, mut write_half) = stream.into_split();
let mut lines = BufReader::new(read_half).lines();
let mut request_id: u64 = 0;
let mut speed = NORMAL_SPEED;
loop {
tokio::time::sleep(POLL_INTERVAL).await;
request_id += 1;
let query = format!("{}\n", get_cache_request(request_id));
if write_half.write_all(query.as_bytes()).await.is_err() {
break;
}
// Skip event lines until our reply arrives.
let cache = loop {
match lines.next_line().await {
Ok(Some(line)) => {
if let Some(value) = parse_cache_response(&line, request_id) {
break value;
}
}
// Socket closed or unreadable: mpv is gone.
_ => return,
}
};
let cache = cache.unwrap_or(f64::NAN);
let next = catchup_speed(cache, speed);
// A proportional law would otherwise re-send on every wobble of the
// reading; only a change worth hearing is worth a round trip.
if (next - speed).abs() > SPEED_EPSILON {
speed = next;
request_id += 1;
let set = format!("{}\n", set_speed_request(request_id, speed));
if write_half.write_all(set.as_bytes()).await.is_err() {
break;
}
crate::log_msg(&format!(
"livesync: cache {cache:.2}s -> playback speed {speed}x"
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deep_buffer_speeds_up_and_drained_buffer_returns_to_realtime() {
assert!(catchup_speed(1.5, NORMAL_SPEED) > NORMAL_SPEED);
assert_eq!(catchup_speed(0.1, MAX_CATCHUP_SPEED), NORMAL_SPEED);
}
#[test]
fn drain_rate_scales_with_how_far_behind_we_are() {
// The point of the proportional law: a small excursion gets a gentle
// nudge, a deep backlog gets real recovery.
let small = catchup_speed(1.5, NORMAL_SPEED);
let large = catchup_speed(4.0, NORMAL_SPEED);
assert!(
large > small,
"deeper buffer must drain faster: {small} vs {large}"
);
assert!(
(small - 1.05).abs() < 1e-9,
"1.5s buffer -> 1.05x, got {small}"
);
}
#[test]
fn drain_rate_is_capped_so_it_never_sounds_absurd() {
// The ~6 s standing buffer measured on the netem rig, and far worse.
assert_eq!(catchup_speed(6.0, NORMAL_SPEED), MAX_CATCHUP_SPEED);
assert_eq!(catchup_speed(600.0, NORMAL_SPEED), MAX_CATCHUP_SPEED);
}
#[test]
fn hysteresis_band_holds_the_current_speed() {
// Between the marks nothing changes, whichever side we came from —
// this is what stops the rate (and audio pitch) oscillating.
for cache in [CACHE_LOW_S, 0.7, CACHE_HIGH_S] {
assert_eq!(catchup_speed(cache, NORMAL_SPEED), NORMAL_SPEED);
assert_eq!(catchup_speed(cache, MAX_CATCHUP_SPEED), MAX_CATCHUP_SPEED);
}
}
#[test]
fn unknown_cache_holds_the_current_speed() {
assert_eq!(
catchup_speed(f64::NAN, MAX_CATCHUP_SPEED),
MAX_CATCHUP_SPEED
);
assert_eq!(catchup_speed(f64::INFINITY, NORMAL_SPEED), NORMAL_SPEED);
}
#[test]
fn a_full_handover_cycle_drains_then_settles() {
// Buffer grows through a loss burst, then drains as we play faster.
let mut speed = NORMAL_SPEED;
for cache in [0.2, 0.5, 1.2, 3.4, 1.4, 0.9, 0.6, 0.3, 0.2] {
speed = catchup_speed(cache, speed);
}
assert_eq!(
speed, NORMAL_SPEED,
"should be back at realtime once drained"
);
}
#[test]
fn requests_are_valid_json_with_their_ids() {
let get: serde_json::Value = serde_json::from_str(&get_cache_request(7)).unwrap();
assert_eq!(get["request_id"], 7);
assert_eq!(get["command"][0], "get_property");
assert_eq!(get["command"][1], CACHE_PROPERTY);
let set: serde_json::Value = serde_json::from_str(&set_speed_request(8, 1.05)).unwrap();
assert_eq!(set["request_id"], 8);
assert_eq!(set["command"][0], "set_property");
assert_eq!(set["command"][1], "speed");
assert_eq!(set["command"][2], 1.05);
}
#[test]
fn parses_our_reply_only() {
assert_eq!(
parse_cache_response(r#"{"error":"success","data":1.25,"request_id":3}"#, 3),
Some(Some(1.25))
);
// Another command's reply, and an unsolicited event, are not ours.
assert_eq!(
parse_cache_response(r#"{"error":"success","data":1.25,"request_id":4}"#, 3),
None
);
assert_eq!(
parse_cache_response(r#"{"event":"playback-restart"}"#, 3),
None
);
assert_eq!(parse_cache_response("not json", 3), None);
}
#[test]
fn reply_without_a_usable_number_is_ours_but_empty() {
// mpv before playback starts, and while the demuxer has no cache.
assert_eq!(
parse_cache_response(r#"{"error":"success","data":null,"request_id":1}"#, 1),
Some(None)
);
assert_eq!(
parse_cache_response(r#"{"error":"property unavailable","request_id":1}"#, 1),
Some(None)
);
}
#[test]
fn socket_path_is_scoped_to_its_token() {
let a = socket_path(Path::new("/run/user/1000"), 42);
assert_eq!(a, Path::new("/run/user/1000/peerspeak-mpv-42.sock"));
assert_ne!(a, socket_path(Path::new("/run/user/1000"), 43));
}
}
+156 -22
View File
@@ -21,6 +21,8 @@ use std::time::Duration;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
pub mod livesync;
use crate::config::{ScreenShareSettings, ShareBuffering, SharePlayer, ShareQuality};
/// The binary we shell out to. Looked up on `$PATH` unless a config override
@@ -45,6 +47,12 @@ const MAX_TICKET_LEN: usize = 512;
/// are short ("Firefox", "mpv"); this only guards against a pathological value.
const MAX_APP_NAME_LEN: usize = 256;
/// Ceiling on the viewer's demuxer byte cache in the Low latency posture. The
/// cache is a *byte* budget, so at a given bitrate it sets the worst-case
/// backlog in seconds; keeping it tight is what stops a lossy link parking the
/// viewer seconds behind before [`livesync`] even gets a chance to drain it.
const LOW_LATENCY_CACHE_CAP_MB: u32 = 1;
/// How long to wait for the host to emit its ticket / the viewer to connect
/// before giving up and killing the child. Startup is normally sub-second; this
/// is only a safety net so a hung pixelpass can't wedge the caller forever.
@@ -607,16 +615,28 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
/// player is reaped in a background task so it doesn't linger as a zombie when
/// its window closes.
///
/// The flags keep latency low while preserving A/V sync. We deliberately do
/// NOT pass mpv's `--untimed`: that displays each video frame the instant it
/// decodes, ignoring audio timestamps, which makes a shared *video* drift
/// progressively out of sync with its audio. Pacing to the audio clock costs a
/// little latency (negligible for pointing at a desktop) and keeps a shared
/// video in sync. We also leave hwdec at the `low-latency` default (software
/// decode): forcing `--hwdec=auto` froze some viewers on frame 1 while audio
/// kept playing.
/// The buffering posture chooses the latency/A/V-sync tradeoff. Low latency
/// keeps the viewer at the live edge: mpv gets an IPC socket and [`livesync`]
/// drains a lagging buffer by playing slightly fast (pitch-corrected, so A/V
/// sync is preserved). Smooth leaves a deeper buffer alone, trading live
/// latency for immunity to jitter. Hardware decoding remains opt-in: forcing
/// `--hwdec=auto` froze some viewers on frame 1 while audio kept playing.
fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<()> {
let mpv_args = mpv_args(settings);
// One socket per viewer launch, so overlapping shares can't collide on it.
// Unix only: mpv's IPC is a named pipe on Windows, which `livesync` does not
// speak, and an unusable socket path on the argv would help nobody.
#[cfg(unix)]
let ipc_socket = Some(livesync::socket_path(
&livesync::socket_dir(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0),
));
#[cfg(not(unix))]
let ipc_socket: Option<PathBuf> = None;
let mpv_args = mpv_args(settings, ipc_socket.as_deref());
let vlc_args = vlc_args(settings);
let first = match settings.player {
SharePlayer::Mpv => ("mpv", &mpv_args),
@@ -627,15 +647,32 @@ fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<(
SharePlayer::Vlc => ("mpv", &mpv_args),
};
let child = match spawn_player(first.0, first.1, url) {
Ok(c) => c,
Err(_) => spawn_player(second.0, second.1, url).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"no media player found — install mpv or vlc to watch screen shares",
)
})?,
let (launched, child) = match spawn_player(first.0, first.1, url) {
Ok(c) => (first.0, c),
Err(_) => (
second.0,
spawn_player(second.0, second.1, url).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"no media player found — install mpv or vlc to watch screen shares",
)
})?,
),
};
// Only when the socket actually reached the argv: mpv (VLC has no
// equivalent IPC) in the Low latency posture. The driver ends by itself when
// the player exits, so it needs no shutdown path.
#[cfg(unix)]
if launched == "mpv"
&& settings.buffering == ShareBuffering::LowLatency
&& let Some(socket) = ipc_socket
{
tokio::spawn(livesync::drive(socket));
}
#[cfg(not(unix))]
let _ = launched;
tokio::spawn(async move {
let mut child = child;
let _ = child.wait().await;
@@ -643,11 +680,23 @@ fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<(
Ok(())
}
pub fn mpv_args(settings: &ScreenShareSettings) -> Vec<String> {
/// Build the argv for an mpv viewer.
///
/// `ipc_socket` is where mpv should expose its JSON IPC socket so [`livesync`]
/// can drain a lagging buffer. It is wired up for Low latency only: Smooth
/// deliberately holds a ~2 s readahead, which the catch-up thresholds would
/// fight on every poll.
pub fn mpv_args(settings: &ScreenShareSettings, ipc_socket: Option<&Path>) -> Vec<String> {
let mut args = Vec::new();
match settings.buffering {
ShareBuffering::LowLatency => {
args.push("--profile=low-latency".to_string());
// Pixelpass carries MPEG-TS through reliable ordered QUIC/TCP, so a
// lossy link turns every retransmission into buffered latency that
// nothing trims back. `--untimed` does NOT fix that (measured
// marginally worse: it only unpaces *presentation*, while audio
// still drains at 1x, so the backlog never shrinks) — the viewer
// instead drains it by playing slightly fast, see `livesync`.
args.push("--audio-buffer=0.2".to_string());
args.push("--demuxer-readahead-secs=0.5".to_string());
}
@@ -656,10 +705,25 @@ pub fn mpv_args(settings: &ScreenShareSettings) -> Vec<String> {
args.push("--demuxer-readahead-secs=2".to_string());
}
}
args.push(format!("--demuxer-max-bytes={}M", settings.cache_mb));
// The byte cap is what bounds how far behind a viewer can silently fall:
// a demuxer allowed 2 MiB will happily sit on ~6 s of a 2.5 Mbps share (as
// measured on the netem rig) and call it a buffer. Low latency therefore
// gets a tighter ceiling than the user's Smooth-oriented setting, so the
// catch-up has less to claw back after a bad patch of link.
let cache_mb = match settings.buffering {
ShareBuffering::LowLatency => settings.cache_mb.min(LOW_LATENCY_CACHE_CAP_MB),
ShareBuffering::Smooth => settings.cache_mb,
};
args.push(format!("--demuxer-max-bytes={cache_mb}M"));
if settings.hardware_decode {
args.push("--hwdec=auto".to_string());
}
if let Some(socket) = ipc_socket
&& settings.buffering == ShareBuffering::LowLatency
{
args.push(format!("--input-ipc-server={}", socket.display()));
}
// Extra args stay last so a user override wins over everything above.
args.extend(split_extra_args(&settings.extra_mpv_args));
args
}
@@ -825,16 +889,86 @@ mod tests {
#[test]
fn mpv_args_default_matches_low_latency_software_decode() {
assert_eq!(
mpv_args(&ScreenShareSettings::default()),
mpv_args(&ScreenShareSettings::default(), None),
vec![
"--profile=low-latency",
"--audio-buffer=0.2",
"--demuxer-readahead-secs=0.5",
"--demuxer-max-bytes=2M",
"--demuxer-max-bytes=1M",
]
);
}
#[test]
fn low_latency_gets_the_ipc_socket_for_live_edge_catch_up() {
let args = mpv_args(
&ScreenShareSettings::default(),
Some(Path::new("/run/user/1000/peerspeak-mpv-1.sock")),
);
assert!(
args.contains(&"--input-ipc-server=/run/user/1000/peerspeak-mpv-1.sock".to_string()),
"low latency drains a lagging buffer over mpv IPC: {args:?}"
);
// The flag that used to hold this posture at the live edge measured no
// better than pacing, and cost A/V sync — it must not come back.
assert!(!args.contains(&"--untimed".to_string()));
}
#[test]
fn smooth_keeps_its_deep_buffer_and_gets_no_ipc_socket() {
let settings = ScreenShareSettings {
buffering: ShareBuffering::Smooth,
..ScreenShareSettings::default()
};
let args = mpv_args(
&settings,
Some(Path::new("/run/user/1000/peerspeak-mpv-1.sock")),
);
assert!(
!args.iter().any(|a| a.starts_with("--input-ipc-server")),
"catch-up would fight Smooth's deliberate ~2s readahead: {args:?}"
);
}
#[test]
fn low_latency_caps_the_byte_cache_but_smooth_keeps_the_user_value() {
// The cache is a byte budget, so at a given bitrate it sets the
// worst-case backlog: 2 MiB held ~6 s of a 2.5 Mbps share on the rig.
let generous = ScreenShareSettings {
cache_mb: 32,
..ScreenShareSettings::default()
};
assert!(
mpv_args(&generous, None)
.contains(&format!("--demuxer-max-bytes={LOW_LATENCY_CACHE_CAP_MB}M")),
"low latency must bound how far behind the viewer can silently fall"
);
let smooth = ScreenShareSettings {
cache_mb: 32,
buffering: ShareBuffering::Smooth,
..ScreenShareSettings::default()
};
assert!(
mpv_args(&smooth, None).contains(&"--demuxer-max-bytes=32M".to_string()),
"smooth is the posture where the user asked for a deep buffer"
);
}
#[test]
fn user_extra_args_still_come_last() {
let settings = ScreenShareSettings {
extra_mpv_args: "--no-osc".to_string(),
..ScreenShareSettings::default()
};
let args = mpv_args(&settings, Some(Path::new("/tmp/s.sock")));
assert_eq!(
args.last().map(String::as_str),
Some("--no-osc"),
"a user override has to win over everything we add: {args:?}"
);
}
#[test]
fn mpv_args_smooth_hwdecode_and_extra_args_last() {
let settings = ScreenShareSettings {
@@ -846,7 +980,7 @@ mod tests {
};
assert_eq!(
mpv_args(&settings),
mpv_args(&settings, None),
vec![
"--cache=yes",
"--demuxer-readahead-secs=2",