Commit Graph
53 Commits
Author SHA1 Message Date
molluskandClaude Opus 5 c82ef07464 audio/ownership: take the depth ceiling from the consumer, not the grammar
Round 12 review, finding 2 — filed as P2, and the interesting part is
that its author retracted it to P3 once we had measurements, while the
remedy it originally proposed would have been a fail-open.

The finding was that our validator rejects nesting `spa-json-dump -s`
accepts, and the suggested fix was a recursive sub-iterator walk to
match the dump tool. Both halves rest on the dump tool being the
reference. It is not. Nothing reads `PIPEWIRE_PROPS` or `PIPEWIRE_ALSA`
with `spa-json-dump`; `pw_properties_update_string` does, in the client
process.

Measured live on this host, against the real ALSA plugin:

    depth 513  dump accept   plugin accept   ours accept
    depth 514  dump accept   plugin accept   ours REJECT
    depth 515  dump accept   plugin REJECT   ours reject
    depth 1000 dump accept   plugin REJECT   ours reject

At 515 the plugin discards the whole object: the node came back as
`alsa_playback.aplay` with no properties at all. So matching the dump
tool would have made us splice carriers into values the consumer throws
away wholesale — losing both, which is the echo this feature exists to
prevent. Over-rejecting costs a routing preference; over-accepting costs
a carrier. Those are not the same price.

What was genuinely wrong is narrower: we sat exactly one level below the
consumer. `pw_properties_update_string` calls `spa_json_container_len`
on a container value, which enters one more sub-iterator before its flat
walk, and that single level is the entire discrepancy. Doing the same
puts the boundaries on the same number.

Codex reached the same three numbers independently by calling
`pw_properties_update_string_checked(NULL, ...)` directly, having
disassembled both call sites; I measured through the live plugin. Two
methods, one table.

The dump differential stays, but it is now labelled a *grammar* oracle
with a warning not to add deep values — it would fail by design. The
acceptance oracle is the new boundary test.

Mutation-verified: removing the container step fails the 514 assertion.
622 -> 623 lib tests, fmt clean, clippy clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 01:59:36 -04:00
molluskandClaude Opus 5 9eab6c118d audio/ownership: let libspa say where the object closes
Round 12 review, finding 1 — a measured fail-open, and the third
distinct door into the same failure.

A trailing comment is valid SPA-JSON and *ends the document*
(`case __COMMENT: return 0` in spa/utils/json-core.h), so an object may
close before the last `}` in the string. `merge_pipewire_props` located
the closing brace with `rfind('}')`, which is a byte scan and not a
parse, so for

    { "target.object" = "my-sink" } # trailing }

it selected the comment's brace and spliced both ownership carriers
*into the comment*. The re-validation did not catch it, because the
result parses perfectly well — as `{ target.object = "my-sink" }`, with
neither carrier present. Confirmed against `spa-json-dump -s`.

That is an untagged node, so no taint root, so echo — exactly what
rounds 10 and 11 each closed by a different route. Latent rather than
live: pixelpass's evaluate() is still audit-only, so today it corrupts
an audit classification and becomes a leak when phase 6 consumes
eligibility.

The whole thesis of round 11 was "do not re-implement someone else's
grammar". The scanner went, but this brace hunt stayed behind in the
caller, which is the same defect wearing different clothes.

So spa_object now reports the object's own closer, taken from libspa:
closing a container at depth 0 writes the brace's position back to the
parent iterator, and spa_json_enter made `outer` that parent. Read
before the trailing check, which advances past it.

Also:
- whatever followed the object is preserved, so a user's trailing
  comment survives instead of being silently deleted;
- the output check now asks whether the object closes where we put our
  brace, not merely whether the string parses. A parse-only check is
  what this finding defeated.

Mutation-verified: restoring `rfind` fails the new test, and dropping
the tail fails it on the deleted comment. Honest note in the code —
mutation cannot distinguish the closer comparison or the is-object
test; both are labelled belt-and-braces rather than presented as
tested.

621 -> 622 lib tests, fmt clean, clippy clean, and the ignored
spa-json-dump differential still agrees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 01:51:14 -04:00
molluskandClaude Opus 5 21ba633825 audio/ownership: validate inherited SPA-JSON with libspa, not a scanner
Round 11 review, findings 2, 3 and 4.

The round-10 fix replaced a brace check with a hand-written scanner. That was
the wrong shape: a second implementation of someone else's grammar drifts in
both directions at once, and measured against `spa-json-dump -s` on this host
it did.

It ACCEPTED `{ "foo" = { garbage } }` (only brackets were balanced, contents
never validated), `{ "a" = "\é" }`, `{ "a" = é }` and `{ "a" = foo\bar }`.
Merging into those put an invalid pair before our carriers, so the daemon
stops at it and drops both -- recreating the exact fail-open the round-10 fix
existed to close. Its own test even pinned `"\é"` as a valid token.

It REJECTED `{ target.object, "my-sink" }`, `{ key == "value" }` and
CR-terminated comments, all valid -- so a user with one of those in their
environment silently lost their routing policy to an overwrite. That half
affects a running Linux user.

Now libspa's own parser validates, and the merge splices into the validated
text instead of re-emitting parsed pairs. Splicing preserves the user's bytes
exactly, which also answers the review's point that re-quoting a bare key can
invent a different one (`foo\bar` -> a string with a \b escape). Three
measured properties make the splice safe -- the last `}` is the object's, a
validated object's brace is never mid-comment, and commas are pure separators
-- and the result is validated again before it is returned.

Mutation testing then deleted the rest: every pairing and recursion check I
had written turned out to be redundant, because spa_json_next already errors
on `{ garbage }` and on nested garbage, and skips containers rather than
descending. ~60 lines of my own grammar logic removed. What remains is gated
by a new differential test against `spa-json-dump -s` over a 27-value corpus
-- the check whose absence caused this round. It found a real disagreement on
its first run (a bare document, which we reject by design, not by accident).

One mutation HUNG rather than failed: dropping the `length < 0` check makes
libspa report the same error without advancing, spinning forever. Kept, now
labelled load-bearing for termination, with a token-count bound beside it.

Finding 4: the ordering test took the first textual match of `fn main`, so a
raw-string decoy above the real function satisfied it while the real one
spawned a thread first. Now requires each of the three anchors to be unique.
Mutation-verified with the review's own decoy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 01:08:17 -04:00
molluskandClaude Opus 5 45b1b97dd8 audio/ownership: pin the no-lost-carrier invariant, and harden the byte scan
Verification round on the round-10 review fixes.

Adds the property the whole of finding 3 is about, stated directly: over
20,000 deterministic inputs built from the exact characters that break
SPA-JSON (braces, brackets, quotes, separators, comment marks, escapes,
newlines, multi-byte characters), the merge always emits both carriers in an
object it can read back. Either outcome — parse and rebuild, or overwrite —
has to end that way, and now nothing can quietly change which.

Also replaces two byte-index steps with character-boundary steps. Both were
correct on the ASCII input they actually see, but `index + 1` after a
reverse find would have split a multi-byte character and panicked the slice.
scan_token gains multi-byte cases for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:00:23 -04:00
molluskandClaude Opus 5 985c63806b audio/ownership: state the playlist policy, and gate main's ordering properly
Round 10 review, findings 2 and 5.

