Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c82ef07464 | ||
|
|
9eab6c118d | ||
|
|
21ba633825 | ||
|
|
45b1b97dd8 | ||
|
|
ae2e9de523 | ||
|
|
985c63806b | ||
|
|
6fc55a286d | ||
|
|
d63db68318 | ||
|
|
e7923a1b5c | ||
|
|
b5569fe2c6 | ||
|
|
503f78153b | ||
|
|
d40385f85c | ||
|
|
bcf1343a55 | ||
|
|
6773a3882b | ||
|
|
1cd19b355f | ||
|
|
297f4397a7 | ||
|
|
283d938b79 | ||
|
|
fd72e6f018 | ||
|
|
8768cd242c | ||
|
|
da72541e18 | ||
|
|
8610ab2eb6 | ||
|
|
100117085d | ||
|
|
cab6bafce5 | ||
|
|
10203e1edb | ||
|
|
88ad5a0807 | ||
|
|
0588d92537 | ||
|
|
b4a4c00711 | ||
|
|
8c4f4a0b8b | ||
|
|
76c4f68bb3 | ||
|
|
c427231858 | ||
|
|
4bfc18463b | ||
|
|
26d66007de |
@@ -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 7–10 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
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "peerspeak"
|
||||
version = "0.6.4"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -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.
@@ -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)],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,822 @@
|
||||
# Implementation plan: whole-desktop screen-share audio without self-echo
|
||||
|
||||
**Status:** 🟢 **v4 — three review rounds applied. Approved to start Phase 0a.**
|
||||
**Date:** 2026-07-21
|
||||
**Design of record:** [`screenshare-audio-exclusion-plan.md`](screenshare-audio-exclusion-plan.md) v3.4 (`8768cd2`), converged round 7.
|
||||
**Scope:** *ordering, gates and acceptance criteria only.*
|
||||
|
||||
**Reference convention.** `v3.4 §N` = the design doc. `plan §N` = this document. The two
|
||||
numbering schemes collide (both have a §11 and a §12) and an unqualified reference in v2 sent
|
||||
Phase 6's most important gate to a section that does not exist. Every cross-reference below is
|
||||
qualified.
|
||||
|
||||
**Review history**
|
||||
| Round | Findings | Outcome |
|
||||
| --- | --- | --- |
|
||||
| 1 | 7 P1 + 5 P2 | 12 accepted, 1 half-rejected → v2. `~/Documents/handoff-docs/Codex/peerspeak/review-2026-07-21-impl-plan-round1.md` |
|
||||
| 2 | 3 P1 + 7 P2 + 1 P3 | all accepted → v3. `…/review-2026-07-21-impl-plan-round2.md` |
|
||||
| 3 | verification pass: 4 of 7 edits landed, 3 partial; 2 P1 + 2 P2 + 1 P3 | all accepted → v4. Approved to start Phase 0a. `…/review-2026-07-21-impl-plan-round3.md` |
|
||||
|
||||
Adjudication in plan §10. Two of my own claims were refuted by Codex with source evidence and
|
||||
two of its claims were refuted or narrowed by mine; both are recorded there rather than
|
||||
quietly dropped.
|
||||
|
||||
---
|
||||
|
||||
## 0. What this plan is optimising for
|
||||
|
||||
The design is converged; the risk has moved from "is it right?" to "will it be built in an
|
||||
order where each mistake is caught while it is still cheap." Three properties drive every
|
||||
ordering decision:
|
||||
|
||||
1. **Nothing that can create an echo runs before the thing that decides eligibility has been
|
||||
validated against a real graph.** The taint engine is the hull. It is built, unit-tested,
|
||||
and floated empty (Phase 5, dry-run) before a single link is created.
|
||||
2. **Every path to unsafe audio is closed structurally before the machinery that could take
|
||||
it is written.** There are **two** such paths, not one — the capture *source* and the
|
||||
capture sink's *inputs*. Both are closed in Phase 0d.
|
||||
3. **Every phase ends in a state that is shippable or trivially revertible**, and every gate
|
||||
is one a broken implementation can *fail*. A gate that cannot fail is not a gate; where a
|
||||
gate asserts only that bad things are absent, it must also assert that good things are
|
||||
present, or "captures nothing at all" passes it.
|
||||
|
||||
The corollary, stated plainly because it is the most likely way this goes wrong: **the
|
||||
temptation will be to write the link manager early**, because it is the visible feature.
|
||||
Fan-out is roughly 400 lines and demos beautifully with a hand-picked node. It is also the
|
||||
component that, shipped ahead of a validated engine, produces exactly the bug this feature
|
||||
exists to prevent — in front of Joe.
|
||||
|
||||
## 0.1 Cross-repo reality
|
||||
|
||||
Two repos, no Cargo dependency; the contract is pixelpass's CLI plus its `--output json`
|
||||
event stream (`peerspeak/src/screenshare/mod.rs:1-14`). The bulk of the work — graph engine,
|
||||
link manager, AEC state machine — is **pixelpass**. peerspeak's share is tagging, argv,
|
||||
capability gating, teardown ordering and the user-visible status surface.
|
||||
|
||||
**Hard ship-order constraint.** peerspeak spawns whatever `pixelpass` resolves on `PATH`. If
|
||||
peerspeak passes `--aec=…` to a pixelpass that predates the flag, clap rejects it and **the
|
||||
share hard-fails** — the documented A23 / audit-P2 skew failure that already governs
|
||||
`--strict-audio` (`screenshare/mod.rs:249-262`).
|
||||
|
||||
> **pixelpass ships the capability first (Phase 7), including the old-peerspeak/new-pixelpass
|
||||
> golden test. peerspeak only passes the new flags to a binary that advertised support
|
||||
> (Phase 8), which owns the new-peerspeak/old-pixelpass golden test. Never "flag present or
|
||||
> absent" as the protocol — v3.4 D5.**
|
||||
|
||||
⚠️ **Capability is resolved twice, against two independently-resolved binaries.**
|
||||
`ListAudioApps` resolves pixelpass and probes it at `core/mod.rs:3375-3378`; `StartScreenShare`
|
||||
resolves it *again* at `:3409-3419`. Between those moments `PATH` or the override can change.
|
||||
Verified in source. Phase 8 must bind the capability result to the resolved path and re-probe
|
||||
if it differs, failing closed.
|
||||
|
||||
---
|
||||
|
||||
## 1. Phase map and dependency DAG
|
||||
|
||||
| # | Phase | Repo | Mutates graph? | Exit gate |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 0a | `object.serial` u64 fix | pixelpass | no | boundary parse tests |
|
||||
| 0b | Explicit teardown + drop ordering | peerspeak | no | **five independent mutations** (plan §2) |
|
||||
| 0c | Graceful stop + connection-owned capture sink | both | sink ownership | two-host SIGKILL live gate **+ SIGINT-first gate** |
|
||||
| 0d | **Typed capture plan + internal mode input** | pixelpass | no | mode matrix; neither unsafe source nor unsafe sink input constructible |
|
||||
| 1 | peerspeak ownership tagging | peerspeak | no | tag on live nodes; literal pinned in plan §3 |
|
||||
| 2 | Graph model + taint engine (pure) | pixelpass | **no PipeWire at all** | v3.4 §12 fixture matrix + degenerate-snapshot case |
|
||||
| 3 | Registry observer + readiness epoch | pixelpass | read-only | six-part gate incl. **PID derivation** |
|
||||
| **3r** | **Observer revision — bind every Node/Device (v3.5 §6.7)** | pixelpass | read-only | **four-part gate (plan §4 "Phase 3 revision")** |
|
||||
| 4 | AEC identity validation state machine | pixelpass | read-only | fake-clock transition matrix |
|
||||
| 5 | **Dry-run audit mode** | pixelpass | read-only | 🚦 **MAJOR GATE** — exact decision partitions (plan §5) |
|
||||
| 6 | Link manager + status events, driven through the real host path | pixelpass | **yes — first mutation** | link-manager matrix (plan §6.1) + live dynamic matrix |
|
||||
| 7 | **Public mode selector** + capability advertisement | pixelpass | no | old-peerspeak/new-pixelpass golden test |
|
||||
| 8 | peerspeak integration (mode flag, argv, picker, UI, status) | peerspeak | no | new/new argv golden (mode **and** `--aec`); new-peerspeak/old-pixelpass golden; causal status delivery |
|
||||
| 9 | Rig upgrade + field matrix | both | yes | 🚦 **SHIP GATE** — plan §7 |
|
||||
|
||||
**Landing DAG** (development may be concurrent; *landing* order may not):
|
||||
|
||||
```
|
||||
0a ──────────────────► 2 ──► 3 ──► 4 ──► 5 ──► 3r ──► 5 (re-run) ──► 6 ──► 7 ──► 8 ──► 9
|
||||
▲
|
||||
0b ──────────────────────────────────────────────────────────────────┤
|
||||
0c ──► 0d ───────────────────────────────────────────────────────────┘
|
||||
1 (r8 carriers) ──────────────────────────────────────► 5 (re-run)
|
||||
```
|
||||
|
||||
⚠️ **Status 2026-07-25 (evening): 3r is BUILT AND MERGED; the re-run has not happened yet.**
|
||||
The phase-5 gate failed on its first live run and put 3r into the DAG; 3r's own four-part
|
||||
gate now passes, including the live prop-recovery row on this host. Phase 5's machinery is
|
||||
built and correct — it is the audit that found the defect, twice — so "5 (re-run)" is a
|
||||
*re-run of the matrix*, not a rebuild. **Phase 6 still does not start** until a passing
|
||||
results file exists. **Phase 1 is a hard prerequisite of the re-run for both carriers**
|
||||
(plan §3).
|
||||
|
||||
⚠️ **A smoke run of the audit against the fixed observer immediately found a second defect
|
||||
(design v3.6 §6.8): a fail-closed `unresolved-ancestry` mark was being promoted to permanent
|
||||
sticky taint.** Fixed in the taint engine (evidence-only sticky pass, 3 new tests,
|
||||
mutation-verified) and merged. Decisions were unaffected — all 57 phase-2 tests passed
|
||||
untouched — so this is a change to what stickiness *remembers*, not to what it *decides*.
|
||||
Note the pattern for the re-run: the matrix rows assert exact partitions, and a stale sticky
|
||||
entry from enumeration would have contaminated every one of them.
|
||||
|
||||
- **0b strictly precedes 6.** v2/v3 drew 0b with no continuing edge. Phase 6 is the first phase
|
||||
that creates objects whose lifetime is tied to pixelpass being alive, so the teardown-ordering
|
||||
guarantee must exist before it: without it, the AEC can unload while a fanning-out pixelpass
|
||||
still holds link proxies and a stale module index (v3.4 §7.1).
|
||||
- **0a strictly precedes 2**: the taint engine's lifetime-awareness (v3.4 §6.1.3) is keyed on
|
||||
`object.serial`; building the model against the current lossy `u32`
|
||||
(`pixelpass/src/host/audio.rs:534-540`; `RouterState::sink_serial` at `:594-598`) means a
|
||||
cross-cutting migration later.
|
||||
- **0c strictly precedes 0d** (round-2): 0d's types must be built around the *final*
|
||||
connection-owned bare sink, not today's `Routing`. Building the type boundary against the
|
||||
legacy pactl sink means rebuilding it when 0c lands.
|
||||
- **1 strictly precedes 5**: without tags the taint engine has no roots and the dry-run can
|
||||
only exercise the forwarder half of the problem.
|
||||
|
||||
---
|
||||
|
||||
## 2. Phase 0 — prerequisites
|
||||
|
||||
### 0a. `object.serial` u32 truncation — pixelpass
|
||||
Parse as `u64` throughout; audit the other `parse::<u32>` at `:369` (determine whether it is a
|
||||
serial or a genuinely-32-bit value before changing it). Tests: value > `u32::MAX`, and the
|
||||
boundary.
|
||||
|
||||
### 0b. Explicit teardown + drop ordering — peerspeak
|
||||
v3.4 §7.2, decision D4. All **three** of v3.4's fixes:
|
||||
|
||||
1. Replace the implicit-drop path at both channel-close sites. **Citation correction:** v3.4
|
||||
says `core/mod.rs:1516`; the actual `None => break` arms are at **`:1514`** and **`:1532`**.
|
||||
Take the session and `shutdown().await` it.
|
||||
2. Move `echo_cancel` (currently `:682`) to the last declared field, after `screenshare_host`
|
||||
(`:685`), with a comment naming the invariant.
|
||||
3. **Last-ditch drop wrapper**: `start_kill` + a bounded `try_wait` reap on the host before the
|
||||
AEC guard unloads. This is the *only* protection on the panic/unwind path, and unwind is
|
||||
reachable — the core has numerous `unwrap()` sites and no `panic=abort` profile.
|
||||
|
||||
⚠️ **Mutation testing: five mutations, each independently breaking a named test.** v1 demanded
|
||||
a mutation that targeted the wrong defense; v2 fixed that but bundled two defenses into one
|
||||
combined mutant, which proves neither. Final form:
|
||||
|
||||
| # | Mutation | Must break |
|
||||
| --- | --- | --- |
|
||||
| 1 | remove `shutdown().await` at `:1514` | close-arm-A teardown test |
|
||||
| 2 | remove `shutdown().await` at `:1532` | close-arm-B teardown test |
|
||||
| 3 | remove the explicit **wait** after host kill | explicit-ordering test (host kill+wait strictly precedes AEC unload) |
|
||||
| 4 | reverse the field order | panic/unwind ordering test |
|
||||
| 5 | remove the wrapper reap | panic/unwind ordering test (distinct assertion from #4) |
|
||||
|
||||
Both channel-close arms get their own test; a single "closes the command channel" test can
|
||||
exercise one arm and leave the other unsafe.
|
||||
|
||||
### 0c. Graceful stop + connection-owned capture sink — both
|
||||
v3.4 §7.4. The **largest hidden cost in Phase 0**: moving the null sink off `pactl load-module`
|
||||
(`pixelpass/src/host/audio.rs:69`, cleaned up only in `Routing::cleanup` at `:259-260`, which
|
||||
SIGKILL skips) onto a connection-owned PipeWire object.
|
||||
|
||||
- peerspeak: SIGINT (**not** SIGTERM — pixelpass installs only `ctrl_c()`,
|
||||
`pixelpass/src/common/signal.rs:6`), bounded wait, SIGKILL fallback, at `core/mod.rs:699`
|
||||
and `:3480`.
|
||||
- pixelpass: connection-owned sink; `--repair` (`src/repair.rs:15-63`) extended and proven safe
|
||||
with a second live host.
|
||||
|
||||
**Exit gate — two halves. The round-2 finding was that v2 gated only the first.**
|
||||
|
||||
*(i) Ownership,* a live two-host test — connection-ownership is a runtime property no unit test
|
||||
can establish:
|
||||
|
||||
```bash
|
||||
pactl list short sinks | rg 'pixelpass_capture_'
|
||||
pw-dump | jq -r '.[] | select(.type=="PipeWire:Interface:Node") | .info.props as $p
|
||||
| select(($p["node.name"] // "") | startswith("pixelpass_capture_"))
|
||||
| [$p["object.serial"], $p["node.name"]] | @tsv'
|
||||
kill -KILL <first-pixelpass-pid>
|
||||
# re-run both: the killed host's object GONE, the second host's REMAINS
|
||||
pixelpass --repair # the live host must be untouched
|
||||
```
|
||||
|
||||
*(ii) Graceful stop,* which the above does not touch at all — it exercises only external
|
||||
SIGKILL, so Stop Share could remain `child.kill().await` (`core/mod.rs:3480`, still true today)
|
||||
and every command above would pass:
|
||||
- a fake-child signal-order test: SIGINT first, SIGKILL only after the bound expires;
|
||||
- a live Stop Share run: SIGINT sent, child exits within the declared bound, **no fallback
|
||||
kill on the normal path**.
|
||||
|
||||
**O1 is closed: no demotion path.** v1 pre-authorised moving 0c after Phase 6 if it ballooned.
|
||||
That is a waiver of settled decision v3.4 D6 hidden in a sequencing document, which is how a
|
||||
converged design quietly decays. If 0c balloons, **stop and reopen D6 as design round 8.**
|
||||
|
||||
### 0d. Typed capture plan + internal mode input — pixelpass
|
||||
|
||||
Today `setup_audio` returns `(Option<Routing>, String)` (`pipeline.rs:123-142`) and the `String`
|
||||
flows untyped into `build_args` (`:151-157`). There are **two** unsafe paths into the capture,
|
||||
and v2 closed only the first:
|
||||
|
||||
**Path 1 — the source string.** `default_audio_monitor()` has exactly one call site,
|
||||
`pipeline.rs:138`. That single line hands `pulsesrc` the real default monitor.
|
||||
|
||||
**Path 2 — the sink's inputs (round-2 P1, the defect v2 missed).** Even with a type-safe source,
|
||||
`Routing::start` loads `module-loopback source=@DEFAULT_SINK@.monitor → pixelpass_capture_*`
|
||||
whenever it runs outside strict-app mode (`audio.rs:80-92`), and it runs whenever
|
||||
`PIXELPASS_AUDIO_VIA_NULL_SINK` is set (`pipeline.rs:124-125`). So `DesktopExcluding` could
|
||||
correctly read *its own* sink's monitor while the legacy loopback has already filled that sink
|
||||
with the whole-desktop mix — **full echo, with no source switch anywhere.** v3.4 §3 states this
|
||||
loopback must never load in the new mode; nothing structurally enforced it.
|
||||
|
||||
Both are closed by construction:
|
||||
|
||||
- `LegacyDesktop` — the only variant that can produce `DefaultMonitor`.
|
||||
- `PerApp { routing }` — legacy `Routing`, unchanged.
|
||||
- `DesktopExcluding { capture_sink }` — owns a **bare** connection-owned sink type (0c) whose
|
||||
API **cannot construct the legacy loopback at all**. Not "does not call it": the constructor
|
||||
is not reachable from this variant.
|
||||
- **Conflict policy, pinned here rather than discovered later:** the new mode combined with
|
||||
`--app` or `PIXELPASS_AUDIO_VIA_NULL_SINK` **rejects at CLI parse time**. It must never fall
|
||||
through to legacy `Routing`, and it must never silently ignore an input the user set.
|
||||
- **Internal mode input lands here too** (round-2 P1): a non-advertised `HostOpts` field plus a
|
||||
hidden trigger, so the variant is reachable through the real host/spawn path before Phase 6
|
||||
needs to measure through it. `HostOpts` has no mode field today and `setup_audio` selects
|
||||
solely on `app` + the env override.
|
||||
|
||||
**Why 0d is a prerequisite rather than a Phase 6 deliverable** (my divergence from Codex's
|
||||
round-1 suggestion; it agreed in round 2): this is a pure non-mutating refactor of one function,
|
||||
and landing it in Phase 6 means the link manager is written against the untyped API and then
|
||||
refactored underneath itself, while the two most dangerous paths in the codebase stay unguarded
|
||||
through four phases of active work around them. Guardrails go up before the scaffolding.
|
||||
|
||||
**Exit gate:**
|
||||
- mode matrix over every `(app, strict_audio, mode, env-override)` combination asserting the
|
||||
resulting capture plan, including every conflict combination rejecting;
|
||||
- type-level: `DesktopExcluding` can name neither the default monitor nor the legacy loopback;
|
||||
- **graph assertion**: with the new mode active, no default-monitor link or module feeds the
|
||||
capture sink;
|
||||
- legacy behaviour byte-identical.
|
||||
|
||||
A constructible-but-not-yet-public variant is acceptable for the interval between 0d and Phase
|
||||
6 provided it is unit-tested and reachable by the hidden trigger.
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 1 — peerspeak ownership tagging (v3.4 §5.1)
|
||||
|
||||
Zero behaviour change; it is what makes Phase 5 observable.
|
||||
|
||||
- Native playback: prop on the stream dict, `src/audio/pipewire_impl.rs:374-388`.
|
||||
- mpv/VLC spawn (`src/screenshare/mod.rs:768-775`), notification spawn (`src/notify.rs:265-272`):
|
||||
`PULSE_PROP` + `PIPEWIRE_PROPS` on the `Command`.
|
||||
|
||||
⚠️ **The literal is a cross-repo wire contract and is pinned HERE, before Phase 1 starts** — not
|
||||
deferred with the v3.4 §11 product naming, which is a separate and genuinely user-facing question.
|
||||
|
||||
```
|
||||
key: peerspeak.owned
|
||||
value: 1
|
||||
```
|
||||
|
||||
⚠️ **Round 8 — a SECOND carrier is required, and its literal is pinned here too** (v3.5 §5.1).
|
||||
`peerspeak.owned` is invisible to the registry `global` event and readable only via a node
|
||||
bind (v3.5 §6.7); the prefix below is announced by the registry and needs no bind, so the
|
||||
primary taint root no longer rests on a single observation mechanism.
|
||||
|
||||
```
|
||||
key: node.name
|
||||
format: peerspeak_owned_<role>_<pid> e.g. peerspeak_owned_mpv_31284
|
||||
prefix: peerspeak_owned_ ← the matched literal
|
||||
```
|
||||
|
||||
- **Both carriers are set at every tagging site.** A node is owned if **either** matches —
|
||||
union, the fail-closed direction. The engine's tag root is `peerspeak.owned == 1` **OR**
|
||||
`node.name` starts with `peerspeak_owned_`.
|
||||
- **`node.description` is NOT touched**, so mixers still show "mpv". Only `node.name`, which
|
||||
is the internal identifier, carries the prefix.
|
||||
- The prefix mechanism is already proven here: `pixelpass_capture_*` is matched on
|
||||
`node.name` and was the only root that kept working under the F1 defect.
|
||||
- Same three requirements as the property literal: one named constant per repo, the
|
||||
black-box cross-repo test driven from a shared fixture, and phase 5 as the real proof.
|
||||
- ⚠️ Native call playback sets both on its own stream dict. The child spawns set the prefix
|
||||
through the same `PULSE_PROP` / `PIPEWIRE_PROPS` env that carries the property —
|
||||
`node.name` is settable there, and **the phase-1 exit gate must show it landing on a live
|
||||
mpv node**, not just in the env.
|
||||
|
||||
**A per-repo literal test is not a contract test.** Two tests, one per repo, each maintained
|
||||
beside its own implementation, get updated in lockstep with a rename and prove nothing. Required:
|
||||
|
||||
1. The literal appears **once** per repo as a named constant, commented with a pointer to this
|
||||
section and to the other repo's constant.
|
||||
2. A **black-box cross-repo test**: peerspeak constructs the child `Command`, the test reads the
|
||||
env it would set, and asserts it produces the exact property string pixelpass's engine
|
||||
matches on — driven from a single shared fixture string committed in both repos.
|
||||
3. The real proof is the **Phase 5 dry-run**, which requires pixelpass to classify all three
|
||||
live peerspeak playback paths as `NotEligible` *for the tag reason*. Emission alone proves
|
||||
only that peerspeak talks, not that pixelpass listens.
|
||||
|
||||
**Exit gate:** `pw-dump` shows the tag on a native call playback node, an mpv node and a
|
||||
notification node on this box. (Consumption is gated in Phase 5.)
|
||||
|
||||
**Non-goal:** the known grandchild-inheritance leak (v3.4 §5.1) stays accepted in v1.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phases 2–4 — the engine (pixelpass)
|
||||
|
||||
### Phase 2 — graph model + taint engine, pure
|
||||
All of v3.4 §6.1–§6.1.3, **with no PipeWire types in any signature**:
|
||||
|
||||
```
|
||||
fn evaluate(snapshot: &GraphSnapshot, ctx: &ExclusionCtx, prior: &StickyState)
|
||||
-> (Decisions, StickyState)
|
||||
```
|
||||
|
||||
- `GraphSnapshot` = plain owned Node/Port/Link/Client structs keyed on `u64` serial, with the
|
||||
recyclable id retained only as a lookup key, never as identity (v3.4 §6.1.3).
|
||||
- `Decisions` carries `Eligibility::NotEligible { reason }` with **stable reason codes**, not
|
||||
prose. That code is the Phase 5 dry-run output, the Phase 6 JSON status event, and the
|
||||
eventual "why isn't this app shared" answer. Design it once, here.
|
||||
- `StickyState` threaded explicitly, so stickiness is testable as a snapshot sequence.
|
||||
|
||||
**Every row of the v3.4 §12 taint-engine table is a required deliverable**, plus one addition:
|
||||
an **empty/degenerate snapshot must yield "nothing eligible", not "everything eligible"** — the
|
||||
fail-closed default asserted at the boundary.
|
||||
|
||||
**Exit gate:** fixture matrix green; the engine has never been linked against libpipewire.
|
||||
|
||||
### Phase 3 — registry observer + readiness epoch, read-only
|
||||
v3.4 §6.3 and §6.4. Replaces (not extends) the existing router, which watches Node and Metadata
|
||||
adds, forwards raw removals, and binds no graph (`src/host/audio.rs:523-585`).
|
||||
|
||||
> ⚠️ **Built and merged, then superseded in part by "Phase 3 revision (round 8)" below.** This
|
||||
> section's node-property requirements assume the registry `global` event carries them. It does
|
||||
> not (v3.5 §6.7). Everything here about removals, the readiness epoch, PID derivation and the
|
||||
> Link path is unaffected and still holds.
|
||||
|
||||
- Node, Port, Link **and Client** globals; adds **and removes**.
|
||||
- Link endpoint props from the global are the **optimisation**; the bind-`LinkInfoRef` fallback
|
||||
is the correctness path.
|
||||
- Readiness = `core.sync()`/`done` **plus** no outstanding required observations, fail-closed
|
||||
timeout. Log which condition released the epoch.
|
||||
- pipewire-pulse PID derivation (v3.4 §6.1.2): consistent `pipewire.sec.pid` across Pulse
|
||||
clients, validated against `/proc/<pid>/comm`.
|
||||
|
||||
**Exit gate — six parts.** A one-time `pw-dump` diff passes while Port observation is absent,
|
||||
removals are ignored, the fallback is dead code, and readiness releases early:
|
||||
|
||||
| gate | proves |
|
||||
| --- | --- |
|
||||
| adapter tests: add **and remove** of all four object types | removal handling exists |
|
||||
| forced-absent Link endpoint props | the bind fallback is live, not decorative |
|
||||
| unresolved-observer timeout test | readiness fails closed |
|
||||
| readiness does not release with an observation outstanding | the epoch means something |
|
||||
| **PID derivation matrix** (round-2): consistent valid PID · inconsistent PIDs · missing client property · `/proc` entry missing · `comm` mismatch · PID reuse — **every failure makes owner-bridge key 4 unusable** | the pure engine can be correct on a wrong context; this is where the context is built |
|
||||
| **live**: create and destroy a controlled node/link topology; diff Nodes, **Ports**, Links and Clients before/during/after | the adapter tracks a *changing* graph, not a static one |
|
||||
|
||||
### Phase 3 revision (round 8) — bind every Node and Device ✅ BUILT AND MERGED 2026-07-25
|
||||
v3.5 §6.7. Phase 3 shipped reading node properties off the registry `global` event, where
|
||||
**eight of them are never announced**. This is the fix. Scope is the observer only — phases 2
|
||||
and 4 are unaffected, and the phase-5 audit machinery is already correct.
|
||||
|
||||
> **🟢 Done.** Pure core + adapter, split-seam with mutual review as in phase 3 (mine and
|
||||
> Codex's respectively, each reviewing the other). All four gate rows below pass, the live
|
||||
> row on this host. Codex's review of the core found no certain P1; two findings taken and
|
||||
> mutation-verified (a `device_props` ambiguity test that checked for one live *Device*
|
||||
> rather than one live *global*, and `device.api` corroborating by presence). Two findings
|
||||
> left open as design items, both pre-existing — hardware playback-to-capture paths and the
|
||||
> readiness-budget calibration, both recorded in design §6.8.
|
||||
>
|
||||
> **Added beyond the spec: a second live gate for the Device-side path.** Row 1's
|
||||
> `session_device` assertion is satisfied by a union, and WirePlumber 0.5.15 copies
|
||||
> `device.api`/`alsa.driver_name` onto ALSA nodes on this host — so row 1 passes through the
|
||||
> node fallback and would keep passing if the Device bind delivered nothing at all, leaving
|
||||
> §6.7 decision 4 ungated on the development machine. Verified by mutation: breaking the
|
||||
> Device-side driver read fails the new test while row 1 still passes.
|
||||
|
||||
**Requirements.**
|
||||
|
||||
1. **Bind every `Node` global**, unconditionally, no `media.class` filter. Retain the proxy
|
||||
and its `info` listener in that global's slot in the existing per-id FIFO
|
||||
(`LiveGlobal.bound_link` generalises to a bound-proxy slot).
|
||||
⚠️ The phase-3 review's finding 3 — record the id and apply the add as **one** step, so
|
||||
the proxy FIFO stays lockstep with the model's `live_ids` — now applies on the **hottest**
|
||||
path in the observer. A recycled Node id must not pop another generation's proxy.
|
||||
2. **The global is an index; `info` is the source of truth.** Read from the global only what
|
||||
must exist before the bind resolves: `object.serial` (identity), the object's id, and
|
||||
`device.id`/`node.id` linkage. **Every** taint-relevant property — including `node.name`
|
||||
and `media.class`, so there is exactly one source — comes from the bound `info` props.
|
||||
3. **A node with no `info` yet is WITHHELD from the snapshot and is a readiness obligation**
|
||||
(`pending_nodes`, beside `withheld` and `pending_links`). `graph_ready` false while any is
|
||||
outstanding; the existing bounded deadline makes an unresolvable bind sticky-`TimedOut`,
|
||||
fail closed. No provisional-ownership admission, ever (v3.4 §6.1.3).
|
||||
4. **Track props for the node's lifetime.** On a later `info` with `PROPS` in `change_mask`,
|
||||
re-read, re-classify, and apply a `NodePropsUpdated` event.
|
||||
⚠️ **Suppression rule:** a prop update may be dropped **only** when the resulting
|
||||
`Projection` is identical to the current one. Anything looser breaks phase 4's
|
||||
no-coalescing contract; anything stricter (emitting on every `info`, including
|
||||
state-only changes) inflates the O5 event rate with non-events.
|
||||
5. **Bind every `Device` global** and read `device.api` **and** `alsa.driver_name` from its
|
||||
`info` props — authoritative, and the phase-3 review's owed fix (on PipeWire ≥ 1.2.6 with
|
||||
WirePlumber < 0.5.13 the driver name is not copied to the node, and the fail-closed
|
||||
absent-driver rule would over-exclude real cards). `factory.name` exists only on the node.
|
||||
`classify` takes both sides; node values are the fallback, Device values win.
|
||||
6. **Ports are NOT bound in v1 — an explicit accepted limitation.** `port.exclusive` is the
|
||||
only port property missing from the global, and it guards a *mutation* (don't fan out into
|
||||
an exclusive port), not echo: an exclusive port rejects the second link, so phase 6 sees a
|
||||
clean link-create failure it must handle correctly anyway. Binding ~21 more objects at
|
||||
rest to pre-empt an error that surfaces safely is not worth the obligation surface in v1.
|
||||
**Revisit trigger:** any phase-6 link-matrix row where an exclusive-port link failure is
|
||||
not cleanly recoverable. (`node.passthrough`, the *other* half of that §6.2 row, is a node
|
||||
property and **is** recovered by this revision.)
|
||||
|
||||
**Exit gate — four parts.** The first is the direct inverse of the F1 finding.
|
||||
|
||||
| gate | proves |
|
||||
| --- | --- |
|
||||
| **live prop recovery**: a `module-null-sink` tagged `peerspeak.owned=true` plus a `module-loopback` reading its monitor — assert the projection carries `peerspeak.owned`, `pulse.module.id`, `node.link-group` **and** `factory.name`/`device.api`/`alsa.driver_name` on a real ALSA node | the eight properties actually arrive — F1 cannot recur silently |
|
||||
| **pure-model prop-update matrix**: props-changed → re-classified; identical props → suppressed; a `session_device`-relevant change flips classification. (A *live* prop mutation has no reliable CLI trigger — the pure test is the gate, a live sighting is opportunistic) | the lifetime-tracking path exists and its suppression rule is exact |
|
||||
| **readiness with node binds**: no projection reports `graph_ready` while a node bind is outstanding; an `info` that never arrives ends in sticky `TimedOut` | withholding and fail-closed timeout still hold with the new obligation class |
|
||||
| **recycled Node id under churn**: repeated add/remove of the same id; no proxy leak, no cross-generation misattribution | the FIFO lockstep rule survives being moved to the hot path |
|
||||
|
||||
**Then re-run the whole phase-5 §5.1 matrix and re-measure O5** with bind I/O included — the
|
||||
existing numbers were taken on the degraded graph and inherit nothing.
|
||||
|
||||
### Phase 4 — AEC identity validation state machine, read-only
|
||||
v3.4 §5.3 verbatim: `NotConfigured / Validating / Validated / Failed / Revoked`;
|
||||
`--aec=off|pulse-module:<idx>` parsing (D5); bounded deadline; **no fan-out while `Validating`**;
|
||||
revocation = loss of *all* nodes bearing the index, never one leg corking. Foreign
|
||||
`echo-cancel-*` groups: warn and exclude (D3).
|
||||
|
||||
**Moved ahead of the dry-run gate (round-1 P1).** v1 put this after the dry-run while the
|
||||
dry-run checklist required AEC validation and revocation semantics — a circular dependency that
|
||||
made the major gate uncompletable as written.
|
||||
|
||||
**Exit gate — a fake-clock/event-sequence transition matrix**, because these are timing
|
||||
semantics a live poke cannot cover: `Validating → Failed` on deadline expiry; `Validating →
|
||||
Validated` on first matching node; partial-node disappearance ⇒ **stays `Validated`**; all
|
||||
nodes gone ⇒ `Revoked`; `Revoked` stops fan-out and drops proxies; a retained stale index does
|
||||
not alias onto a reloaded module (v3.4 §5.2 correction 3 — indices *are* reused). Parsing: JSON
|
||||
number and string forms, `> u32::MAX`, absent, malformed.
|
||||
|
||||
Then wire the state machine's output into the dry-run so `Validating`/`Failed`/`Revoked` are
|
||||
observable in Phase 5 before they gate anything real.
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 5 — dry-run audit mode 🚦 MAJOR GATE
|
||||
|
||||
> **🚦 STATUS 2026-07-25: BUILT, RUN, AND THE GATE FAILED.** Results:
|
||||
> `docs/screenshare-audio-exclusion-phase5-results.md`. The machinery is correct and needs no
|
||||
> rework — **it found the defect on its first live run**, which is the phase working exactly as
|
||||
> designed. What failed is the observer beneath it (v3.5 §6.7). **Phase 6 does not start.** The
|
||||
> matrix re-runs after phase 3r and phase 1's second carrier land; no row was completable
|
||||
> under the defect, so none of it carries over. O5's numbers do not carry over either.
|
||||
>
|
||||
> Read this before re-running: `PIXELPASS_AUDIO_AUDIT_FILE=… PIXELPASS_AUDIO_AUDIT_AEC=off
|
||||
> pixelpass --audit-audio`. **Every partition row must run with `AEC=off`** — a
|
||||
> configured-but-unvalidated AEC shuts the fan-out gate and empties the eligible half of every
|
||||
> row, which reads as a failure that is really a harness error.
|
||||
|
||||
**Adds no capability. Its entire purpose is to be wrong loudly and safely.**
|
||||
|
||||
A hidden trigger (`PIXELPASS_AUDIO_AUDIT=1`) running Phases 2–4 against the live graph on every
|
||||
graph event, emitting per `Stream/Output/Audio` node: serial, name, decision, stable reason
|
||||
code, graph epoch. It creates **no links**. Output goes to **stderr or a defined JSON event** —
|
||||
never unstructured prose into `--output json`, which peerspeak parses
|
||||
(`screenshare/mod.rs:92`).
|
||||
|
||||
Why this is the gate: the C2/C3-class defects are graph-*reasoning* defects. A fixture proves
|
||||
the code matches my model of PipeWire; only a live run proves my model matches PipeWire. A wrong
|
||||
answer here costs a log line; the same wrong answer in Phase 6 costs an echo.
|
||||
|
||||
### 5.1 Every row asserts an exact partition, not a spot check
|
||||
|
||||
Round 2's sharpest structural point: checking only named targets constrains nothing about
|
||||
everything else, so **each row must assert the complete candidate universe partitioned into
|
||||
exact eligible and excluded sets, with reason codes on the excluded side.** That single
|
||||
requirement is also the answer to O7 — it is the over-exclusion gate, because an
|
||||
exclude-everything implementation fails the eligible half of every row.
|
||||
|
||||
| # | Scenario | Excluded (with reason code) | Eligible |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `module-null-sink` + `module-loopback` forwarder (the v3.4 §6.1 measured shape) | output leg, reason = **owner bridge**, naming the key — *not* a Link walk | same forwarder shape with **no** tainted input |
|
||||
| 1b | *opportunistic, non-gating:* Sunshine's null-sink topology while it is routing desktop audio | its forwarder leg, if a re-emitting leg exists | — |
|
||||
| 2 | `gst-launch pulsesrc ! pulsesink` split clients, input **explicitly rooted on a tainted monitor** | output leg via key 4 | the same process reading an **untainted** source |
|
||||
| 3 | **two** Pulse modules; **one** tainted input | the tainted module's output only | **the other module's output must be ELIGIBLE** — this is what makes wrong pipewire-pulse-PID fusion observable |
|
||||
| 4 | peerspeak native call playback | that node, reason = tag | — |
|
||||
| 5 | peerspeak-spawned **mpv** (watched share) | that node, reason = tag | mpv launched by hand |
|
||||
| 6 | peerspeak **notification** sound | that node, reason = tag | — |
|
||||
| 7 | a **second** pixelpass host's capture sink, **plus a controlled forwarder reading that sink's monitor** | the forwarder's **named output serial** (cycle prevention, v3.4 §6.2) | — |
|
||||
| 8 | EasyEffects running | combined output leg | EasyEffects stopped ⇒ ordinary streams |
|
||||
| 9 | Firefox: music only / mic on untainted source / capturing a tainted monitor | the third only (v3.4 §6.1.1) | the first two |
|
||||
| 10 | sticky taint: tainted input leg removed, output leg lives | still excluded | after full owner teardown + restart |
|
||||
| 11 | recycled serial/index/link-group after teardown | — | must **not** inherit taint |
|
||||
| 12 | AEC loaded, then unloaded | four nodes; then `Revoked` | — |
|
||||
| 13 | `Audio/Duplex` device | over-taints, recorded as **known accepted** (v3.4 §6.1 caveat) | — |
|
||||
|
||||
Rows 3 and 7 were vacuous in v2: row 3 had no tainted module, so incorrect fusion of all
|
||||
pipewire-pulse modules changed no emitted decision; row 7 observed a capture sink without naming
|
||||
a downstream candidate, so recognising `pixelpass_capture_*` as a mere sink name would pass
|
||||
without any transitive propagation.
|
||||
|
||||
### 5.2 Also record, per O5
|
||||
Graph-event rate, recompute duration **distribution and maximum**, and whether events queue
|
||||
behind recompute/logging. v3.4 §6.4's "full recompute is fine for v1" then rests on measured
|
||||
headroom and epoch lag rather than on a node count.
|
||||
|
||||
### 5.3 ⚠️ Do not build a gate on a transient topology
|
||||
v1 leaned on v3.4 §6.1.0's "the hazard is LIVE on this machine right now." **Measured
|
||||
2026-07-21 ~14:55 — no longer true**, six hours after it was written: `Default Sink` is
|
||||
`alsa_output.pci-0000_10_00.6.analog-stereo` (IDLE), all three `sink-sunshine-*` null sinks
|
||||
SUSPENDED. Sunshine is still running (pid 4104) and still reads a monitor — but the hardware
|
||||
sink's, via active link `56 → 95`, not a null sink's. So Sunshine running is **not** sufficient
|
||||
for the topology to be present; see plan §11.
|
||||
|
||||
The **controlled fixture (row 1) is authoritative** — deterministic and always available. But
|
||||
the wild sample is not therefore unnecessary: a fixture I build tests my model against my own
|
||||
assumptions, whereas Sunshine is an uncontrived third-party forwarder nobody designed for this
|
||||
test. It stays as row 1b, **opportunistic and non-gating**, because it cannot be relied on to
|
||||
be present.
|
||||
|
||||
**Any surprise here goes back to the design doc as round 8. Phase 6 does not start until this
|
||||
results file exists.**
|
||||
|
||||
---
|
||||
|
||||
## 6. Phases 6–8 — mutation, capability, integration
|
||||
|
||||
### Phase 6 — link manager + status events, driven through the real host path
|
||||
v3.4 §4.2 + §6.2 + §6.3 items 3–4. Non-lingering links (rig gotcha: `object.linger=false` is
|
||||
*ignored* by `pw-link --props` and `pw-cli create-link`; only `pw-link -m` yields one), proxies
|
||||
retained for the life of the share, per-port link sets, "captured" only when **every** required
|
||||
link is `ACTIVE`, same-epoch revalidation immediately before each creation, proxy drop on
|
||||
ancestry becoming unsafe.
|
||||
|
||||
Failure ⇒ report the stream unsupported. **Never** fall back to the default monitor — and after
|
||||
0d that fallback is unconstructible in this mode, by either path.
|
||||
|
||||
**Everything here is measured through the real selector → sink → link manager → `pulsesrc`
|
||||
path**, using 0d's hidden trigger. A harness-only measurement would pass while the production
|
||||
CLI still reaches only legacy branches.
|
||||
|
||||
**Status events land here** (round-1/2: no phase owned them). pixelpass's event enum
|
||||
(`src/common/output.rs:36-66`) has nothing for exclusion status, and capture-spawn failure
|
||||
(`host/mod.rs:309-312`) replies to the viewer while emitting no event at all. Required as
|
||||
**versioned wire-shaped events**, not stderr lines. Without them the safe failure mode is
|
||||
unexplained silence after the first viewer connects — and a sharer who cannot see why will
|
||||
switch back to unsafe whole-desktop audio.
|
||||
|
||||
There are **four** production causes, and each needs an **exact JSON golden plus a cause →
|
||||
emission test** — not a shared "an event is emitted" assertion, which passes while three of the
|
||||
four remain unwired:
|
||||
|
||||
| cause | event | trigger under test |
|
||||
| --- | --- | --- |
|
||||
| per-stream link failure | `stream_unsupported` | link-matrix row 8c |
|
||||
| AEC validation deadline | `aec_failed` | Phase 4 `Validating → Failed` |
|
||||
| AEC identity lost mid-share | `aec_revoked` | Phase 4 `Validated → Revoked` |
|
||||
| foreign `echo-cancel-*` present (D3) | `foreign_aec_warning` | a second AEC module loaded |
|
||||
|
||||
Phase 8 owns the other half of each: parse, traverse the **new mode's** notice channel, and
|
||||
reach the intended UI state. The channel is currently created only for `audio_app`
|
||||
(`core/mod.rs:3424`) and only the two `AppAudio` events are translated (`:3431-3434`).
|
||||
|
||||
#### 6.1 Link-manager matrix (local anchor — v3.4 §12 has only a one-line bullet)
|
||||
|
||||
v2 pointed its most important gate at a "v3.4 §12 bookkeeping matrix" that does not exist.
|
||||
Here it is. Each row is a deterministic test with an injected graph, not a live observation:
|
||||
|
||||
| # | Case | Assertion |
|
||||
| --- | --- | --- |
|
||||
| 1 | graph mutated to tainted **between evaluation and `create_link`** | **zero unsafe `create_link` calls** — not "eventually cleaned up" |
|
||||
| 2 | per-port enumeration | exact set of attempted links and their states |
|
||||
| 3 | partial activation (FL `ACTIVE`, FR not) | **not** reported captured |
|
||||
| 4 | duplicate enumeration of the same node | idempotent; no second link set |
|
||||
| 5 | ancestry becomes unsafe after `ACTIVE` | owned proxies dropped |
|
||||
| 6a | `port.exclusive` port | refused, reason code emitted |
|
||||
| 6b | encoded stream | refused, reason code emitted |
|
||||
| 6c | passthrough (IEC958) stream | refused, reason code emitted |
|
||||
| 7 | capture sink replaced | relink **succeeds** — every required port back to `ACTIVE` and the node reported captured again; stale proxies dropped |
|
||||
| 8a | AEC `Failed` (validation deadline) | plan stays `DesktopExcluding`; no capture |
|
||||
| 8b | AEC `Revoked` mid-share | plan stays `DesktopExcluding`; fan-out stops |
|
||||
| 8c | link creation error | plan stays `DesktopExcluding`; that stream reported unsupported |
|
||||
| 8d | capture-sink creation failure | plan stays `DesktopExcluding`; mode fails, does not degrade |
|
||||
| 8e | readiness-epoch timeout | plan stays `DesktopExcluding`; fail closed |
|
||||
| 9 | an **eligible** late-arriving node | **positively captured** — the over-exclusion counterpart to row 1 |
|
||||
|
||||
Rows 6a–6c were one combined fixture in v3: a single working refusal predicate would have
|
||||
masked two missing ones. Row 7 required only "relink attempted", which a permanently-failing
|
||||
attempt satisfies while v3.4 §4.2 requires a live owner to actually restore links after sink
|
||||
recreation. Rows 8a–8e replace an unenumerated "any failure path", under which testing one
|
||||
handler passes while another silently swaps the plan to `LegacyDesktop`.
|
||||
|
||||
Row 1 is the one v2 could not falsify: "clean → tainted mid-share ⇒ links dropped" can pass by
|
||||
observing eventual removal, while an unsafe link genuinely existed for a window. Row 8 is O8's
|
||||
answer: 0d's enum prevents a `DesktopExcluding` value from *containing* `DefaultMonitor`, but
|
||||
not a failure handler from replacing the whole plan with `LegacyDesktop`, so this needs a
|
||||
release-mode integration test per failure transition. A `debug_assert!` is cheap and worth
|
||||
adding, but it is not a gate.
|
||||
|
||||
Plus the live dynamic matrix (SIGKILL removes owned links; node appearing after share start;
|
||||
sink recreation) and the three-arm leak measurement re-run through the production path with
|
||||
v3.4 §12's rig discipline (`media.class` filter first, never drop stderr, verify the link is
|
||||
in-graph, `parec -d <sink>.monitor`). The deliberately-naive predicate used as that
|
||||
measurement's positive control lives in a **test-only injected implementation**, never a
|
||||
shippable runtime override.
|
||||
|
||||
### Phase 7 — public mode selector + capability advertisement (pixelpass ships first)
|
||||
|
||||
⚠️ **Round-3 P1: nothing in v3 ever promoted the hidden trigger to a public flag.** 0d added an
|
||||
internal mode input; Phase 7 advertised capability and naming; Phase 8 added `--aec`, the picker
|
||||
and status. No phase required the actual **mode selector** to exist publicly or to be passed.
|
||||
The result would be a capability-gated picker entry that, when chosen, still spawns legacy
|
||||
whole-desktop capture — the feature appearing to ship while doing nothing. Reachable: peerspeak's
|
||||
host argv has no mode parameter (`screenshare/mod.rs:152`) and pixelpass's `HostOpts` has no mode
|
||||
field (`cli.rs:153`); v3.4 §11 requires a distinct mode selector.
|
||||
|
||||
So Phase 7 lands **both**:
|
||||
1. the public mode flag (naming per v3.4 §11), replacing the 0d hidden trigger as the production
|
||||
entry point — the hidden trigger may remain for testing;
|
||||
2. D2's **versioned machine-readable** capability response or bitset. Must **not** overload
|
||||
`app_audio_supported: bool` — per-app-strict and desktop-excluding are independent
|
||||
capabilities. `--help` substring probing survives only as the legacy fallback.
|
||||
|
||||
**Old-peerspeak + new-pixelpass golden test lands here, before pixelpass ships**: behaviour
|
||||
byte-identical, absent `--aec` still accepted.
|
||||
|
||||
v3.4 §11 public naming is a **blocking user input at the start of this phase**. Internal typed
|
||||
variant names (0d) do not block on it.
|
||||
|
||||
### Phase 8 — peerspeak integration
|
||||
- `EchoCancelGuard::module_index()` accessor (currently private; only `source_name()` /
|
||||
`sink_name()` exist).
|
||||
- **Emit the public mode flag** when the new picker choice is selected. Gated by an **exact
|
||||
new/new argv golden** that requires *both* the mode flag and `--aec=…` to be present — the
|
||||
round-3 P1. An argv test that checks only `--aec` passes while the mode flag is never sent and
|
||||
pixelpass silently runs legacy capture.
|
||||
- Always pass `--aec=off|pulse-module:<idx>` — absence is not a protocol state (D5).
|
||||
- **Bind capability to the resolved binary path**; re-probe immediately before constructing
|
||||
new-mode argv if resolution differs from the probe's; fail closed. Closes the `:3375-3378`
|
||||
vs `:3409-3419` double-resolution gap.
|
||||
- **New-peerspeak + old-pixelpass golden test** (round-2: the phase map promised "both
|
||||
directions" and only one was specified) — against an old-capability response and an old fake
|
||||
binary: the new picker entry stays absent and **no new flags are emitted**. Path rebinding
|
||||
alone does not test the failure policy.
|
||||
- Parse and surface the Phase 6 status events, with a **causal** test: an event emitted by
|
||||
pixelpass must reach the UI. Matching enums defined independently in both repos would
|
||||
otherwise pass. The notice channel is currently created only when `audio_app` is set
|
||||
(`core/mod.rs:3424`) and only the two `AppAudio` events are translated (`:3431-3434`);
|
||||
everything else is logged and lost — so the new mode needs its own channel creation path.
|
||||
- Capability-gated picker entry; wording per v3.4 §11.
|
||||
- Regression: existing `--app` / `--strict-audio` argv byte-identical to today.
|
||||
|
||||
---
|
||||
|
||||
## 7. Phase 9 — rig upgrade and field tests 🚦 SHIP GATE
|
||||
|
||||
v3.4 §9.2's rig upgrade is **owed before any exclusion claim is published**: two orthogonal
|
||||
PN/MLS probes, windowed per-channel normalised cross-correlation reporting max per-window
|
||||
correlation, plus xrun telemetry. Until it exists the only defensible claim is the gross-leak
|
||||
distinction, in v3.4 §9.2's exact wording.
|
||||
|
||||
**Every row gets a declared pass/fail threshold before the run, not after.** Baseline for all
|
||||
rows: excluded probe ≤ the declared rig criterion; **eligible control audio present**; original
|
||||
playback routes intact; zero surviving owned links or capture sinks after teardown; xrun and CPU
|
||||
within recorded bounds.
|
||||
|
||||
The **full** v3.4 §12 matrix — v1 silently dropped rows 2 and 7:
|
||||
|
||||
1. Sharer in a call while sharing, AEC on **and** off.
|
||||
2. **Sharer simultaneously viewing another share while sharing** (restored). Highest-value test
|
||||
of the child-tag path: mpv playing a watched share while hosting. Reachable —
|
||||
`StartScreenShare` stores a host at `core/mod.rs:3458`, `ViewShare` stores viewer children at
|
||||
`:3525`, no mutual exclusion.
|
||||
3. Lifecycle, each separately: Stop, room leave, UI crash, pixelpass panic, SIGINT, SIGTERM,
|
||||
SIGKILL, last viewer, pipewire-pulse restart, PipeWire daemon restart, `--repair`.
|
||||
4. EasyEffects running for the whole share.
|
||||
5. Output-device switch mid-share via the real `Ctrl+Meta+F` / `Ctrl+Meta+S` scripts.
|
||||
6. Two concurrent hosts; notification mid-share; app that starts playing after the share.
|
||||
7. **Sample-rate / channel / passthrough behaviour on real sinks, and CPU cost** (restored).
|
||||
|
||||
⚠️ **Mid-share taint-root arrival — v3.4 §6.1.4 names an unreachable case, and so did my first
|
||||
replacement.** v3.4 says "AEC-load-mid-share is the case to test": unreachable, because there is
|
||||
exactly one `echo_cancel::enable` site at session join (`core/mod.rs:1850`), the guard moves
|
||||
into `ActiveSession` at `:2729`, and `StartScreenShare` rejects `active_session == None` at
|
||||
`:3397-3404` ("Join a call before sharing your screen") — so the AEC always predates the share.
|
||||
My proposed replacement, "a peer joining creates their playback node," is **also wrong**:
|
||||
peerspeak starts **one mixed playback stream** at session construction (the sole core
|
||||
`start_playback`, `core/mod.rs:1900`), and `PeerJoined` (`:2396-2409`) only admits and connects
|
||||
the sender. No per-peer node is ever created.
|
||||
|
||||
The reachable newly-created mid-share taint roots are: **a notification sound played mid-share**
|
||||
(`notify.rs:265-272`), and **starting to view another share mid-share**, which spawns a tagged
|
||||
mpv/VLC (`screenshare/mod.rs:768-775`). Those are the transition-window field tests. Owned-AEC
|
||||
mid-share load stays a **synthetic** test until a second `enable` site or hot reload arms it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions — final status
|
||||
|
||||
| # | Question | Status |
|
||||
| --- | --- | --- |
|
||||
| O1 | Is 0c a true blocker? | **CLOSED — yes, no demotion path.** The *echo* argument for demoting it is sound and irrelevant: D6 settled it. Balloon ⇒ round 8. |
|
||||
| O2 | Ship the dry-run mode? | **CLOSED — keep**, env-gated, stable reason codes + epoch + serial, stderr or defined JSON event so `--output json` stays clean. |
|
||||
| O3 | Naming | **SPLIT.** The `peerspeak.owned` wire literal is pinned in plan §3 now (a contract, not product wording). Public mode/picker wording remains the **user's call**, blocking at the start of Phase 7 only. |
|
||||
| O4 | Sticky state: engine or observer? | **CLOSED — pure engine.** Observer supplies lifetime-bearing membership/removal facts; the engine decides. |
|
||||
| O5 | Is full recompute really fine? | **CLOSED — measure it in Phase 5**: duration distribution, maximum, and queueing, not a recompute count. |
|
||||
| O6 | Can the default-monitor fallback be made structurally impossible? | **CLOSED — yes, but it took two closures, not one.** Source path *and* sink-input path, both in 0d. Placement 0d rather than Phase 6 was my divergence; Codex agreed in round 2 with the added constraint `0c → 0d`. |
|
||||
| O7 | Does over-exclusion need its own gate? | **CLOSED — subsumed.** Codex correctly narrowed my premise: an exclude-everything build already fails the eligible controls in six Phase 5 rows *provided they are asserted*. Fix is the exact-partition requirement (plan §5.1) plus link-matrix row 9's positive capture assertion. |
|
||||
| O8 | Runtime assertion for "no source switch on failure"? | **CLOSED — 0d's enum is insufficient.** It stops a `DesktopExcluding` value containing `DefaultMonitor`, not a failure handler swapping the whole plan for `LegacyDesktop`. Release-mode integration test per failure transition (link-matrix row 8); `debug_assert!` in addition, but it is not the gate. |
|
||||
|
||||
---
|
||||
|
||||
## 9. Risk register
|
||||
|
||||
| Risk | Where it bites | Mitigation |
|
||||
| --- | --- | --- |
|
||||
| Engine correct, **source** wrong | full echo, engine bypassed | 0d path 1 — typed capture plan |
|
||||
| Engine correct, **sink inputs** poisoned | full echo, no source switch anywhere | 0d path 2 — bare sink type + conflict rejection + graph assertion |
|
||||
| Taint engine subtly wrong about real PipeWire | Phase 6 leaks the call into the share | Phase 5 exact-partition gate with negative controls |
|
||||
| Unsafe link exists briefly, then is cleaned up | a real leak that "eventual cleanup" tests score as a pass | link-matrix row 1: zero unsafe `create_link` calls |
|
||||
| Link manager built before the engine is validated | same, discovered in front of a viewer | strict DAG; plan §0's stated temptation |
|
||||
| Tag literal mismatch across repos | v3.4 §5.1 silently does nothing, *quietly* | literal pinned in plan §3; cross-repo black-box test; consumption gated in Phase 5 rows 4–6 |
|
||||
| Cross-repo skew | share hard-fails on spawn | pixelpass-first; a golden test in **each** direction; capability bound to resolved path |
|
||||
| Fail-closed with no explanation | user switches back to unsafe whole-desktop audio | causal status-delivery test, Phase 6 → Phase 8 |
|
||||
| Over-exclusion ships as "working" | mode captures silence, all gates pass | exact partitions (plan §5.1) + link-matrix row 9 — **🟢 FIRED 2026-07-25 and worked**: the build *was* the exclude-everything degenerate case, and the empty eligible half is what exposed it |
|
||||
| **A property the engine reads is silently absent at the observation boundary** | engine correct, context permanently `None`; fails in *both* directions at once (F1: no taint root ⇒ echo; F2: no owner key ⇒ exclude everything) | **v3.5 §6.7 — never read node/device props off a registry global.** Phase 3r's live prop-recovery gate asserts each one arrives. General form: `pw-dump` is a **bound** view; the registry is not, and the difference is silent |
|
||||
| Wrong pipewire-pulse PID | mass over-exclusion from a correct engine on a wrong context | Phase 3 PID-derivation matrix |
|
||||
| 0c balloons | prerequisites eat the schedule | reopen D6 as round 8 — no silent waiver |
|
||||
| "It works on my box" | the only box is this box | two-machine field test is the ship gate |
|
||||
|
||||
## 10. Adjudication record
|
||||
|
||||
**Round 1 — 13 items, 12 accepted.** Phase reorder (AEC machine before dry-run); typed capture
|
||||
plan (accepted, moved *earlier* than proposed); Phase 3 five-part gate; tag-consumption gating;
|
||||
Phase 6 matrix mandatory; 0b unwind backstop restored **and my mutation test corrected — it
|
||||
targeted the wrong mutation**; Phase 9 rows restored with pre-declared thresholds; DAG stated;
|
||||
O1 demotion language removed; 0c two-host gate; skew tests moved to Phase 7; status events
|
||||
assigned. **Half-rejected:** "the Sunshine topology is unverified and unnecessary" — *unverified*
|
||||
was right and it has since flipped; *unnecessary* rejected, retained as non-gating row 1b.
|
||||
|
||||
**Round 2 — 11 items, all accepted.** The three that mattered:
|
||||
|
||||
- **P1, the sink-input path.** My 0d closed the source and left the capture sink's inputs open;
|
||||
`PIXELPASS_AUDIO_VIA_NULL_SINK` + `Routing::start` would have filled the owned sink with the
|
||||
whole-desktop mix and produced full echo with no source switch. This is the "at least one
|
||||
comparable error" I asked round 2 to find, and it was in the fix for round 1's headline P1.
|
||||
- **P2, my v3.4 §6.1.4 replacement was also unreachable.** I claimed a peer joining creates their
|
||||
playback node; verified false — one mixed playback stream at session construction
|
||||
(`core/mod.rs:1900`), `PeerJoined` only admits the sender. Replaced with notification sound and
|
||||
watched-share start, both reachable.
|
||||
- **P1, no invocable production selector**, so Phase 6's "production path" measurement would have
|
||||
run through a harness. Internal mode input moved into 0d.
|
||||
|
||||
**Round 3 — verification pass, 5 items, all accepted.** It confirmed 4 of the 7 round-2 edits
|
||||
landed and 3 were partial, which is the reason to run a verification round at all rather than
|
||||
declaring the fixes done. The one that mattered:
|
||||
|
||||
- **P1, the public mode selector was never assigned to any phase.** 0d added a hidden trigger,
|
||||
Phase 7 added capability + naming, Phase 8 added `--aec` — and nothing required the mode flag
|
||||
itself to exist publicly or be passed. A capability-gated picker entry would have appeared and,
|
||||
when chosen, spawned legacy whole-desktop capture: the feature shipping while doing nothing,
|
||||
with the echo intact. Fixed in Phase 7 (flag) and Phase 8 (emission + new/new argv golden).
|
||||
- Three link-matrix rows I had just written were insufficiently falsifiable — a combined
|
||||
exclusive/encoded/passthrough fixture (one working predicate masks two missing), "relink
|
||||
attempted" (a permanently-failing attempt passes), and an unenumerated "any failure path".
|
||||
Split into 6a–6c, a success assertion, and 8a–8e.
|
||||
- Status delivery was gated by one generic causal test that passes while three of the four
|
||||
events stay unwired. Now four exact JSON goldens with named triggers.
|
||||
- `0b` was drawn in the DAG with no outgoing edge; it now explicitly precedes Phase 6.
|
||||
|
||||
**Codex's positions I narrowed:** it agreed the Sunshine sample is worth keeping as non-gating,
|
||||
and corrected my wording — the topology appears when Sunshine *routes desktop audio through its
|
||||
null-sink topology*, not merely whenever Sunshine is running, since I measured it running
|
||||
without that topology. It also correctly narrowed O7's premise (an exclude-everything build does
|
||||
already fail six rows' eligible controls, *if* asserted) while agreeing the exact-partition fix
|
||||
is right.
|
||||
|
||||
**Verified by me before accepting:** the single `default_audio_monitor` call site
|
||||
(`pipeline.rs:138`); the double binary resolution (`core/mod.rs:3375-3378` vs `:3409-3419`); the
|
||||
no-session guard on `StartScreenShare` (`:3397-3404`); the sole core `start_playback` (`:1900`)
|
||||
and `PeerJoined`'s scope (`:2396-2409`); and the current default-sink/Sunshine graph state.
|
||||
|
||||
## 11. Corrections owed to the design doc — ✅ APPLIED in v3.5 (round 8, 2026-07-25)
|
||||
|
||||
Both are now in the design doc (§6.1.0 and §6.1.4 respectively), alongside round 8's own
|
||||
finding (§6.7, the observation boundary). Kept here as the record of what was owed and why:
|
||||
|
||||
1. **v3.4 §6.1.0's "🔴 the hazard is LIVE on this machine right now" is time-dependent and has
|
||||
already flipped.** Measured 2026-07-21 ~14:55 (details in plan §5.3). The reachability
|
||||
argument is unaffected — the topology appears **when Sunshine routes desktop audio through
|
||||
its null-sink topology**, which is narrower than "whenever Sunshine is running," since it was
|
||||
measured running without it. Nothing should gate on its presence.
|
||||
2. **v3.4 §6.1.4's nominated test case is unreachable, and so was my first replacement.**
|
||||
Details in plan §7. The conclusion (the transition window exists only for newly-created
|
||||
roots) stands; the example must become the notification sound or watched-share start.
|
||||
|
||||
## 12. Not in this plan
|
||||
|
||||
**Round 8 additions:** **port binding** (so `port.exclusive` is never observed — plan §4 "Phase
|
||||
3 revision" item 6, with its revisit trigger), **per-node quarantine** (an unresolvable node
|
||||
bind fails the whole readiness epoch closed instead of isolating that one node — v3.5 §6.7
|
||||
decision 3), and the **serial-continuity signal** for the AEC validator's no-coalescing
|
||||
contract (phase 4's owed F4 hardening).
|
||||
|
||||
Everything v3.4 §14 lists as out of v1 — port-granular taint, timed drain, hot-AEC-reload epoch
|
||||
protocol, native PipeWire AEC, incremental dirty-set, seamless daemon-restart recovery — plus
|
||||
v3.4 §10 items 2 and 3 (per-app debt; D6 says they do not block Option C), the v3.4 §5.1
|
||||
grandchild leak, and the general "AEC binds to the default sink when no device is pinned" defect
|
||||
(v3.4 §6.1.0, resolved for this user, own task).
|
||||
@@ -0,0 +1,282 @@
|
||||
# Phase 5 — dry-run audit gate: results
|
||||
|
||||
**Status: 🚦 GATE FAILED. Phase 6 does not start.** Two defects found, one of them
|
||||
fatal to the whole mechanism. Both go to the design doc as **round 8** per impl
|
||||
plan §5.3.
|
||||
|
||||
- **Run date:** 2026-07-25
|
||||
- **Host:** `cazen` — PipeWire 1.6.8, WirePlumber 0.5.15, CachyOS
|
||||
- **Audit build:** pixelpass branch `phase5-dry-run-audit`, release profile
|
||||
- **Ambient load during the runs:** FINAL FANTASY XIV playing audio (`client.id`
|
||||
88, pid 14651), Arctis 1 Wireless as an active sink
|
||||
|
||||
The audit itself worked exactly as designed: it observed the live graph, ran
|
||||
phases 2–4 on every registry event, created no links, and reported a complete
|
||||
eligible/excluded partition with stable reason codes. **It found the defects on
|
||||
the first live run.** That is the phase doing its job — §5's argument was that a
|
||||
fixture proves the code matches my model of PipeWire while only a live run proves
|
||||
my model matches PipeWire, and my model was wrong.
|
||||
|
||||
---
|
||||
|
||||
## F1 🔴 FATAL — the registry `global` event delivers only a filtered subset of node properties
|
||||
|
||||
**The phase-3 adapter reads eight node properties that the PipeWire registry
|
||||
never announces.** They are parsed off `obj.props` in the registry `global`
|
||||
callback (`pixelpass/src/host/observer/adapter.rs`), where they are silently
|
||||
absent, so every one of them is permanently `None`/`false`.
|
||||
|
||||
### Measured
|
||||
|
||||
The complete set of keys the registry announces for a `Node` global on this host
|
||||
(union over every node, via `pw-cli ls Node`):
|
||||
|
||||
```
|
||||
application.name client.api client.id device.id factory.id media.class
|
||||
node.description node.name node.nick object.path object.serial
|
||||
priority.driver priority.session
|
||||
```
|
||||
|
||||
Against what the adapter tries to read:
|
||||
|
||||
| property | announced? | what dies without it |
|
||||
| --- | --- | --- |
|
||||
| `object.serial` | ✅ | — |
|
||||
| `node.name` | ✅ | — |
|
||||
| `media.class` | ✅ | — |
|
||||
| `client.id` | ✅ | — |
|
||||
| `device.id` | ✅ | — |
|
||||
| **`peerspeak.owned`** | ❌ | **the primary taint root (v3.4 §5.1, all of phase 1)** |
|
||||
| **`pulse.module.id`** | ❌ | **AEC identity exclusion + phase 4 validation** |
|
||||
| **`node.link-group`** | ❌ | the link-group owner key (echo-cancel, EasyEffects, loopback siblings) |
|
||||
| **`application.process.id`** | ❌ | the process owner key (GStreamer split clients, §5.1 row 2) |
|
||||
| **`node.passthrough`** | ❌ | the passthrough local exclusion (a second link corrupts an encoded stream) |
|
||||
| **`device.api`** | ❌ | `session_device` classification |
|
||||
| **`factory.name`** | ❌ | `session_device` classification — the discriminator itself |
|
||||
| **`alsa.driver_name`** | ❌ | `session_device` classification (the `snd_aloop` denylist) |
|
||||
|
||||
Ports and Links are also affected, one materially:
|
||||
|
||||
| object | announced | missing |
|
||||
| --- | --- | --- |
|
||||
| Port | `node.id`, `object.serial`, `port.direction`, `port.monitor`, `port.physical`, `port.terminal`, `port.group`, `port.alias`, `port.name`, `port.id`, `audio.channel`, `format.dsp` | **`port.exclusive`** — the `port-exclusive` local exclusion never fires |
|
||||
| Link | `object.serial`, `link.output.node`, `link.input.node`, `link.output.port`, `link.input.port`, `client.id`, `factory.id` | nothing the engine needs |
|
||||
| Client | `object.serial`, **`pipewire.sec.pid`**, `application.name`, `module.id`, `pipewire.access`, `pipewire.protocol`, `pipewire.sec.{uid,gid,socket}` | nothing the engine needs |
|
||||
|
||||
**Links and Clients are fine.** Notably the pulse-PID derivation (v3.4 §6.1.2)
|
||||
works: `pipewire.sec.pid` is announced. Also notable: the Link endpoint props are
|
||||
*always* present, which confirms the phase-3 exit-gate worry that the
|
||||
bind-`LinkInfoRef` fallback is dead code in practice — it is correctness
|
||||
insurance, never exercised on this host.
|
||||
|
||||
### Demonstrated end to end
|
||||
|
||||
A null sink carrying `peerspeak.owned=true`, its monitor read by a
|
||||
`module-loopback` whose playback leg is a fan-out candidate — the exact shape the
|
||||
tag exists to exclude:
|
||||
|
||||
```
|
||||
pactl load-module module-null-sink sink_name=ppgate_src \
|
||||
sink_properties="peerspeak.owned=true"
|
||||
pactl load-module module-loopback source=ppgate_src.monitor sink=ppgate_dest \
|
||||
source_output_properties=node.name=ppgate_cap \
|
||||
sink_input_properties=node.name=ppgate_play
|
||||
```
|
||||
|
||||
Audit verdict:
|
||||
|
||||
```json
|
||||
{"kind":"audit","graph_ready":true,"epoch":"complete","aec_state":"not-configured",
|
||||
"fan_out_permitted":true,
|
||||
"candidates":[{"serial":280,"name":"FINAL FANTASY XIV","eligible":true,"sticky":false},
|
||||
{"serial":309,"name":"ppgate_play","eligible":true,"sticky":false}],
|
||||
"eligible_count":2,"excluded_count":0,"taint":[]}
|
||||
```
|
||||
|
||||
`ppgate_play` **eligible**, and the `taint` set **empty** — the tagged sink was
|
||||
not even recognised as a root. In phase 6 this is an echo: peerspeak's own call
|
||||
playback carries `peerspeak.owned` and would be fanned straight into the share.
|
||||
|
||||
The AEC path fails in the other direction. With
|
||||
`PIXELPASS_AUDIO_AUDIT_AEC=pulse-module:536870918` (a real live module index):
|
||||
|
||||
```
|
||||
aec_state = failed fan_out_permitted = false gate_reason = aec-failed
|
||||
```
|
||||
|
||||
Correct behaviour given its inputs — `pulse.module.id` never arrives, so the
|
||||
identity can never be observed and the validator times out fail-closed — but it
|
||||
means **§5.1 row 12 cannot be run as written**, and that with a real AEC
|
||||
configured phase 6 would refuse to share any audio at all.
|
||||
|
||||
### The fix (for round 8)
|
||||
|
||||
The full property set *is* reachable: **bind each Node global and read the props
|
||||
off its `info` event**, which is exactly how `pw-dump` obtains them. Verified on
|
||||
the same objects that were missing them from the registry:
|
||||
|
||||
```
|
||||
alsa_output.usb-SteelSeries… factory.name = 'api.alsa.pcm.sink'
|
||||
device.api = 'alsa'
|
||||
alsa.driver_name = 'snd_usb_audio'
|
||||
ppgate_src peerspeak.owned = True
|
||||
pulse.module.id = 536870917
|
||||
ppgate_play pulse.module.id = 536870918
|
||||
node.link-group = 'loopback-2528-13'
|
||||
FINAL FANTASY XIV application.process.id = 14651
|
||||
```
|
||||
|
||||
Two notes for whoever designs that change:
|
||||
|
||||
- **The pattern already exists.** Phase 3 built exactly this for Links (bind →
|
||||
`LinkInfoRef` → `LinkEndpointsResolved`, "the optimisation is the props, the
|
||||
bind is the correctness path"). Nodes need the same, but as the *only* path
|
||||
rather than a fallback, and the readiness epoch must hold an obligation per
|
||||
unbound node — which the model already supports (`withheld` / `pending_links`).
|
||||
- **`factory.id` is not a shortcut.** The Factory global for `factory.id=19`
|
||||
(which every ALSA node claims) resolves to `factory.name = "adapter"`, not
|
||||
`api.alsa.pcm.sink`. The node's own `factory.name` is a different property and
|
||||
binding is the only way to it.
|
||||
|
||||
Also relevant: **`device.api` is announced on the *Device* global** even though it
|
||||
is absent from the Node. That is the phase-3 review's owed fix ("read the ALSA
|
||||
driver from the backing Device global, authoritative") — now not merely better
|
||||
but load-bearing, though `factory.name` and `alsa.driver_name` are absent from
|
||||
the Device global too, so node binding is still required.
|
||||
|
||||
---
|
||||
|
||||
## F2 🟠 Machine-wide over-exclusion cascade, downstream of F1
|
||||
|
||||
With F1 in force, `pixelpass_capture_*` (matched on `node.name`, which *is*
|
||||
announced) is the only taint root that still fires. Running §5.1 row 7 —
|
||||
a capture sink plus a controlled forwarder reading its monitor:
|
||||
|
||||
```
|
||||
candidates:
|
||||
FINAL FANTASY XIV | eligible: false | reason: unresolved-owner
|
||||
ppgate7_play | eligible: false | reason: tainted-owner-bridge
|
||||
taint:
|
||||
Midi-Bridge | tainted-owner-bridge
|
||||
bluez_midi.server | tainted-owner-bridge
|
||||
alsa_output.pci-0000_03_00.1.hdmi-stereo-… | tainted-owner-bridge
|
||||
alsa_output.usb-SteelSeries_…-analog-stereo | tainted-upstream
|
||||
alsa_input.usb-SteelSeries_…-mono-fallback | tainted-owner-bridge
|
||||
alsa_output.pci-0000_10_00.6.analog-stereo | tainted-owner-bridge
|
||||
alsa_input.pci-0000_10_00.6.analog-stereo | tainted-owner-bridge
|
||||
FINAL FANTASY XIV | unresolved-owner
|
||||
ppgate_dest | tainted-upstream
|
||||
pixelpass_capture_ppgate7 | pixelpass-owned
|
||||
ppgate7_play | tainted-owner-bridge
|
||||
ppgate7_cap | tainted-upstream
|
||||
```
|
||||
|
||||
Row 7's own assertion held — `ppgate7_play` is excluded via the owner bridge, so
|
||||
the cycle-prevention mechanism works. But the row **fails the §5.1 exact-partition
|
||||
requirement**, because the eligible half is empty: FFXIV should have been
|
||||
eligible and was not.
|
||||
|
||||
The mechanism: with `node.link-group`, `application.process.id` and
|
||||
`pulse.module.id` all absent, no node has a *strong* owner key — `client.id` is
|
||||
explicitly not one (v3.4 §6.1.3). So every tainted capture stream is an
|
||||
**unbounded tainted reader**, which trips phase 2's documented fail-closed
|
||||
backstop (`taint/mod.rs`, `an_unbounded_tainted_reader_excludes_every_output`)
|
||||
and excludes every `Stream/Output/Audio` on the machine. Every device node
|
||||
separately keeps its coarse keys (`session_device` is universally false, also from
|
||||
F1) and they all share WirePlumber's `client.id = 42`, which fuses them into a
|
||||
single owner and spreads the taint across the whole device layer.
|
||||
|
||||
So the engine's *net* live behaviour today is: exclude everything, always, as soon
|
||||
as pixelpass's own capture sink exists. Fail-closed, so silence rather than echo —
|
||||
but the feature is entirely non-functional, and it is non-functional in a way that
|
||||
would have looked like "working safely" to any test that only asserted exclusions.
|
||||
|
||||
**This is the §5.1 argument vindicated in the most direct possible way.** The
|
||||
current build *is* the degenerate exclude-everything implementation the plan
|
||||
warned about, and it is the eligible half of the partition — asserted, per §5.1 —
|
||||
that caught it. An exclusion-only checklist would have passed this build.
|
||||
|
||||
---
|
||||
|
||||
## §5.2 — O5 measurements
|
||||
|
||||
Recorded under deliberate churn: five load/unload cycles of
|
||||
`module-null-sink` + `module-loopback`, 6.5 s wall.
|
||||
|
||||
```json
|
||||
{"kind":"metrics","graph_events":308,"tick_events":26,"emitted_records":308,
|
||||
"span_us":6499634,"graph_events_per_sec":47.39,
|
||||
"recompute_max_us":15,"recompute_mean_us":4,
|
||||
"recompute_p50":"<50us","recompute_p90":"<50us","recompute_p99":"<50us",
|
||||
"recompute_distribution":[["<50us",334]],
|
||||
"emit_max_us":12,"emit_mean_us":2,"emit_distribution":[["<50us",308]],
|
||||
"busy_us":2331,"busy_fraction":0.0004,
|
||||
"queued_events":198,"queue_threshold_us":100}
|
||||
```
|
||||
|
||||
**O5 is closed: full recompute per graph event has roughly four orders of
|
||||
magnitude of headroom.** Every one of 334 recomputes finished in under 50 µs, the
|
||||
worst at 15 µs, against a 47 Hz event rate under churn far heavier than a desktop
|
||||
produces at rest. The observer thread spent 0.04 % of wall time working.
|
||||
|
||||
`queued_events: 198` looks alarming and is not: PipeWire delivers enumeration and
|
||||
teardown as back-to-back bursts, so most events do begin within 100 µs of the
|
||||
previous one completing. With a 15 µs worst-case recompute the backlog drains
|
||||
faster than it forms. `busy_fraction` is the number to trust here — it needs no
|
||||
inference, and it is 0.0004.
|
||||
|
||||
**Caveat, and it is a real one.** These numbers were measured on the *degraded*
|
||||
graph F1 produces. The recompute cost is over the same node and link count so the
|
||||
taint-engine figure is representative, but the F1 fix adds a bind and an `info`
|
||||
round-trip **per node**, which is new I/O this run did not measure at all. O5
|
||||
should be re-measured after round 8 rather than inherited from here.
|
||||
|
||||
---
|
||||
|
||||
## Matrix status (§5.1)
|
||||
|
||||
| # | scenario | status |
|
||||
| --- | --- | --- |
|
||||
| 1 | null-sink + loopback forwarder, owner bridge | ⛔ blocked by F1 — needs a taint root (`peerspeak.owned`) |
|
||||
| 1b | Sunshine's topology (opportunistic, non-gating) | not attempted |
|
||||
| 2 | gst split clients, tainted input | ⛔ blocked by F1 — needs `application.process.id` |
|
||||
| 3 | two Pulse modules, one tainted | ⛔ blocked by F1 |
|
||||
| 4–6 | peerspeak playback / mpv / notification | ⛔ blocked by F1 — all three are `peerspeak.owned` tags |
|
||||
| 7 | second host's capture sink + forwarder | 🟠 mechanism verified, **partition fails** (F2) |
|
||||
| 8 | EasyEffects | ⛔ blocked by F1 — needs `node.link-group` |
|
||||
| 9 | Firefox three cases | ⛔ blocked by F2 (everything excluded) |
|
||||
| 10 | sticky taint across teardown | ⛔ blocked by F1 |
|
||||
| 11 | recycled serial / index / link-group | ⛔ blocked by F1 |
|
||||
| 12 | AEC loaded → unloaded → Revoked | ⛔ blocked by F1 — `pulse.module.id` never arrives; validator goes `failed` |
|
||||
| 13 | `Audio/Duplex` device | not attempted (none present on this host) |
|
||||
|
||||
**No row can be completed until F1 is fixed.** The matrix is not re-runnable in a
|
||||
meaningful sense before then — every row's eligible half is empty for the same
|
||||
reason.
|
||||
|
||||
---
|
||||
|
||||
## What the audit machinery got right
|
||||
|
||||
Worth recording, because none of it needs revisiting in round 8:
|
||||
|
||||
- Running the recompute **inline on the observer thread**, once per applied
|
||||
registry event, upholds phase 4's no-coalescing contract and put the cost
|
||||
exactly where O5 could measure it.
|
||||
- The **complete-partition record** is what caught F2. A record of only the
|
||||
interesting nodes would have shown row 7 passing.
|
||||
- **Reason codes survived the trip** and were immediately diagnostic:
|
||||
`unresolved-owner` on FFXIV named the backstop, not a symptom, and pointed
|
||||
straight at the missing strong keys.
|
||||
- The **`peerspeak.owned` / `pulse.module.id` fixtures were right** — phase 2's
|
||||
engine does the correct thing when handed correct properties. The defect is
|
||||
entirely at the observation boundary, which is where phase 5 was designed to
|
||||
look.
|
||||
|
||||
## Next
|
||||
|
||||
1. **Design round 8** on F1: node binding in the observer, readiness obligations
|
||||
per unbound node, and where `session_device` reads its inputs from.
|
||||
2. Re-run this matrix in full afterwards. Rows 4–6 additionally need peerspeak
|
||||
running; rows 8, 9 and 1b need EasyEffects, Firefox and Sunshine respectively.
|
||||
3. Re-measure O5 with node binding in place.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -328,4 +328,54 @@ mod tests {
|
||||
assert_eq!(seek_target(-1.0, total), Duration::ZERO);
|
||||
assert_eq!(seek_target(2.0, total), total);
|
||||
}
|
||||
|
||||
/// **The fourth playback path's exit gate (round 10, R10-2).** Drives a
|
||||
/// real [`ClipPlayer`] — the same object the app uses for chat clips, peer
|
||||
/// music and the local playlist — and asserts the node it puts on the
|
||||
/// graph carries both ownership carriers.
|
||||
///
|
||||
/// This path was untagged through all of phase 1, which is a real echo:
|
||||
/// B broadcasts music, A tunes in, A shares their desktop, B hears their
|
||||
/// own track. It was missed because phase 1 worked from the impl plan's
|
||||
/// list of three playback sites and that list was incomplete — so this
|
||||
/// gate drives the *player*, not the tagging helper.
|
||||
///
|
||||
/// ⚠️ **Run alone**: it sets a process-wide environment variable, which is
|
||||
/// only sound single-threaded. In production `main` does this before
|
||||
/// anything is spawned; a test binary has no such guarantee, hence
|
||||
/// `--test-threads=1`.
|
||||
///
|
||||
/// `cargo test --lib -- --ignored --test-threads=1 clip_player_node`
|
||||
#[test]
|
||||
#[ignore = "live: requires a running PipeWire daemon and pw-dump; run with --test-threads=1"]
|
||||
fn clip_player_node_carries_both_ownership_carriers() {
|
||||
use crate::audio::ownership::{self, live_test};
|
||||
|
||||
// SAFETY: `--test-threads=1` is documented above and in the ignore
|
||||
// reason; this is the same call `main` makes, exercised for real
|
||||
// rather than reimplemented, so the gate cannot pass against a
|
||||
// formatter that production never uses.
|
||||
unsafe { ownership::tag_this_process_alsa_audio() };
|
||||
|
||||
let (player, _status) = ClipPlayer::new(1.0);
|
||||
// Six seconds of silence: long enough for the poll, inaudible.
|
||||
player.play([0u8; 32], live_test::silent_wav(6));
|
||||
|
||||
let prefix = live_test::expected_prefix(ownership::CLIP_ROLE);
|
||||
let found = live_test::poll_for_owned_node(&prefix, Duration::from_secs(5));
|
||||
player.stop();
|
||||
|
||||
let (name, owned) = found.unwrap_or_else(|| {
|
||||
panic!("no live clip-player node named {prefix:?} appeared within 5s")
|
||||
});
|
||||
assert!(
|
||||
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(
|
||||
owned.as_deref(),
|
||||
Some(ownership::OWNED_PROP_VALUE),
|
||||
"carrier 1 must be on the live node, not just carrier 2"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,10 @@ pub mod eq;
|
||||
pub mod gate;
|
||||
pub mod limiter;
|
||||
pub mod multitrack;
|
||||
// The cross-repo ownership tag (plan §5.1). Platform-neutral on purpose: the
|
||||
// carriers only matter on PipeWire, but the literals are a wire contract and
|
||||
// their test must run on every platform so a rename can't pass CI elsewhere.
|
||||
pub mod ownership;
|
||||
pub mod pan;
|
||||
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
|
||||
// pure, so it builds (and its tests run) everywhere even though only the cpal
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
use crate::audio::ownership;
|
||||
use crate::audio::{AudioBackend, AudioError};
|
||||
use pipewire as pw;
|
||||
use pw::{properties::properties, spa};
|
||||
@@ -371,6 +372,11 @@ fn run_playback(
|
||||
mainloop_clone.quit();
|
||||
});
|
||||
|
||||
// Ownership tag, both carriers (`crate::audio::ownership`, plan §5.1).
|
||||
// This is the node that carries the far end's voice, so it is the single
|
||||
// most important thing for pixelpass to refuse to fan out: sharing it
|
||||
// would send the call back to the person already speaking on it.
|
||||
let owned_node_name = ownership::owned_node_name(ownership::NATIVE_PLAYBACK_ROLE);
|
||||
let mut props = properties! {
|
||||
*pw::keys::MEDIA_TYPE => "Audio",
|
||||
*pw::keys::MEDIA_CATEGORY => "Playback",
|
||||
@@ -379,6 +385,19 @@ fn run_playback(
|
||||
// buffer — the real fix is the explicit Buffers param below — but it
|
||||
// expresses the intended quantum for any node that honours it.
|
||||
*pw::keys::NODE_LATENCY => "1024/48000",
|
||||
ownership::OWNED_PROP_KEY => ownership::OWNED_PROP_VALUE,
|
||||
// Set explicitly rather than relying on the stream name passed to
|
||||
// `StreamBox::new` below: props win over that name, and this one has
|
||||
// to be exact.
|
||||
*pw::keys::NODE_NAME => owned_node_name.as_str(),
|
||||
// Measured: this stream sets neither `application.name` nor a
|
||||
// description, so a mixer falls back to `node.name` — which the line
|
||||
// above just turned into an internal identifier. The plan's rule is
|
||||
// that the ownership prefix must not reach `node.description`; a
|
||||
// human label there is what keeps that rule's *intent* (mixers stay
|
||||
// readable) true for our own stream, exactly as mpv's own
|
||||
// description does for the spawned players.
|
||||
*pw::keys::NODE_DESCRIPTION => "PeerSpeak",
|
||||
};
|
||||
if let Some(target) = target_node {
|
||||
props.insert("node.target", target);
|
||||
@@ -637,6 +656,62 @@ mod tests {
|
||||
use std::time::Duration;
|
||||
use std::{sync::mpsc, thread};
|
||||
|
||||
/// Phase-1 exit gate, native-playback half (impl plan §3): the stream
|
||||
/// that carries the far end's voice appears on the graph with **both**
|
||||
/// ownership carriers, and still with the `Communication` media role.
|
||||
///
|
||||
/// The third and most important of the three tagged paths — this is the
|
||||
/// node whose audio, if fanned out, would send the call back to whoever
|
||||
/// is speaking on it.
|
||||
///
|
||||
/// Feeds silence, so the gate is inaudible. Live: needs PipeWire and
|
||||
/// `pw-dump`. `cargo test --lib -- --ignored native_playback`
|
||||
#[test]
|
||||
#[ignore = "live: requires a running PipeWire daemon and pw-dump"]
|
||||
fn native_playback_node_carries_both_ownership_carriers() {
|
||||
use crate::audio::ownership::{self, live_test};
|
||||
use crate::audio::{AudioBackend, PLAYBACK_TARGET_SAMPLES};
|
||||
|
||||
let backend = super::PipeWireBackend::new();
|
||||
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
||||
let ring_fill = Arc::new(AtomicUsize::new(0));
|
||||
backend
|
||||
.start_playback(rx, None, ring_fill.clone())
|
||||
.expect("playback starts");
|
||||
|
||||
// Keep the ring fed so the node stays live for the whole poll; the
|
||||
// stream is created on connect, but a starved one is not a fair test
|
||||
// of what a real call looks like on the graph.
|
||||
let feeder = thread::spawn(move || {
|
||||
let silence = vec![0i16; 960 * 2];
|
||||
for _ in 0..300 {
|
||||
if ring_fill.load(Ordering::Relaxed) < PLAYBACK_TARGET_SAMPLES
|
||||
&& tx.send(silence.clone()).is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
});
|
||||
|
||||
let prefix = live_test::expected_prefix(ownership::NATIVE_PLAYBACK_ROLE);
|
||||
let found = live_test::poll_for_owned_node(&prefix, Duration::from_secs(5));
|
||||
let _ = backend.stop();
|
||||
let _ = feeder.join();
|
||||
|
||||
let (name, owned) =
|
||||
found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s"));
|
||||
assert!(
|
||||
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(
|
||||
owned.as_deref(),
|
||||
Some(ownership::OWNED_PROP_VALUE),
|
||||
"carrier 1 must be on the live node, not just carrier 2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_in_range_is_honored() {
|
||||
// The graph's requested quantum is produced verbatim when it fits.
|
||||
|
||||
@@ -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);
|
||||
|
||||
+16
@@ -3,6 +3,22 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
// Tag the audio we play through ALSA (rodio's `ClipPlayer`: chat clips,
|
||||
// peer music, local playlist tracks) so the screen-share exclusion engine
|
||||
// can recognise it as ours and refuse to fan it back to the far end.
|
||||
//
|
||||
// First statement in the program, and that is load-bearing: this sets an
|
||||
// environment variable, which is only sound while the process is still
|
||||
// single-threaded, and PipeWire's ALSA plugin reads it when a stream is
|
||||
// opened. See `audio::ownership::tag_this_process_alsa_audio`.
|
||||
//
|
||||
// SAFETY: nothing has been spawned yet, so no thread can be reading the
|
||||
// environment concurrently.
|
||||
#[cfg(target_os = "linux")]
|
||||
unsafe {
|
||||
peerspeak::audio::ownership::tag_this_process_alsa_audio()
|
||||
};
|
||||
|
||||
if let Err(e) = peerspeak::app::run_gui() {
|
||||
eprintln!("Error running GUI: {:?}", e);
|
||||
}
|
||||
|
||||
+81
-4
@@ -10,6 +10,8 @@
|
||||
//! leaves a zombie. Any failure (no player, no audio) is silent by design — a
|
||||
//! missing chime should never disrupt a call.
|
||||
|
||||
#[cfg(not(windows))]
|
||||
use crate::audio::ownership;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
@@ -80,6 +82,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 +103,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 +123,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 +141,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 +159,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",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,12 +266,20 @@ fn escape_powershell_single_quoted(s: &str) -> String {
|
||||
#[cfg(not(windows))]
|
||||
fn spawn_player(path: &Path) {
|
||||
for player in ["pw-play", "paplay", "aplay"] {
|
||||
let started = Command::new(player)
|
||||
let mut command = Command::new(player);
|
||||
command
|
||||
.arg(path)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
.stderr(Stdio::null());
|
||||
// Ownership tag (plan §5.1). A chime is short, but it is still our
|
||||
// audio on the default sink, and an untagged one is an unowned root
|
||||
// the exclusion engine would have to reason about from scratch.
|
||||
// Measured on this host: all three fallbacks tag correctly, `aplay`
|
||||
// included — it reaches the graph through PipeWire's ALSA plugin,
|
||||
// which honours `PIPEWIRE_PROPS` like any other client.
|
||||
ownership::tag_child(&mut command, ownership::NOTIFICATION_ROLE);
|
||||
let started = command.status();
|
||||
// `status()` errors only if the player binary isn't present; on a real
|
||||
// playback error it still returns (non-zero), so a started player ends
|
||||
// the loop either way — we don't want to double-play through fallbacks.
|
||||
@@ -289,6 +323,49 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
/// Phase-1 exit gate, notification half (impl plan §3): a chime peerspeak
|
||||
/// actually plays produces a live PipeWire node carrying **both**
|
||||
/// ownership carriers.
|
||||
///
|
||||
/// ⚠️ Deliberately drives `play()`, not `tag_child()`. The unit test in
|
||||
/// `audio::ownership` proves the environment is built correctly; only a
|
||||
/// live run proves this module *uses* it and that the audio stack honours
|
||||
/// it end to end. The chime is silent (a zero-filled WAV), so running it
|
||||
/// never makes noise.
|
||||
///
|
||||
/// Live: needs a running PipeWire daemon, `pw-play`/`paplay` and
|
||||
/// `pw-dump`. `cargo test --lib -- --ignored notification_chime`
|
||||
#[test]
|
||||
#[ignore = "live: requires a running PipeWire daemon and pw-dump"]
|
||||
#[cfg(not(windows))]
|
||||
fn notification_chime_node_carries_both_ownership_carriers() {
|
||||
use crate::audio::ownership::{self, live_test};
|
||||
|
||||
let dir = temp_wav_dir("ownership");
|
||||
let path = dir.join("silence.wav");
|
||||
std::fs::write(&path, live_test::silent_wav(6)).unwrap();
|
||||
|
||||
set_enabled(true);
|
||||
set_sound_enabled(Sound::PeerJoin, true);
|
||||
play(Sound::PeerJoin, Some(path.to_str().unwrap()));
|
||||
|
||||
let prefix = live_test::expected_prefix(ownership::NOTIFICATION_ROLE);
|
||||
let found = live_test::poll_for_owned_node(&prefix, std::time::Duration::from_secs(5));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
|
||||
let (name, owned) =
|
||||
found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s"));
|
||||
assert!(
|
||||
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(
|
||||
owned.as_deref(),
|
||||
Some(ownership::OWNED_PROP_VALUE),
|
||||
"carrier 1 must be on the live node too, not just carrier 2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_play_truth_table() {
|
||||
// Plays only when BOTH the master and the per-sound flag are on.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+210
-25
@@ -21,6 +21,10 @@ use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
|
||||
use crate::audio::ownership;
|
||||
|
||||
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 +49,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 +617,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 +649,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 +682,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 +707,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
|
||||
}
|
||||
@@ -701,20 +767,69 @@ fn spawn_player(bin: &str, args: &[String], url: &str) -> std::io::Result<Child>
|
||||
// and is not needed to verify the flags. Logged on each attempt, so a
|
||||
// fallback from the preferred player to the other one is visible too.
|
||||
crate::log_msg(&format!("player spawn: {bin} {}", args.join(" ")));
|
||||
Command::new(bin)
|
||||
let mut command = Command::new(bin);
|
||||
command
|
||||
.args(args)
|
||||
.arg(url)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(false)
|
||||
.spawn()
|
||||
.kill_on_drop(false);
|
||||
// Ownership tag (plan §5.1): this player is playing the *incoming*
|
||||
// screenshare's audio, so it is exactly what must not be fanned back out
|
||||
// if this machine also starts sharing. The role is the player binary, so
|
||||
// a `pw-dump` during a field test names which one produced the node.
|
||||
ownership::tag_child(command.as_std_mut(), bin);
|
||||
command.spawn()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Phase-1 exit gate, player half (impl plan §3): the mpv peerspeak
|
||||
/// actually spawns produces a live node carrying **both** ownership
|
||||
/// carriers, tagged with the player's own name as the role.
|
||||
///
|
||||
/// ⚠️ Drives the real [`spawn_player`], for the same reason the notify
|
||||
/// gate does: the plan requires the tag to be shown "landing on a live
|
||||
/// mpv node, not just in the env". Plays a silent WAV, so it is quiet.
|
||||
///
|
||||
/// Live: needs PipeWire, `mpv` and `pw-dump`.
|
||||
/// `cargo test --lib -- --ignored spawned_player`
|
||||
#[tokio::test]
|
||||
#[ignore = "live: requires a running PipeWire daemon, mpv and pw-dump"]
|
||||
async fn spawned_player_node_carries_both_ownership_carriers() {
|
||||
use crate::audio::ownership::live_test;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("peerspeak-playertest-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("silence.wav");
|
||||
std::fs::write(&path, live_test::silent_wav(6)).unwrap();
|
||||
|
||||
let mut child = spawn_player(
|
||||
"mpv",
|
||||
&["--no-video".to_string(), "--really-quiet".to_string()],
|
||||
path.to_str().unwrap(),
|
||||
)
|
||||
.expect("mpv spawns");
|
||||
|
||||
// The role is the player binary, so this also pins that the call site
|
||||
// passes `bin` and not a fixed literal.
|
||||
let prefix = live_test::expected_prefix("mpv");
|
||||
let found = live_test::poll_for_owned_node(&prefix, std::time::Duration::from_secs(5));
|
||||
let _ = child.kill().await;
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
|
||||
let (name, owned) =
|
||||
found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s"));
|
||||
assert!(
|
||||
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(owned.as_deref(), Some(ownership::OWNED_PROP_VALUE));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_args_guard_neutralizes_flag_like_ticket() {
|
||||
// A malicious "ticket" that looks like a flag must end up positional,
|
||||
@@ -825,16 +940,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 +1031,7 @@ mod tests {
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
mpv_args(&settings),
|
||||
mpv_args(&settings, None),
|
||||
vec![
|
||||
"--cache=yes",
|
||||
"--demuxer-readahead-secs=2",
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Screenshare audio exclusion — ownership tagging wire contract.
|
||||
#
|
||||
# peerspeak PRODUCES these carriers on every audio node it owns; pixelpass
|
||||
# CONSUMES them as the primary taint root of the exclusion engine. Neither
|
||||
# repo depends on the other, so this file is the contract: it is committed
|
||||
# byte-identical in both, and each repo has a test that asserts its own named
|
||||
# constants (and, on the producer side, the environment a real child Command
|
||||
# would carry) match these values exactly.
|
||||
#
|
||||
# peerspeak/tests/fixtures/ownership-tag-contract.txt
|
||||
# pixelpass/tests/fixtures/ownership-tag-contract.txt
|
||||
#
|
||||
# Pinned by peerspeak docs/screenshare-audio-exclusion-impl-plan.md §3 and
|
||||
# docs/screenshare-audio-exclusion-plan.md §5.1 (v3.5). Changing a value here
|
||||
# is a cross-repo breaking change: both repos must land in the same session,
|
||||
# and the phase 5 matrix must be re-run.
|
||||
#
|
||||
# Two carriers, matched as a UNION — a node is peerspeak-owned if EITHER
|
||||
# matches. Round 8 added the second because a property is invisible to the
|
||||
# PipeWire registry `global` event and readable only via a node bind, so the
|
||||
# primary taint root must not rest on one observation mechanism alone.
|
||||
|
||||
# Carrier 1 — a node property, matched EXACTLY: `prop_value` below is the
|
||||
# ONLY spelling the consumer reads as owned. A producer emitting "true", "yes"
|
||||
# or "" is NOT owned on this carrier, and only carrier 2 would still catch it.
|
||||
#
|
||||
# ⚠️ This wording is load-bearing and it CHANGED in round 10. The consumer
|
||||
# used to accept any value other than "false"/"0", on the theory that leniency
|
||||
# over-excludes and is therefore safe. It is not: leniency buys false-positive
|
||||
# exclusion, and it let any process suppress a rival application's audio from
|
||||
# the share with a property it did not even have to spell right. Fail-closed
|
||||
# on this feature is about ANCESTRY — an unresolvable graph is not eligible —
|
||||
# not about parsing.
|
||||
prop_key=peerspeak.owned
|
||||
prop_value=1
|
||||
|
||||
# Carrier 2 — a `node.name` prefix, announced by the registry without a bind.
|
||||
# `node.description` is deliberately NOT touched, so mixers still show "mpv".
|
||||
# Only the prefix is matched; the rest of the name is for diagnostics.
|
||||
node_name_prefix=peerspeak_owned_
|
||||
node_name_format=peerspeak_owned_<role>_<pid>
|
||||
node_name_example=peerspeak_owned_mpv_31284
|
||||
Reference in New Issue
Block a user