Finding 2 — R10-2's rationale for tagging local playlist audio was factually
wrong. It claimed a local track is "already being broadcast to peers on the
same keypress", but shared listening is opt-in: music_broadcast defaults to
false, play_music_index starts local playback unconditionally, and
broadcast_track returns immediately when can_broadcast_music is false. So a
default-config playlist is not already broadcast.

The tag stays, now as an explicit policy with the real reason: the carriers
reach rodio through PIPEWIRE_ALSA, which is process-wide, and clip_player
and music_player are two ClipPlayer instances in one process — no value of
that variable can tag one and not the other. Exempting the playlist means
giving it a separately taggable stream, which is a large change for a case
with a one-step workaround (play it in any other app). Tagging is not
optional for received clips and peer music, which are the far end's own
audio.

Finding 5 — the ordering test proved only "before run_gui", which a
thread::spawn inserted above the tag still satisfies while making the
set_var a data race. It now requires the tag to be the first executable
statement in main: attributes, `unsafe` and block punctuation are stripped,
and any residue fails. Mutation-verified against a spawn, an unrelated
statement, and the call deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:51:02 -04:00
molluskandClaude Opus 5 6fc55a286d audio/ownership: parse inherited SPA-JSON instead of trusting its braces
Round 10 review, finding 3. The merge's shape check was the outer braces
only, so an inherited `PIPEWIRE_ALSA='{ garbage }'` was spliced into rather
than overwritten, producing an object the daemon does not accept.

Measured live 2026-07-25, and the failure is worse than a rejection: with
PIPEWIRE_ALSA set to the old merge's output, a real aplay node came up as
node.name=alsa_playback.aplay, no peerspeak.owned, and a junk property
`garbage = "peerspeak.owned"` — the lenient parser ate our key as their
value and stopped. Both ownership carriers lost on a live
Stream/Output/Audio node, which is an echo.

So: parse the inherited object and REBUILD it with our pairs last, rather
than splicing before the closing brace. Rebuilding is what makes the result
independent of the input's formatting — a value ending in a `#` comment
would otherwise swallow everything appended after it.

The three values the new merge emits were verified against the live daemon
(user props preserved, both carriers present) and are pinned byte-for-byte.
scan_token is gated on its own postcondition: at the object level an
unterminated string is also caught by "the object never closed", so the two
implementations only disagree at the seam.

Also parameterizes the malformed-value warning, which always named
PIPEWIRE_PROPS even when PIPEWIRE_ALSA was the malformed one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:47:32 -04:00
molluskandClaude Opus 5 d63db68318 audio/ownership: apply the merge rule to the ALSA carrier too
Verification round on round 10's own fixes, not on the next layer.

R10-5 preserved a user's PULSE_PROP and PIPEWIRE_PROPS but
tag_this_process_alsa_audio still clobbered their PIPEWIRE_ALSA, which is
the same kind of routing policy and deserves the same treatment. Both it
and tag_child now merge.

MEASURED, rather than assumed, because "our pairs go last so they win"
was load-bearing for the whole merge design and was never checked:
  PIPEWIRE_PROPS='{ "node.name"="theirs_first", "media.role"="music",
                    "node.name"="ours_last" }' on pw-play
    -> node.name=ours_last, media.role preserved.
  The PULSE_PROP equivalent on paplay -> the same.
So last-wins holds on both grammars: a user who already sets node.name
cannot silently untag us, and their other keys survive.

That also makes tag_child's ALSA carrier merge from the inherited value
safely: in production main has already put this process's `clip` tag
there, and the child's own role now overrides it by coming last. The
existing row could not see this — the test binary never runs main, so it
only ever exercised the merge-into-nothing case. Added a row that drives
the real shape directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:07:33 -04:00
molluskandClaude Opus 5 e7923a1b5c audio/ownership: merge inherited player env vars instead of clobbering
PULSE_PROP and PIPEWIRE_PROPS can legitimately carry a user's own routing
policy — media.role, a target sink — and replacing them changes where the
user's audio goes as a side effect of a tagging mechanism that is
supposed to be behaviourally invisible.

PULSE_PROP is space-separated key=value, so merging is appending;
PIPEWIRE_PROPS is a SPA-JSON object, so it is an insert before the
closing brace. Our pairs go last in both, so they win a duplicate key —
without that, a user with node.name already set would silently untag us.
A value that does not match the expected shape is logged and overwritten:
a half-merged string that fails to parse would drop the tag silently,
which is worse than losing a routing preference. No full SPA-JSON parser,
which would be over-engineering for a case with no live consumer
(measured: neither variable is set anywhere in this user's env or config).

Also sets PIPEWIRE_ALSA on the child, with the child's own role. A player
configured for ALSA output is reached by neither of the other two
variables, so this closes a real gap rather than only a cosmetic one —
and without it such a child would inherit this process's `clip` tag from
tag_this_process_alsa_audio and report the wrong role in the audit.

Corrects a stale doc comment on OWNED_PROP_VALUE that still claimed
pixelpass accepts any truthy value; R10-4 made the match exact. Codex's
F5 was reasoned partly from a stale comment of mine, so these are worth
fixing on sight.

Codex phase-1 review F4. Round 10, R10-5.
8 new rows; 5 mutations verified (clobber PULSE_PROP, our pairs first,
naive object concat, doubled trailing comma, drop the ALSA carrier).
All 4 live ownership gates re-run green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:03:32 -04:00
molluskandClaude Opus 5 b5569fe2c6 audio: tag the fourth playback path, rodio's ClipPlayer
ClipPlayer opens a rodio default sink, which on Linux reaches the graph
through PipeWire's ALSA plugin. It was untagged through all of phase 1,
and it is a real echo path: B broadcasts music, A tunes in, A shares
their desktop, B hears their own track played back at them. Confirmed
live as `alsa_playback.peerspeak-...` with no ownership properties.

rodio exposes no way to set PipeWire node properties, so the carrier is
PIPEWIRE_ALSA, set once at the top of main while still single-threaded.

Measured, with PIPEWIRE_PROPS and PULSE_PROP unset, to establish that
setting it process-wide is safe:
  - aplay (ALSA plugin)   -> both carriers land. Confirms the mechanism.
  - pw-play (native)      -> untouched. Our own call-playback and capture
                             streams are native, so they keep their own
                             explicit tagging and are unaffected.
  - arecord (ALSA capture)-> IS tagged, on a Stream/Input/Audio. Not
                             surgical in the role dimension; harmless only
                             because R10-1 honours the carriers on
                             producers alone. This is why R10-1 lands first.

Local playlist tracks are tagged too, not just inbound peer audio. A
local track is already broadcast to peers over the call on the same
keypress, so sharing it again through the screen share would send the far
end two copies at differing latency. That is a defect, not a feature.

Codex phase-1 review F1. Round 10, R10-2.

New live exit-gate row drives the real ClipPlayer; mutation-verified
(drop the tag -> no node within 5s). The wiring guard is mutation-
verified too, and its first version was WRONG: it searched raw source and
passed against a main with the call deleted, because the comment above it
named the function. It strips comments now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:52:22 -04:00
molluskandClaude Opus 5 503f78153b audio/ownership: refuse an ambiguous contract fixture
Producer half of the same fix (Codex phase-1 review, finding 3, P2).
This side collected fixture lines into a map, so a duplicated key
silently took the last value while pixelpass took the first — both
repos green on different contracts.

Mutation-verified in both repos with a duplicated `prop_value`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 20:25:27 -04:00
molluskandClaude Opus 5 bcf1343a55 phase 1: tag every audio node peerspeak owns, on both carriers
Zero behaviour change. This is what makes the screenshare exclusion
engine able to see us at all (plan §5.1, impl plan §3): pixelpass must
refuse to fan out our own playback, and until now it had no way to
recognise it.

Two carriers, matched by pixelpass as a union — `peerspeak.owned=1`
and a `node.name` prefix `peerspeak_owned_<role>_<pid>`. Round 8 added
the second after the phase-5 audit found a node property is invisible
to the PipeWire registry `global` event and recoverable only by
binding the node; the prefix is announced directly. A union is also
the fail-closed direction: a missed tag leaks call audio into a share,
a spurious one only over-excludes.

Three tagging sites, all three verified live on this host:
  - native call playback  → props on the stream dict
  - screenshare mpv/VLC   → PULSE_PROP + PIPEWIRE_PROPS on the child
  - notification chimes   → same, on pw-play/paplay

The literals are a cross-repo wire contract, so they appear once here
as named constants and are pinned in a fixture committed byte-identical
in both repos (tests/fixtures/ownership-tag-contract.txt). The contract
test is black-box: it builds a real child `Command` and reads back the
environment it would carry, rather than testing our own formatter.

Three live `#[ignore]`d exit-gate tests drive the real call sites and
poll `pw-dump` for the resulting node — the plan requires the tag be
shown landing on a live node, not just in the env. All three
mutation-verified (drop either carrier, or the role, and the matching
gate fails).

Measured while verifying: mpv, VLC, pw-play and paplay all honour
`node.name` from those env vars. The native stream set neither
`application.name` nor a description, so a mixer fell back to
`node.name` — which the tag turns into an internal identifier. Added
an explicit `node.description = "PeerSpeak"` there, which keeps the
plan's rule (the prefix must not reach `node.description`) while
preserving its intent: mixers stay readable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 19:19:25 -04:00
molluskandClaude Opus 4.8 c902db2e90 style: rustfmt the 0.6.2 additions (A17b + version-in-UI)
CI / check (push) Failing after 2m34s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
CI's fmt --check caught that Codex's hand-written additions in these two files
weren't rustfmt-formatted (the senior gate ran clippy + tests but not
fmt --check). Pure line-wrapping, no logic change. Keeps the crate fmt-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:52:31 -04:00
molluskandClaude Opus 4.8 1bf14ba08f perf(recording): move multitrack stem disk I/O off the mixer path (A17b)
The multitrack recorder wrote every per-stem WAV frame (and the potentially
large late-joiner back-pad) inline on the caller thread while holding the
recorder mutex, so a slow/contended disk stalled the playout mixer (local
underruns) and the events loop. This is the multitrack counterpart to A17
(e0325d4), which moved the single-file recorder's writes off the mixer path.

Design: the front (MultitrackRecorder) now keeps only cheap in-memory state
(known-peer set, mic FIFO, a pending-cycle builder) and on each end_cycle
assembles ONE whole-cycle batch (new peers + mic frame + optional mix frame +
the map of peer frames written this cycle) and try_sends it over a bounded
sync_channel(256) to a dedicated writer thread. The writer thread owns every
WavWriter, is authoritative for its own cycle count, back-pads a brand-new
peer by cycles_written*frame_samples, fills absent peer/mix frames with
silence, latches the first write/create error then drains, and finalizes all
headers on channel close.

The unit of hand-off is a whole cycle, not a track: the writer appends exactly
frame_samples to every existing track per applied batch, and a full queue
DROPS the entire batch (counted + logged at 1 and every 256). So a dropped
cycle omits the same 20ms from every stem at once and all tracks stay
equal-length and sample-aligned by construction even under disk back-pressure.
On drop the batch's new-peer announcements are rolled back out of the known set
so they re-announce (and correctly re-back-pad) on the next applied cycle.

Public method signatures are unchanged -> zero core/mod.rs edits. The
WAV/file format is unchanged (no wire/on-disk change), no new deps
(std::sync::mpsc + std::thread, as A17). Writer logic is factored behind a
generic SampleWriter seam so the apply-batch alignment invariant is unit-tested
without spawning the thread; new tests cover the back-pad-on-apply invariant,
the dropped-cycle equal-length property, and async create-error surfacing at
finalize. The three existing end-to-end tests pass unchanged (now exercising
the threaded path). 496 lib tests, clippy --all-targets clean, release builds.

Codex-implemented (gpt-5.5 xhigh), senior-reviewed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:53:52 -04:00
molluskandClaude Opus 4.8 d0a16cb8b9 style: apply cargo fmt across the crate (A20)
The repo never enforced rustfmt, so formatting had drifted broadly. This is a
single mechanical `cargo fmt` pass over the whole crate (no behavioral change;
lib suite green, 493 passed). Going forward fmt should be enforced (planned CI
fmt --check step). Part of the 0.6.1 hygiene pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 02:11:44 -04:00
molluskandClaude Opus 4.8 e0325d4590 perf(recording): move single-file WAV disk I/O off the mixer path (A17)
Recorder::write_frame ran on the playout mixer path and did a blocking write_all
to disk per 20ms frame; slow/contended storage could stall the mixer and cause
local playback underruns. Now the mixer thread only does the cheap mic-sum
(extracted as the pure mix_with_mic helper) and try_sends the frame to a
dedicated writer thread over a bounded sync_channel(256). The writer thread owns
the WavWriter, writes queued frames, records the first write error then drains
without writing, and patches the WAV size fields on channel close. A full queue
DROPS the recording frame (counted + logged at 1 and every 256) rather than
blocking call audio; a disconnected writer surfaces BrokenPipe. finalize() closes
the channel, joins the thread, and returns the first write error or the finalize
result (thread panic handled).

Scope: single-file Recorder only; WavWriter unchanged so the multitrack recorder
is untouched (its writer-thread offload is deferred as A17b). Public method
signatures preserved -> no core/mod.rs changes. New end-to-end threaded WAV
readback test + mix_with_mic helper tests; existing FIFO/mic-sum intent kept.
No new deps, no wire change. Codex-implemented (gpt-5.5 xhigh), senior-reviewed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 02:10:29 -04:00
molluskandClaude Opus 4.8 f21a027e78 feat(chat): volume control for inline audio clips
Add a per-clip volume slider plus a master volume control with a
"Universal volume" toggle in the chat header.

- ClipPlayer gains a SetVolume command; the worker remembers gain across
  clips and reapplies it to each freshly connected player.
- New config.clip_volume (universal level) and config.clip_volume_universal
  (mode toggle, default on), both persisted; old configs load at unity in
  universal mode.
- Universal on: master and per-clip sliders drive one shared level applied
  to every clip. Universal off: each clip keeps its own in-memory level and
  the master slider is inert.
- play_ready_audio applies the resolved effective gain right after Play.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:24:10 -04:00
6100abef33 fix(core,audio,app): Tier B bug-sweep fixes (F-05, F-06, F-10, F-11)
Four confirmed P2 findings from the 2026-06-22 adversarial bug sweep.
None change the wire format / PeerState / GOSSIP_PROTO — all local.

- F-05: re-key the A8 rejoin archive (known_peers) and RecoveryContext
  by topic_id ([u8; 32]) instead of the raw ticket string. A W7-
  restamped member ticket shares the room's topic but not its string,
  so a rejoin-from-Recents previously missed the retained bootstrap
  bucket and dropped to an empty bootstrap — the exact dead-end A8
  fixed. Topic is derived once via PeerSpeakTicket::topic_of in Join;
  a malformed ticket now fails early and clean.
- F-06: an in-call Join no longer leaks the old room's peers/chat into
  the new room, nor strands stale presence on a failed switch. Core
  captures was_in_room, clears current_room at teardown, and emits a
  new local UiEvent::RoomReset on every post-teardown failure path so
  a failed switch lands idle on Home. The UI's room-scoped clearing is
  factored into AppState::reset_room_state(), called by RoomLeft,
  RoomReset, and at the top of RoomJoined — so a successful switch
  clears+repopulates seamlessly on the Room screen (no Home bounce, no
  leave chime).
- F-10: echo-cancel virtual nodes now get per-PID-unique names
  (peerspeak_echocancel_{source,sink}.<pid>); the guard carries them
  and core targets them instead of the fixed constants. unload_stale
  only unloads our modules whose owner PID is dead (/proc check, cfg-
  gated; conservative elsewhere), so enabling AEC in one instance can
  no longer tear down another live instance's call. Pure
  pid_from_ec_args / ec_module_is_stale seams.
- F-11: a recording write failure now stops recording atomically
  (best-effort finalize via stop_recording + one UI Error) instead of
  looping the error at ~50 Hz with silent data loss. Both mixer
  branches release the recorder mutex before calling stop_recording to
  avoid a self-deadlock on the non-reentrant std::Mutex.

407 lib tests pass (+4), clippy --all-targets clean, release build
green. Tests-green only; the rejoin (F-05), in-call switch (F-06),
two-instance AEC (F-10), and disk-full (F-11) paths need a real run.
Implemented by Codex, reviewed + gates re-run by senior.

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:38:48 -04:00
6b0b23ef69 fix(audio,game): Tier A bug-sweep fixes (S-01, F-04, F-08, F-09, S-02)
Five confirmed findings from the 2026-06-22 adversarial bug sweep:

- S-01: clamp PipeWire capture chunk size to the mapped slice before
  indexing, so a bad reported size can't panic (= process abort) from
  the RT capture callback. Extracted testable for_each_capture_sample.
- F-04: reserve ring occupancy before publishing a frame on the PipeWire
  playback path (mirrors the cpal fix), preventing the RT consumer from
  popping an uncounted sample and wrapping fill_gauge to usize::MAX,
  which permanently wedged mixer pacing. Extracted publish_frame.
- F-09: GameDetector::spawn now returns io::Result and retains its
  JoinHandle (joined on Drop); core fuses a closed watch receiver to
  None via next_game_change so a dead detector can't busy-loop select!.
- F-08: collision-free recording paths — Recorder::create and the
  multitrack session dir use create_new/create_dir with bounded suffix
  retry, so two recordings in the same second no longer truncate the
  first.
- S-02: bound the Windows SteamPath registry read (<=4 KiB, even length,
  re-checked type/returned length) before allocating/decoding.

403 lib tests pass (+6), clippy --all-targets clean. Implemented by
Codex, reviewed + gates re-run by senior.

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 03:34:29 -04:00
mollusk adf7d1c0e2 Fix inline audio duration detection 2026-06-21 01:41:57 -04:00
mollusk bcb597a0ea Add inline chat audio player 2026-06-21 01:05:24 -04:00
molluskandClaude Opus 4.8 b0fdd4e058 audio(win): filter choose_config to drivable formats (Codex B3/B5 re-review P3)
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
Codex's xhigh re-review of 306bc29 confirmed B3 sound and bounded_rate
correct (no P1/P2), and caught one real P3: choose_config ranked supported
config ranges by sample rate + channel count only, but the stream builders
accept just F32/I16/U16 — cpal can also expose U8/I8/I32/U32/I64/U64/F64.
An unsupported-format range (or a zero-channel range) could therefore out-
rank a usable one, win selection, and then hard-fail in setup()'s
`other => Err(unsupported sample format)` arm without trying another
candidate. This was latent in the exact-48 kHz path too, not only B5's
bounded case 3.

Fix: a pure `format_supported` predicate + `usable_range` (nonzero channels
AND a drivable format), applied as a filter in BOTH the exact-48 kHz `pick`
and the bounded `pick_bounded`, so an undrivable range is never ranked. A
zero-channel range can no longer be logged as "using bounded …" and then
rejected by resolve. +1 unit test enumerating every cpal SampleFormat.

Verified: windows-gnu cargo check --release --lib --tests --bins clean, no
warnings; Linux paths untouched (cfg(windows)).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:54:26 -04:00
molluskandClaude Opus 4.8 306bc295b1 audio(win): land the deferred cpal start-resilience items (B3 + B5)
Closes the two Windows-only follow-ups Codex deferred in the RT-audit
re-review (review-2026-06-19-cpal-rt-audit.md). Both are cfg(windows),
so they carry zero risk to the shared Linux audio path.

B3 — orphan-thread tombstone on a wedged start. On the FINISH_START_TIMEOUT
path the owner thread is detached (not joined) so start_*/stop can't hang;
previously the slot was left empty, so a retry against a permanently wedged
device spawned ANOTHER orphan worker holding its own COM/device handle, and
so on without bound. The slot is now a SlotState { Idle | Live | Wedged }:

- Each worker carries an `exited: Arc<AtomicBool>` flipped true by an
  ExitGuard at the top of the thread body — fires on normal return, panic
  unwind, or whenever the wedged driver call finally releases the thread.
- A timed-out start detaches its thread and leaves a `Wedged { exited }`
  tombstone instead of an empty slot.
- `ensure_idle` (pure, unit-tested) rejects new starts while the orphan is
  still alive, but clears the tombstone once `exited` flips, so the slot
  becomes reusable after the device recovers. `stop` restores a still-live
  tombstone rather than silently clearing it.

B5 — choose_config picks a bounded supported rate before the device default.
A device whose default rate is outside the drivable 8k–384k window but which
also exposes a usable in-window config was previously rejected by resolve().
New case 3 scans the supported config ranges for one overlapping the window
and drives it at a `bounded_rate` (48 kHz when reachable, else the nearest
in-window bound), preferring the native layout; the device default is now a
last resort. `bounded_rate` is pure and unit-tested.

6 new unit tests (bounded_rate x4, ensure_idle x2) — they're in the
cfg(windows) module, so they compile/run under the windows-gnu target, not
the Linux lib suite.

Verified: Linux cargo test --lib 326/0 + clippy --lib --tests clean (shared
paths untouched); windows-gnu cargo check --release --lib --tests --bins
clean, no warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:48:07 -04:00
molluskandClaude Opus 4.8 8e0b4c16ec audio(win): tighten the cpal start-handshake (Codex re-review B1/B2/B4)
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
Codex's xhigh re-review of the prior cpal RT fixes confirmed W2/W3/W7/W4-diag
addressed (and validated the reserve-first ring-publish ordering), but found the
W1/W6 start-handshake fixes were partial. This closes the holes:

- B1 (P1): wait_for_stream_start checked the liveness flag before the error code,
  so a callback that ran then failed in the same WASAPI cycle could still report
  Ok on a dead stream. Readiness now (a) treats the error as terminal — checked
  first each loop AND re-checked before returning Ok — and (b) requires
  MIN_START_CALLBACKS (2) completed callbacks, not one, so a fire-once-then-die
  stream is caught by the error/timeout path. The liveness signal is now a
  callback counter (AtomicUsize) instead of a one-shot bool.
- B2 (P2): on the inner STREAM_START_TIMEOUT the owner sent Err and THEN dropped
  the stream; since cpal Stream::drop joins its (wedged) WASAPI worker and
  finish_start joins the owner on that Err, start_*/stop could still hang past the
  backstop. The owner now drops the stream BEFORE reporting Err, so a wedged drop
  withholds the Err and lets finish_start's timeout branch detach.
- B4 (P3): the two timeouts didn't compose — a slow-but-valid setup plus a slow
  first callback could exceed the 6s backstop and be falsely failed. Raised
  FINISH_START_TIMEOUT to 10s (setup budget + callback wait + cleanup slack) and
  corrected the comment.

Deferred (logged in review-2026-06-19-cpal-rt-audit.md): B3 (orphan-thread
tombstone accounting on a permanent >10s driver wedge — rare, non-crashing, needs
a slot-state redesign) and B5 (choose_config picking a bounded supported rate for
an oddball sub-8k/over-384k default-rate device — rare; the safety validation
already prevents the panic/spin).

Verified: Linux cargo test --lib 326/0, clippy --all-targets clean; windows-gnu
cargo check --lib --tests --bins clean; windows-gnu release peerspeak.exe links.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:27:35 -04:00
molluskandClaude Opus 4.8 f52b5ea64e audio(win): fix RT-safety + start-handshake bugs in the cpal backend
Addresses Codex's xhigh RT-audio audit of the new Windows cpal path (review
2026-06-19; all Windows-only, no Linux-path change):

- W1 (P1): start_capture/start_playback reported Ok as soon as cpal's play()
  returned, but cpal's WASAPI play() only QUEUES IAudioClient::Start(); a later
  Start failure left the UI joined-but-silent. Readiness is now driven by the
  stream actually proving itself: the first RT data callback sets a started
  flag (or the error callback sets an error code), and the owner thread waits
  (bounded by STREAM_START_TIMEOUT) before reporting Ok.
- W2: both RT error callbacks ran format!+log_msg on the time-critical stream
  thread. They now store a category in an AtomicU8 only; the owner / health
  logger translate + log off the RT path.
- W3: the playback ring was published one interleaved sample at a time, letting
  the RT consumer read a half-written L/R pair and letting a raced fetch_sub
  wrap ring_fill to usize::MAX (wedging mixer pacing). Now reserves occupancy
  before publishing and writes the whole frame with a single push_slice.
- W6: finish_start did an unbounded recv() while holding the slot mutex, so a
  wedged driver hung start_* and any concurrent stop. Now recv_timeout with a
  FINISH_START_TIMEOUT backstop; on timeout it signals + detaches (never joins).
- W7: OS-reported device geometry is validated in resolve() (channels>0, rate in
  8k-384k) so 0 channels can't panic chunks_exact(0) and a 0/absurd rate can't
  make an infinite/huge resample ratio. resample.rs constructors also clamp
  rates >=1 (release-safe; +2 tests) instead of a debug-only assert.
- W4 (diagnostic half): the playout-health logger compared raw device samples
  against the internal-stereo prefill target. The callback now records demand in
  internal 48 kHz-stereo units (internal_demand) so the comparison is correct
  for remapped/non-48k devices. The dynamic-target restructure stays deferred.

Deferred (logged in review-2026-06-19-cpal-rt-audit.md): W5 (bounded mixer->
worker channel) touches the shared Linux audio path and wants its own design +
regression pass; the W2 dynamic-target sizing needs a real WASAPI callback.

Verified: Linux cargo test --lib 326/0, clippy --all-targets clean; windows-gnu
cargo check --lib --tests --bins clean; windows-gnu release peerspeak.exe builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:15:49 -04:00
mollusk 20bfcffe6d Complete Windows audio remap path
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
2026-06-19 04:56:01 -04:00
molluskandClaude Opus 4.8 185d47aa8d W4 (WIP): dep-free resampler + capture/config wiring (playback pending)
- src/audio/resample.rs: pure linear PushResampler (capture) +
  StereoPullResampler (playback pull), 6 unit tests green on Linux.
- choose_config: prefer native 48kHz, else fall back to device default
  config and convert at the boundary instead of hard-erroring.
- run_capture: resample device-rate mono -> 48kHz on the drain thread.
- i16<->f32 helpers. Playback build_output remap still TODO (Codex).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 04:42:51 -04:00
molluskandClaude Opus 4.8 fdd532de53 Windows audio (cpal): surface device fallback + callback-size diagnostics (W7, W2)
Two safe, host-independent hardening steps from the Codex Windows-compat review.

W7 — when a saved input/output device name no longer resolves (WASAPI friendly
names can change across driver/endpoint changes), resolve() now logs the
fallback to the system default instead of switching devices silently — so a
"my audio went to the wrong device" report has a log line explaining why.
(cpal 0.15 exposes only the device name, so a stable hardware id isn't available
to persist; this surfaces the limitation rather than hiding it.)

W2 — the output RT callback now records the largest interleaved buffer length it
is ever asked for (a wait-free fetch_max into an atomic, kept off the log/alloc
path). The once-per-second health-logger reports that size and, if a callback
ever exceeds the prefill target (PLAYBACK_TARGET_SAMPLES), warns explicitly —
that's the exact signature of the WASAPI-shared-mode underrun-every-cycle bug.
This is the diagnostic a real-host test needs before committing to the
structural fix (larger target / fixed buffer); no behavior change.

Windows-only file (cfg(windows)); compile-verified via the windows-gnu
cross-build, not yet exercised on a real WASAPI host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 04:21:53 -04:00
mollusk 6ccad0d37a Merge Windows-compat quick wins (W5/W6/W9) into windows port
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
2026-06-19 04:03:55 -04:00
molluskandClaude Opus 4.8 ddb3d2aabc Windows compat quick wins: echo-cancel UI gate, pixelpass .exe, cfg tighten (W5/W6/W9)
Three Windows-compatibility fixes from the Codex review. Implemented by Codex
(gpt-5.5); reviewed and committed by Claude.

W5 — echo cancellation is a Linux/PipeWire feature, but the toggle was shown and
live on Windows, so a Windows join tried `pactl` and errored before falling back.
Now `#[cfg(target_os = "linux")]` gates the core enable path (and the
ActiveSession guard field); on other targets the Settings + in-call controls
render as a disabled checkbox with a "not available on Windows yet" note.

W6 — pixelpass PATH lookup only tried `pixelpass`; on Windows it now also tries
`pixelpass.exe` via a cfg-selected candidate list (+ unit test).

W9 — the Linux audio stack (pipewire/pw_cli/echo_cancel/audio_probe + the
`PlatformAudioBackend` alias and device-enum re-export) was gated `cfg(unix)`;
tightened to `cfg(target_os = "linux")` so a hypothetical macOS build won't try
to compile PipeWire. cpal stays `cfg(windows)`. Genuinely-Unix file/key
permission code in lib.rs/identity.rs left as `cfg(unix)`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 04:03:49 -04:00
molluskandClaude Opus 4.8 bbbe2d8f17 Windows audio (cpal): startup handshake + RT-safe capture ring (W1, W3)
Two correctness fixes for the cpal/WASAPI backend from the Codex Windows-compat
review, plus device logging.

W1 — start_capture/start_playback no longer return Ok before the stream exists.
The owning thread did device resolution, config selection, build_stream, and
play() and only *logged* failures, so a missing 48 kHz config / unsupported
format / WASAPI error left the UI in a joined-but-silent room. The worker now
reports readiness over a channel and start_* blocks on it via finish_start(),
returning the real AudioError on failure (and joining the dead worker).

W3 — the RT capture callback no longer allocates or sends on a channel. It now
only downmixes and wait-free-pushes mono samples into a preallocated lock-free
HeapRb; the owning thread drains that ring, frames it (the Vec allocation lives
off the RT path), and sends completed frames. A full ring increments an overrun
counter instead of blocking. Restores the no-alloc/no-block-in-callback contract
the PipeWire backend already honors.

Also logs the selected device name / sample format / channels / rate on stream
start (a review nice-to-have) and logs capture overruns when they occur.

Windows-only file (cfg(windows)); Linux build unaffected. Compile-verified via
the windows-gnu cross-build; not yet run on a real WASAPI host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 03:57:41 -04:00
molluskandClaude Opus 4.8 2937e5191a Windows port Phase 2: cpal device enumeration
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
Give the Windows device pickers a real device list (Phase 0/1 left pw_cli
returning nothing off-Linux) and generalize enumeration into a
platform-neutral interface.

- audio/mod.rs: move AudioDevice here (neutral home), gate pw_cli to
  cfg(unix), and re-export enumerate_audio_devices per-platform (pw_cli on
  unix, cpal_impl on windows). Also drop a now-stale "no-op stub" doc note.
- cpal_impl.rs: add enumerate_audio_devices() — iterate the cpal host's
  input + output devices into AudioDevice (name == description == the cpal
  friendly name, which is what resolve() matches target_node against, so a
  saved selection round-trips), sorted by description.
- pw_cli.rs: use super::AudioDevice instead of a local copy; parsing +
  tests unchanged.
- app/mod.rs: one-line import change; the device-picker logic is untouched.

Verified: shipped Linux state green (build --locked, clippy, 316/316,
pw_cli parse tests 6/6); the cpal enumerator compiles against real cpal via
the Linux/ALSA toggle. Runtime device listing on Windows is pending a real
host (M2/M3). WASAPI names are less stable than PipeWire node names, so a
saved device may not always round-trip (falls back to default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:28:12 -04:00
molluskandClaude Opus 4.8 47c58047ce Windows port Phase 1: real cpal/WASAPI audio backend
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
Replace the Phase 0 no-op CpalBackend stub with a working cpal backend
(WASAPI on Windows), preserving the exact PipeWire AudioBackend contract
so the mixer/encoder/jitter pipeline is unchanged.

- Capture: input stream -> downmix to mono -> 960-sample (20ms) i16 frames
  -> tx, matching the encoder/jitter frame size.
- Playback: 200ms stereo ring prefilled to PLAYBACK_TARGET_SAMPLES; the
  output callback drains it (silence on underrun) while the owning thread
  feeds it from rx. ring_fill is the exact delta-maintained occupancy
  counter (fetch_add on push, fetch_sub on pop), preserving the clock-paced
  production design (not ringbuf's stale occupied_len).
- cpal::Stream is !Send, but AudioBackend is Send+Sync and shared via Arc,
  so each stream lives on its own owning thread (built/played/dropped
  there); the struct holds only the running flag + JoinHandle. stop()
  flips the flag and joins.
- Generic over F32/I16/U16 sample formats; device selected by name else
  default; requires a native 48kHz config (clear error otherwise, no
  resampling yet). Mirrors the PipeWire drain_loop and playout-health line.
- Cargo.toml: add cpal 0.15 under cfg(windows).

Verified by temporarily compiling cpal_impl against real cpal on Linux/ALSA:
build + clippy clean, 6/6 cpal_impl unit tests pass. Reverted to windows-only
gating; shipped Linux state green (316/316). Runtime/WASAPI end-to-end is
unverified and pending a Windows host (plan M2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:15:56 -04:00
molluskandClaude Opus 4.8 85b12a26c9 Windows port Phase 0: platform-select the audio backend
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
Make the tree compile for Windows without touching core logic, by
confining all Linux/PipeWire assumptions behind cfg gates and a single
platform-selected backend alias. No new dependencies — the cpal/WASAPI
backend lands in Phase 1; this ships a no-op stub.

- Cargo.toml: move pipewire + rfd(xdg-portal) under cfg(unix); add a
  cfg(windows) rfd using the Win32 dialog backend.
- audio: gate pipewire_impl to unix, add a cpal_impl stub for windows,
  and select between them via the new PlatformAudioBackend alias.
- core: use PlatformAudioBackend instead of the concrete PipeWireBackend.
- lib: gate the unix-only 0o600 log-file mode code (+ its test); Windows
  logs inherit the directory ACL.
- audio_probe: gate this PipeWire diagnostic to unix with a stub main.
- app: open URLs via rundll32 on windows, xdg-open on unix (shell-free).
- ci: add .gitea/workflows/windows-build.yml (M1) — build + lib tests for
  x86_64-pc-windows-msvc, with CMAKE_POLICY_VERSION_MINIMUM=3.5 for the
  vendored libopus build. Needs a windows act_runner to actually run.

Linux build/clippy/tests green (316/316). The Windows path is verified by
inspection only (no local Windows toolchain); CI is the real gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:47:13 -04:00
mollusk 44bad7b70b Fix jitter restart and WAV size overflow 2026-06-16 17:28:41 -04:00
mollusk 20643a24de Add audio controls and focused hotkeys 2026-06-16 17:23:38 -04:00
molluskandClaude Opus 4.8 f6520b79f7 feat(audio): multitrack stem recording — Stage 2 (wire into the mixer)
Wires MultitrackRecorder into the live audio path, behind a recording_mode.

- config: RecordingMode { Mixed, Multitrack, Both } + AppConfig.recording_mode
  (serde-default Mixed, back-compat); CoreCommand::SetRecordingMode, sent at
  app startup from config.
- multitrack.rs: mic now arrives async via push_mic into an internal FIFO,
  drained one frame per end_cycle (mirrors recorder.rs) so the mic track tracks
  the cycle clock; added dir() accessor. mic is a plain WavWriter now.
- core: parallel `multitrack` slot + `is_multitrack` fast-path gate (exactly one
  of the mixed/multitrack recorders is active). SetRecording start branches on
  mode: Mixed → single-file Recorder (unchanged); Multitrack/Both → a per-session
  dir, MultitrackRecorder, and registers everyone already in the room (named,
  silence-aligned from t=0). The mixer taps each peer's RAW frame (pre-volume/
  mute/limiter) into stems and writes peer stems + mix (Both) + end_cycle per
  cycle; the capture thread pushes mic to whichever recorder; PeerJoined adds a
  late joiner's stem track. stop_recording finalizes both.

No UI yet to pick the mode (Stage 3) — defaults to Mixed, so behaviour is
unchanged until then; set recording_mode in config.json to exercise stems.
168 lib tests, clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:04:49 -04:00
molluskandClaude Opus 4.8 fde6f8a680 feat(audio): multitrack stem recording — Stage 1 (pure core + plan)
Scopes the differentiating "record every peer to their own synced track"
feature and lands its pure, isolated core (no live-audio wiring yet).

- docs/multitrack-recording-plan.md: scope contract + locked decisions
  (raw stems pre-volume/mute, stems + a mixed track, silence-pad late joiners).
- src/audio/multitrack.rs: MultitrackRecorder over the existing WavWriter.
  One master clock = the mixer cycle; every end_cycle() appends exactly
  FRAME_SAMPLES to every track (silence where idle) so all stems stay
  sample-aligned. add_peer back-pads a late joiner to cycle 0; track_filename
  gives fs-safe `<slug>-<shortid>.wav` (reuses sanitize_name). Optional mix
  track for "Both" mode.
- +5 unit tests: equal length across tracks, late-joiner leading silence,
  stems-only omits mix, fit() pad/truncate, filename slugging/disambiguation.

Stage 2 (wire into the mixer) is next, behind a checkpoint. 168 lib tests,
clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:48:07 -04:00
molluskandClaude Opus 4.8 c899b1cb94 fix(audio): playback worker re-checks running flag so stop() can't hang (A7)
The playback worker thread looped `while running { rx.recv() }`. A blocking
recv() never re-checks the `running` flag — it only wakes on a new frame or the
sender being dropped. So when stop() set running=false and called
worker_handle.join(), the worker stayed parked in recv() and join() hung until
the frame Sender happened to be dropped. audio_probe reproduced this every run
(it calls backend.stop() while its tx is still in scope), hanging on exit; the
GUI could hang on shutdown on any teardown path that stops audio before dropping
the sender.

Fix: extract a `drain_loop` seam that uses recv_timeout(WORKER_POLL=100ms) so
the loop re-checks `running` at least every 100ms even when idle, and returns
promptly on Disconnected. stop() now joins within one poll interval regardless
of the sender's lifetime. +3 unit tests (151 lib): exits on running-flip with
the sender still alive (the exact hang case, asserted via is_finished), returns
on disconnect, and delivers frames. Verified: audio_probe now self-exits cleanly
(exit 0, "done.", no lingering process). clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:58:16 -04:00
molluskandClaude Opus 4.8 5bd32250a5 fix(audio): honor per-cycle quantum in playback (A1, crackle on non-1024 hw)
The playback RT callback pinned the PipeWire buffer to exactly one 1024-frame
quantum (the 2026-05-31 crackle fix). That is only correct when the machine's
clock.quantum is 1024 — on hardware running quantum 512 or 2048 the pinned
slice mismatches the device's per-cycle demand and the crackle returns. We just
shipped a release to a friend whose quantum is unknown, so this was P1.

Fix: enable the pipewire `v0_3_49` feature (exposes Buffer::requested(), the
graph's per-cycle quantum) and fill exactly that many frames each callback via a
new pure `frames_to_produce()` seam, with a safe ≤1024 fallback when the graph
reports 0 (never the whole slice — over-pulling past the ring depth is the
original crackle). Relax the Buffers size pin from a hard 1024 to a generous
8192-frame max so the mapped slice fits any plausible quantum; requested(), not
the buffer size, now governs per-cycle output.

Verified locally with `pw-metadata clock.force-quantum` + audio_probe at forced
quanta 512/1024/2048: each shows `underrun +0` steady, `quantum=` matching the
forced value, and callbacks/s ≈ rate/quantum — proving requested() is live (the
health line would otherwise read the 1024 fallback). +4 unit tests on
frames_to_produce (148 lib tests, clippy --all-targets clean).

Still pending (field test): one real desktop<->dopedart call through the fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 19:49:53 -04:00
molluskandClaude Opus 4.8 68d78ff411 chore(test): drop useless vec! in recorder wav test
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 22:00:02 -04:00
molluskandClaude Opus 4.8 c3cf00f46f feat: local call recording (your mic + incoming mix) to WAV
Opt-in recording of the full call as you experienced it. New dep-free
src/audio/recorder.rs: a canonical mono S16LE WavWriter (header patched on
finalize) plus a Recorder that buffers your transmitted mic in a bounded FIFO
and sums it, sample-aligned, with each incoming-mix frame the playout mixer
produces. The two independently-clocked streams stay aligned via the FIFO
(capped at ~200ms so drift lag can't grow without bound); silent stretches
record the incoming mix alone. Dep-free UTC timestamp -> sortable filename.

Wiring: CoreCommand::SetRecording toggles an Arc<Mutex<Option<Recorder>>> gated
by an is_recording flag (so the capture/mixer hot paths only lock while actually
recording); capture pushes post-gate mic, the mixer writes the pre-deafen mix.
Recording finalizes on stop, room leave, and room switch. UI: a Record/Stop
button in the controls and a red "● REC m:ss" pill in the room header;
core-confirmed Recording{Started,Stopped} events drive the UI flag so a failed
start can't lie. Files land in ~/peerspeak-recordings/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:48:46 -04:00
molluskandClaude Opus 4.8 90717cda37 test(audio): dense battery for the mix-bus soft limiter
Ten more cases pinning the SoftLimiter contract (Gemini, senior-audited):
sustained-loud ceiling both polarities, out_gain participation (boost + atten),
instant-attack no-overshoot, release direction/monotonicity + gradualness,
cross-call state continuity (split == continuous), empty input, extreme
i32::MIN/MAX magnitudes, and bit-exact transparency just under the ceiling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:31:23 -04:00
molluskandClaude Opus 4.8 38c92ced62 feat(audio): mix-bus soft peak limiter
Replace the mixer's per-sample hard clamp with a lossless i32 bus sum fed
through a feed-forward soft limiter (instant attack, ~120ms release). Below
the ceiling it's transparent and sample-exact; loud multi-peer moments are
ridden down to the ceiling instead of shattering into hard-clip distortion.
State carries across frames so a sustained-loud stretch doesn't re-attack
every 20ms frame. The master output gain now applies inside the limiter so a
boost past the ceiling is limited too.

mix_frames now returns the lossless i32 sum (saturation responsibility moved
to the limiter); its tests assert losslessness, and the new limiter module
carries the saturation/transparency/release guarantees.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 21:27:32 -04:00
molluskandClaude Opus 4.8 8614b26824 test(audio): unit tests for parse_pw_nodes device parser
Covers the pure pw-cli parser seam: multi-node parse sorted by description
(non-audio dropped), Source=>input / Sink=>output, description-falls-back-
to-name, empty/non-audio inputs yield nothing, EOF-flush of the final block,
and incomplete blocks (no media.class) dropped. pw_cli tests 0 -> 6.

Implemented by Gemini per next-task.md; left uncommitted per the operating-
agreement default, reviewed against the real diff and re-verified (build +
clippy --all-targets + test all green) by the senior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:08:44 -04:00
molluskandClaude Opus 4.8 ec7d1a85b5 refactor(audio): extract pure parse_pw_nodes from device enumeration
Splits the pw-cli output parsing out of enumerate_audio_devices into a pure
fn parse_pw_nodes(&str) -> Vec<AudioDevice> (with a push_device helper),
leaving only the subprocess call in enumerate_audio_devices. Behavior-
preserving — same id-block boundaries, Audio/* filter, Source=>input,
description-falls-back-to-name, and sort-by-description. Creates a testable
seam (the parsing had zero coverage). Build + clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:03:25 -04:00
molluskandClaude Opus 4.8 fa951e570f test(audio): edge-case unit tests for the noise gate
Covers the previously-untested branches of the NoiseGate envelope/timing:
frame_rms known values, empty-frame transmit-follows-state, disabled gate
parks the envelope open (no fade-in on re-enable), hold-window-then-release
ordering, sustained mid-level refreshes the hold, and a loud signal
re-opening a releasing gate. Gate tests 6 -> 12; test-only, no prod change.

Implemented by Gemini per next-task.md; reviewed against the real diff and
re-verified (build + clippy --all-targets + test all green) by the senior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:00:15 -04:00
molluskandClaude Opus 4.8 99bd1f0c57 feat: echo cancellation via PipeWire module-echo-cancel
Add an opt-in Echo Cancellation toggle (Settings) that routes the call
through PipeWire's module-echo-cancel (WebRTC AEC + noise suppression + AGC)
instead of running an in-process canceller. PipeWire already sees both the
mic and the speaker monitor, so it handles the echo-reference alignment for
free and we avoid a C++ DSP dependency.

src/audio/echo_cancel.rs (new):
- enable(real_source, real_sink) loads the module via pactl (aec_method=webrtc),
  bound to the chosen devices with source_master/sink_master (defaults if unset),
  waits for the virtual nodes to appear, and returns an RAII guard that unloads
  the module on drop. Best-effort pre-clean of a stale instance from a crashed run.
- EC_SOURCE / EC_SINK are the virtual cleaned-mic source and reference sink.

core: when echo_cancellation is set on Join, load the module and point capture
at EC_SOURCE / playback at EC_SINK; stash the guard in ActiveSession so it
unloads on shutdown (after the audio streams release the nodes). Any failure
logs + warns the UI and falls back to the direct devices — never blocks the call.

config: new echo_cancellation_enabled (serde default false). app: Settings
checkbox under Mic Sensitivity, applied on next room join. An ignored live
smoke test covers the real load/unload path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 16:41:43 -04:00
molluskandClaude Opus 4.8 a7ad07d1f5 feat: smooth noise gate for mic sensitivity
Replace the per-frame hard-cut noise gate with a stateful envelope gate
(src/audio/gate.rs):

- Hysteresis: opens at the slider threshold, closes only below 0.6x that,
  so speech near the threshold doesn't flap the gate.
- Attack/release: per-sample gain ramp (5ms open, 80ms close) instead of a
  click — the gate fades rather than dropping frames outright.
- Hangover: holds the gate open 200ms after the level drops, so word tails
  and brief pauses aren't chopped.

A fully-closed frame still reports don't-transmit, preserving the original
bandwidth win of not sending pure silence (receiver jitter buffer conceals
the gap). The live slider value is read per frame so changes apply
immediately. Settings slider gains a one-line hint. 6 unit tests cover
attack shape, hysteresis, hangover-then-release, disabled pass-through, and
the closed-gate no-transmit path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 16:35:41 -04:00
molluskandClaude Opus 4.8 039c34322c fix: eliminate playback crackle by pinning the PipeWire buffer to one quantum
The playback stream was negotiated with a ~256ms (12288-frame) maxsize
buffer. The sink drains the graph quantum (1024 frames) per cycle, so one
of our buffers lasted ~12 cycles and `process` was called only ~4x/sec,
each time asking us to fill all 12288 frames -- far more than the 200ms
(9600-sample) playout ring could ever hold. So ~half of every buffer was
silence-fill: a steady ~46% underrun, audible as constant crackle. This
is a consumer-side buffer-size bug, upstream of production pacing, which
is why earlier mixer-pacing attempts never moved the numbers.

Fix: pass an explicit SPA_TYPE_OBJECT_ParamBuffers param on connect,
pinning buffer size to one 1024-frame quantum (2048 bytes mono S16LE).
PipeWire now hands us a quantum-sized buffer ~47x/sec, the ring satisfies
every callback, and slice.len()/stride equals the quantum so we never
over-pull. A node.latency hint is added too (not load-bearing on its own
-- the hint alone changed nothing; the Buffers param is the fix). Note:
pipewire 0.9.2 only exposes feature v0_3_32, so Buffer::requested() is
unreachable -- pinning the buffer size is the available lever.

Verified with the probe (quantum=1024, 47 cb/s, underrun +0 steady) and
by ear: clean 440Hz tone, no clicks. Local playout path only -- not yet
verified on a live two-peer call.

Also in this commit (the investigation scaffolding that proved it out):
- Fill-paced mixer: production tracks the hardware clock via a shared
  exact ring-occupancy gauge (Arc<AtomicUsize>) kept near
  PLAYBACK_TARGET_SAMPLES, replacing the fixed 20ms timer that beat
  against the 1024 quantum.
- src/bin/audio_probe.rs: drives a sine through the real start_playback
  path with no network/mic, for isolating the local output stage.
- playout-health logging: quiet in normal use (logs only on underrun/
  dropped > 0); set PEERSPEAK_AUDIO_VERBOSE=1 for the per-second
  heartbeat (audio_probe sets it automatically).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:35:52 -04:00
molluskandClaude Opus 4.8 7af0235736 chore: senior-review cleanup pass
- Remove Gemini's committed update_*.py regex-surgery scripts
- Drop unused iroh-tickets dependency (hand-rolled ticket is used instead)
- Replace ToString antipattern with Display impl on PeerSpeakTicket
- Route debug log to XDG state/cache dir instead of hardcoded /home path
- Clear all compiler + clippy warnings (unused imports, collapsible ifs,
  redundant pattern matching, missing Default)

Builds clean with zero warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 15:36:55 -04:00