Repository navigation
Conversation
…ixed size on the wire The design for voice notes, written before any code so the maintainer can object to the wire format before anything depends on it. A member records up to 60 s; muster_module captures it, encodes hard-CBR Opus at 16 kbps (48 kHz mono, 20 ms, DTX and FEC off) and holds a draft in memory; Send posts exactly two author-signed voice/<nid>/<author>/<i>/2 events, sealed under the epoch key like text, each frame padded to 90,112 B of plaintext through a planned session.publish(padTo), so every note costs 180,306 B on the wire whatever its length. Audio never crosses IPC. It covers: why room events rather than FileShare; the clip record and event format; why the pad goes on the frame (after/afterSig are added after the value is signed); the size budget; the fold's rules (first copy wins, no squatting, broken notes); thread rows synthesized from the fold; the record -> draft -> send state machine and voice_status; the device-callback rules and the linear capture buffer that enforces the cap; who sees what, with one narrow FS-9 claim; macOS (Basecamp 0.3.2's logos_host has no audio-input entitlement and no usage string, read on the installed app, so an all-zero recording is refused as "silent"); Linux dlopen; co-resident callers with the access policy off; F-13; and what is not verified. The leak list from the published design note is folded in and stays true: a voice is a biometric against FS-7; size and timing are visible to the store node (only length is hidden); co-resident modules can record, send and play while the access policy is off; leaving re-keys nothing; no mix test. Everything not yet built is marked planned. No exo id exists yet, so the scope is voice-notes and nothing touches .pebbles/; the doc asks the maintainer for an id and for spec sign-off (§18). TDD-Exempt: docs — a design document written before any code; no behavior changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tions Review fixes to docs/design/voice-notes.md (S0), as a new commit on top of ad3f3c9. Major: §7 let any member forge a voice-note row. The row's card rode `body`, and any member or co-resident module can post a message/ body {"kind":"voice-note","note":"<Alice's nid>"}; RoomThread makes a card of any body with a kind, so it would draw under the forger's name and Play would play Alice's audio (against invariant 9). Now the synthesized row carries the note in its own `voice` field, which reduceMessages cannot set (it builds rows from author/ts/body only, muster_module.nim:4574-4577); the card draws from modelData.voice alone; parsedCard refuses voice-note from a body, so a forged one shows as plain text; and the card names reduceVoice's author. MusterCard.known no longer gains voice-note. S5's author probe and an S8 gallery fixture pin the forged case. Minor: - §5 used 3,000 packets for 60 s; §3.2's own count gives ceil((2,880,000 + 312) / 960) = 3,001. Rows are now 126,119 / 63,060 / 84,080 / about 85,257 / about 4,855 (recomputed with a script that reproduces the old 3,000-packet figures exactly). The voice event carries no parents; the worst case assumes []. 500 ms is 1,166 B. - §4: session.publish pads every voice/ key to VoiceFrameBytes itself and raises on another non-zero padTo, so the held re-seal path (a retried Send, a re-announce) cannot emit an unpadded frame; the fixed-size probe publishes a note twice and asserts both frames are 90,153 B. - §11/§15: name both inferred macOS outcomes (zeros, or TCC terminating logos_host); S11's first check is whether logos_host survives voice_record_start under Basecamp 0.3.2, and a "no" goes back to the operator. Impersonal phrasing; bundle facts re-read with codesign and PlistBuddy. - §3.1: ctl values as cint, GET arguments as ptr cint; S1's read-back discriminates only on darwin-arm64. - §10: the store node sees the publishing node unless mix is on, never which member; a co-resident module can also discard the draft and play it aloud (also §13); the "claim [4]" pointer names the registry gap. - §7: a 0.3.x peer's flow view and room map do show each part. - Citations: RoomComposer.qml:1254-1314 / :1305-1313, pump list muster_module.nim:2838-2845. The design note's absence from the repo is stated. Gates run: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check all pass; gen-fake-backend --check fails identically at ad3f3c9 (the stale gallery FakeBackend.qml that S8 regenerates). TDD-Exempt: docs — a design-document correction; no code exists yet, so no behavior changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What: the codec half of voice notes (docs/design/voice-notes.md §3.1-§3.2, §5), in a
new module/src/audio/:
- opus.nim: libopus bound by importc with no header, in sodium.nim's style. The ctl
functions are {.varargs.}, every SET value a cint, every GET a ptr cint. Request
numbers checked against the -dev headers of libopus 1.5.2 (builder pin e9f00bd) and
1.6.1 (the registry nixpkgs run-suite links): identical.
- voice_clip.nim: the clip record as CDE over src/dcbor, and its strict reader. Pure:
no codec linked, so the room's fold (S5) can import it alone. Refusals are values
(codec settings, packets of 1..1275 B, at most 60 s, ceil((samples + preskip)/960)
packets, exactly nine fields, canonical bytes). maxClipBytes is §5's budget: 126,119
B at 60 s, 1,166 B at 500 ms.
- voice_codec.nim: an incremental encoder (feed / finish / clip) at VOIP, SIGNAL_VOICE,
hard CBR 16 kbps, DTX 0, FEC 0, complexity 5, wideband. Every setting is read back
after it is set, so a binding that set a wrong value refuses to encode instead of
making clips of the wrong size. A short packet is opus_packet_pad'ded to 40 B; a
longer one is refused (not-cbr). decodeClip validates through voice_clip, requires
every packet to be one 20 ms frame, then decodes and trims the preskip.
- metadata.json: codegen.nim.link gains "opus", nix.packages.runtime "libopus".
- run-suite.sh: libopus rides the same nix build as libsodium on both OS branches
(with nixpkgs' gcc on Linux), MUSTER_OPUS overrides it, MUSTER_OPUS_LIB is exported
as MUSTER_SODIUM_LIB is, and FLAGS links it for every test.
- src/audio/CLAUDE.md (the claude-md-coverage hook wants one in a new source
directory; S2 adds the device-thread rules) and tests/README.md (the runner now links
libopus; the two tests).
Why: a note's fixed wire size (§4, §5) rests on CBR making a clip's size a pure
function of its length, and on libopus decoding room-supplied bytes only after a check
that needs no codec. Nothing in muster_module calls this yet (S3 links it into the
plugin).
Tested-Behavior: voice_clip_test §1-§3 and voice_codec_test §1-§5 (module/tests/run-suite.sh voice_clip | voice_codec, darwin-arm64, nim 2.2.4). Red first: both failed to compile before the modules existed ("cannot open file: ../src/audio/voice_clip", "cannot open file: ../src/audio/opus"). The ctl read-back, by mutation: with the SET path bound fixed-arity, §1 is red on darwin-arm64 (all SETs fixed: "opus ctl 4024: invalid argument"; SET_BITRATE alone: "opus ctl 4002: invalid argument"); the silent case the prototype saw, a SET_BITRATE that lands 300000 with OPUS_OK, is red twice: newClipEncoder raises "opus ctl 4003 read back 300000, want 16000", and with that guard removed §1 fails `e.readBack(OPUS_GET_BITRATE_REQUEST) == VoiceBitrate` 300000. Green: voice_clip_test all OK; voice_codec_test all OK on libopus 1.6.1 and, with MUSTER_OPUS, on 1.5.2 (preskip 312, P(1k)/P(3.1k) ~5e8, lag-0 correlation 0.907, 60 s within 126,119 B); the full suite, module/tests/run-suite.sh, 277 passed, 0 failed (of 277).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ackend (S2)
What: the device half of voice notes (docs/design/voice-notes.md §9), in module/src/audio/:
- audio_io.nim: the AudioIo seam, in the shape of Transport and BlobStore. An AudioBuffer is
the only thing a device thread touches: allocShared0, sized to its cap, linear (it never
wraps), Atomic write and read indices, `capped`, `done` and the peak. captureInto and
playFrom are the device side, under {.push stackTrace: off, checks: off.}, gcsafe,
raises: [], with no GC and an explicit min() on every index (inbound_queue.nim:1-11's
rule). Capture stops writing at the cap and sets `capped`. Playback writes zeros past the
end and sets `done`. startCapture / stopCapture / startPlayback / stopPlayback / pump /
close return "" or a refusal word (busy, no-device, no-source, wav-format, wav-truncated,
no-sink, sink-write), with one stream at a time. Two in-process implementations whose pump
runs that same device side 10 ms at a time on a clock: SyntheticAudioIo (a source PCM on a
VirtualClock, and a collecting sink) and FileAudioIo (a 48 kHz mono s16 WAV source and a
raw little-endian PCM sink, for S9's MUSTER_VOICE_SOURCE / MUSTER_VOICE_SINK).
- miniaudio.nim: MiniaudioIo(abSystem | abNull) over miniaudio 0.11.23. maDir finds
miniaudio.h on the --path at compile time (querySettingSeq(searchPaths)). One global passC
carries -I, MA_ENABLE_ONLY_SPECIFIC_BACKENDS (CoreAudio, PulseAudio, ALSA, Null) and the
MA_NO_* switches; {.compile.} builds miniaudio.c. Every header type stays in this module,
and MiniaudioIo's fields are plain pointers. The callbacks are cast to an importc'd
ma_device_data_proc. The context is made on the first start and reused until close.
abSystem lists CoreAudio (macOS), or PulseAudio then ALSA (Linux). It never lists Null and
never passes an empty list (miniaudio's default order ends in Null), so with no backend the
answer is no-device, never a recording of zeros.
- metadata.json: codegen.nim.packages gains mackron/miniaudio at f40cf03 (tag 0.11.23),
sha256-ZrfKw5a3AtIER2btCKWhuvygasNaHNf9EURf1Kv96Vc=, nixpkgs' miniaudio.src. The builder's
own fetchFromGitHub with that hash (nixpkgs e9f00bd) gives the same store path,
/nix/store/hbgp7rm4383pyz4krdivaka6ssm6ig75-source. tools/nim-closure.sh fetches the new
entry with no change, and its checkout equals that store path (diff -rq, .git aside).
probe_return_marshalling_host's closurePaths picks it up from metadata.json.
- run-suite.sh: FLAGS gain --path:"$P/miniaudio". The checkout holds no .nim, .nims or
nim.cfg, so the path changes no other test's imports.
- src/audio/CLAUDE.md: the two files; the device-thread rules; never import miniaudio
outside this directory; abNull only in tests. tests/README.md: the two tests and $MA.
Checked by hand, not committed: the seam and MiniaudioIo built into an --app:staticlib
--noMain --mm:orc -d:useMalloc -d:release archive with the builder's nim 2.2.4 and clang
19.1.7, the miniaudio store path on --path. The archive defines ma_context_init and leaves
dlopen/dlsym/dlclose undefined. The module .lgx was not built (S3).
Why: the voice surface (S7) records and plays without the module thread touching a device or
the device thread touching the GC, and its tests need a device path that runs with no
microphone and no permission prompt. No C is tracked (scripts/check-no-cpp.py green). Nothing
in muster_module imports this yet; S3 links it into the plugin.
Tested-Behavior: audio_io_test §1-§5 and audio_io_null_test §1-§4 (module/tests/run-suite.sh audio_io_test | audio_io_null, darwin-arm64, nim 2.2.4, clang 19.1.7). Red first: both failed to compile before the modules existed ("audio_io_test.nim(9, 20) Error: cannot open file: ../src/audio/audio_io", "audio_io_null_test.nim(10, 20) Error: cannot open file: ../src/audio/audio_io"). Each behaviour was also turned red by mutation. With `capped` never set, audio_io_test §1 fails "`buf.capped == (w == buf.cap)` 4800" and audio_io_null_test §2 fails "not capped within 1002 ms (9600 frames)". With `done` never set, audio_io_test §3 fails "`buf.done and buf.readIndex == 2000`". With no peak, §2 fails "`buf.takePeak() == 1000`". MiniaudioIo without its busy check fails audio_io_null_test §4 "`io.startCapture(b) == "busy"`". Green: audio_io_test all OK; audio_io_null_test all OK on the null backend (backendName "Null"; first frames after 15-22 ms, all zero; capped at exactly 9,600 after 191-205 ms; 49,133-50,880 frames/s; a 100 ms buffer done after 78-86 ms) on 6 runs; probe_return_marshalling_host PASS with the new pin; the full suite, module/tests/run-suite.sh, 279 passed, 0 failed (of 279).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…no nil buffer
What: the S2 review's findings on the audio seam (docs/design/voice-notes.md §9).
- miniaudio.nim reads every stream back after ma_device_init (S1's read-back rule). It binds
ma_device's sampleRate and the client-side format and channels of its anonymous playback
and capture structs. A start whose device reads back as anything but 48 kHz mono s16 is
refused with "device-format", and `streamFormat(io)` exports the read-back. Before this,
a wrong config (44.1 kHz, f32, stereo) passed every test. The null device only ever
delivers zeros, and a 36,000..60,000 band measured from the start accepts 44,100.
- `done` now means heard, not handed over. An AudioBuffer gains a `tail`, the silence the
device side writes after the last sample before it sets `done`, and a `silenceAfter`
count. beginPlayback(tail) sets the tail. MiniaudioIo reads it back at start: miniaudio's
intermediary buffer, plus the device's internalPeriods x internalPeriodSizeInFrames at the
client rate, which is 1,920 frames on the null device. ma_device_uninit drops what those
hold. The voice pump (S7) has no clock to wait on, so it can stop on `done` and drop only
silence. The in-process sinks queue nothing, so their tail is 0 and they behave as before.
- Every start refuses a nil buffer with "no-buffer", on the module thread, before a context
or device is made. On miniaudio's thread the nil would be dereferenced with checks off, a
SIGSEGV in logos_host. SyntheticAudioIo and FileAudioIo refuse it too.
- `backendNames(b)` reads a backend list through ma_get_backend_name, with no context and no
device, so the rule "abSystem never lists Null, never empty" is now tested.
- backendsOf returns a fixed array plus a count instead of a seq[ma_backend]. The seq's hooks
had put an #include of miniaudio.h into system's C (seen in the pre-fix nimcache: a
`ma_backend data[]` seq struct and `NIM_ALIGNOF(ma_backend)`). In a fresh nimcache of
audio_io_null_test, and of an --app:staticlib build, only miniaudio.nim.c includes the
header now. The header comment is corrected on three points: which files include the
header and why; that nim links nothing into an --app:staticlib (the archive leaves dlopen,
dlsym and dlclose undefined, rechecked with nm, for the plugin's own link in S3); and that
the leftover symbols are three, not one.
- src/audio/CLAUDE.md and tests/README.md describe the new rules and checks.
Not changed: the review's last item, voice_device_e2e in run-suite's E2E list. The plan
and §17 put that test in S11, and src/audio/CLAUDE.md already says so.
Why: a regression in the stream config would have played notes at the wrong pitch, or
recorded garbage, on real devices with every test green. A stop on `done` could cut what the
device still held (40 ms on the null device's defaults) off a note's end. A caller's nil buffer would have crashed the
host from a thread Nim does not own.
Tested-Behavior: audio_io_test §3 (the tail: done only once 1,200 zeros followed the last sample, not in the callback that handed it over) and §6 (no-buffer from every in-process start, nothing opened); audio_io_null_test §1 and §3 (streamFormat == 48 kHz mono s16 on capture and playback; playback 48,000 frames/s ±5% between two reads 600 ms apart; a short buffer done only after its 1,920-frame tail of silence), §4 (no-buffer before any context) and §5 (backendNames: abNull is ["Null"], abSystem ["Core Audio"] on macOS, never Null, never empty). Run with module/tests/run-suite.sh unit audio_io (darwin-arm64, nim 2.2.4, clang 19.1.7). Red first: both tests failed to compile ("audio_io_null_test.nim(20, 13) Error: undeclared identifier: 'StreamFormat'", "audio_io_test.nim(133, 15) Error: undeclared field: 'tail' for type audio_io.AudioBuffer"). With everything but the nil checks in, both crashed at the new no-buffer blocks ("SIGSEGV: Illegal storage access. (Attempt to read from nil?)" after audio_io_test §5 and after audio_io_null_test §3). Mutations after the fix, each restored and cmp-checked: cfg.sampleRate 44,100 fails §1 "`io.startCapture(buf) == """; 44,100 with the refusal removed fails §1 "`io.streamFormat == Clip` (rate: 44100, channels: 1, s16: true)"; capture f32 stereo fails §1 at the start; Null added to abSystem fails §5 "`sys == @["Core Audio"]` @["Core Audio", "Null"]"; no tail at start fails §3 "`short.tail == 1920` 0"; done set with the last sample fails audio_io_test §3 "`not buf.done` done with the last samples still queued" and audio_io_null_test §3 "`short.silenceAfter >= short.tail` 0 frames of silence"; no nil check in SyntheticAudioIo.startCapture fails §6 with SIGSEGV. Green: audio_io_test 1-6 OK and audio_io_null_test 1-5 OK. Over 29 null runs, 16 of them 8 at a time, the rate was 47,211-49,159 frames/s and done came 111-130 ms into a 100 ms buffer, after 1,920-2,400 frames of silence. Gates green: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check. gen-fake-backend --check is red, and equally red on HEAD and origin/main (FakeBackend.qml stale; no ui/ input touched here). The full suite was not rerun: only these two tests import src/audio/audio_io or miniaudio.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… length no clip has
What: three of the S1 review's findings on the codec (docs/design/voice-notes.md §3.1, §3.2, §5).
- run-suite.sh links the libopus the .lgx ships. On macOS that is nixpkgs#libopus at the
builder's pin (metadata.json nix.packages.runtime "libopus": 1.5.2 at e9f00bd), read off
the same lock the Darwin branch already read for nim. builder_nixpkgs moves out of the
no-nim branch, and builder_pin reads it once into NIXPKGS_PIN for both uses, so a Mac with
nim on PATH still gets the pin's libopus. libsodium is unchanged (nixpkgs'). On Linux
libopus stays where libsodium comes from, the one nix build with nixpkgs' gcc, so it shares
that gcc's glibc. With MUSTER_SODIUM set and MUSTER_OPUS unset, Linux now stops with a
message naming MUSTER_OPUS instead of linking nixpkgs' libopus under the host's compiler.
MUSTER_OPUS still overrides on either OS. voice_codec_test §1 prints
opus_get_version_string, now without a doubled "libopus".
- maxClipBytes raises ValueError for ms < 0 or ms > VoiceMaxMs. A negative ms wrapped
through cbUint into a 121-byte budget, and an over-cap one sized a clip decodeRecord
refuses (or overflowed). S4/S5 size the frame from this budget.
- Comments, corrected to what the code does:
- MaxPacketBytes: 1275 is the largest Opus frame (RFC 6716 §3.2.1). A single-frame packet
can be 1,276 bytes, its TOC plus the frame (opus.h's repacketizer doc says so); §3.2
still refuses over 1,275.
- Decode does not require "one 20 ms frame". It requires 20 ms of audio, 960 samples as
opus_packet_get_nb_samples counts them off the TOC byte (and a code-3 packet's frame
count), so several frames adding up to 20 ms pass. Fixed in voice_codec.nim's header and
decodeClip doc, src/audio/CLAUDE.md's rule, voice_codec_test's header and §4, and
tests/README.md. Checked against libopus 1.5.2's src/opus_decoder.c: opus_decode refuses
only a packet longer than its frame_size.
- The fixed-arity ctl: the silent 300000 read-back is what the prototype saw (§3.1). In
this build on darwin-arm64 a fixed-arity SET failed with OPUS_BAD_ARG instead, since
va_arg reads whatever is on the stack. Fixed in opus.nim, voice_codec_test §1 and
src/audio/CLAUDE.md.
- run-suite.sh's header, tests/README.md and src/audio/CLAUDE.md say which libopus the
runner links.
Not changed: the review's §3 alignment finding (a trim off by a multiple of 48 samples
passes voice_codec_test §3), which is outside this follow-up.
Why: the suite tested the codec on 1.6.1 while the .lgx ships 1.5.2. The hard-CBR 40-byte
packets, the 312-sample lookahead and "none padded" can change between versions. The budget
function would have sized a frame from a nonsense length without complaint. The comments
are rules S5 and S6 will read.
Tested-Behavior: voice_clip_test §3 (maxClipBytes refuses -1, -20, low(int), VoiceMaxMs + 1 and high(int) with ValueError) and voice_codec_test on the builder pin's libopus (module/tests/run-suite.sh voice_clip | voice_codec, darwin-arm64, nim 2.2.4 and clang 19.1.7 from the builder pin). Red first: voice_clip_test failed "voice_clip_test.nim(158, 5) `r == "refused"` maxClipBytes(-1) = 121 [AssertionDefect]"; voice_codec_test under the old runner printed "libopus libopus 1.6.1, preskip 312" and the link warned "libopus-1.6.1/lib/libopus.dylib was built for newer macOS version (14.0) than being linked (11.3)". Green: voice_clip_test 1-3 OK ("3. maxClipBytes(60 s) = 126,119, (500 ms) = 1,166; a negative or over-cap length refused OK"); voice_codec_test 1-5 OK printing "libopus 1.5.2, preskip 312" (/nix/store/f3kcnzp35b1wy1fg8cd4f2354xhww150-libopus-1.5.2, no libopus link warning), and MUSTER_OPUS=<the 1.6.1 store path> still runs 1.6.1. The pin read with no flake.lock (a fresh clone) gives the same github:NixOS/nixpkgs/e9f00bd8…; the Linux MUSTER_SODIUM-without-MUSTER_OPUS stop was checked as an isolated line with HOST_OS=Linux (exit 1 with the message; exit 0 with MUSTER_OPUS set or on Darwin), not on a Linux host. Full suite on the new runner (module/tests/run-suite.sh): "── 279 passed, 0 failed (of 279) ──"; voice_clip and voice_codec rerun green on the final tree. Gates green: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus (the existing WARN), gen-claims-qml --check. gen-fake-backend --check was not run; it is red on origin/main and nothing under ui/ is touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s and miniaudio (S3)
What: the native build gate of docs/design/voice-notes.md (§8, §9, §17 S3).
- muster.lidl gains `voice_status() -> tstr`, and tools/regen.sh regenerated muster_gen.nim
(its forward declaration, dispatch case and get_methods entry; 105 methods, the other 104
entries unchanged). muster_gen.nim was not edited by hand.
- coordination/voice_live.nim, the hosted half's stub: a VoiceLive over an AudioIo, and
`voiceStatus`, which returns §8's JSON: ready.codec from libopus (opus_get_version_string,
through voice_codec), ready.capture and ready.playback "unknown" since nothing has opened
a device, recording idle with capMs 60000, playing null. It opens nothing.
- muster_module.nim hosts musterVoiceStatus. It makes the voice half on the first voice call
over newMiniaudioIo(abSystem), which makes miniaudio's context only on a first start, so
libopus and miniaudio are both linked into the plugin and neither is opened.
- The host-return probe (derived-exo-526) calls voice_status. Its harness requires the method
to be in the module's method table as QString, the JSON to name the libopus the harness
itself links, and both devices to read "unknown". The probe gains opusTokens(), which finds
libopus as sodiumTokens() finds libsodium (MUSTER_OPUS_LIB, which run-suite.sh already
exports, then pkg-config, then the store; under the grader .probe-env/opus).
closurePaths() already puts miniaudio's pin on the inner --path. scripts/grade-specs.sh
links .probe-env/opus from the same nix build as its libsodium and gcc, or MUSTER_OPUS.
- src/audio/CLAUDE.md: muster_module.nim is the one place outside src/audio that imports
miniaudio (it picks the implementation; voice_live codes against AudioIo), and voice_live
is the one coordination file that links the codec. miniaudio.nim's header records the
darwin plugin link. tests/README.md describes the probe's new call and libopus.
Not done here: the plan's push to the fork and `gh workflow run lgx-variants.yml` (this run
does not push). So the linux-amd64 and linux-arm64 plugin links have not been run.
voice_status does not drive a voice pump yet; that, and the six other methods, are S7.
Why: until this, nothing in the shipping module referenced libopus or miniaudio, and macOS
links a plugin with -undefined dynamic_lookup, so a missing library would show only at load.
Tested-Behavior: probe_return_marshalling_host (derived-exo-526): a host client calling voice_status on the module compiled into the host harness observes a typed QString whose JSON names the libopus linked ("libopus 1.5.2") with capture and playback "unknown". Run with module/tests/run-suite.sh probe_return_marshalling_host (darwin-arm64, nim 2.2.4, clang 19.1.7). Red first, twice: (1) the harness's new check with no libopus on its link failed to build: 'Undefined symbols for architecture arm64: "_opus_get_version_string", referenced from: _voiceStatusTyped… ld: symbol(s) not found for architecture arm64'; (2) with opusTokens() added and no voice_status in the module, the client observed {"call":"voice_status","expect":"voice_status","client_type":"empty","client_value":"","client_return_typed":false} and the probe failed "`allTyped` a host client observed a non-typed (bool/empty/default) return". Green: PASS probe_return_marshalling_host 17s, 16 of 16 observations typed, lp_stub_calls 0, voice_status's client_value {"ready":{"capture":"unknown","playback":"unknown","codec":"libopus 1.5.2","note":""},"recording":{"state":"idle","elapsedMs":0,"capMs":60000,"peak":0,"capped":false,"silent":false,"error":""},"playing":null}. Mutations after the fix, each restored and cmp-checked: opusTokens() left off the link fails the build with _opus_get_version_string undefined, referenced from _opusVersion in voice_codec.nim.c.o (the module's own reference) as well as from the harness; "codec": "libopus" in voice_live fails the probe with client_return_typed false. Full suite: module/tests/run-suite.sh, 279 passed, 0 failed (of 279). The gate, on the tree this commit holds (staged, then built): cd module && nix build .#lgx-portable -o ../.run/mac/voice-module-lgx, exit 0 in 92 s at 3.3 GB peak; .lgx sha256 9b18872e7c679ec0f018bcbe88bb0c107e0d44fde779ed54585994a53e419379, one variant darwin-arm64. otool -L muster_module_plugin.dylib lists @loader_path/./libopus.0.dylib beside @loader_path/./libsodium.26.dylib, no /nix/store load path in any bundled dylib, and no CoreAudio, AudioToolbox or AudioUnit; libopus.0.dylib names itself @loader_path/./libopus.0.dylib and links only libSystem. nm: "T _ma_context_init", "U _opus_get_version_string" (from libopus); nm -u shows no _ma_ and no _logos_module_ symbol. Headless, in a fresh LOGOSCTL_CONFIG_DIR with logosctl 0.3.2 (aarch64-macos): daemon config set - ({}), daemon start --detach, package install of this .lgx and of the eight .lgx in .run/basecamp/catalog-0.3.2, module load muster_module ("status":"ok", eight dependencies loaded), then `logosctl call muster_module voice_status` printed exactly: {"method":"voice_status","module":"muster_module","result":"{\"ready\":{\"capture\":\"unknown\",\"playback\":\"unknown\",\"codec\":\"libopus 1.5.2\",\"note\":\"\"},\"recording\":{\"state\":\"idle\",\"elapsedMs\":0,\"capMs\":60000,\"peak\":0,\"capped\":false,\"silent\":false,\"error\":\"\"},\"playing\":null}","status":"ok"}; health answered "ok"; daemon stop. Gates green: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus (its one existing WARN), gen-claims-qml --check. gen-fake-backend --check is red, as on origin/main (FakeBackend.qml stale; nothing under ui/ touched).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ens a device, libopus apart
What: the S3 review's findings (all minor), against docs/design/voice-notes.md.
- §8's type rule now says what voice_status returns: integers, strings, booleans and null,
never a float, and never audio. capped and silent are flags (voice_record_stop's draft
carries silent too) and playing is null when idle, so the doc is amended rather than the
JSON. muster.lidl's description says the same words, and voice_live's doc comment matches.
- A device opens in voice_record_start or voice_play, not "on the person's own action": with
the access policy off any co-resident module can make those calls (§13). Reworded in
muster.lidl, muster_module.nim's voice block, voice_live.nim, miniaudio.nim's
newMiniaudioIo, and §9's device lifetime, which said the same and contradicted §13.
- miniaudio.nim's header: the darwin plugin binds dlopen, dlsym and dlclose to libSystem
(nm -m on the S3 .lgx, sha256 9b18872e…9379, shows all three "(from libSystem)"). And
nothing calls close yet: muster_module has no unload hook (muster_gen.nim exports seven
logos_module_* entry points, none an unload), and nothing in it starts a device before S7.
§9 and §17's S7 now carry close-before-unload as S7's, since a context left alive when the
host unloads the plugin is a crash risk; S7's voice_live_test holds §8's type rule in every
state.
- scripts/grade-specs.sh resolves libopus in a nix build of its own, after the libsodium and
gcc pair. Before, one build named all three under `|| true`, so a libopus that failed to
fetch or build left every libsodium probe without its library and the nix gcc as well.
- The doc's muster_module.nim citations follow the S3 lines and this commit's one added
comment line: :2776 to :2778 (fileshare_files' pump), :2838-2845 to :2854-2861
(coordinate_intents' pump list), :4566-4578 to :4582-4594 and :4574-4577 to :4590-4593
(musterCoordinateMessages), :4574 to :4590, and :4624-4625 to :4640-4641
(coordinate_conversations' lastTs; the review missed these two).
- §15's Linux-only checks gain grade-specs.sh exo-526 (the .probe-env/opus path no Mac run
takes) and the Linux plugin links through lgx-variants.yml: each variant's lib dir must hold
libopus.so.0, its NEEDED and RUNPATH must resolve it, since a plain load of libopus that
fails takes all of muster_module down, and dl* must resolve from glibc. §18 asks the
maintainer to sign off on exo-526's wider oracle (its criterion names health, propose and
status; S3 added voice_status), or to move those checks to a voice spec's probe.
Not done: no lgx-variants dispatch (nothing is pushed), so the Linux plugin links are still
unrun; grade-specs.sh's oracle was not run (exophial is not installed on this Mac); the .lgx
was not rebuilt (no compiled code changed: comments, the LIDL description and docs only).
Tested-Behavior: scripts/grade-specs.sh exo-526, run from the repo with a stand-in `nix` first on PATH (an external subprocess: it prints one store path per installable and, with STUB_OPUS_FAIL=1, fails the whole build when libopus is named), a stand-in exophial python (the oracle is not installed here, so nothing was graded) and KEEP_PROBE_ENV=1, then reading module/.probe-env. Red, at HEAD with libopus failing: "warning: libsodium not found (set MUSTER_SODIUM); probes that need it will not build" and the libopus warning, one call `nix build nixpkgs#libsodium.out nixpkgs#gcc.out nixpkgs#libopus.out --no-link --print-out-paths`, and .probe-env with no sodium, no opus and no cc link. Green, after the fix with libopus failing: only "warning: libopus not found (set MUSTER_OPUS); exo-526's host probe will not build", two calls (`nix build nixpkgs#libsodium.out nixpkgs#gcc.out …` then `nix build nixpkgs#libopus.out …`), and .probe-env/sodium and .probe-env/cc linked; with libopus building, all three linked; with MUSTER_OPUS set, no libopus build and .probe-env/opus is MUSTER_OPUS's lib. bash -n clean. The slice's probe after the edits: module/tests/run-suite.sh probe_return_marshalling_host, PASS 17s (18s again after a last comment reflow in voice_live.nim), 16 observations typed, lp_stub_calls 0, voice_status {"ready":{"capture":"unknown","playback":"unknown","codec":"libopus 1.5.2","note":""},"recording":{"state":"idle","elapsedMs":0,"capMs":60000,"peak":0,"capped":false,"silent":false,"error":""},"playing":null}. The edited muster.lidl through the SDK's lidl-gen (the binary tools/regen.sh built for S3): "105 methods", output cmp-identical to the committed muster_gen.nim. Full suite: module/tests/run-suite.sh, 279 passed, 0 failed (of 279). Gates: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus (its one existing WARN), gen-claims-qml --check, all exit 0; gen-fake-backend --check exit 1 (FakeBackend.qml stale), as on origin/main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e session (S4) What: fixed-size sealed frames, docs/design/voice-notes.md §4 as revised (22d9cb6), §3.4, §17 S4. - session.publish(ev, padTo = 0). With padTo > 0 the event's JSON is followed by ASCII spaces up to exactly padTo bytes before it is sealed. padTo = 0 is today's frame, byte for byte, for every key but one. - A voice/ key is always padded to VoiceFrameBytes (90,112), so every voice frame is 1 + 24 + 16 + 90,112 = 90,153 B on the wire. The key decides, never the caller: publish pads the new event and the held copy it re-seals alike, so a retried Send or a re-announce cannot go out unpadded. Any other non-zero pad for a voice key, and any negative pad, raises ValueError. "Voice key" is the key's first segment, as authorOf classifies a kind, so a bare "voice" is one and "voicemail/…" is not. - FrameTooLarge (a CatchableError): an event whose JSON is over its pad raises before it is ingested, journalled or sent, so the log, its heads, the journal and the topic are as they were. - The pad is computed after the held/new resolution and before s.log.ingest; the ingest, sealedIn, seal, journal and transport steps are otherwise unchanged. encodeEvent stays private; decodeEvent and the ingest path are untouched. - VoiceFrameBytes lives in session.nim, the layer that enforces it. S5's fold can import it from there. - Not done, as the revised doc says: publishAuthored gains no padTo, and no call site passes a pad. The plan's caller-supplied pad is superseded. authorship.nim is unchanged: the authorOf voice branch is S5's, so a voice part cannot be author-signed yet, and §5 of the test signs a message/ event instead. Measured, not arithmetic (§5): the worst-case event JSON (a 63,060 B half as 84,080 base64 characters, a 128-hex author in the key, "parents":[], 8 heads in after, afterSig) is 85,257 B, 4,855 B under the pad, which agrees with §5's figures. Found on the way: at nim 2.2.4 (the builder pin's nim-wrapper), parseJson accepts trailing spaces and also trailing NUL bytes, and refuses a trailing letter or "}". §4's "refuses trailing garbage" holds for those, not for NULs. Nothing pads with NULs. The test now finds the JSON's end by re-serializing what parseJson read, so a NUL pad fails it (below). Tested-Behavior: session_pad_test (new), over LocalTransport with a plain subscriber recording every frame on the topic and the session's journal recorded: (1) every voice/ frame is exactly 90,153 B with no pad passed, for a 1 KB and an 84 KB value with 0, 1 and 8 (MaxHeads) heads and again on the held path, the plaintext the JSON then spaces only; a bare "voice" key padded, "voicemail/1" not; (2) a padded frame decodes through the unchanged ingest path, a member's live receive and a replay of the journal, into the same event (id, after, afterSig); (3) FrameTooLarge for a voice event over 90,112 B and for an event over an explicit padTo, ValueError for a voice key with padTo 4096 and for padTo -1, each leaving the log, its heads, the journal, the topic and the other member unchanged; a pad of exactly the JSON's length fits with no space, one byte less raises; (4) padTo 0 is today's frame byte for byte, held to three literal JSON plaintexts; (5) an author-signed message published with padTo 8192 reaches the other member, whose roomEvents keeps it with its signed after. Run with module/tests/run-suite.sh session_pad (darwin-arm64, nim 2.2.4). Red first, twice: before any session change, with sections 1-2 only, "session_pad_test.nim(99, 7) `frame.len == WireBytes` 1 KB, 0 heads: a 1554 B frame [AssertionDefect]"; with every section, "session_pad_test.nim(120, 12) Error: undeclared identifier: 'VoiceFrameBytes'". Green: PASS session_pad_test, 1-5 OK, printing "1. every voice/ frame is 90153 B: 1 KB and 84 KB values, 0, 1 and 8 heads, the held path; worst-case event JSON 85257 B, margin 4855 B OK". Mutations of session.nim, each restored and cmp-checked: no key rule fails §1 ("1 KB, 0 heads: a 1554 B frame"); padding after ingest fails §3 ("a voice/ event over VoiceFrameBytes: the log holds it"); the held path unpadded fails §1 ("held path: a 1554 B frame"); padding every key fails §1 ("voicemail/ is not a voice key"); refusing at equality fails §3 (FrameTooLarge "an event of 54 B does not fit a 54 B frame"); a NUL pad first got to §5, then, after jsonEnd was changed to re-serialize, fails §1 at `b == byte(' ')`. Full suite: module/tests/run-suite.sh, "── 280 passed, 0 failed (of 280) ──" (279 before plus session_pad_test). Gates: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus (exit 0, its existing WARNs on unverified claims in authored family files), gen-claims-qml --check, all exit 0. gen-fake-backend --check not run: red on origin/main, and nothing under ui/ is touched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es its author in its key (S5) What: docs/design/voice-notes.md §3.3, §6, §7 as revised (22d9cb6), §17 S5. - coordination/voice.nim (new), pure: it imports voice_clip for the record's structural checks and never libopus or a device. clipDigest is a muster.voice.clip.v1 hash-input record over the record's bytes; noteIdOf(author, clip digest, ts) is 16 bytes (32 hex) of a muster.voice.note-id.v1 record. voiceEvents(author, clip, ts) gives the nid and exactly two unsigned parts, voice/<nid>/<author>/<i>/2, value {v, ts, durMs, digest, part}: integers and strings only, the record's halves (the larger first) as base64, no parents. The author signs and publishes each with publishAuthored; session.publish pads the frame itself (S4). voicePartEvents builds parts from any bytes, for what a fold must refuse. VoiceParts 2 and VoiceMinMs 500 (S7 refuses too-short) live here. - reduceVoice(events) folds the room's authentic view into {nid, author, ts, durMs, state, parts} ordered by (ts, nid), state complete | arriving | broken, by §6's rules: the key exactly voice/<32 hex>/<128 hex>/<0|1>/2, lowercase; of each part, the first well-formed copy in canonical order stands; a note is kept only when a part's own (digest, ts) derives its nid from its author (squatting dropped); both parts must agree on ts, durMs and digest, and the record they make must match the digest, pass decodeRecord and be its own length. `parts` names the standing parts' event ids, for S7's verdict memo. voiceRecordOf(events, nid) is a complete note's record, as checked. - threadRows(events) merges reduceMessages (unchanged) with one row per note, ordered by (ts, id), a message first at an equal (ts, id). A note's row has an empty body, the fold's author, and the note in its own field; rowJson writes {id, author, ts, body} and adds `voice` {v, note, durationMs, state} on a note's row only, so no message/ event, whatever its body or value claims, puts a voice field on a row. threadLastTs counts a note. - authorship.authorOf: a `voice` branch, first, that reads the author off the key's third segment and never parses the 84 KB value; any `voice` shape is author-bearing, so one too short to name an author never verifies. The header table gains the row. authorship_test gains §11, appended before its final echo (invariant tests are append-only), and a header line for it. - Where the doc is silent, decided here: a malformed part (key or value: extra fields, v other than 1, a non-integer ts or durMs, durMs outside 0..VoiceMaxMs, a digest that is not 64 lowercase hex, base64 that is not canonical) is dropped as if never sent, never raised on; well-formed parts that disagree make the note broken. The squat check is per note, not per part, so parts with different digests under one nid read broken rather than arriving. voiceEvents raises ValueError for an author that is not 128 hex. authorSig is optional in the fold, the room's view having verified it. - Probes, as oracles for the spec that waits on the maintainer's exo id: probe_voice_fixed_size_stepper (0.5, 1, 10, 30, 59.98, 60 s x 0, 1, 8 heads x epochs 0, 1, each note also sent again on the held path: every frame 90,153 B, part 0's after names the heads, Bob folds it complete at that epoch, its record byte-identical), probe_voice_author_stepper (genuine, forged, unsigned, other room, edited, squatted x either part, plus Mallory's signed message/ claiming voice-note with Alice's nid, with and without Alice's note: no voice row but the fold's), probe_voice_order_stepper (every order, doubling and subset of p0, p1, a copy of p0 and an equivocating p1' that sorts first), over the shared fixture probes/voice_room.nim (synthetic CDE clips of 40-byte packets, so no probe links libopus; probes/config.nims and grade-specs.sh unchanged). - tests/README.md lists voice_test and the probes; audio/CLAUDE.md names voice.nim; voice_live.nim's S7 note says threadRows goes into coordinate_messages there. Not done: threadRows is not wired into coordinate_messages or coordinate_conversations' lastTs, and there is no memo of complete-note verdicts (both S7, the hosted layer); flow, room map and provenance rows for voice/ (S6); no spec JSON, so grade-specs.sh grades none of the three probes (and exophial is not installed on this Mac); the .lgx was not rebuilt (nothing in muster_module imports voice.nim yet; authorship.nim compiled into the module via probe_return_marshalling_host, PASS). Tested-Behavior: voice_test (new, 11 sections over LocalTransport, synthetic clips, no libopus), authorship_test §11 (appended), and three new stepper probes, run one at a time with module/tests/run-suite.sh <substring> on darwin-arm64 (nim 2.2.4). Red first, before any source change: authorship_test ran sections 1-10 OK, then "authorship_test.nim(209, 5) `authorOf(e).authored` author-bearing shape: voice/0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f/884b…3f50/0/2 [AssertionDefect]"; voice_test "voice_test.nim(49, 27) Error: cannot open file: ../src/coordination/voice"; probe_voice_author_stepper, probe_voice_fixed_size_stepper and probe_voice_order_stepper each "voice_room.nim(17, 30) Error: cannot open file: ../../src/coordination/voice". Green: PASS voice_test, 1-11 OK ("4. a note of 0.5 s, 10 s or 60 s leaves as two frames of 90153 B OK", "10. threadRows: messages and notes by (ts, id), a metadata-only voice field, lastTs counts a note, a forged voice-note body stays its author's plain row OK", "11. a member admitted after a note cannot fold it; one sent in their epoch they hold OK"); PASS authorship_test with "11. a voice/ part names its author in its key: every shape is author-bearing, only that member's signature counts OK"; PASS probe_voice_author_stepper, probe_voice_fixed_size_stepper, probe_voice_order_stepper (each run by hand checks every case; in stepper form each prints {"successors":[{"case":…,"decision_correct":true}]}). Mutations, each restored and checked: voice.nim with no squat check fails voice_test §6 and the author probe (case 10, squatted); the last copy standing fails §8 and the order probe; no digest check fails §8 ("bytes that miss the digest: complete") and the order probe; no length check fails §8 ("the wrong length: complete"); accepting non-canonical base64, or any n in the key, fails §7; no agreement check fails §8 ("another ts: complete"); a message body of kind voice-note made a voice row fails §10 and the author probe (case 12, forged row); session.nim padding no voice key fails voice_test §4 ("500 ms: a 1527 B frame") and the fixed-size probe (case 0); authorOf reading a voice part's author from its value fails authorship_test §11, voice_test and the author probe. Full suite: module/tests/run-suite.sh, "── 284 passed, 0 failed (of 284) ──" (280 before, plus voice_test and the three probes). Gates: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check, all exit 0. gen-fake-backend --check not run: red on origin/main, and nothing under ui/ is touched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…m, audio over graph and proof
What: the S5 review's findings (docs/design/voice-notes.md §6, §10, §13, §15, §17, §18).
- Blocker. A member could sign a voice part that crashed the fold. std/base64's decode
(Nim 2.2.4, lib/pure/base64.nim:252-262) skips whitespace inside its 4-character loop and
then reads past the end of the string: " AB" and "AAAA AAA" (a multiple of 4 long)
each raise IndexDefect, which `except CatchableError` never caught, so reduceVoice,
threadRows, threadLastTs and voiceRecordOf all aborted. voice.nim now decides canonical
base64 before any decode: a length that is a multiple of 4, the standard alphabet (a
table), `=` only as final padding, zero spare bits. A string that fails is no part
(dropped), as before for non-canonical base64; one that passes is the one spelling of its
bytes, so the `encode(raw) == s` re-check is gone.
- The fold is now two passes. The header pass (parsePart, gather, header) checks key and
value shape, base64 spelling included, decodes nothing, and decides which parts stand and
which notes are kept. The verdict pass (verdict) decodes the halves and checks agreement,
digest and record. reduceVoice(events, memo = nil) and threadRows(events, memo = nil) take
an optional VoiceMemo (newVoiceMemo, len, hits): verdicts, complete and broken alike, keyed
by the two standing parts' event ids, so a part that comes to stand in another's place is
judged afresh. S7's hosted layer keeps one per session; nothing hosted uses it yet.
threadLastTs uses the header pass alone. Measured on darwin-arm64, -d:release, 20 notes of
60 s in the authentic view, 30 calls each (scratch, not committed): before, reduceVoice
45.68, threadRows 46.92, threadLastTs 47.13 ms a call; after, reduceVoice 43.63 (no memo),
12.10 (warm memo), threadRows 44.95 / 13.18, threadLastTs 13.20. What a warm memo still
pays is parsing each 84 KB value and hashing its id.
- Major (doc). coordinate_graph copies every field of a value into an entry's `content`
(room_graph.nim:198, :348) and coordinate_proof returns every raw value (proof.nim:79,
muster_module.nim:3230), so either would hand any caller a note's Opus, against "audio
plaintext never crosses IPC". §10 now says so, with §1, §13 and §15. S6 (§10, §17) strips
`part` from a voice/ entry's room-map content and pins it field by field. The proof is put
to the operator in §18 (Still open): name the proof export as an exception, or a new proof
form that carries voice parts as committed digests (today's event id hashes the value's text,
log.nim:48-58, so the field cannot simply be dropped). No code change for either here:
room_graph is S6's, and the proof needs that decision.
- Minor, tests. §7: whitespace in the base64 (" AB", "AAAA A", " ABC",
"AAAA\n\n\n\nAB", "AAAA AAA", "AAAA\n\n\n\n\nAAA", " AAAA"), URL-safe, set spare bits,
padding inside or alone, durMs -1 and VoiceMaxMs+1 beside Alice's genuine part 0, each signed
by her, read arriving; a part 0 of -1 or VoiceMaxMs+1 ms alone names no note; and 4,000
random strings over "ABQRgw+/=-_ \n\r" as part 1's half read broken exactly when std/base64
round-trips them (handed only strings without whitespace) and arriving otherwise, never
raising. §8: part 0 the odd one (another ts, another clip, bytes that miss the digest) beside
a genuine part 1 reads broken, ts and durMs read off part 1. §10: a note whose nid sorts
before a message id at one ts and one after (a message id is "0x"-prefixed hex, so only a
nid starting "0" sorts first; salts tried until each exists), in (ts, id) order. §12 (new):
the memo. probe_voice_author_stepper gains ten malformed cases (five whitespace strings x
either part), appended after the forged-row cases so case numbers 0-13 are unchanged.
- tests/README.md's voice_test and author-probe entries say so.
Not done: S6's room-map stripping and the operator's answer on coordinate_proof; the hosted
memo (S7); roomEvents' authorship cost over 84 KB values is not measured. Seen, not touched:
transport/received.nim:50 and bitcoin/psbt.nim:167 also hand strings to std/base64's decode;
whether either can be handed whitespace is not checked here.
Tested-Behavior: voice_test (§7, §8, §10 extended; §12 new) and probe_voice_author_stepper (ten malformed cases), run with module/tests/run-suite.sh <substring> on darwin-arm64, nim 2.2.4, -d:release. Red first, before any source change: voice_test "voice_test.nim(558, 14) Error: undeclared identifier: 'newVoiceMemo'"; the same test without §12, on an export of HEAD, ran 1-6 OK then "Error: unhandled exception: index 6 not in 0 .. 5 [IndexDefect]" in §7; probe_voice_author_stepper on that export, "Error: unhandled exception: index 6 not in 0 .. 5 [IndexDefect]". Green: "── 5 passed, 0 failed (of 5) ──" for voice_test, probe_voice_author_stepper, probe_voice_order_stepper, probe_voice_fixed_size_stepper and authorship_test; voice_test 1-12 OK ("12. a memo of verdicts keyed by the standing parts' ids changes no fold; another part standing is judged afresh; threadLastTs is the newest row's ts OK"); the author probe in stepper form, {"case": 23} gives {"successors":[{"case":0,"decision_correct":true}]}. Mutations on a scratch copy of the final tree, each run with run-suite.sh all voice_test probe_voice: the review's M1 (rows by ts alone) fails voice_test at 518 (the equal-ts order); M2 (no durMs range check) fails at 289 ("durMs -1"); M5 (the note read off part 0 whenever it is in) fails at 141 ("one note …, not 0", §8's part-0 cases); a base64 screen that admits any character, and one that admits only whitespace besides the alphabet, each make voice_test and probe_voice_author_stepper die on "index 12 not in 0 .. 11 [IndexDefect]" ("AAAA AAA"); a memo keyed by (nid, author) fails at 587 ("not the old pair's verdict"). The order and fixed-size probes pass under all six, and the author probe under M1, M2, M5 and the memo mutant (voice_test kills those). Full suite: module/tests/run-suite.sh, "── 284 passed, 0 failed (of 284) ──" (no test added: voice_test grew from 11 sections to 12). Gates: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check, all exit 0. gen-fake-backend --check not run: red on origin/main, and nothing under ui/ is touched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nce; the map carries no audio (S6) What: docs/design/voice-notes.md §10 and §17 S6, and the S5 review's major finding (e755164): coordinate_graph served every voice part's half of the clip, as base64 Opus, to any caller. - flow.nim: a voice/ branch before the unknown-prefix fallback. Each part gives exactly room-member voice-note and voice-note-length (the members who held its epoch's key) and store-node timing, topic and fixed-size; no other observer, and no new Observer. It goes by the key's prefix, as session.publish pads every voice/ key (S4), and never parses the value. - room_graph.nim: "voice" joins AuthorSigned, so a part provenance lists is author-signed and credited to the member its key names. Beside the file/ branch, an attribution branch: an authentic entry under voice/ that is no note's part (so not in provenance) is still author-signed by the key's author, uncounted. A voice part's content is voiceContentOf: v, ts and durMs where each is an integer, the digest where it is 64 lowercase hex, and nothing else (no part, no authorSig), so no audio rides the map coordinate_graph serves, whatever a part holds. facetsFor gains a voice case (who sent it: imperative, its author's signature; what it says: motivational, its author) where the map said "no guarantee is declared for this kind of entry". - intents.nim logProvenance: a voice item, icPeerMessage, kind voice, "a voice note (part i of 2)", account the key's author, accountable, the guarantee text a message carries, its epoch from sealedIn. It is classed by the fold's own key rule (voice.nim's partKey, now exported as voicePartKey) and never by the value; a voice/ key that is no note's part is not listed. - coordinate_proof is unchanged: it returns raw events by design, and §18 leaves the audio in a proof export to the operator. - Docs: voice-notes.md's §10 row for co-resident modules and §15's bullet now say the room map is closed and the proof still returns the audio; room-map.md's "content, all of it" and room_graph.nim's header name the voice exception; tests/README.md's voice_test entry. Decided here, where the doc and plan are silent: - Provenance lists a part by its key's shape alone, so a voice entry's "counted" in the map means an authentic part of a note's key shape, not the fold's standing part: an equivocating copy, a squatted nid or a malformed value is still listed. Telling those apart needs the value, which the plan keeps these three folds from parsing. - The map type-checks each field it keeps, so a part whose digest is not 64 hex (in the test, one holding its own half of the clip) shows no digest. - The voice facets above. Not done: coordinate_graph is not driven through the hosted module here (the room map is tested at reduceRoomGraph and its toJson, which is what coordinate_graph serves beside "dropped"; voice_live_test is S7); labels for the new flow fields and the voice kind in FlowView and the room map's view (S8); coordinate_proof (the operator's decision, §18). Tested-Behavior: voice_test §13-15, appended before its final echo, run with module/tests/run-suite.sh all voice_test on darwin-arm64 (nim 2.2.4). Red first, before any source change: "voice_test.nim(627, 3) `fields(obRoomMember) == @["voice-note", "voice-note-length"]` @[] [AssertionDefect]" (§1-12 OK). A scratch copy with §14 replaced by a non-aborting probe, run against the same tree and deleted uncommitted, printed "RED-PROBE 14: 16 of 16 voice parts' halves are in the served map; a voice entry's content keys: @["authorSig", "digest", "durMs", "part", "ts", "v"]" and then §15 failed with "zz_s6_red_scratch_test.nim(692, 5) `mine.len == 1` voice/74e3…/2df0…/1/2: 0 [AssertionDefect]". After the flow branch alone: "13. … OK", then "voice_test.nim(675, 5) `n.attrs["basis"].getStr() == "author-signed"` voice/afc3…/af06…/0/2: "sealed" [AssertionDefect]". After the room map alone: "voice_test.nim(678, 5) `n.attrs["counted"].getBool() == part` …: counted false [AssertionDefect]". Green after provenance: PASS voice_test, 1-15 OK, "voice_test: all OK". Mutations, each applied alone and restored, each FAIL in voice_test: the flow without fixed-size (628, `fields(obStoreNode)` got @["timing", "topic"]); voice rows to the storage peer too (630, "storage-peer learns of a voice note"); the map's content unstripped (691, keys); no digest check (691, keys); voice not in AuthorSigned (675, basis); provenance listing any voice/ key (675, basis); no fallback attribution (675, basis); no voice facets (682, abouts); provenance's epoch read by position (729, `it.epoch == bobS.sealedIn[...]`). Related run (voice_test, flow_test, room_graph, provenance, epoch_attribution, swarm_test, probe_room_map, authorship_test, probe_voice): 20 of 21 passed; room_graph_scale_test missed its timing budget there under 8-way load (1875.5 vs 797.1 ms), then passed alone twice (550.4 vs 522.1 ms; 551.0 vs 522.6 ms), as on the tree before this change (550.1 vs 537.5 ms). Full suite: module/tests/run-suite.sh, "── 284 passed, 0 failed (of 284) ──", room_graph_scale_test 959.7 vs 934.8 ms and probe_return_marshalling_host (which compiles nim-lib/muster_module.nim, intents.nim now importing voicePartKey) PASS. Gates, all exit 0: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check. gen-fake-backend --check not run: red on origin/main, and nothing under ui/ is touched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…view; an older client's audio What: the S6 review's findings on 9ad9963 (docs/design/voice-notes.md §6, §7, §10, §13, §15, §18). - Major. The room map counted voice parts the voice fold refuses: a value of the wrong shape, an equivocating later copy and a squatted note id were counted, against room_graph's own rule that an entry the fold refused is marked so (invariant 9). The reason given in 9ad9963 ("the plan keeps these three folds from parsing") did not hold: voiceContentOf already parsed every value. voice.nim gains voiceStanding(events): the header pass's standing parts of the notes the fold keeps, by event id, exactly the ids reduceVoice's notes name in `parts`, whatever each note's state (rules 2 to 4; it decodes no clip). logProvenance lists a voice item only for a standing part, and the room map counts what provenance lists, so its `counted` is now the fold's word. voicePartKey is private again: no other fold reads a part off its key alone. - Major (doc). A member on Muster 0.3.x or earlier returns every note's Opus through their own coordinate_graph and coordinate_proof: on main, authenticEventsWith passes every event authorOf does not mark authored (authorship.nim:238-240) and authorOf has no voice/ branch, decodeEvent reads a padded frame unchanged (session.nim:82-90), and contentOf copies every field (room_graph.nim:198, :348). This PR cannot reach that code. voice-notes.md now says so in the §10 table (a new row) and as "What still leaks" item 6, in §7 (the 0.3.x map entry's content carries `part`), §13, §15, §18 (a question for the operator) and the S10 gap list (exposed; the party is the modules loaded beside an older member's Muster). room-map.md says the same. - Minor. The flow gave an entry under voice/ that is no note's part the room-member rows. It now gives `voice-note` and `voice-note-length` only to a standing part; any other voice/ entry gets the store node's timing, topic and fixed-size alone (the sending client's session.publish pads every voice/ frame, whatever its shape). - Minor. An uncounted entry the map still names its signer of (author-signed by authorship.nim) had the facet "the fold refused it, so it counts for nothing and names no one". facetsFor takes `attributed`, and such an entry now reads "the fold counts it for nothing; its author signed it, bound to this room". That covers voice/ entries that are no note's part, and, beside them, every file/ entry, account disclosure and ceremony join, which provenance never lists and the map has always named. RoomMap.qml's lead for such an entry says the same instead of "so it names no one". - Minor. Comments: room_graph.nim's header and room-map.md now say the map strips audio "so that no hosted method but coordinate_proof returns audio"; voicePartKey's doc names rule 2's key half; intents.nim and the map's fallback name rules 2 to 4; flow.nim says fixed-size is padded by the sending client and not checked on ingest, so for another member's entry it is what an honest client sends. - Minor, test. §14's leak check took the whole half and one 48-character window at its midpoint. It now looks up every 48-character window of every half, at a 24-character step and at its end, among all the served map's 48-character windows (a set), so a leaked stretch of 71 characters or more is found wherever it sits. The doc and tests/README.md say exactly that. - Minor. The room map's store-node facet for a voice entry names the one fixed size, which marks a voice note but not its length. - Minor (doc). §10's account of coordinate_graph is in the past tense, "What the code adds" is marked built with the test that pins each item, the status line and §15's lead say S1-S6 are built, and the new code is cited "on this branch". Not taken as given: the review's citation finding. The doc says every file:line is to main at 67701f8, and on main room_graph.nim:69 is AuthorSigned, :327 the author-signed line, :198 and :348 contentOf's copy and its use, flow.nim:121-124 the unknown-prefix fallback and intents.nim:829 logProvenance, so those citations were right. New code is now cited "on this branch". Not done: coordinate_proof (the operator's decision, §18); a 0.3.x client is not run against a voice note, its behaviour is read from main's source; RoomMap.qml's new lead is checked by qmllint only, not seen on a display; the cost of two header passes in a room-map read (provenance and the flow each run voiceStanding) is not measured, and coordinate_graph, coordinate_flow and coordinate_provenance are each called on demand, not polled. Tested-Behavior: voice_test §13-15 and swarm_test §8, run with module/tests/run-suite.sh all voice_test swarm_test on darwin-arm64 (nim 2.2.4). The tests add four genuinely signed voice/ entries that are no note's part: a key the fold refuses (i "x"), a value it refuses (a digest field holding a half), an equivocating second copy of Bob's part 0 that sorts after his first, and Mallory's own clip under Bob's note id (squatted). Red first, the final tests against 9ad9963's four source files (put back afterwards, cmp-checked): "voice_test.nim(689, 7) `mine == @[("store-node", "fixed-size"), ("store-node", "timing"), ("store-node", "topic")]` voice/…/x/2, no note's part: @[("room-member", "voice-note"), ("room-member", "voice-note-length"), ("store-node", "fixed-size"), ("store-node", "timing"), ("store-node", "topic")] [AssertionDefect]" (§1-12 OK), and "swarm_test.nim(190, 7) … {"about":"this entry","class":"not-applicable","party":"","mechanism":"the fold refused it, so it counts for nothing and names no one"} [AssertionDefect]". A scratch copy whose §13-15 doAsserts report and go on (its lines 6 past the test's), run in the same go and deleted uncommitted, gave 36 failures: §13 all four extras given room-member rows (x4); §14 the bad value, the equivocating copy and the squat counted (x3) with a counted part's facets (x3), no voice entry's store-node facet naming the fixed size (x18), the uncounted facet saying "names no one" or absent (x4), Bob's part 0 counted twice (x1); §15 the same three listed in provenance (x3). Built up in steps before the squat was added: after voiceStanding in the flow and provenance, "13. … OK", then "voice_test.nim(731, 5) `"one fixed size" in exists["mechanism"].getStr() and …` [AssertionDefect]", the map's counted already the fold's. Green on the final tree: PASS voice_test ("13. …", "14. …", "15. … OK", "voice_test: all OK") and PASS swarm_test ("8. flow rows and room-map attribution for file entries OK"). The new leak check discriminates: with a scratch mutation adding the first 100 characters of the 0.5 s note's part 0 to its node as an extra attr (room_graph.nim put back, cmp-checked), voice_test FAILs at "voice_test.nim(770, 7) `half[k ..< k + W] notin windows` voice/…/0/2: the clip from 0 rides the room map", while a scratch copy holding §14's old check (the whole half and its midpoint window) PASSes ("14. … OK"); with every half's first 100 characters leaked, both fail. Related run (voice_test, flow_test, room_graph*, provenance*, epoch_attribution, swarm_test, probe_room_map*, authorship_test, probe_voice*, accounts_test, frost_ceremony_room_test; JOBS=4, before the squat was added): 23 of 24 passed, the failure lez_frost_account_e2e, a chain-bound test the "account" filter caught ("LEZ getLastBlockId: Could not connect to remote host"), outside the default suite. Full suite, module/tests/run-suite.sh: before the squat case and two comment edits, "── 284 passed, 0 failed (of 284) ──" (room_graph_scale_test and probe_return_marshalling_host PASS); on the final tree (docs edited after it only), "── 284 passed, 0 failed (of 284) ──" again, room_graph_scale_test and probe_return_marshalling_host (which compiles nim-lib/muster_module.nim with intents.nim and flow.nim importing voiceStanding) PASS. qmllint (qtdeclarative 6.9.2 from the nix store) on RoomMap.qml exits 0, as on HEAD's copy. Gates on the final tree, all exit 0: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check. gen-fake-backend --check not run: red on origin/main, left to round 4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…read's rows carry notes (S7)
The hosted voice surface of docs/design/voice-notes.md §8-§9, and its state machine.
- coordination/voice_live.nim: VoiceRecorder (renamed from S3's VoiceLive) over the AudioIo
seam, the room's session and the member's keystore. States idle, recording and draft, and a
playback of a note or the draft beside idle or draft. The pump, which voice_status and
coordinate_intents drive, moves what is due, drains the capture buffer into the incremental
Opus encoder, ends a recording at the device side's `capped` (the 60 s cap) and a playback
at `done`, and releases the device: every stream that ends closes the AudioIo, which for
miniaudio uninitialises the device and the context, so between streams nothing is open.
Send refuses an all-zero recording as `silent` with the platform note as `detail`
(platformNote: on macOS "Basecamp 0.3.2 can't use the microphone on macOS yet: it lacks the
microphone permission.", hostOS choosing; ready.note says it too), one under 500 ms as
`too-short`, no room as `not-joined`, and a part that does not fit its frame (FrameTooLarge)
as `too-large`; the draft is kept on every refusal. The note's ts is fixed at the draft's
first Send, so a retry after a failure between the parts re-sends the same note. Play
refuses `unknown-note`, `incomplete`, `broken` (the fold's verdict, or a clip libopus
refuses), `busy`, `no-draft`, `not-joined`. One VoiceMemo per room serves the thread's rows
(`rows`). voiceIoChoice: MUSTER_VOICE_SOURCE / _SINK give FileAudioIo, MUSTER_VOICE_BACKEND=null
the miniaudio null device, anything else the system backends.
- muster.lidl: voice_record_start, voice_record_stop, voice_draft_send, voice_draft_discard,
voice_play(note_id), voice_play_stop, each -> tstr; coordinate_messages' and
coordinate_conversations' descriptions say a voice row carries metadata only. muster_gen.nim
regenerated by tools/regen.sh (logos-nim-sdk 6077eb70a870, "111 methods"), not edited.
- muster_module.nim: the six methods; the AudioIo from voiceIoChoice; an exit proc
(std/exitprocs) that closes the recorder, since the generated surface has no unload hook;
voicePump in coordinate_intents' pump list; coordinate_messages over threadRows with the
room's memo (rowJson plus alias and self); coordinate_conversations' lastTs from threadLastTs.
- Docs: voice-notes.md (status S1-S7 built; §6 the hosted reads measured; §8 the refusals as
built; §9 device lifetime as built; §13 the peak served raw; §15 what is unverified,
including the held-path variant that makes a retried part too-large for good, which is
session.nim's to fix and is not fixed here; §17, §18), tests/README.md, src/audio/CLAUDE.md,
miniaudio.nim's header.
Measured outside the repo (20 notes of 60 s, LocalTransport, -d:release, darwin-arm64), per
call: roomEvents about 37 ms cold, then 3 ms; the rows coordinate_messages reads about 48 ms
with a cold memo, then 16 ms; threadLastTs(roomEvents) 16 ms; threadRows with no memo 48 ms.
Not done: coordinate_proof still returns a voice part's `part` (the operator's decision, §18);
the peak is not coarsened (open, §13); the exit proc running at a plugin unload with a device
open is inferred, not observed; nothing on Linux; no UI (S8).
Tested-Behavior: module/tests/voice_live_test.nim (§1-§15, two members over LocalTransport, recorders on SyntheticAudioIo with a virtual clock, FileAudioIo and miniaudio's null device; libopus for real). Red first: `module/tests/run-suite.sh voice_live_test` against S6's voice_live.nim: "voice_live_test.nim(100, 16) Error: undeclared identifier: 'VoiceRecorder'". §15 (the retry) written after the rest went green, so it was run red first against a mutant whose Send takes a new ts every time: "voice_live_test.nim(487, 3) `sent{"note"}.getStr() == first[0].nid` the retry sends the same note: {"note":"267a73a990e851b00021008415e6c073"} [AssertionDefect]", then green on the real code. Twelve mutants of voice_live.nim, each restored and cmp-checked, every one caught: no finish at the cap (§2, "the cap made the draft"), no silent refusal (§3), 500 ms refused (§4, "500 ms sends"), encoding at Stop only (§9, "1 s of pumps: 0 ms encoded"), done never ending playback (§1, "done: idle again"), the device kept open between streams (§13, "the context is released when the stream ends"), libopus's refusal ignored (§7), start beside a draft (§5), no memo (§11), a new ts per Send (§15), the peak not reset on read (§1), send with no room (§12 crash). Green: "PASS voice_live_test" with "1. a 3 s tone recorded, sent as two 90153 B frames, played by the other member: the clip's samples exactly, 1 kHz; idle after done OK" through "15. a Send refused between its parts keeps the draft; the retry sends the same note, complete, nothing stranded OK" and "8. 231 JSON replies, every state: no part, no base64 run over 64, no float, under 1 KB OK". probe_return_marshalling_host, which compiles nim-lib/muster_module.nim with the new methods, PASS (voice_status still {"ready":{"capture":"unknown","playback":"unknown","codec":"libopus 1.5.2","note":""},...}). Full suite, module/tests/run-suite.sh: "── 285 passed, 0 failed (of 285) ──". Gates, all exit 0: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus, ui/tools/gen-claims-qml.py --check. gen-fake-backend --check not run: red on origin/main, left to round 4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
§15's "the voice methods through a host" said the hosted glue was called through a host "as the S7 commit records"; it was not, since the host run came after that commit. It now says what one headless run of the S7 .lgx in logosctl 0.3.2's logos_host showed (darwin-arm64, a throwaway room joined on logos.dev, FileAudioIo from MUSTER_VOICE_SOURCE / _SINK, then MUSTER_VOICE_BACKEND=null), and that no note was sent from a host. "The exit proc at unload" now records the unload with a null-device recording open (no crash report, a reload idle), that the exit proc's running is not observed, and the delivery_module crash at daemon stop, which is not this code. TDD-Exempt: docs only; the run it records is a manual headless check, its commands and output in the S7 report, not a committed test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a draft kept to its room, every call pumps first The S7 review of 5eca325 and 1af0a0c (docs/design/voice-notes.md §8, §10, §15, §18). All ten findings applied; none disputed. - coordinate_proof returned every raw log value, a voice part's base64 Opus among them, to any caller. That broke the fixed decision that no hosted method returns audio. It now refuses {error: "voice-notes-present", detail} for a room whose raw log holds any voice/ entry, unsigned or malformed ones included. The check is voice_live.roomProof, using session.isVoiceKey (now exported), the same rule that pads a voice frame. A room with none exports its proof as before. The operator still has to choose between an exception and a proof form that commits a part by digest (§18). - A draft belongs to the room it was recorded in. recordStart stores the room's topic, and a Send through another room's session is refused as `room-changed`: nothing is published and the draft is kept. - Start, Send and Play now pump first, as Stop already did. A playback that played to its end with no status read since is no longer `busy`, and Send finds the draft the cap made. - Send now checks too-short before silent. A recording of nothing (Start, then Stop at once) is `too-short` and raises no microphone note. In the draft JSON and in voice_status, `silent` now means a draft that has samples, all of them zero. ready.note is set only for a draft that Send refuses as silent. - coordinate_messages' row JSON moved into voice_live.messagesJson with the same shape. The hosted method calls it, so voice_live_test pins the row shape. - The wording now matches what the code does. A retry sends the same note after `too-large` or after a transport that raises. Both parts take the held path, because the log takes a part before the transport sees it. DeliveryTransport's send is fire-and-forget, so a frame the fleet loses goes unseen: Send returns {note}, the draft is gone, and the note can stay arriving for other members. This is in §8, §15, voice_live.nim's header and the lidl descriptions; §18 gains a re-send path and the draft's room in voice_status as open items. §15 also records: - the sender's thread showing a note complete while the room holds half; - that a UI before S8 draws a note as an empty chat line; - that the delivery_module crash at daemon stop was not investigated, and no run of origin/main was made to compare. The voice_record_stop, voice_draft_send and coordinate_proof descriptions changed in muster.lidl. tools/regen.sh regenerated muster_gen.nim ("111 methods"); the output is byte-identical, since descriptions do not reach it. - A correction to 5eca325's trailer: the no-room mutant crashed in §6, not "§12". Its red against S6's voice_live.nim now reads at (118, 16), not (100, 16), because the Refuser helper added before §15 moved the first use of VoiceRecorder. Tested-Behavior: module/tests/voice_live_test.nim §15 (amended: the sender's fold complete, both parts in its log, the other member's arriving) and §16-§23 (too-short before silent, each call pumping first, room-changed, device release on Send and Discard while the draft plays, on a sink-write past an RLIMIT_FSIZE limit, on a refused start and on close, messagesJson's rows, roomProof's refusal), plus probe_return_marshalling_host, whose harness now calls the six voice methods, coordinate_messages and coordinate_proof with no room joined through the generated dispatch, each checked to the byte. Red first against 5eca325's voice_live.nim: `module/tests/run-suite.sh voice_live_test` gave "voice_live_test.nim(657, 15) Error: attempting to call undeclared routine: 'messagesJson'"; with §22-§23 disabled, "(535, 3) ... {"draft":{"durationMs":0,"silent":true,"peak":0}}"; with §16 disabled too, "(555, 3) `a.play(aliceS, nid1).ck.ok` a playback heard to its end is not busy to Play"; with §17 disabled as well, "(577, 3) `r.err == "room-changed"` Send in another room: {"note":"9e8b85476156461d380ef55be93c3e20"}". §19-§21 and the probe pin behaviour 5eca325 already had, so their red comes from mutants. Each mutant was applied alone, the file restored afterwards, and its md5 checked unchanged. All 16 voice_live.nim mutants were caught: Send and Discard leaving the draft playing (595, 601), a refused start not closing the io (640), close not closing it (643), the pump ignoring sink-write (626, "playback":"ok"), no pump in Play, Start and Send (555, 557, 561 {"error":"no-draft"}), silent before too-short (543), silent counting an empty draft (535), the note for a short draft (538), no room check (577), roomProof with no voice check (679, with keys format, events, ids, ...), roomProof checking the authentic view only (691), self and alias wrong (663, 661). All 3 muster_module.nim mutants were caught by the probe: voice_play polling a nil session ("host harness exited non-zero (139)"), voice_record_stop wired to recordStart (observed {"error":"not-joined"}), and voice_draft_discard returning JSON (observed {"ok":"ok"}). Green: "PASS voice_live_test", with "16. a recording of nothing, or 300 ms of zeros, is too-short, with no microphone note OK" through "23. coordinate_proof refuses voice-notes-present for a room with any voice/ entry; a room with none exports a proof that verifies OK", and "8. 267 JSON replies, every state: no part, no base64 run over 64, no float, under 1 KB OK". "PASS probe_return_marshalling_host" showed all 11 voice observations typed, among them voice_record_start {"error":"not-joined"}, voice_record_stop {"error":"not-recording"}, voice_draft_send {"error":"no-draft"} and coordinate_proof {"error":"not-joined"}, with voice_status still "unknown" for both devices afterwards and lp_stub_calls 0. The slice's tests passed 14/14 (voice*, session_pad, authorship, coordination_messages, log_proof, audio_io). The full suite, module/tests/run-suite.sh, gave "── 285 passed, 0 failed (of 285) ──". All six gates exit 0: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus and ui/tools/gen-claims-qml.py --check. gen-fake-backend --check was not run: it is red on origin/main and left to round 4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd the gaps voice leaves (S10)
What: docs/design/voice-notes.md §10's claim and its four gaps, as entries in
contracts/claims/registry.json (step open-room, appended after the Monero entries), and
ui/src/qml/ClaimsRegistry.qml regenerated from it by ui/tools/gen-claims-qml.py.
- protects, FS-9, imperative: "A voice note's length does not show in its size". Muster sends
every note as two frames of one fixed size, each sealed under the room's epoch key as text
is, so a store node learns that a note was sent, on which topic and when, not its length;
the body names the gap that still stands. Its test is probe_voice_fixed_size_stepper (S5):
0.5 to 60 s x 0, 1, 8 heads x two epochs, each note also re-sent on the held path, every
frame 90,153 B, the other member folding it complete under that epoch. The title is the
doc's wording, which wins over the plan's ("size does not tell its length").
- gap, exposed, status none: "A voice can identify the speaker" (FS-7 scopes identity to the
room, a voice is not scoped; party: everyone who plays the note).
- gap, exposed, partial: "Modules loaded beside Muster can record and send as you" (with the
access policy off; they cannot read the audio back: no voice method returns it, the room
map carries none, a proof of a room holding a note is refused). Partial because the fix,
--access-policy enforce with infra/access-policy.json, ships in the host but is off by
default, and voice has not run under it.
- gap, exposed, partial: "Leaving a room re-keys nothing". coordinate_leave announces nothing,
no method removes a member, and EpochCrypto.admit wraps each later epoch to every member of
the current set, the one who left included. Partial because the key layer rotates on
removal (EpochCrypto.removeMember) and no room path calls it. The doc calls this a
room-wide gap no entry named.
- gap, exposed, partial: "An older Muster in the room hands voice notes to the modules beside
it" (§10's item 6; the doc lists it among the S10 gaps, beside the three the task names).
- The existing gap "The store node still sees the conversation graph" is unchanged.
Not changed: the F-16 protects claim's "Removal rotates the epoch immediately" is true of the
key layer (probe_epoch_removal_immediate); the new gap says no room path reaches it. Whether
these gaps stay entries of their own is still the maintainer's call (§18).
Tested-Behavior: the protects claim's evidence is probe_voice_fixed_size_stepper, and the registry gate binds each new entry to it. Red first: after the registry edit, before regenerating, `python3 ui/tools/gen-claims-qml.py --check` exited 1 with "FAIL ui/src/qml/ClaimsRegistry.qml has drifted from contracts/claims/registry.json — run ui/tools/gen-claims-qml.py". Green: after `python3 ui/tools/gen-claims-qml.py` ("wrote ui/src/qml/ClaimsRegistry.qml (85 lines from 44 claims)"), --check printed "ui/src/qml/ClaimsRegistry.qml is in sync with the registry." and exited 0; `python3 scripts/check-claims-registry.py` printed "claims registry OK — 44 claims across 6 steps, all evidence resolves." (39 before). `module/tests/run-suite.sh probe_voice_fixed_size` (darwin-arm64, nim 2.2.4): "PASS probe_voice_fixed_size_stepper", run with no argv, so all 36 cases doAsserted, then {"successors":[{"case":1,"decision_correct":true}]}. The gate discriminates: in scratch copies of the checker, FURPS and the registry (module/ and ui/ linked, the repo untouched), each mutation applied alone to the committed registry fails it: the probe path renamed ("test does not resolve — file 'module/tests/probes/probe_voice_fixed_size_gone.nim' does not exist"), the claim made motivational ("a protects claim (requirement + test) is imperative by construction"), requirement FS-99 ("is not a FURPS id"), the biometric gap with no party ("a exposed claim must name its party"), the leaving gap with no fix ("a gap must point at a fix that would close it"); the unmutated copy passes. qmllint (qtdeclarative 6.9.2) on the regenerated file exits 0, with the same import-path warnings as HEAD's copy. Gates: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus and gen-claims-qml --check exit 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ce gate, and what S10 built
What: S10's docs (docs/design/voice-notes.md §17).
- docs/labbook/voice-notes-audio-stack.md (new): the traps S1-S7 found, each with what
happened, why, the fix and the test that holds it: the libopus ctl functions are variadic
and only Apple arm64 shows a fixed-arity binding (the prototype's silent 300000, this
build's OPUS_BAD_ARG); miniaudio's -D flags in one global passC, and no generic over a
header type (a seq[ma_backend] put miniaudio.h into system's C); clang 19 and the
ma_device_data_proc cast; miniaudio's runtime linking kept (MA_NO_RUNTIME_LINKING needs
frameworks on a flat, all-platform link list), and macOS's -undefined dynamic_lookup hiding
a missing symbol until load; the device read back as 48 kHz mono s16 (44.1 kHz f32 stereo
passed every test before), with `done` meaning heard and the nil buffer; std/base64's decode
reading past the end of a string with whitespace (an IndexDefect no CatchableError catches);
the suite linking libopus 1.6.1 while the .lgx ships 1.5.2; parseJson taking a NUL as end
of input. A last section lists what is not found out yet (TCC, Linux, the exit proc).
- CLAUDE.md Layout: a src/audio/ line; voice + voice_live in the coordination list; session
padding every voice/ frame itself. The Current-phase section is unchanged, so
check-readme-rot needs no re-stamp.
- module/tests/README.md: probe_voice_fixed_size_stepper is the registry claim's test; no
test opens a real device; voiceIoChoice's MUSTER_VOICE_SOURCE / _SINK / _BACKEND; the
real-device test voice_device_e2e (S11, not built) behind MUSTER_VOICE_REAL, by name only,
its microphone leg only with consent. module/src/audio/CLAUDE.md points at the labbook.
- voice-notes.md: status S1-S7 and S10 built, S8, S9 and S11 not; "planned" made "built"
where the slice is (§4 S4, §6 and §7 S5, §8's methods, §9's seam and build); §5 says what
of the budget is measured (S4's worst-case JSON, S5's 60 s frames, both over
LocalTransport) and what is S1's arithmetic; §10 quotes the claim as the registry has it
and gives each gap's status; §15, §17 and §18 follow. §4's "refuses trailing garbage" is
corrected, the S4 review's finding left for a later doc commit: parseJson takes a NUL as the
end of its input and accepts anything after one.
Not done: the plan's optional mock of the mic and the voice card in
ui/prototype/desktop-v3.html, left for S8.
TDD-Exempt: docs only, no code or test changes. The two stdlib facts the labbook re-checks were run at the builder pin's nim 2.2.4 in a scratch program outside the repo: decode(" AB") "IndexDefect: index 6 not in 0 .. 5", decode("AAAA AAA") "index 12 not in 0 .. 11", decode(" ABC") "index 5 not in 0 .. 4"; parseJson accepted "{} ", "{}\0\0" and "{}\0garbage", and refused "{} x" and "{}}" (JsonParsingError). Gates, all exit 0: check-no-cpp, check-claims-registry (44 claims), check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check. Full suite on this tree (the claim commit plus these docs): module/tests/run-suite.sh, "── 285 passed, 0 failed (of 285) ──".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wn by the feature, a size per frame What: the S10 review's findings on fbad5d5 and 80438c6 (docs/design/voice-notes.md §10, §18). - Major. The five new registry entries put words on screen that the plain-language pass of scripts/ui-lint.sh (exo-364) refuses: FS-7, F-16 and muster_module, which are never on screen, and "epoch" 6 times and "policy" 7 times past ClaimsRegistry.qml's baseline of 4 and 2. They are reworded with no spec id and no module name. "The room's key" (its current key, the key when a note was sent, each new room key) stands for the epoch key, and "access checks" for Basecamp's access policy. The co-resident fix now reads "Basecamp run with its access checks enforced and Muster's list for them, which lets only Muster's own interface call Muster". ui/tests/qmllint-baseline.json is unchanged, and ClaimsRegistry.qml is regenerated. The same pass found one more "fold" in RoomMap.qml, added by c30fa60 (S6's fix: "Not counted: the fold counts this entry for nothing, though its author signed it."). It now reads "Not counted: this entry counts for nothing, though its author signed it.", so the whole of ui/src/qml is back at its baseline. - Major. The older-client gap drew its line as "Muster 0.3.x or earlier" and said "This version", though this branch is 0.3.2 and muster-v0.3.2 is released. The line is now the feature itself. Title: "A Muster from before voice notes hands them to the modules beside it". Body: "a Muster from before voice notes (0.3.2 or earlier)", and "A Muster with voice notes keeps audio out ...". voice-notes.md §4, §7, §10 (the table, item 6, the gap list), §13, §15 and §18, and room-map.md, say the same. No version is bumped, because RELEASING.md step 1 bumps at release time. Instead §18 asks the maintainer to number the release that carries voice notes above every 0.3.x (0.4.0, say), and notes that a release cut from main before this PR merges would move "0.3.2". - Minor. §18 gains an item for the maintainer. The F-16 protects claim's "Removal rotates the epoch immediately." is true of the key layer only, and no room path removes a member (the new gap). Reword it or keep it; this PR leaves it as it is on main. This also corrects fbad5d5's body: probe_epoch_removal_immediate exercises crypto/epochs.nim's Group model, and EpochCrypto.removeMember (epoch_crypto.nim:135) is exercised by epoch_crypto_test.nim:61. §10's gap list cites both. - Minor. §4 and labbook §8 now say that parseJson at nim 2.2.4 also accepts trailing `//` and `/* */` comments, treats a NUL as the end of its input, and refuses any other trailing text. The pad is spaces, so behaviour does not change. - Minor. The protects claim is worded per frame: "Muster sends each voice note as two parts, each one frame of one fixed size whatever the note's length, and a re-send is the same size." §10's quote mirrors it. Its test paragraph now says that the probe asserts four frames per note (both parts, sent twice), each 90,153 B, where it said "both frames". Not done: scripts/ui-lint.sh itself was not run, because there is no runner at .run/runner and none was built. Its plain-language pass was run instead (below), and its qmllint and palette passes were not. The walkthrough with the new words has not been seen on a display. Tested-Behavior: the plain-language gate of scripts/ui-lint.sh. Its NEVER, TERMS and QSTR regexes were copied verbatim into a scratch script outside the repo, which grades ui/src/qml/*.qml against the plain-language keys of ui/tests/qmllint-baseline.json and exits 1 on a new finding, as ui-lint.sh does. Red on 80438c6, exit 1: "NEW 6x ClaimsRegistry.qml · plain-language · developer term: epoch", "NEW 7x ... policy", "NEW 1x ... never on screen: F-16", "... FS-7", "... muster_module", "NEW 1x RoomMap.qml · plain-language · developer term: fold", then "plain-language: 104 finding(s), 87 in the baseline: 17 new, 0 fixed". At 90fcbd4 only the RoomMap finding showed; on origin/main, exit 0 with 87 of 87. Green on this tree after `python3 ui/tools/gen-claims-qml.py` ("wrote ui/src/qml/ClaimsRegistry.qml (85 lines from 44 claims)"): "plain-language: 87 finding(s), 87 in the baseline: 0 new, 0 fixed", exit 0. gen-claims-qml --check failed with "has drifted" after the registry edit and passed with "is in sync with the registry." after regenerating. qmllint (qtdeclarative 6.9.2): ClaimsRegistry.qml exits 0 with no warnings, and RoomMap.qml's 748 warnings (unresolved imports without the runner's Logos library) are identical before and after the edit. The parseJson facts come from a scratch program at nim 2.2.4 (arm64-apple-darwin nim-wrapper): it accepted "{} ", "{}\n\t\r ", "{} // trailing comment", "{} /* c */", "{}\0\0" and "{}\0garbage", and refused "{} x", "{}}", "{} /", "{} /* c */ x" and "{} // c\nx" with "EOF expected". The claim's test, `module/tests/run-suite.sh probe_voice_fixed_size`: "PASS probe_voice_fixed_size_stepper". Full suite on this tree, `module/tests/run-suite.sh` (darwin-arm64): "── 285 passed, 0 failed (of 285) ──". Gates, all exit 0: check-no-cpp, check-claims-registry ("claims registry OK — 44 claims across 6 steps, all evidence resolves."), check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ssage row, both backends (S8)
docs/design/voice-notes.md §7, §8 and §17 S8. The view only triggers and polls: no audio
crosses the contract in either direction.
- The contract (ui/src/muster_ui.rep): SLOTs loadVoice, startVoiceRecording,
stopVoiceRecording, sendVoiceDraft, discardVoiceDraft, playVoiceNote(noteId) and
stopVoicePlayback; PROPs voiceJson (voice_status) and voiceResultJson (the last call's
answer). Both backends implement them, as ADR-019 asks: the C++ in FileShare's pattern,
the Nim through the generated mm.voice_*Async twins. Send re-reads the thread in both.
- CardVoiceNote.qml: pure render from a row's `voice` field, the author the fold
verified, Play and Stop as signals, m:ss, how far it has played, and words for a note
still arriving or whose parts disagree; only a complete note plays. RoomThread draws it
for a row with `voice` and owns the calls.
- A message cannot pose as a note: RoomThread refuses kind voice-note from a body, so such
a message is plain text, its raw words under its poster's name. MusterCard.known does not
gain voice-note.
- The unknown-kind fallback is fixed: a body whose kind MusterCard does not draw is the
message's text (RoomThread's isText). Before, the thread hid the text and the card drew
nothing.
- The mic is an IconAction in the message row, beside Send, not a tile of "Propose
something" (§14). RoomVoiceNote.qml above the row: a banner whenever voice_status says
recording, whoever started it (§13), with a red dot, the time on a QML clock, the level,
Stop and Discard; the draft with Play, Send, Discard and §8's disclosure line before
anything is sent; and the module's refusal or note in an InlineNotice, the detail as the
module gives it (on macOS, the silent refusal's Basecamp 0.3.2 note).
- Room.qml parses voiceJson and voiceResultJson once, reads voice_status on its 1 s tick,
and every 250 ms only while a note records or plays.
- Words for the voice fields and kind in FlowView ("voice note", "voice note length", "one
fixed size") and the room map (kinds "voice note" and "shared file", which it lacked; the
store node's one fixed size when the map holds a voice entry).
- Icons mic, play and stop, in the style of the others.
- ui/tests/gallery/FakeBackend.qml regenerated. `gen-fake-backend.py --check` was already
red on main (FileShare's slots were never generated); this regeneration also adds them.
- Gallery fixtures room-voice-forged, -notes, -record and -flow; Components.qml shows the
card in its four states. Their voice rows, voiceJson, voiceResultJson and flowJson are
typed from the shapes the module's tests pin, over a recorded snapshot, as
room-thread-times does: no runner records a voice row until S9.
- voice-notes.md: S8 built, and what is not verified (§15).
Not done here: which room holds the draft is still not in voice_status (§18), so the draft
surface shows in whichever room is open and says whose it is only when Send is refused.
Tested-Behavior: the gallery fixtures ui/tests/gallery/fixtures/room-voice-{forged,notes,record,flow}.json, run by ui/tests/gallery/Gallery.qml against the real Main.qml and FakeBackend.qml with Qt 6.9.2's qml (offscreen, software) on darwin-arm64, against both design systems: Basecamp 0.3.2's (88330d0) and the runner's (94092b0). Red first: against 2dbcd12 the three voice fixtures give "GALLERY-ERROR not a contract PROP: voiceJson", then "no object named voiceNoteCard_6f1d0c2b9a8e7d6c5b4a39281706f5e4" (forged, notes) and "no object named voiceRecording" (record); with the .rep, FakeBackend and only the plain text's objectName added, the forged body gives "GALLERY-ERROR messageText_0x7a11ce5f0a9ed0b0d4f0a1b2.visible.true is false" and the unknown kind "messageText_0x5011a5ed0c7b1e2f3a40c9d8.visible.true is false"; the flow fixture, on this tree with FlowView's matrix fields as before, "flowFields_room-member.text.voice note, voice note length is "voice-note, voice-note-length"". Green: all four exit 0 on both design systems (5, 16, 18 and 2 GALLERY-PASS lines, among them the expected slot calls playVoiceNote [note], stopVoicePlayback, loadVoice from the poll, stopVoiceRecording, playVoiceNote ["draft"], sendVoiceDraft, discardVoiceDraft and startVoiceRecording). Every other fixture gives the same result as on 2dbcd12 in the same environment (7 need an X display for a popup there); room-news and settings-btc-payer each failed once on one tree by timing and passed twice on both trees on rerun. The C++ backend against the new .rep: `nix build "git+file:///Users/demetrickferguson/Downloads/muster?dir=ui#lgx-portable" --override-input muster_module "git+file:///…?dir=module"` red first, "error: allocating an object of abstract class type 'MusterUiBackend'" with "unimplemented pure virtual method 'loadVoice'" through 'stopVoicePlayback'; green, the darwin-arm64 .lgx, whose plugin names voice_status through voice_play_stop and whose qml/ and icons/ match the tree file for file. The Nim backend: ui-nim builds on Linux only, so a scratch harness type-checked ui-nim/app/backend.nim with Nim 2.2.4 against stubs made from the .rep (every SLOT a forward-declared Impl) and muster.lidl (every method's Async twin in the SDK's a710a31 shape): red with 2dbcd12's backend.nim, "Error: implementation of 'harness.stopVoicePlaybackImpl(o: MusterUi)' expected"; green, exit 0. `python3 ui/tools/gen-fake-backend.py --check`: red on 2dbcd12 and after the .rep change ("FakeBackend.qml is stale"), green after regeneration. scripts/ui-lint.sh's gate (qmllint 6.9.2 plus its python checks, plain language included) on each design system: "87 finding(s), 87 in the baseline: 0 new, 0 fixed". check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus and gen-claims-qml --check exit 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le it holds, the draft waits The S8 review's six findings, on top of 6ce5b4a. - The forged body (major). room-voice-forged checked only that the forged row shows as text. It now also checks that neither the forged row nor the unknown-kind row draws a voice card, that the forged row has no enabled Play for the note it names, and that its header names its poster (§17 S5: "S8 pins the render side"). The header's author text gets an objectName for it, messageAuthor_<row id>. - The poll claim (minor). The 250 ms read of voice_status runs while not idle: a note recording, a draft held, or a note playing. A draft can be held for hours and is read at that rate all the while. 6ce5b4a's comment and message said "only while a note records or plays"; the code was right (the doc says "while not idle") and is kept, and the Room.qml comment and §8 now say so. The Timer gets an objectName, voicePoll, so room-voice-record and room-voice-notes check it runs while recording, with a draft held and while a note plays, and stops once idle. - The room map's voice words (minor). New fixture room-map-voice: a voice note's part is a "voice note by Bob" entry, a FileShare entry a "shared file", and the store node's fields and detail row say one fixed size. room-map-leaks checks that a room without a voice note shows only timing and topic. The store node's field text and the inspector's rows get objectNames for it. - The draft's Play (minor). While a note in the thread plays, the draft's Play is disabled, as the card's Play and the mic are; it stays enabled as Stop while the draft itself plays. - A refusal that outlives its cause (minor). Room.qml keeps the last voice call's answer with the room it was made in and where voice_status stood then (the recording's state and the note playing). Once another room opens, or voice_status moves on, the answer is no longer shown, even when voice_status comes back to where it was. New fixture room-voice-refused: a busy refusal goes once the other caller's recording has ended, and a room-changed refusal goes once another room opens and stays gone on coming back. - The prototype (minor). §17's S10 line said the optional mock in desktop-v3.html was "left for S8". It now says the mock is dropped from this PR as optional, as FileShare's card has no mock there either. No .rep change, so FakeBackend.qml is unchanged and `gen-fake-backend.py --check` stays green (6ce5b4a fixed the drift it already had on main). Neither backend changes: the fix is QML that both backends serve (ADR-019). Tested-Behavior: the gallery fixtures ui/tests/gallery/fixtures/room-voice-{forged,record,notes,refused}.json, room-map-voice.json and room-map-leaks.json, run by ui/tests/gallery/Gallery.qml against the real Main.qml and FakeBackend.qml with Qt 6.9.2's qml (offscreen, software) on darwin-arm64, against both design systems: Basecamp 0.3.2's (88330d0) and the runner's (94092b0). Red first, the new steps against 6ce5b4a's QML: forged "no object named messageAuthor_0x7a11ce5f0a9ed0b0d4f0a1b2", record and notes "no object named voicePoll", refused "voiceNotice.visible.false is true", room-map-voice "no object named roomMapRow_Voice notes", room-map-leaks "no object named roomMapFields_o:store-node". With only the objectNames added, the two behaviour changes are red: record "voiceDraftPlay.enabled.false is true", refused "voiceNotice.visible.false is true". Each other new step was shown red on a one-line mutant of the tree. The review's mutant, RoomThread drawing CardVoiceNote from a voice-note body: "voiceNoteCard_0x7a11ce5f0a9ed0b0d4f0a1b2.visible.false is true". The poll never running: "voicePoll.running.true is false" (record, notes); running whenever joined: "voicePoll.running.false is true" (record, notes). RoomMap without the word for voice: "roomMapInspectorTitle.text.voice note by Bob is "voice by Bob""; without the word for file: "roomMapInspectorTitle.text.~shared file by is "file by You""; holdsVoice false: "no object named roomMapRow_Voice notes"; the store node's fields without one fixed size: "is "timing, topic""; holdsVoice always true: room-map-leaks "is "timing, topic, one fixed size"". The refusal not sticky, without the room check, or brought back on returning to the room: refused red at the idle, room-switch and return steps; the draft's Play unguarded, or disabled while the draft itself plays: "voiceDraftPlay.enabled.false is true", "voiceDraftPlay.enabled.true is false". Green: all six exit 0 on both design systems (10, 26, 18, 15, 5 and 5 GALLERY-PASS lines), and room-voice-flow and room-map as before (2, 4). Every other fixture gives the same result as in the review's run of every fixture on 6ce5b4a, on this Mac with the runner's design system (7 need an X display for a popup there); room-news failed once by timing and passed twice on this tree and twice on 6ce5b4a's QML on rerun. scripts/ui-lint.sh's gate (qmllint 6.9.2 plus its python checks, plain language included) on each design system: "87 finding(s), 87 in the baseline: 0 new, 0 fixed". check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check and gen-fake-backend --check exit 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r's file, the autopilot in both backends (S9)
docs/design/voice-notes.md §17 S9. The UIs still only trigger and poll voice_status; the
audio stays in the module (FileAudioIo reads the WAV and writes the PCM).
- scripts/voice-self-test.sh, in fileshare-self-test.sh's shape: two offscreen runners in one
room, a scratch HOME each. A records VOICE_SECS (3) seconds from a 3 s 1 kHz WAV the script
writes (MUSTER_VOICE_SOURCE) and sends; B plays the note into a PCM file
(MUSTER_VOICE_SINK). Passes when A logs the note it sent and delivery refused none of A's
sends after it, B's thread holds the note complete and B logs it playing then nothing
playing, B's file holds exactly the draft's samples with the 1 kHz tone (Goertzel: at least
half the source's power, over 100 times that at 500 Hz, 1.5 kHz and 3.1 kHz), and no log
has a QML TypeError or ReferenceError. Then C joins on MUSTER_VOICE_BACKEND=null: its Send
must be refused silent and B must still hold one note (VOICE_SILENT=0 skips C).
- The autopilot in both backends (ADR-019), on the autojoin tick: MUSTER_AUTOVOICE=<seconds>
is the mic's Start, Stop and Send once someone else is in the room; MUSTER_AUTOVOICEPLAY=1
is the card's Play for a complete note another member sent, while nothing plays, a refused
Play tried again up to five times. Each tick logs VOICE (voice_status), VOICE-NOTES (the
thread's voice fields) and each call's VOICE-RESULT. The Nim backend gains Do forms of the
voice slots, which the slots now call.
- ui-parity.sh: a voice entry.
- scripts/lib/ui-build.sh launches and cleans up on macOS too: there is no setsid there (a
python3 os.setsid stands in), its ps has no session id (it prints its keyword list
instead, so cleanup kills the process group, which is the runner's pid either way), and
bash 3.2 calls an empty array unbound under set -u. Linux takes the same path as before.
- Docs: voice-notes.md (S9 built and not run, and what did run), the macOS runbook (the
runner does not build on a Mac: below), CLAUDE.md's commands, scripts/lib/CLAUDE.md.
ui/tests/gallery/FakeBackend.qml needs no regeneration here: the .rep is unchanged, and
gen-fake-backend.py --check, red on origin/main ("FakeBackend.qml is stale"), stays green
since 6ce5b4a (S8) regenerated it.
Not run: the self-test itself, on either UI. The standalone runner does not build on this
Mac: `BUILD_MEM= make build` (1 h 28 min from cold) stopped in lez_core 0.5.0's
logos-execution-zone-wallet-ffi-deps-0.1.0, where risc0-sys 1.5.0's build script runs
`xcrun --sdk macosx metal`, the Command Line Tools have no Metal compiler ("xcrun: error:
unable to find utility "metal", not a developer tool or in PATH"), and risc0-build-kernel
2.0.1 panics "Could not build metal kernels". So neither MUSTER_FLEET=local nor logos.dev was
tried, and the gallery fixtures for the card's states and the message row's recording and
draft are not recorded (ui-screenshot.sh record needs a runner, Xvfb and Qt's xcb platform).
Tested-Behavior: scripts/voice-self-test.sh is this behaviour's test, and it has NOT run (no runner builds on darwin, above); it is the maintainer's Linux run, with MUSTER_UI=cpp and nim. What ran, on darwin-arm64: (1) the Nim autopilot, in a scratch harness (not committed) that includes ui-nim/app/backend.nim against stubs made from ui/src/muster_ui.rep and muster.lidl (every method's Async twin answering later, from a fake module with voice-notes.md §8's state machine) and drives its timers in real time: red on 9cc1005's backend.nim, "HARNESS-CALLS []" and no VOICE line in either role; green, the recorder "HARNESS-CALLS ["voice_record_start()","voice_record_stop()","voice_draft_send()"]" with VOICE-RESULT start {"ok":true}, stop {"draft":…}, send {"note":"n1"}, and the player "HARNESS-CALLS ["voice_play(n9)"]" over a thread holding an arriving note, its own note, another's complete note and a message whose body claims a note, its VOICE lines naming n9 playing then null; four mutants each caught (no self check: plays n8 too; no played set: n9 three times; no complete check: plays n7; Play while one plays: n10 twice). (2) The C++ autopilot compiles: `nix build "git+file:///Users/demetrickferguson/Downloads/muster?dir=ui#lgx-portable" --override-input muster_module "git+file:///…?dir=module"` exit 0, the darwin-arm64 muster_ui_plugin.dylib carrying MUSTER_AUTOVOICE, MUSTER_AUTOVOICEPLAY, VOICE-NOTES and the four VOICE-RESULT lines; it has not run. (3) The script under macOS's /bin/bash 3.2 against a stand-in runner (MUSTER_RUNNER, not committed) writing the autopilot's lines by role, with B's sink the tone round-tripped through the shipping libopus 1.5.2 (run-suite's toolchain): SUCCESS ("1 kHz power 66644011 of the source's 67095156 · 500 Hz 0.9, 1.5 kHz 0.2, 3.1 kHz 2.1"); FAIL for a sink one millisecond short ("151152 samples, not the draft's 3150 ms x 48 = 151200"), a quarter of the amplitude, a send delivery refused ("1 accepted, 1 refused"), C's note not refused ("C's Send was not refused as silent") and A never sending ("B never played A's note (sent: none"); the audio check alone also fails silence, a 1.5 kHz tone, a 3.1 kHz tone added, and a row arriving, absent or of another length; no process left after any run. (4) ui-build.sh on darwin with a stand-in runner: red on 9cc1005, "env: setsid: No such file or directory", nothing launched, and a second ui_cleanup "UI_PIDS[@]: unbound variable" (exit 127); green, two instances launched, each process group 3 processes then 0 after ui_cleanup, a second ui_cleanup exit 0. Gates exit 0: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check, gen-fake-backend --check; ui-lint's plain-language check alone, 87 findings, 0 new; shellcheck -S warning: the new script's SC2164 (the cd every self-test opens with) and ui-build.sh's SC2034 at :105, which 9cc1005 has too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in its own thread, the window from the note, the Nim autopilot's check in the tree S9's review (68d6db4) found holes in scripts/voice-self-test.sh, a false line in the macOS runbook, and Nim autopilot evidence that existed only in a scratch harness. - voice-self-test.sh, the delivery check: the count of A's send results now starts at A's `VOICE-RESULT stop`, which both backends log before calling voice_draft_send (cpp:1523; Nim's stop callback). Before, it started at the Send's own line, which both backends log only after voice_draft_send's re-reads. A send's result comes back on delivery's thread whenever it does (delivery.nim publish, lp_invoke_async, onSendResult), so it could land above the Send line and go uncounted. It now also wants at least two accepted (a note is two frames, one send each) beside none refused, and prints both. "0 accepted, 0 refused" used to pass. - voice-self-test.sh, the null-device leg: C's Stop must call the draft silent. C's own thread after its Send (the VOICE-NOTES lines after C's Send line) must hold no note of its own, which is a check that does not wait on the fleet, since a sender's fold shows its note at once (§8). On macOS (uname Darwin) the refusal must carry a detail, the module's platformNote; elsewhere the module gives none, so nothing is asserted there. The check that B holds one note stays. - voice-self-test.sh, VOICE_SECS: the Goertzel window now runs from 0.25 s to 0.25 s before the shorter of the note and the WAV, sized in whole 500 Hz periods. Before, it was fixed at 0.25 s to 2.25 s, so any VOICE_SECS under 3 failed "too short for the window" whatever B heard. VOICE_SECS must now be a whole number of seconds, 1 or more, checked before anything launches. Both backends read MUSTER_AUTOVOICE as an integer, and anything else as 0, which records nothing, so a 2.5 used to wait out the 240 s loop. - ui-nim/tests/autovoice/ (new; gen.py, fake_module.nim, run.sh, CLAUDE.md): the scratch harness behind 68d6db4's Nim autopilot evidence, now in the tree. gen.py builds a harness that includes ui-nim/app/backend.nim whole against stubs generated from muster_ui.rep and muster.lidl. fake_module.nim answers every voice_* call as the module does (voice_live.nim's shapes), one turn of the loop later. run.sh builds it with the nim on PATH, else ui-nim's own nixpkgs pin's (nim 2.2.12, read off ui-nim/flake.lock without evaluating that flake), and checks two roles. The recorder: Start, Stop about VOICE_SECS later, Send, once each, the sent note its own in the thread. The player: Play for complete notes from another member, one at a time, once each; never an arriving note, one's own, or a message whose body claims a note; a refused Play tried five times, no more. It runs on a Mac, where ui-nim's flake does not build. The fake stands in for muster_module only, the process boundary. - scripts/ui-parity.sh refuses a bash older than 4 (exit 2, naming `nix shell nixpkgs#bash -c scripts/ui-parity.sh`). Under macOS's /bin/bash 3.2 it failed on its first associative array ("line 41: card: unbound variable") and exited 0 having run nothing. - docs/runbooks/macos.md: the line that read as if ui-parity.sh would run on a Mac once the runner builds now says it needs bash 4 and how to give it one. - voice-notes.md: §17 S9 has the new pass conditions and the harness. §15 has what ran, including the four holes, each shown red then green. It also has the maintainer's Linux runs that close S9: the self-test with MUSTER_FLEET=local, the same on the Nim build, ui-parity.sh voice, and a red run on a runner built from 9cc1005, which must FAIL "sent: none". The PR stays a draft until those exist. A new §15 entry names the voice gallery fixtures as an open breach of ui/tests/gallery/CLAUDE.md's "recorded, never hand-typed". CLAUDE.md and ui-nim/CLAUDE.md list the harness. Not fixed here, because the fixes cannot run on this Mac: the review's two majors. The self-test has still not run on either UI, and the voice gallery fixtures are still typed, not recorded. Both need the standalone runner, which does not build here: `xcrun --sdk macosx -f metal` still finds no Metal compiler (Command Line Tools only), which is where 68d6db4's `BUILD_MEM= make build` stopped after 1 h 28 min, so it was not run again. The recording also needs Xvfb and Qt's xcb platform. Both are now written into §15 as the maintainer's steps, and S9 is not called done. Tested-Behavior: voice-self-test.sh against a stand-in runner (scratch, not committed: it writes each role's autopilot log lines and, as B's sink, the 1 kHz tone round-tripped through the shipping libopus 1.5.2), under macOS's /bin/bash 3.2. Red, 68d6db4's script: SUCCESS when a refused frame's result landed before the Send's line ("1 accepted, 0 refused"), with no send result at all ("0 accepted, 0 refused"), with C's Stop calling its draft not silent, with C's thread holding a note of its own, and with C's macOS refusal carrying no detail; FAIL "too short for the window" for VOICE_SECS=2 with a good 2.1 s sink. Green, this script: each of those five FAILs ("delivery must accept at least the note's two frames and refuse none" twice, "C's Stop did not call its draft silent", "C's thread holds a note of its own", "C's refusal has no detail on macOS"); VOICE_SECS=2 and 1 pass ("from 0.25 s to 1.85 s", "to 0.85 s"); VOICE_SECS=0 and 2.5 refused up front (exit 2); the good run SUCCESS ("2 accepted, 0 refused", "from 0.25 s to 2.75 s, 1 kHz power 66557984 of the source's 67095156 · 500 Hz 2.6, 1.5 kHz 0.1, 3.1 kHz 2.3"); and still FAIL for a sink one millisecond short, a quarter of the amplitude, a refused send after the Send line, C's note not refused, and A never sending ("sent: none"); no process left. ui-nim/tests/autovoice/run.sh: red on 9cc1005's backend.nim and sched.nim (recorder and player "calls []", no VOICE line); green on this tree's ("recorder: [voice_record_start(), voice_record_stop(), voice_draft_send()], Stop 1073 ms after Start", "player: [voice_play(n9), voice_play(n10), voice_play(n12) x5] · playing [None, n9, None, n10, None]"); six mutants of backend.nim each FAIL (no self check: plays n8; no played set: n9 five times; no complete check: plays n7; Play while one plays: n10 twice; no five-try limit: n12 ten times; Stop not waiting VOICE_SECS: "Stop 171 ms after Start"). ui-parity.sh: red, /bin/bash 3.2 "line 41: card: unbound variable", exit 0; green, exit 2 with the message, and under `nix shell nixpkgs#bash` (5.3.15) the voice entry runs (red: "build the runner first: make build", exit 1). Gates exit 0: check-no-cpp, check-claims-registry, check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check, gen-fake-backend --check (green since 6ce5b4a regenerated FakeBackend.qml; the .rep is unchanged); ui-lint's plain-language check alone, 87 findings, 0 new (no QML changed); shellcheck -S warning, nothing new (the new run.sh clean). Nothing compiled into the .lgx changed (no file under ui/ or module/). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n the system backend behind MUSTER_VOICE_REAL (S11)
docs/design/voice-notes.md §9 and §17 S11. The person consented to the microphone leg.
- module/tests/voice_device_e2e.nim (new): MiniaudioIo on the system backend (Core Audio here;
PulseAudio, then ALSA, on Linux) through the AudioIo seam the module uses, no mock.
- The gate: MUSTER_VOICE_REAL unset (or 0) prints "SKIP voice_device_e2e: ..." and exits
0. 1 (or all) runs both legs, mic or speaker one, a comma list both; any other value
fails, naming the values.
- The microphone leg captures 2 s into a 96,000-frame buffer. It must read back as 48 kHz
mono s16, frames must arrive at 48 a millisecond within ±5% (measured over 1 s, 200 ms
after the first frames), the write index must only grow and never pass the cap, and the
cap must stop it at exactly 96,000 frames with capped set, still there 100 ms later, after
at least 1.5 s. A second start while it runs is busy. The device side's peak must equal
the samples' own. Then the module's own libopus path at the fixed settings
(encodeClip, encodeRecord, decodeClip): 101 packets of 40 B, a record within
maxClipBytes, decoded back to the same length. It prints the peak and RMS and no other
number from the audio, and writes no file. An all-zero capture fails last, naming the
likely cause: on macOS the microphone permission of the app macOS holds responsible, or
no input device.
- The speaker leg plays digital silence by default; only MUSTER_VOICE_REAL_AUDIBLE=1 plays
0.5 s of 440 Hz at -30 dBFS peak (1,036), with 10 ms fades. As audio_io_null_test §3:
read back as 48 kHz mono s16, 48,000 frames a second ±5% (over 1 s, once the device took
its first frames), and a 100 ms buffer done only after the device's own tail of silence.
- A watchdog thread arms before each device start, stop and close. Past 20 s (10 s for a
stop) it prints what it waited on and exits 1. A leg's failure is recorded and the next
leg still runs.
- module/tests/run-suite.sh: voice_device_e2e joins the E2E list, so no default, unit or
probes run selects it. A test that exits 0 with a line "SKIP <name>:" is counted SKIP, not
PASS, and the summary then says "n skipped". Checked under macOS's /bin/bash 3.2.
- module/tests/README.md: the real-device paragraph no longer says "not built yet": how to run
it, both legs, the watchdog; a row in the E2E table; the test in the voice list and the
$OPUS and $MA groups.
- voice-notes.md: §17 S11 has the test and this run; the status line, §9, §11 ("Outside
Basecamp", now seen) and §15 follow it. A new §15 entry: a first voice_record_start can
hold the module thread on a Mac (inferred from the run below; no timeout guards a start).
- docs/labbook/voice-notes-audio-stack.md §9: an unanswered microphone prompt holds the
capture start; module/src/audio/CLAUDE.md names the test and that rule.
The run on this Mac (macOS 26.5.2, arm64, outside Basecamp, from Claude Code in the Claude
desktop app), MUSTER_VOICE_REAL=1: the microphone leg got no frame. startCapture had not
returned after 20 s, and the watchdog ended the test. tccd logged the request charged to
com.anthropic.claude-code (Claude Code's own claude.app, started by the desktop app's
disclaimer helper), not the Claude desktop app: AUTHREQ_PROMPTING for kTCCServiceMicrophone,
then no result. A preflight a minute later still read it undecided (authValue=1). It was the
middle of the night, so nobody answered, and the leg was not run again. So there is no peak
or RMS from a real microphone yet. The speaker leg, run alone (MUSTER_VOICE_REAL=speaker),
passed on Core Audio. After the microphone run, only the messages (to name Claude Code) and
the point the cap's pace is timed from (the start's return) changed, neither at the step
where it stopped.
Tested-Behavior: the gate, red then green: before the E2E entry, `run-suite.sh voice_device` selected the test in the default run and counted it "PASS voice_device_e2e" while it ran nothing; after, `run-suite.sh voice_device` and `unit voice_device` find "no tests match", and `e2e voice_device_e2e` and `all voice_device` without MUSTER_VOICE_REAL give "SKIP voice_device_e2e 0s (MUSTER_VOICE_REAL is not set)" and "0 passed, 0 failed, 1 skipped (of 1)"; MUSTER_VOICE_REAL=yes FAILs "say 1 (both legs), mic, speaker, or mic,speaker". Two scratch mutants of the test on the null device (not committed), red as they must be: abNull for abSystem FAILs both legs "backend Null not in @["Core Audio"]"; with the backend checks also removed, the microphone leg FAILs "every one of the 96000 samples is zero (peak 0): the microphone delivered silence. On macOS ..." after every other check passed (48.13 frames/ms, capped at exactly 96000 after 2010 ms, peak 0, RMS 0.0, 101 packets of 40 B, a 4318 B record decoded back to 96000 samples), and its speaker leg passes (48.12 frames/ms, tail 1920, done after 120 ms). The real devices, MUSTER_VOICE_REAL=1: FAIL by the watchdog, "mic: the capture device did not start within 20 s. On macOS this is most likely a microphone permission prompt nobody answered", with tccd's AUTHREQ_PROMPTING for com.anthropic.claude-code and no result (recorded, not re-run). MUSTER_VOICE_REAL=speaker, green twice: "Core Audio, read back (rate: 48000, channels: 1, s16: true); 48480 frames in 1010 ms = 48.00 frames/ms", "100 ms done after 130 ms" (127 ms on the committed text), "1920 frames of silence after it (the device's tail: 1920 frames, 40.0 ms)". No regression: run-suite.sh voice audio session_pad, 10/10 PASS (voice_codec_test "libopus 1.5.2, preskip 312", audio_io_test, audio_io_null_test "29280 frames in 609 ms (48007/s)", voice_live_test, voice_clip_test, voice_test, session_pad_test, probe_voice_fixed_size_stepper, probe_voice_author_stepper, probe_voice_order_stepper). Gates exit 0: check-run-targets, check-readme-rot, check-no-cpp, check-claims-registry; shellcheck -S warning on run-suite.sh finds only the SC2164 at `cd "$MODULE"` it found before. Not run: the whole default suite, the microphone leg with the permission granted, and anything on Linux.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s two frames leave back to back
Two findings of the whole-diff review, both in the publish path.
- A member's planted copy no longer blocks a retried Send (privacy-protocol-1). The held path
re-sealed the copy the log keeps (smallest afterDigest), and Log.ingest takes a copy with an
`after` of any length, unsigned, from any member. A member who received part 0 of a Send that
failed between its frames could plant a copy whose `after` alone overflows the 90,112 B pad
and sorts first; every retry then raised FrameTooLarge, returned `too-large`, and the room
kept half the note for good while the sender's thread called it complete.
- session.publish gains `asGiven`: seal the copy passed, on either path.
- authorship.copyToSeal picks the copy publishAuthored seals: a new event as before; a held
one as this member signed its `after` (the smallest by afterDigest, as roomEvents picks),
else the event with no ordering claim. Never whichever copy the log keeps.
- log.variantsOf reads one id's copies; the `after` check roomEvents caches is shared.
- A note's two frames leave back to back (privacy-protocol-2). Send published one part at a
time, so between the two transport calls ran part 1's signing, hashing and encoding, about
20-30 us more per second of audio, and each Waku message carries its publisher's timestamp.
- session.publishTogether: each event made once the previous is in the log (so part 1's
`after` still names part 0), padded, recorded, journalled and sealed; only then every
frame goes to the transport, back to back. FrameTooLarge from any is raised before any
frame of the burst is sent.
- authorship.publishAuthoredTogether; voice_live.draftSend sends both parts through it.
- voice-notes.md §4 (which copy the held path seals; a part that does not fit is refused before
any frame), §8 (the retry), §10 item 2 (the spacing, measured before and after), §15 (the
variant entry is gone; a new one: the delivery node's own spacing is not measured).
causal-parents.md §3.2 and the voice_live.nim header follow; tests/README.md lists §24, §25.
Tested-Behavior: voice_live_test §24 and §25, appended. Red on the unfixed code: §24 "the retry is not blocked: {"error":"too-large"}" (Mallory's 1,400-id copy, ground to sort first, kept by Alice's log); §25, with §24 skipped, "at the first send of a 1 s note: (logged: 1, journalled: 1, frames: 2)". Green after: "24. ... re-seals the author's own copy, two frames of 90,153 B, complete for Bob OK", "25. both parts of a 1 s and a 59 s note are logged and journalled before the first frame leaves ... OK". A scratch probe (not committed) timing the two transport calls on a transport that does not deliver back, 40 trials per length: one part at a time, medians 354 / 550 / 981 / 1618 us for 0.5 / 10 / 30 / 60 s notes; publishAuthoredTogether, under 1 us (0) at every length, frames [90153, 90153]. No regression: run-suite.sh all voice session_pad authorship audio causal swarm, 21 passed, 1 skipped (voice_device_e2e, gate closed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…est's gate, hints and words
The whole-diff review's native-build-2 and the S11 device-test review.
- audio_io_null_test §3 read `short.silenceAfter` for its echo after freeAudioBuffer(short):
a heap use-after-free of the allocShared buffer in the suite's own test of those lifetime
rules. The value is read into a local before the frees.
- voice_device_e2e:
- MUSTER_VOICE_REAL of separators alone (`,`, ` , `) named no leg, printed its header and
exited 0, so run-suite counted PASS with nothing run. It now fails, naming the values.
- The microphone hints were one text for two failures. A start that delivers no frame in
5 s now names an input whose clock does not run (a virtual or aggregate device); an
all-zero capture names the permission, or a default input that hears nothing (muted,
volume zero, BlackHole or a Zoom or Teams device with nothing playing into it), and no
longer "no input device", which is the no-device failure's own message.
- The tccd hint reads the last hour, AUTHREQ_PROMPTING or _ATTRIBUTION: on a re-run while
an earlier prompt still waits, tccd logs only a preflight, so five minutes found nothing.
- The messages name the responsible app in general terms (the terminal, or the app that
started the shell), not one tool's bundle id; the run record in §17 keeps the specifics.
- The header says the watchdog ends the whole test, the legs after it unrun, and the
speaker leg's tail check guards audio_io's done rule rather than measuring latency.
- tests/README.md, labbook §9 and voice-notes.md §11 and §17 S11 say the same. §11 and the
labbook no longer say the start is held "until they answer": what was seen is a start
still not returned after 20 s with the prompt unanswered; its return on an answer is
inferred. The tail of 1,920 frames is miniaudio's buffer and periods, not Core Audio's
output latency.
Tested-Behavior: audio_io_null_test under ASan/UBSan (nim 2.2.4, Apple clang 21, -d:useMalloc --debugger:native, -fsanitize=address,undefined): red, "AddressSanitizer: heap-use-after-free ... READ of size 8 ... audio_io_null_test.nim:112; freed by ... :110", rc 1; green, all five sections OK ("28800 frames in 600 ms (47997/s); 100 ms done after 125 ms, 2400 frames of silence after it"), rc 0; and PASS in run-suite.sh's own build. The gate: red, MUSTER_VOICE_REAL="," and " , " gave "1 passed, 0 failed (of 1)" with the log only "legs: "; green, both FAIL "MUSTER_VOICE_REAL=, names no leg: say 1 (both legs), mic, speaker, or mic,speaker", "yes" FAILs as before, "0" and unset SKIP. A scratch null-device mutant (not committed: abNull, backend checks removed) prints the new all-zero message after every other check passed, and its speaker leg passes ("100 ms done after 117 ms, 1920 frames of silence"). The real devices were not opened: the no-frames and stall hints are unrun.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y's store node, and the refusals in plain words
The whole-diff review's ui-scripts-1, -4 and -5 and docs-claims-1 and -8.
- A refusal the person dismissed showed nothing when the next call was refused in the same
words: InlineNotice keyed a dismissal on its words, and a real backend's second, identical
answer does not even change voiceResultJson. InlineNotice gains `occasion` (empty: the
words alone, as before for every other notice) and its × an objectName,
`<notice>Dismiss`. Room counts its voice calls (`voiceCalls`), and RoomVoiceNote keys a
refusal by that count, so a dismissed `silent` shows again on the next Send; a platform
note stays dismissed until its words change.
- RoomMap's `holdsVoice` scanned every entry, ignoring the replay position, so a map scrubbed
back to the admission said the store node had learned "one fixed size" and showed the
"Voice notes" row. It now counts voice entries up to `k`, as `learned()` and `heldCount`.
- The refusal copy: `codec` on Play names the decoder, not the encoder; `device-format` is
a sentence; any other device or encoder word reads "The microphone did not work (<word>)."
and a dropped recording "the voice encoder could not encode it (<word>)", the word after
the words; `too-large` no longer promises that Send again works ("It was too large to send.
Discard it and record it again."), since a retry fails the same way and no draft under the
cap gets it; `broken`, here and on the card, "it arrived damaged", true of parts that
disagree, a record that fails its checks and a clip the decoder refuses, where "its parts
don't match" was not.
- RoomVoiceNote's header, voice-notes.md §8 and §13: the recording banner is part of a room's
view, so on Home, Settings or Contacts nothing in Muster shows the microphone is on; §18
lists an indicator outside the room as open. §8 says how a dismissed refusal comes back;
§15's half-sent entry no longer says the surface asks for Send again.
Tested-Behavior: the gallery (Qt 6.9.2 qml, offscreen, software, Basecamp 0.3.2's design system). New steps, red on the committed QML: room-voice-refused "voiceNotice.visible.true is false" after dismissing `silent` with the × and sending again, then (one at a time, on HEAD's QML) "text.~decoder is \"The voice encoder could not start on this device.\"", "text.!Send again is \"It did not fit to send. Press Send again; ...\"", "text.~would not record is \"The microphone refused: device-format\"", "text.~encoder is \"The recording stopped: not-cbr\""; room-voice-notes "This voice note can't be played: it arrived damaged. is \"...its two parts don't match.\""; room-map-voice "roomMapFields_o:store-node.text.timing, topic is \"timing, topic, one fixed size\"" at k=0. Green: all five voice fixtures and room-map-voice GALLERY-DONE, the latter also "timing, topic, one fixed size" at k=1, and a dismissed platform note staying dismissed across a call. Every fixture: 90 of 98 GALLERY-DONE; the other 8 (popups wanting an X capture, and `components`) fail the same way on the unchanged QML, since this Mac has no Xvfb. qmllint (Qt 6.9.2, both trees): no gated warning before or after. ui-lint's plain-language and harness-name checks alone (no runner here): 87 findings, 87 in the baseline, 0 new. The .lgx is rebuilt after the last commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… docs and claims the review found untrue
The whole-diff review's ui-scripts-2, -3 and -6, native-build-1 and docs-claims-2 to -6.
- scripts/voice-self-test.sh:
- B playing the note: both autopilots log voice_status once a tick, before they decide to
Play, and never the re-read after it, so a note of a second could end before B's next
tick logged it playing, and the script waited out 240 s and failed. A Play's own answer,
`VOICE-RESULT play <nid> {"ok":true}`, now counts as B playing it, beside a voice_status
naming it; a voice_status with nothing playing must still follow.
- VOICE_SECS must be 1 to 60: past the cap the module stops while the Stop still waits, and
the 240 s wait could not fit.
- The QML check holds the voice QML only where the view loads (MUSTER_UI=nim,
MUSTER_QPA=xcb): the header says so, and a C++ offscreen run prints a NOTE saying no voice
QML ran.
- The co-resident gap (registry, ClaimsRegistry.qml regenerated) and §10, §13: such a module
can make any room current (coordinate_join, one it founds or whose members it admitted) and
record and send there, so the voice reaches a party of its choosing.
- The device past its stream's end (native-build-1, at the reviewer's minor; no watchdog
built): the cap and `done` are noticed by a pump alone, so with nothing calling the module
the microphone stays open past 60 s. voice-notes.md §9, §13 and a §15 entry,
src/audio/CLAUDE.md and the voice_live.nim header no longer call the device "bounded" or
say every stream releases its device "at the cap".
- room-map.md §3 and the room_graph.nim header said coordinate_proof returns audio; since
90fcbd4 it refuses `voice-notes-present`. Both now say no hosted method returns audio.
- voice-notes.md: muster_module.nim cites re-pointed to main (4566-4578, :4574, :4575-4577,
:4624-4625, :2776, :2838-2846; the metadata.json ones were right and stay), "except those
marked on this branch"; §9 names dlopen, dlsym and dlclose; §15's prototype bullet keeps
only what is still prototype-only; the status line and §18 say S4-S10 are built on the wire
format, so an objection means reworking them; §17 says S9's self-test has run only against
a stand-in; §15 lists which typed fixtures voice-self-test's recording can replace, and that
the rest are a recorded snapshot with one row or answer edited; §15's "1 and 2 pass" is
scoped to the stand-in, with the two holes this commit closes.
Not changed: docs-claims-7 (the real-device test cited as present) is already true at this
branch's HEAD, since 2cc3c53 built voice_device_e2e and put it in run-suite's E2E list.
Tested-Behavior: voice-self-test.sh against a stand-in runner (scratch, not committed: S9's, writing each role's autopilot lines and B's sink of the tone round-tripped through libopus 1.5.2), with a new mode in which B's Play is answered ok and no voice_status ever names the note playing. Red, the committed script: that mode "FAIL: B never played A's note (sent: ab12cd, played: 0)" after ~240 s; VOICE_SECS=61 accepted and run. Green, this script: that mode SUCCESS; the good run SUCCESS ("2 accepted, 0 refused", "ok 151200 samples (3150 ms)", C refused silent), with the NOTE that no voice QML ran; a sink one sample short still FAIL; VOICE_SECS=60 SUCCESS; 0, 2.5 and 61 refused with exit 2 before anything launches; no process left. shellcheck -S warning: only the SC2164 at `cd` it found before. Gates exit 0: check-no-cpp, check-claims-registry (44 claims), check-readme-rot, check-run-targets, check-action-corpus, gen-claims-qml --check, gen-fake-backend --check; ui-lint's plain-language and harness-name checks alone, 87 findings, 0 new. The docs changes have no test of their own; each was checked against the code it describes (muster_module.nim at 67701f8 for the cites).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wer arrives The check of 2656f71..2828cc1 found that ec43eb0 keyed a dismissed voice refusal on Room's call counter, which moves when a call starts, while Room.voiceResult keeps the previous answer until the new one lands. So a refusal the person dismissed came back the moment they made another call (a dismissed busy, then Start, showed "Recording did not start." for the whole round trip, even when that Start succeeded) and stayed until the answer arrived. - The view contract gains PROP voiceResultSeq ("0"), a decimal count of voice answers: both backends put the answer in voiceResultJson and then count it (the C++ voiceAnswer, the Nim voiceDone), so an answer identical to the last one still reads as new. A real backend's second identical answer leaves voiceResultJson as it was, which is why the count is needed. FakeBackend.qml is regenerated from the .rep. - Room records the count when it makes a voice call (voiceCallAnswers) and, until the count moves, shows no answer: the last one answered an earlier call, and under the new call's title it said the wrong thing ("Nothing played." over the Send's silent). RoomVoiceNote keys a refusal's dismissal by the count (voiceAnswers) instead of the call counter, which is gone. - voice-notes.md §8 says how a dismissed refusal comes back now. Tested-Behavior: the gallery (Qt 6.9.2 qml from the store, offscreen, software). room-voice-refused gains three cases, each red on the committed QML (with the new FakeBackend and fixture): a dismissed busy, Start pressed again, "GALLERY-ERROR voiceNotice.visible.false is true" (the checker's probe); a dismissed silent, Send pressed again, the same error; silent showing, Play pressed, the same error (the Send's answer shown under "Nothing played."). Green on this QML: the notice stays hidden through the call and a 300 ms wait, and shows again once the same refusal is answered with the count one more; every answer in room-voice-refused and room-voice-record now carries its count, and room-voice-record checks the Send's own refusal words, not only the platform note they share. All six voice fixtures GALLERY-DONE on both design systems (Basecamp 0.3.2's 88330d0 and the runner's 94092b0). Every fixture on both: the same failures as HEAD's tree and no other (seven popups wanting an X capture, and `components` on 88330d0 only); room-news failed once under a parallel run and passes alone on both trees. qmllint (Qt 6.9.2): no warning on RoomVoiceNote.qml, Room.qml's nine unchanged; ui-lint's gate 87 findings, 0 new, on both. ui-nim/tests/autovoice/run.sh (backend.nim compiled whole against the new contract, 65 props): SUCCESS. gen-fake-backend --check: clean. The C++ backend's change has no test of its own here; the ui .lgx is rebuilt after the last commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, and each hint its own causes The check of 2656f71..2828cc1 found two of voice_device_e2e's messages untrue or unusable. - The stalled start's hint (WhoWasAsked) grepped tccd's log for every AUTHREQ_PROMPTING and AUTHREQ_ATTRIBUTION line, and tccd logs an attribution for every process's request: 143 lines in the last hour on this Mac, the test's own among them. It now keeps the microphone prompts and the lines naming the test's binary (`identifier=voice_device_e2e`, the name taken from the source file, since `nim r` adds a hash), and says that `responsible=` in them is the app macOS asked. It also writes /usr/bin/log in full: in zsh, macOS's default shell, `log` is zsh's own builtin, and the committed `log show ...` failed with "too many arguments". - NoFramesHint and ZeroHint named the same virtual devices (BlackHole, a Zoom or Teams device) as the cause of both a capture with no frames and an all-zero one. A loopback driver runs its own clock and gives frames of silence while nothing plays into it, so it is a cause of the all-zero failure only. ZeroHint keeps it, as a virtual loopback device; NoFramesHint says a start that gives no frames at all has not been seen, so its cause is not known (an aggregate device whose clock device is missing is named as an unchecked guess), and asks for another input. Linux's says the same of its audio server. - tests/README.md says the same, and how the stall's message finds the app asked; labbook §9 gives the narrowed command, the counts, and zsh's `log`. Tested-Behavior: the hint's command, printed by a scratch program built from the test's own const block (not committed). Red, the committed text: in zsh "zsh:log:1: too many arguments"; through /usr/bin/log its grep printed 143 lines for the last hour. Green, this text: in zsh, with --last 12h, 9 lines (1 AUTHREQ_PROMPTING for kTCCServiceMicrophone, 8 AUTHREQ_ATTRIBUTION naming voice_device_e2e_C701DB…, every one naming com.anthropic.claude-code), 0 for the last hour. The no-frames and all-zero messages were read as printed; neither can be reached here (no input here gives no frames, and the microphone leg was not run). MUSTER_VOICE_REAL=speaker module/tests/run-suite.sh e2e voice_device_e2e: PASS on Core Audio (48.17 frames/ms; 100 ms done after 124 ms, the device's tail 1,920 frames), playing silence; the microphone leg not run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
corpetty
left a comment
There was a problem hiding this comment.
Thanks, this is careful work. Here's a review at bbae7d8, with the Linux checks §18 asked for. In short: the core is sound, two things should be fixed before merge, and the §18 decisions are still open.
Linux checks (asked in §18)
- Module suite on Linux x86_64:
module/tests/run-suite.shgives 285 passed, 0 failed, includingvoice_clip_test,voice_codec_test,audio_io_test,audio_io_null_test,session_pad_test,voice_test,voice_live_testand the three voice stepper probes. - The Linux module package loads. I checked the
lgx variantsrun (39776903474):linux-amd64carrieslibopus.so.0beside the plugin, with RPATH$ORIGIN;lddresolves every NEEDED library, libopus from the package itself, against the Qt in Basecamp 0.3.2's AppImage. The worry that a missing codec takes down all ofmuster_moduledoesn't apply to this package.
- Not run here: the standalone runner,
scripts/voice-self-test.sh, thevoiceparity entry, or the Linux device backends.- miniaudio dlopens
libpulse.so.0/libasound.so.2by name, and a nix-built runner's glibc doesn't read/etc/ld.so.cache, somake runwill likely report no device. - That's safe, because Null isn't in the system list, so it never records zeros. But it should be stated as current Linux behaviour.
- miniaudio dlopens
What checks out
- Wire: every
voice/frame is padded to 90,112 B inside the sealed plaintext, on both send paths. A wrong pad on a voice frame raises, and an oversize frame is refused before ingest.- Non-voice frames are byte-identical to
main, and the event id and signatures are unchanged. - Receive is unchanged, so the size is fixed for honest senders, as the
flow.nimcomment says.
- Non-voice frames are byte-identical to
- The fold:
- A member can't forge or squat another's note: the key format, the author check in the authentic view, and the content-derived nid all hold.
- Disagreeing parts or a digest mismatch give
broken, nevercomplete. - Order and duplicates fold the same.
- Malformed base64, CBOR, packet sizes and sample counts are all bounded. I found no crash, overflow or unbounded allocation.
- A
message/that claimsvoice-notestays text, in the module and in the QML.
- No existing test was weakened. The diffs to
authorship_test,swarm_test, the host harness andgrade-specs.shonly add. - Audio FFI:
- Opus buffer sizes and TOC checks are right.
- Encoder and decoder lifetimes are right.
- The device callbacks don't allocate, and use release/acquire positions with clamped copies.
- Buffers are freed only after
ma_device_uninit. - The miniaudio pin
f40cf03is tag 0.11.23, with a fixed hash.
- UI parity: all 7 new SLOTs and 3 PROPs exist in both the C++ and the Nim backends.
gen-fake-backend.py --checkpasses, and existing card kinds render as before. - Privacy: recording starts only from the mic button. A remote member can't start recording or playback, and nothing is written to disk.
Should fix before merge
- One member can switch off
coordinate_prooffor the whole room, for good (module/src/coordination/voice_live.nim:361-364).roomProofrefuses when the raw log (s.log.allEvents()) holds anyvoice/key, so an unsigned or malformed junk entry is enough.- The log is append-only, so the refusal is permanent.
- Minimum fix: refuse only when the authentic view holds a part the fold stands. Better: settle §18 Q4, a proof form that commits a part by digest.
- Starting a device can hold the module thread (
voice_live.nim:258startCapture,:409startPlayback; miniaudio init/start has no timeout).- On macOS a pending microphone prompt held the start for over 20 s, as §15 and
src/audio/CLAUDE.mdrecord. - Every module call queues behind it, and in the C++ UI
startVoiceRecordingis a synchronousvoice_record_start, so the view freezes too. - This is §18 Q6. I'd fix it before merge: start the device off the module thread and report through
voice_status, or give the start a timeout.
- On macOS a pending microphone prompt held the start for over 20 s, as §15 and
Worth fixing
- Every poll re-parses every voice part (
voice.nim:212-228,gather→parsePart: about 84 KB of JSON per part, fromcoordinate_messagesand fromthreadLastTsfor every joined room).- The memo caches verdicts, not the parse, so the cost grows with each room's whole voice history, on the one thread.
- Memoising the header parse by event id would fix it.
playalso gathers twice and decodes without a memo.
- Microphone consent with co-resident modules. With Basecamp's access policy off, any loaded module can call
voice_record_start. The only sign is the banner in the room view; Home, Settings and Contacts show nothing.- At least record this as an open gap in the claims registry.
- A global recording indicator in
Main.qmlwould close most of it.
publishAuthoredchanges for every author-signed kind (authorship.nim:290-315).- A re-announce now re-seals the member's own
after-signed copy instead of the log's. - I think it's an improvement, and convergence holds. But it changes non-voice kinds, so it deserves a line in the description and a test for a non-voice kind on that path.
- A re-announce now re-seals the member's own
- Nits:
run-suite.shreports SKIP for any test that prints^SKIP <name>:, which could hide a non-run.- The suite links unpinned
nixpkgs#libopus(1.6.1) while the package ships 1.5.2. - A held draft keeps the 250 ms poll running indefinitely.
Still open
- Unverified: a real microphone capture, a note drawn in Basecamp's window from a real room, and the two-runner self-test.
- The §18 decisions: these are for @corpetty to answer here: the exo id and spec, sign-off on the wire format, F-13, the proof question, the re-send path, older clients, and the version.
Not mergeable yet. The two should-fix items and the §18 answers are what's left.
Generated by Claude Code
…, not for junk under voice/
The PR 340 review's first should-fix. coordinate_proof refused a room whose raw log held any
`voice/` key, so one unsigned or malformed entry from any member switched the room's proof
off, and the log is append-only, so it stayed off.
- voice_live.roomProof now refuses only when the room's authentic view holds a part the fold
stands (voiceStanding over roomEvents: a part of a note complete, arriving or broken, as
every member's own note is), with the same error and detail as before. Junk under
`voice/` (unsigned, a forged author, a malformed value, a squatted note id, a key of the
wrong shape) refuses nothing and leaves with the proof whole, like any raw entry (exo-f76).
- What is left, written down in voice-notes.md §10 and pinned by the test: junk that copies a
note's bytes carries them out of a room whose view holds none of that note's parts (an
earlier epoch's note re-posted, or a note whose parts the fleet lost while the copy
arrived); a member who can play a note can also post its bytes in a message's body.
- voice-notes.md §10, §15 and §18's proof item say what changed and the residual; the
voice_live.nim header, session.isVoiceKey's comment, musterCoordinateProof's comment and
tests/README.md follow. The claims registry already says "a room holding a voice note".
Tested-Behavior: voice_live_test §26, appended, and §23's last assertion turned to the new rule (it asserted that an unsigned `voice/not-a-note` refuses). Red on the old roomProof: §23 "a voice/ entry no fold stands refuses nothing: \"voice-notes-present\""; with §23's new lines held back, §26 "junk alone refuses nothing: {\"error\":\"voice-notes-present\",...}". Green after: "26. a room whose raw log holds only junk under voice/ (...) exports a proof that accounts for every entry; a note arriving, complete or broken refuses voice-notes-present as before OK". No regression: run-suite.sh all voice session_pad return_marshalling, 9 passed, 1 skipped (voice_device_e2e, gate closed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…keeps each part's header
The PR 340 review's first worth-fixing item. The memo held verdicts only, so every
coordinate_messages poll, and coordinate_conversations' lastTs for every joined room,
parsed each part's value (about 84 KB of JSON for a 60 s note) again, and Play gathered
twice, the second time with no memo, and judged its note again.
- voice.nim: VoiceMemo keeps each part's header fields by event id (ok, ts, durMs, digest),
never its half. A pass parses only a part the memo has not seen and keeps a fresh part's
half for that pass alone; a verdict or a decode reads a half back from its event (once per
pair judged, once per Play). Every fold takes the memo: reduceVoice, threadRows,
threadLastTs, voiceStanding, voiceRecordOf, and the new voiceNoteOf, which reads one note,
and its record when complete, from one header pass. Two counting seams for tests,
voiceParses and voiceHalfReads, beside VoiceMemo.hits.
- voice_live.nim: Play reads its note through voiceNoteOf with the room's memo; lastTs reads
threadLastTs with it; forgetRoom drops a room's memo, memoRooms counts them.
- muster_module.nim: coordinate_conversations' lastTs goes through the room's memo, and
coordinate_leave drops it (a forward-declared voiceForgetRoom), so the memos are the joined
rooms' alone, each bounded by its room's append-only log.
- voice-notes.md §6 (the memo: headers, verdicts, where it lives, memory, the counts, the
numbers before and after), §15's fold cost, §17's S7 line; tests/README.md.
Measured (scratch bench, not committed; Apple M5, -d:release, 20 notes of 60 s and 20
messages, per call, before / after): threadRows with a warm memo 13.2 / 3.5 ms, threadLastTs
13.3 with no memo / 3.5 with a warm one, Play's lookup 26.0 / 3.0 ms; with no memo 44 ms
either way.
Tested-Behavior: voice_test §16 and voice_live_test §27, appended. Red first, on the counting seam with no header cache: voice_test "each part parsed once: 144, headers 0"; voice_live_test "over an unchanged log coordinate_messages' rows and threadLastTs parse 0 parts: (57, 0)". Green after: "16. a memo of part headers changes no fold output, in any order or as the set grows; a part is parsed once, and a new part alone is OK", "27. the room's memo keeps each part's header: an unchanged log parses 0 parts, a new part alone is parsed, Play gathers once and reads back the two halves it decodes; a room left drops its memo OK". Mutants, each red: Play's old two passes ("Play: (21, 0)"), lastTs with no memo ("(38, 0)"). The probe_voice_* steppers, voice_test §1-15 and voice_live_test §1-26 unchanged and green; run-suite.sh all voice session_pad return_marshalling flow provenance room_graph room_map swarm, 31 passed, 1 skipped (voice_device_e2e, gate closed).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r's own copy for a kind that is not voice The PR 340 review's last worth-fixing item: 2656f71 changed publishAuthored for every author-signed kind, not voice alone. A re-announce or a retried publish of an event the log already holds now re-seals the copy whose `after` its author signed (copyToSeal), where main re-sealed the copy the log keeps, the smallest afterDigest, which any member who received the event can plant. No test held it outside voice. - authorship_test §12, appended: Victor discloses an account; Mallory sends a copy of it with an `after` she made up and nobody signed, ground to sort first, so every log keeps hers; Carol is admitted in a new epoch and Victor discloses the account again. Carol receives Victor's own copy (his `after` and `afterSig`), and Alice, Victor, Mallory and Carol each read the disclosure once, Victor's, with his order. - voice-notes.md §4 says the change covers every author-signed kind; publishAuthored's comment and tests/README.md say so too. causal-parents.md §3.2 already described it. Tested-Behavior: authorship_test §12. Red on main's publishAuthored (a scratch mutant, not committed): "the author's own copy, not the one the log keeps: @[\"abab…01\", \"cdcd…cd\"]", Mallory's planted after. Green on this branch: "12. a re-announced account disclosure re-seals the author's own copy, not a member's planted one the log keeps; every member, a later joiner included, reads it with the author's order OK". No regression: run-suite.sh all authorship after_signed causal_publish voice_live swarm, 12 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l carries After 6aa1793 a proof is refused only for a note the room's view holds, so an entry under `voice/` that is no note's part leaves with the proof, a member's copy of a note this member's view does not hold included (§10, "What is left"). §10's row for a module loaded beside Muster and §13's paragraph on the two raw-log methods said the proof refuses a room holding a note and stopped there; each now points at that case. TDD-Exempt: docs only, two sentences brought in line with §10 and voice_live_test §26; no code changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…osted method returns The second pass of PR 340's review. Since 6aa1793, voice-notes.md §10 says a proof carries every entry under voice/ that is no note's part, junk that copies a note's bytes included when this member's view holds none of that note's parts, and words the fixed decision as "no hosted method returns a note's audio from this member's view". room-map.md §3 and room_graph.nim's header still said "So no hosted method returns audio", and that the proof refuses "a room holding a voice note". Both now qualify it as §10 does, and point at §10, "What is left". TDD-Exempt: docs and a code comment only, brought in line with voice-notes.md §10 and voice_live_test §26; no code changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the room's memo
The second pass of PR 340's review. The worth-fixing item "every poll re-parses every voice
part" was still unmet for two polled methods, and §6 called them read on demand:
coordinate_flow runs on the room's 5 s tick and coordinate_graph every 2 s while the map is
open, in both UIs, and each folded with no memo. So every poll parsed every part's value
again: once in the flow, and three times in the map (its provenance, its flow and each
entry's content).
- reduceFlow, logProvenance and reduceRoomGraph take an optional VoiceMemo and hand it to
voiceStanding. The map hands it to its provenance, its flow and each voice/ entry's content.
- The map's content of a voice/ entry moves to voice.voiceContentOf, the same for every
entry. For a part whose header the memo holds, it is read off that header, not parsed. An
entry that holds no part's value (a key of no part's shape, a value the fold refuses, an
equivocating later copy) is still parsed on each call, as a message is, and voiceParses
counts that parse too.
- voice_live: the hosted methods call recorder procs that pass the room's memo, so the memo
wiring is code the tests run. flowRows serves coordinate_flow, graphOf coordinate_graph,
provenanceOf coordinate_provenance. lastTs(s, events) takes the events
coordinate_conversations already read, and replaces lastTs(s), which no production code
called.
- voice-notes.md: §6 (where the memo is used, what coordinate_proof and junk entries still
cost, the tests), §10's line references, §15 (the fold's cost, and a new line: the hosted
lines that call these procs, and coordinate_leave's voiceForgetRoom, are built but no test
runs them), §17's S7 line. tests/README.md too.
Tested-Behavior: voice_test §17 and voice_live_test §28, appended; voice_live_test §27's four lastTs calls now pass the events. Red first, with the memo parameters added and not passed on: voice_test "the map, cold: each part's value once, for its provenance, its flow and its content: 42"; with lastTs(s, events) given no memo, §27 "over an unchanged log ... parse 0 parts: (38, 0)"; with lastTs wired and flowRows, graphOf, provenanceOf not, §28 "cold: each part once across the four reads: 126". Mutants, each red: the map's content ignoring the memo (voice_test "...: 28"), the header-built content missing `v` ("the map with a memo is the map without one"). Green after: "17. the flow, provenance and the room map take the memo: each output is the memo-less one, in any order; a cold memo parses each part once, a warm one none, and the map parses only an entry that holds no part's value OK", "28. the flow, the map, provenance and lastTs go through the room's memo, the thread's own: the memo-less output; each part parsed once across them, none on the next poll OK". run-suite.sh all voice session_pad return_marshalling flow provenance room_graph room_map swarm: 31 passed, 1 skipped (voice_device_e2e, gate closed). Whole suite (run-suite.sh): 285 passed, 0 failed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…never holds the module thread
PR 340's review, should-fix 2 (voice-notes.md §18's device-start question): MiniaudioIo
started a device on the module thread with no timeout, and on a Mac Core Audio held a
capture start behind an unanswered microphone prompt for over 20 s (S11), so every module
call, and the C++ UI's synchronous voice_record_start, would have queued behind it. Fixed
the way the review preferred: the device starts off the module thread and voice_status
reports it.
- audio_io: a start job. startCapture / startPlayback return at once with the stream
`starting`; the blocking half runs on a detached pthread that keeps the device thread's
rule (no GC, no raise, checks off, C's heap and atomics only). The two threads meet at one
atomic by compare-and-swap: the start thread swaps pending to running or failed; a stop
or close before then swaps pending to let go and forgets the job, never waiting, and the
start thread then stops what it started and frees it. AudioBuffer is calloc'd and counted
by reference, so whichever side finishes last frees it. At most four starts wait at once;
past them a start is refused device-start-slow. waitStarts(ms) waits a bounded while.
- SyntheticAudioIo takes a StartGate: its start waits on the gate on a start thread until
the test releases it (run, or fail with a device word). FileAudioIo stays synchronous.
- MiniaudioIo: ma_context_init, ma_device_init, the read-back and ma_device_start run in the
start job; the pump settles it (the io takes the context and device, or reports the
failure's word once); the context moves with each start and back when it runs.
- voice_live: recording.state `starting` until the device runs, then `recording`, the
elapsed time and the 60 s cap counted from the device's start; a start that fails ends
idle with recording.error and ready.capture its word; one still pending 15 s after the
call (StartBoundMs: time to answer a prompt, no longer than anyone waits on "Starting the
microphone…") ends with device-start-slow, let go. Stop while starting is not-started,
Discard and close let the start go. Play the same: playing carries `starting` until the
speaker runs, at position 0, and the pump's done detection is unchanged.
- The hosted exit proc closes the recorder, then waits up to 2 s for start threads, so close
at unload never hangs on a start that never returns (logos_host runs one module per
process: its exit ends that thread).
- UI (shared QML; both backends pass voiceJson through): RoomVoiceNote's banner says
"Starting the microphone…" with Stop and Discard, no time and no level; a start that
failed or was too slow, and a Play whose speaker did not start, say so in words; a card
says "Starting…" while its speaker starts; nothing in the thread plays while a recording
starts. Both autopilots (MUSTER_AUTOVOICE) time the recording from the first voice_status
that says recording. Gallery fixture room-voice-starting; room-voice-notes and -record
carry the playing shape's new `starting`.
- voice_device_e2e pumps until a start lands, under its watchdog, and fails a start call
that takes 100 ms or more to return.
- Docs: voice-notes.md §8 (the state machine, the methods, voice_status's shape), §9 (the
start thread, the buffer, device lifetime, the exit proc), §11, §15 (the blocked start is
fixed, with what ran and what has not), §17 (S7, S11) and §18 (done in this PR; the
maintainer may still object); the labbook's §9 with the three traps; src/audio/CLAUDE.md's
rules for the start thread; muster.lidl's descriptions; tests/README.md; the layout line.
Tested-Behavior: audio_io_test §7-§9 and voice_live_test §29-§31, written first and run red on the seam as it was, where the gated start blocked its caller: audio_io_test "§7 the start returns at once while the device start is held: 1774 ms", voice_live_test (its §1 playing shape held back) "§29 voice_record_start answers at once while the device start is held: 1760 ms, {\"ok\":true}", a safety net releasing the held start after 1.5 s. Green after: "29. voice_record_start answers at once while the device start is held: starting, the module answering meanwhile; released, recording, timed and capped from the device start OK", 29b (Stop not-started, Discard, close, a failed start no-device, one past 15000 ms device-start-slow; buffers counted), 30 (Play), 31 (close and the bounded wait); audio_io_test 7-9. ui-nim/tests/autovoice/run.sh against a fake that starts for 1.5 s: red on the committed backend ("recorder: the fake never said recording (-1 ms)", Stop answered not-started), green after ("recording 1994 ms after Start, Stop 3036 ms after Start"). audio_io_null_test §6 on the null device thread (held green across the change): "80 of 80 let go before their start returned", every buffer freed; §1-§4 now wait for the start to land. Whole suite: run-suite.sh, 285 passed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p saw it, then a stop S2's review, its first major: of the three orders a start and a stop can meet in, the committed tests held two. The third is a start whose thread has returned (swapped pending to running or failed) when a Stop, Discard or close arrives before any pump saw it: letGo is false, so the module thread must finish the job itself (SyntheticAudioIo's letGoStart, and MiniaudioIo's settle then closeStream). The recorder reaches it: draftDiscard and close drop a starting recording without pumping first, and on the null device most starts have returned within a few ms. Two mutants of that branch left every committed test green; a regression there would leave the microphone open and the job and buffer leaked. - audio_io_test §8b: a gated start released (run, or "no-device"), waited out with no pump, then stopCapture / stopPlayback / close: idle at once, nothing reported later, the caller's free the last one, nothing stopped by the start thread. - audio_io_null_test §6b: a null-device start waited out with no pump, frames moving, then a stop or a close: the device closed at once (frames stop), the context kept by a stop and released by a close, every buffer freed. - voice_live_test §29c: Discard and close while the recorder still says starting, after the start returned (synthetic run, synthetic failed, the null device): idle at once, the buffer freed at once, the null device's context released. - voice-notes.md §9 names the three orders and the section holding each, and says the tests do not race the two threads at the swap itself; §15 lists the new sections and the mutant runs. src/audio/CLAUDE.md and tests/README.md name them too. The code is unchanged: it was right in every order. Tested-Behavior: written against two mutants in a scratch copy of module/src, red: M1 (SyntheticAudioIo.letGoStart `discard letGo(io.job)`): audio_io_test "§8b stop-capture: the module thread finished the start: buffers left 1", voice_live_test "§29c synthetic discard: the buffer freed at once: 1 left"; M2 (MiniaudioIo.letGoStart forgetting the job instead of `discard io.settle()`): audio_io_null_test "§6b stop capture: a stop keeps the context its start made, a close releases it: ''", voice_live_test "§29c null discard: the buffer freed at once: 1 left". Green on the code as committed: "8b. … the module thread finishes it, every buffer freed, nothing reported later OK", "6b. … the device closed at once, the context kept by a stop, every buffer freed OK", "29c. … the buffer freed at once, the null device's closed OK"; run-suite.sh unit audio_io voice_live: 3 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the device on S2's review, a minor: MiniaudioIo's start thread never read the start's phase, so a start given up (Stop, Discard, close, or the 15 s bound) whose ma_device_init then returned still called ma_device_start, and only afterwards did startMain's failed swap stop it. On a Mac where the person answers the prompt after giving up, the microphone would come on, its indicator lit, after the room said the recording ended. - audio_io: wasLetGo(job), a start-thread read of the phase (atomics only), and StartLetGo, the code a run returns when it starts nothing; gateWait is exported for a hold a test puts in another implementation's start. - miniaudio: maRun reads wasLetGo just before ma_device_start and, let go, releases the device and context it made and returns StartLetGo. A start let go after that read still starts the device, and its thread stops it at once, as before. newMiniaudioIo takes an optional StartGate, a test's hold between the device's init and its start (the module passes none), and devicesStarted() counts the ma_device_start calls that started a device. - audio_io_null_test §7: a gated null-device start, stopped or closed while held (capture and playback), then released: no device starts, every buffer freed; a gated start the io keeps starts exactly one. - voice-notes.md §9 says a start let go before its device starts never starts it, what is left (a let-go in the instant after the read, or while ma_device_start itself waits), and that which Core Audio call holds a prompt is not known; §11 says the same for a prompt answered after giving up. src/audio/CLAUDE.md's start-thread rule names the read. Tested-Behavior: audio_io_null_test §7, written with the hold and the count in place and no read of the phase, red: "stop capture: a start let go before its device started started it: 1 device(s)". Green with the read: "7. a start let go while held before its device starts never starts it; one the io keeps does OK"; audio_io_test, audio_io_null_test and voice_live_test 3 passed; voice_device_e2e compiled and skipped with its gate closed, and MUSTER_VOICE_REAL=speaker passed on Core Audio (silence: 48.15 frames/ms, done after the 1,920-frame tail). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e word the last start left
S2's review, a minor: recordStart set ready.capture only on an immediate refusal or a
synchronous start, and play() ready.playback likewise, so after a start that failed or was
too slow, the next Record reported ready.capture "device-start-slow" (or "no-device") while
voice_status said starting. No banner shows it today (noticeText reads ready.capture only
when recording.error is set), but any other reader of ready during a start would show a
failure that is not current. The gallery fixture room-voice-starting, typed from the
module's shapes, already said "unknown" there, so it did not hold what the module gave.
- voice_live: recordStart's starting branch sets ready.capture to unknown, and play's sets
ready.playback to unknown, until the pump sees the device run (ok) or fail (its word).
DeviceUnknown's doc says so, and play's names device-start-slow among the device seam's
immediate words.
- voice_live_test §29d: a start past the bound, then another Record while the gate still
holds it: starting, ready.capture unknown; the same for Play and ready.playback.
- room-voice-starting: the second Play's starting step says playback unknown, as the module
now does (it said ok, the first Play's word); its about names §29d.
- voice-notes.md §8 says what ready says while a start is on its way; §15 lists §29d.
Tested-Behavior: voice_live_test §29d, written first, red on the recorder as it was: "a capture start on its way says unknown, not the last start's word: {"ready":{"capture":"device-start-slow",…},"recording":{"state":"starting",…}}". Green after: "29d. while a start is on its way, ready.capture and ready.playback say unknown, not the word the last start left (device-start-slow) OK"; voice_live_test and probe_return_marshalling_host pass; the gallery's room-voice-starting 40 passes, 0 QML warnings, on design systems 88330d0 and 94092b0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…was recorded
S2's review, a minor: Stop while starting is answered {error: "not-started"}, but by then
voice_status has moved to idle, and Room drops an answer once voiceAt changes, so
RoomVoiceNote's "Nothing was recorded: the microphone had not started yet." never showed;
the person saw only the banner go. That move is the Stop's own doing, so its answer is
about it, not stale.
- Room.qml: onVoiceAtChanged lets the answer follow voice_status from starting to idle when
the call was a Stop, whichever of the move and the answer comes first; every other move
still drops the last answer, as before (room-voice-refused unchanged).
- room-voice-starting: the first Stop while starting (the move, then the answer) now expects
the notice with those words and a shot of it; a second one gives the answer first, while
voice_status still says starting, then the move, and the words stay. Later answers count
on from there.
- voice-notes.md §8 says Stop while starting ends the banner and says so, and why this one
answer follows voice_status.
Tested-Behavior: room-voice-starting, its expectations written first and run red on the old Room.qml: "GALLERY-ERROR voiceNotice.visible.true is false" for the first Stop (the move, then the answer), and the same for the second (the answer, then the move) in a scratch copy without the first's expectations. Green after on design systems 88330d0 and 94092b0: room-voice-starting 48 passes, 4 shots, 0 QML warnings; every other room-voice-* fixture and room-map-voice unchanged (flow 2, forged 10, notes 18, record 27, refused 42, map 8); ui-lint's python gate "87 finding(s), 87 in the baseline: 0 new, 0 fixed" on both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… state S2's review, a minor: muster.lidl's descriptions lagged the recorder. voice_play's busy read "recording or playing", though play also refuses while a recording is starting, and it did not name device-start-slow, which a fifth start gets while four are pending; voice_draft_ discard did not say it drops a recording that is starting; and voice_status did not say that capture and playback read unknown again while a start of that device is on its way (since the previous commit). Descriptions only: no method, argument or type changes, and nothing generated carries them (muster_gen.nim, the .rep and FakeBackend.qml hold no description), so no regen. TDD-Exempt: description strings in the contract only; no behaviour changes and no generated surface reads them (check-action-corpus, gen-fake-backend --check and gen-claims-qml --check pass). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ophone, as observed S2's review, its second major: the docs still called open, or contradicted, what was established on this Mac on 2026-10-22, and S2's own new text built on that: §11's two outcomes "inferred, not tested" and "Launching Basecamp from Terminal does not help", §15's "Neither outcome of §11 is observed" and "got no frame", the window checks "still open", and three sentences (§15, §18 Q6, the labbook's §9) leaving "whether Basecamp's logos_host is asked at all" to a check that has since run. Now written as observed (at bbae7d8, before the start thread): - §11: Basecamp launched as the Dock does (open -n -a): logos_host survives voice_record_start; tccd's "Prompting policy for hardened runtime; service: kTCCServiceMicrophone requires entitlement com.apple.security.device.audio-input but it is missing for responsible=LogosBasecamp", "Policy disallows prompt", authValue=0; the banner "The microphone is on, but no sound has reached it yet.", Send's silent refusal with the Basecamp 0.3.2 note. A profile launched from a shell under Claude Code is charged to com.anthropic.claude-code (allowed) and captures real audio, so a terminal launch is not a person's Basecamp. The inferred outcomes are kept as one sentence saying which held. - §8, §9, §11, §18 Q6, the labbook §9, StartBoundMs's doc and src/audio/CLAUDE.md: Basecamp 0.3.2's logos_host is never prompted, so its start does not wait; the start thread and the 15 s bound guard hosts whose responsible app can prompt (a shell or terminal launch) and a future Basecamp with the entitlement and a usage string. - §15: the microphone entry is what a person's Basecamp does; a new entry for the note between two Basecamp 0.3.2 profiles in its window (A's C++ autopilot from the 1 kHz WAV, B's card and autopilot: exactly 137,808 samples, 2,871 ms × 48, the tone at 0.99 of the source's power); the S8 and S9 entries say the C++ UI and autopilot ran there, at bbae7d8, and what has not (the Nim UI, S2's starting state and wait); the speaker leg has run on the start thread, the microphone leg not. - §17 S11: the window check is done; the microphone leg's first run got no frame and it passed later that day (Core Audio 48 kHz mono s16, 48.37 frames/ms, capped at exactly 96,000, peak -41.2 dBFS, the codec round trip keeping 96,000 samples); what is left is that leg on the start thread, with the person's consent. The status line at the top says so. - The labbook's open list drops what is found out and keeps the start-thread microphone run and which Core Audio call waits on a prompt. Still true and kept: the microphone leg has not run on the start thread (it needs the person's consent), and nothing of S2 has run in a host. TDD-Exempt: documentation and code comments only (voice-notes.md, the labbook, src/audio/CLAUDE.md, StartBoundMs's doc comment); no behaviour changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…other that says SKIP fails PR 340's review, a nit: run-suite.sh counted SKIP for any test whose log held a line "SKIP <name>:", so a test outside the gated list that printed one and exited 0 would read as skipped, a test that never ran hidden in the summary. - run_one counts SKIP only for a test in the E2E list (the gated, chain-bound and device tests: voice_device_e2e without MUSTER_VOICE_REAL among them). Any other test that prints the line FAILs, saying why: it printed "SKIP <name>:", but only a gated test in run-suite.sh's E2E list may skip, with its log's path. The list reaches the xargs subshells as E2E_LIST, a space-padded string (bash arrays do not export; bash 3.2 runs it). - module/tests/README.md and voice-notes.md §17 S11 say so. Tested-Behavior: a scratch test (module/tests/zz_skip_scratch_test.nim, not committed: it prints "SKIP zz_skip_scratch_test: nothing ran, and this test is not gated." and exits 0), red on the old runner: "SKIP zz_skip_scratch_test 1s (nothing ran, and this test is not gated)", "── 0 passed, 0 failed, 1 skipped (of 1) ──", exit 0. Green on the new one, under macOS's /bin/bash 3.2: "FAIL zz_skip_scratch_test 0s (it printed "SKIP zz_skip_scratch_test:", but only a gated test in run-suite.sh's E2E list may skip: …/zz_skip_scratch_test.log)", "── 0 passed, 1 failed (of 1) ──", exit 1; the gated one still skips: run-suite.sh e2e voice_device_e2e "SKIP voice_device_e2e 1s (MUSTER_VOICE_REAL is not set)", "── 0 passed, 0 failed, 1 skipped (of 1) ──", exit 0. Whole suite: "── 285 passed, 0 failed (of 285) ──". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and §12 says what Linux does with the devices PR 340's review, a nit and its Linux checks. The nit: on Linux run-suite.sh linked nixpkgs' current libopus (1.6.1), from the one nix build with nixpkgs' gcc, while the package ships the builder pin's 1.5.2 (macOS already took the pin's, c2b2dc7). It was left unpinned there for glibc: a nix-built library needs a glibc at least as new as the one it was built on. The pin's runs under that gcc: - for x86_64-linux, "$NIXPKGS_PIN#libopus.out" evaluates to /nix/store/39jlnb9vgammhav2skbf3dvkbciqsh14-libopus-1.5.2.drv (out /nix/store/mzksc9fpj2hvhvr8ggzqcy0j96fyqr79-libopus-1.5.2), built on glibc-2.40-66, while nixpkgs#gcc's libc is glibc-2.44-25 (nix eval / nix path-info --derivation, no build); - its libopus.so.0, read from cache.nixos.org's copy (nix store cat), needs only libm.so.6 and libc.so.6, at GLIBC_2.2.5 to GLIBC_2.29, with RUNPATH the pin's glibc: glibc 2.44 runs it; - a test binary links libc and libm itself (Nim's std/math, which strutils imports, passes -lm on Linux), so the gcc's glibc is loaded before libopus's RUNPATH is read and only one glibc runs. So libopus now comes from the builder pin on both OSes: the libopus lines are the same on macOS and Linux, and ran here. Not run on Linux: voice_codec_test §1 prints the version a run used ("libopus 1.5.2, preskip 312" here). MUSTER_SODIUM still needs MUSTER_OPUS on Linux. scripts/grade-specs.sh still takes nixpkgs' libopus for exo-526's host probe; not changed. The Linux checks, in voice-notes.md §12, as the code behaves today, with what is known and what is not: the maintainer's Linux run (285 passed; the linux-amd64 package carries libopus.so.0 with RPATH $ORIGIN and ldd resolves it against Basecamp 0.3.2's AppImage Qt); miniaudio dlopens libpulse.so.0 / libasound.so.2 by soname, and a nix-built runner's glibc does not read /etc/ld.so.cache, so make run likely reports no device: Record answers {ok}, voice_status says starting, and the next pump ends it with recording.error and ready.capture no-device ("No microphone was found on this device."). Null is not in the system list, so it never records zeros. Inside Basecamp's AppImage it is unverified. §15 says the same, and that the linux-amd64 link is checked; tests/README.md, src/audio/CLAUDE.md and the labbook §7 say which libopus the suite links. TDD-Exempt: test-runner infrastructure on a path only Linux takes, which cannot run on this Mac (evaluated for x86_64-linux and read off the cache's ELF instead); the libopus lines it now shares with macOS ran in the whole suite here: "── 285 passed, 0 failed (of 285) ──", voice_codec_test "libopus 1.5.2, preskip 312". The rest is documentation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d a held draft polls at the ordinary tick PR 340's review, "Microphone consent with co-resident modules": with Basecamp's access policy off any loaded module can call voice_record_start, and the only sign was the banner in a room's view; Home, Settings and Contacts showed nothing. And a nit: a held draft kept the room's 250 ms poll running indefinitely. - Main.qml: appVoicePoll reads voice_status every second whenever the backend is up, room or none (loadVoice needs no room in either backend: the C++ one calls voice_status, the Nim one voice_statusAsync, and the hosted method makes the recorder, opening nothing). While it says starting or recording, a strip above every page (contentArea now starts below it; its height is 0 otherwise, so every other shot is unchanged) says "Muster is starting the microphone…" or "Muster is using the microphone", the time of the cap, "If you did not start it, press Discard.", and Stop and Discard, through the room's own calls so their answers show in the room. MusterStyle / Logos.Theme tokens, as the room's banner. - Room.qml: voicePoll runs only while the microphone starts or records or a note plays; a held draft and idle are read on Main's 1 s tick, and the room's 1 s tick no longer calls loadVoice (Main's does). - Claims: the gap "Modules loaded beside Muster can record and send as you" says what the strip covers and what it does not (Muster's screens not running, a person not looking, a short note started and sent between two reads, who started it and for which room); ClaimsRegistry.qml regenerated. - voice-notes.md §8 (the polling), §13 (the mitigation and what it leaves; the open device needs a pump, which the view's 1 s read now gives on every page), §15 (fixture lists), §17 S8 and §18 (the indicator done, what it leaves). RoomVoiceNote's header comment says so. Tested-Behavior: gallery fixtures, written first and run red on the old QML (Qt 6.9.2 qml offscreen, software, design systems 88330d0 and 94092b0): the new home-voice-indicator and room-voice-record's new expectations, "GALLERY-ERROR no object named voiceIndicator" on both; and room-voice-record with only the held-draft step, "GALLERY-ERROR voicePoll.running.false is true". Green on the new QML on both systems: home-voice-indicator 28 passes (idle: no strip and loadVoice from appVoicePoll with no room joined and the room's poll off; starting and recording on Home, then Settings, Contacts, Learn and the Wallet; Stop and Discard make their calls; a draft and idle hide it), room-voice-record 35 (the strip in a room while recording, gone for a draft; the 250 ms poll off for a held draft, on while it plays, off again), 0 QML warnings. Every fixture: 92 and 93 of 100 pass, the same failures as HEAD's tree (popup shots needing an X capture, and components on 88330d0); idle-voice shots pixel-identical to HEAD's. ui-lint's python gate "87 finding(s), 87 in the baseline: 0 new, 0 fixed" on both; gen-claims-qml --check, gen-fake-backend --check and check-claims-registry pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…window, at bbae7d8, and what has not The lead's facts from this Mac (2026-10-22) were folded into §11, §15 and §17 S11 by 61242ee. Two places still summarised them short: - the status line: the window checks ran at bbae7d8, before §9's start thread; macOS never asks for the microphone and denies it, so the capture is zeros and Send refuses it silent with the Basecamp 0.3.2 note; the note between two profiles was drawn and played; and S9's two-runner self-test and the start thread in any host have not run; - §15's opening: the same, and that nothing since bbae7d8 has run in Basecamp's window. TDD-Exempt: documentation only (docs/design/voice-notes.md's status line and §15's opening paragraph). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ux run says what B did On the fork's Linux CI (runs 39776743154, 39822900956, 39836679442, at bbae7d8) the script ran on two real runners and failed both legs, "B never played A's note (sent: 48410f05…, played: 0)", while B had played it: in 39836679442's kept logs B's thread held the note complete, its Play answered ok, voice_status named it playing at 990 and 1,990 ms and then nothing, and B's sink held exactly 143,280 samples (2,985 ms x 48) with 0.99 of the source's 1 kHz power; delivery accepted A's two frames and refused none. The C++ UI's lines reach the logs through ui-host's relay, `ui-host [ "muster_ui" ]: "<chunk>"`, QDebug-quoted, several to a chunk, and the script only turned \" into ", so every VOICE line kept a closing quote or a joined line and every json.loads failed, silently. The same logs show ui-host also cutting lines: a chunk ending inside one of the UI's lines (210 to 240 times a log; the cut line then a whole number of 128 bytes so far, 906 of 908 times), and another process's timestamped line landing inside a relayed one (20 to 30 times a log). The product was right; the script was not. - scripts/lib/ui_log.py, `ui_log <log>` in ui-build.sh: the log with each of the UI's lines whole on a line of its own: the relay's quoting undone (\xHH as UTF-8, "" after a hex escape), a line cut across two chunks joined, another process's line cut out of a chunk and printed after the lines written before it; every other line unchanged. On the four CI logs all 1,960 voice lines read as JSON (the diagnostic run's reader: 8 do not) and every bare line comes out unchanged and in order. ui-build.sh's contract header and scripts/lib's CLAUDE.md say how the C++ UI's lines arrive. - scripts/voice-self-test.sh reads through it. A line it cannot read is the script's error, named with the line, never "B never played": A's Send not JSON after three reads ("the script could not read A's Send"), A's Send naming no note (refused) at once, A's Stop or B's notes the audio check cannot read ("bad: the script could not read …"), and a NOTE counting B's VOICE lines that were not JSON. Lines logged every tick (B's notes, C's Send, Stop and notes) are taken from the last one that reads as JSON, since a runner may still be writing the last. C's checks read JSON instead of grepping for substrings, and B's thread must hold exactly A's note. The pass criteria are otherwise unchanged. - VOICE_REPLAY=<dir> gives the verdict on a finished run's files and launches and deletes nothing; VOICE_OS names the OS the runners ran on. - scripts/tests/voice-self-test-replay.py, with lines cut verbatim from 39836679442's logs (scripts/tests/fixtures/voice-relay/), holds ui_log.py on each genuine kind of cut and the script's verdict through VOICE_REPLAY: SUCCESS on the run as it was and with B's lines cut as ui-host cuts them, FAIL for B never idle, a refused send, one frame, a short sink, a refused or unreadable Send, an unreadable Stop, C not refused, no detail on macOS, a second note at B, and the same on the logs written bare, as the Nim UI writes them. - voice-notes.md: the status line, §15 (what the CI runs showed, the fix, what has not run), §17's opening and S9; CLAUDE.md's command list. Not changed: ui/src/muster_ui_backend.cpp, the Nim backend, fileshare-self-test.sh (it still reads with the sed and grep -o, which the relay's quoting happens to leave alone there). Tested-Behavior: scripts/tests/voice-self-test-replay.py, written first. Red on bbae7d8's script ("── 24 failed ──": no ui_log, no replay mode, "build the runner first: make build"); red on this script with bbae7d8's lg() put back ("── 14 failed ──", the run as it was "FAIL: the script could not read A's Send (its reading of the log, not A's): {"note":"48410f05599e54b38ffd8b515ba4017e"}""); red with the diagnostic run's decoder as ui_log.py ("── 8 failed ──": every genuine cut, B's lines cut as ui-host cuts them "FAIL: B never played A's note (sent: 48410f05…, played: 1)", and it rewrites the Nim UI's bare SELFTEST lines). Green: "── all passed ──", 31 checks. Replayed on 39836679442's whole kept logs and sinks: "SUCCESS: B played A's voice note (48410f05599e54b38ffd8b515ba4017e) …", sink "ok 143280 samples (2985 ms) … 1 kHz power 66639028 of the source's 67095156", and the local leg (442b7dba…, 143232 samples). Against a scratch stand-in runner that relays the autopilot's lines through an emulated ui-host (128-byte pieces, a VOICE line across two chunks every other tick, another thread's timestamped lines landing inside 22 of A's 36 relayed lines), under macOS's bash 3.2: SUCCESS with C's leg ("the null device's zeros were refused as silent"), SUCCESS bare as the Nim UI writes, SUCCESS with VOICE_SECS=1, "FAIL: A's Send names no note: {"error":"busy","detail":""}" at once, "played: 1" when B never stops playing; bbae7d8's script against it "FAIL: B never played A's note (sent: 3b760bcd… 3b760bcd…, played: 0)", as on CI; no stand-in process left. Module suite: "── 285 passed, 0 failed (of 285) ──". Gates pass; ui-lint's python gate "87 finding(s), 87 in the baseline: 0 new, 0 fixed" on both design systems. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…f off for good The final check of PR 340's review fixes found §18 overclaiming: it said that since the review "no member can switch a room's proof off for good". Since 6aa1793 junk under voice/ refuses nothing, but a note still refuses, whatever its state, and the log is append-only: any member can switch the proof off for good by sending one note, a broken one included (two parts of their own, signed, whose clip fails its checks; voiceStanding keeps a broken note's parts, and voice_live_test §26 asserts that a broken note refuses). §10 said "now only a note does", which is true; it now says the rest too. - §18 "Still open", the proof item: junk can no longer do it, a note does, until the proof question is decided; a proof form that commits a part by digest would close both. - §10: any member can still do it by sending one note, a broken one included, until then. TDD-Exempt: documentation only; the behaviour it states is held by voice_live_test §26 as it stands ("a note arriving, complete or broken refuses voice-notes-present as before"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… off the same pin as run-suite PR 340's review, the libopus nit, as the final check of its fixes found it: 0a82be1 moved run-suite.sh to the builder pin's libopus 1.5.2 (the .lgx's) on Linux, but scripts/grade-specs.sh still built nixpkgs' current libopus for exo-526's host probe, and voice-notes.md §15 did not say so. - module/tools/builder-nixpkgs.sh: run-suite.sh's lock reader, moved out unchanged, so both scripts read one pin (module/flake.lock, else the builder's own lock via nix flake metadata; never an evaluation of the flake). It prints github:NixOS/nixpkgs/e9f00bd893984bc8ce46c895c3bf7cac95331127 here, as the old function did, from module/, from another directory, and in a scratch copy with no flake.lock. - run-suite.sh's builder_pin calls it; nothing else in run-suite changes. - grade-specs.sh builds "$PIN#libopus.out" with that pin, warning when the pin cannot be read. For x86_64-linux it evaluates to /nix/store/mzksc9fpj2hvhvr8ggzqcy0j96fyqr79-libopus-1.5.2, where it took /nix/store/ah26jmgs60s1dzphf0vi4f9pqmlpl9y6-libopus-1.6.1 (nix eval). The block run alone here gives /nix/store/f3kcnzp35b1wy1fg8cd4f2354xhww150-libopus-1.5.2. - voice-notes.md §15, the labbook §7, tests/README.md's manual recipe and CLAUDE.md's layout say so. TDD-Exempt: test-runner infrastructure. grade-specs.sh cannot run on this Mac (no exophial; it links libsodium.so and libopus.so, Linux's names), so its libopus block was run alone and evaluated for x86_64-linux instead; run-suite.sh's refactored pin reader ran: "PASS voice_codec_test", "libopus 1.5.2, preskip 312", and the whole suite below. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ppened on the page they were pressed on
The final check of PR 340's review fixes found that on a page with no room view (Home,
Settings, Contacts, Learn, the Wallet) the strip's Stop and Discard went through the room's
calls, whose answer only a room's view shows: a Stop while the microphone was still starting
("Nothing was recorded…"), or any refusal, said nothing where it was pressed, and the strip
just went.
- Main.qml: the strip's buttons call micCall(op), which notes the room's answer count and
calls the room as before; the first answer after it (Room.voiceAnswers moves) is the
call's. Off a room's view, a recording kept or discarded is said once in the toast
("Stopped. The recording is kept, unsent: you send it from its room." / "Discarded.
Nothing was sent."), and a refusal shows in the strip's place as an InlineNotice
(voiceIndicatorAnswer), in the room banner's words, until it is dismissed, the microphone
comes on again, or a room's view opens. In a room's view nothing changes: the room's
banner says it.
- MusterStyle.voiceRefusalWords: RoomVoiceNote's refusal words, moved unchanged, so the
banner and the strip say one thing; RoomVoiceNote.refusalWords calls it.
- voice-notes.md: §13, §15, §17 S8 and §18 say what the strip's
buttons say off a room's view, and §18 names the room view's two Stop/Discard pairs (the
strip and the banner, gallery room-voice-starting) as the maintainer's call, kept.
Not changed: the C++ and Nim backends (the QML is shared, ADR-019 parity holds), the claims
registry (its gap says nothing about where an answer shows).
Tested-Behavior: gallery fixture home-voice-indicator, extended first: a Stop from Home kept as a draft (the toast), a Discard from Settings (the toast), a Stop while starting answered not-started (the notice, its words and tone), still shown on Contacts, dismissed, shown again for a new answer, gone when the microphone comes on again and stays gone, gone in a room's view and after it. Red on HEAD before the change: "GALLERY-ERROR newsToast.text.Stopped. The recording is kept, unsent: you send it from its room. is """. Green after: "home-voice-indicator: 48 pass, 6 shot(s), 0 qml warning line(s)" on both design systems (88330d0, 94092b0). Mutants, each red: without the room view's clearing, "voiceIndicatorAnswer.visible.false is true" at the Home step after the room; without onMicOnChanged's, the same after the microphone came on and went. Every other voice fixture passes on both systems (room-map-voice 8, room-voice-flow 2, -forged 10, -notes 18, -record 35, -refused 42, -starting 48 passes, 0 QML warnings), and each of their 19 shots, and home-voice-indicator's four earlier ones, is byte-identical to HEAD's (cmp). ui-lint's python gate on both systems: "87 finding(s), 87 in the baseline: 0 new, 0 fixed".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…0 full notes, measured The final check of PR 340's review fixes noted that the header memo removed the re-parse the review named, but that each poll still does work growing with the room's voice history on the module thread (eventId's memo key copies and hashes each part's ~84 KB value; the authentic view and each pass copy every event), and that this was unmeasured. §6 also said "a room of 20 full notes now costs a few ms a second, not tens", which nothing had timed. Measured at 199f7bd on this Mac (Apple M5, run-suite's -d:release flags), in a scratch bench outside the repo: two members over LocalTransport, 20 notes of 60 s and 20 messages, read as the second member, warm memo, per call: - eventId of one 84,346 B part from its memo 22.6 µs (a message's 1.3 µs, uncached 470 µs); the 40 parts 0.9 ms; - roomEvents 3.0 ms; rows (coordinate_messages) 6.36 ms; lastTs 6.35 ms; threadRows alone 3.35 ms; canonicalOrder 1.09 ms; flowRows 7.62 ms and graphOf 26.7 ms over the view; - the same room with notes of 1 s (1,745 B parts): rows 0.40 ms, lastTs 0.40 ms, flowRows 0.54 ms, graphOf 1.88 ms. So about 12 ms of each second with the room map closed and 27 ms with it open, for one room: the values' bytes, not parsing. §6 now gives these numbers in place of the untimed sentence, and that the growth with each full note is inferred (one room size, two note lengths); §15's "The cost of the fold" says what is measured and what is not (the rest of coordinate_conversations, larger rooms, other hosts), and names the fix to try, an event id carried with the event from ingest, which changes the kind-agnostic log's Event and is not made here. TDD-Exempt: documentation only; the numbers come from a scratch bench that is not committed (timings are not a test), whose output is quoted above. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…stops saying they have not run The doc at 08dfcb3 still said the microphone leg had not run on §9's start thread, that the start thread had not run in any host, and that S9's self-test, as it reads its logs now, had not run on a runner. All three ran on 2026-10-23 at 08dfcb3: - voice_device_e2e, both legs, on this Mac (macOS 26.5, Apple Silicon), launched from a shell under Claude Code, whose responsible app is allowed the microphone, so no prompt stood; both starts through MiniaudioIo's start thread. Microphone: 48000/1/s16, 47.79 frames/ms, capped after 1996 ms at exactly 96000 frames, peak -45.6 dBFS, libopus 1.5.2 round trip keeping 96000 samples. Speaker: 48.10 frames/ms, 100 ms done after 131 ms past the 1920-frame tail. PASS. - Three headless logosctl 0.3.2 hosts on logos.dev (the official aarch64-macos release), the scratch harness making the UI slots' module calls: A (FileAudioIo) recorded 4,894 ms and sent; B played exactly 234,912 samples at 0.99 of the 1 kHz power; C on the null device, its start on the start thread inside logosctl's logos_host, was refused silent with the macOS note, and B still held one note. - The fork's CI, run 40030788377 (08dfcb3 plus fork-only workflow commits, ci/voice-notes- linux at 7fe3812): the replay "all passed", the runner built, and voice-self-test.sh SUCCESS on logos.dev (143,280 samples, 2,985 ms x 48) and on MUSTER_FLEET=local (143,328, 2,986 ms x 48), C refused silent, C's thread empty, B's holding A's note. What changes in docs/design/voice-notes.md: - The status line, §15's opening and its device-start, real-microphone, headless-hosts and S9 entries, §17's opening, S9's header and S11, and §18 say what ran where on 2026-10-23 and what still has not: a start while a permission prompt stands, anything in Basecamp's window since the start thread, the Nim UI's runner, ui-parity.sh voice, any device on Linux. - §12 opens with the fork's CI as well as the maintainer's run: run 39836679442 (bbae7d8's tree, both arches, JOBS=4) 284 of 285 on each, room_graph_scale_test failing on its own timing and passing alone; and run 40030788377's suite jobs, still running when this was written (their first step, the voice and audio tests, passed on both arches; the logs are not readable until a job ends, so the libopus version printed there is not read). - §15's delivery and size-budget entries no longer say a note crossed logos.dev once. - §18: the start thread is the first of the review's two options, with a 15 s bound as well, not "the way the review preferred" (the review ranked neither); the proof question and older clients are labelled for the maintainer, as his review lists them, and §10, §13, §15 and S7 say the maintainer decides the proof; the Linux-only item lists what is still not run on Linux instead of asking who runs it. - docs/labbook/voice-notes-audio-stack.md: its header, §7's Linux libopus note, §9's last paragraph and "Not found out yet" carry the same facts. Not changed: the registry gap and §18's sentence on what it covers (the next commit), and the CLAUDE.md files and code comments that still say the operator decides the proof. TDD-Exempt: documentation only; the runs it records are the evidence logs named above (voice_device_e2e.log, hosts-08dfcb3.log, the CI job log), not tests added here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…thing for a held draft or for playback The claims gap "Modules loaded beside Muster can record and send as you" says such a module can send or discard your draft and play your draft or any note aloud, then lists what the microphone strip does not cover: Muster's view not running, a person not looking, a short note started and sent between two reads, who started it or for which room. It left out what §13 already says: "a draft held, or a note or the draft played aloud: the strip is for the microphone only" (Main.qml shows the strip only while voice_status says starting or recording). So the gap promised more of the strip than it does. - contracts/claims/registry.json: the gap's body ends "And it shows nothing for a draft that is only held, which such a module can still send or discard, or for a note or your draft played aloud: the strip is for the microphone only." - ui/src/qml/ClaimsRegistry.qml regenerated (python3 ui/tools/gen-claims-qml.py). - docs/design/voice-notes.md §18, "An indicator outside the room": its list of what the gap says the strip does not cover names that case too. Ran: python3 scripts/check-claims-registry.py (OK, 44 claims), python3 ui/tools/gen-claims-qml.py --check (in sync), check-readme-rot, check-run-targets and check-no-cpp (all pass). The gallery's walkthrough fixture, which renders the registry, ran on both design systems (88330d0, 94092b0): ok, 1 shot, 0 QML warnings; its one shot is the top of the page, so it does not show this gap's text. TDD-Exempt: copy only, in the claims registry and the QML generated from it; check-claims-registry.py and gen-claims-qml.py --check hold its shape and the generated file, and no test reads a gap's wording. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hat the self-test and the fork's CI have run Two reviews of 762099e and 175c7cf found sentences that said more, or less, than the runs show. 762099e dated both device legs at bbae7d8, before the start thread. Only the microphone leg ran then. The speaker leg passed on 2026-10-22 before bbae7d8 (2cc3c53 is 04:09, bbae7d8 07:13), and it ran on the start thread in S2's review (588e327) and its fixes, as §17 S11 says. Two entries still said no harness in the tree joins a room. scripts/voice-self-test.sh does join one, and its C++ autopilots made the voice calls through the runner's host at 08dfcb3 (ci-selftest-08dfcb3.log: "A's Start: {"ok":true} · Stop: ... · Send: {"note":"89d79b72..."}"). "Any device on Linux" left out miniaudio's null device, which has run there. What changes in docs/design/voice-notes.md: - The status line and §15's opening: the microphone leg at bbae7d8, before the start thread; the speaker leg on that thread in S2's review; both legs again at 08dfcb3. §15's device-start entry gets back the speaker leg's S2 runs on the start thread. - §15's opening list is a selection now ("Among what has still not run"). It adds Record and Play in Basecamp's AppImage, S9's red run and grade-specs.sh, and it says "any real device on Linux (PulseAudio, then ALSA; only miniaudio's null device has run there)". - §12: the maintainer checked the package's load as well as running the suite. Run 40030788377's first test step is the voice and audio tests; its first four steps are setup. Its ubuntu-24.04-arm job has ended: 284 of 285 passed, four at a time. room_graph_scale_test failed on its timing (folds 4,021 ms, bound 4,000) and passed alone. Its ubuntu-latest job was still running when this was written. The arm job's log gives PASS lines only, and gh returned 404 BlobNotFound for the running job's log, so which libopus either loaded is not read. The text no longer says a log cannot be read until a job ends. §15's libopus entry, its "audio facts" entry and its Linux-only list match this. - §15, the voice methods through a host (S7): the hosted glue with a room joined has run in one test in the tree, S9's self-test. Its autopilots call voice_status, record_start, record_stop, draft_send and play, but not draft_discard or play_stop. Outside any test, the same calls ran in Basecamp's window at bbae7d8 and from the headless harness. The room's memo entry: no test runs those hosted lines on a room holding a note. The self-test calls only coordinate_conversations, once per runner as it joins, before any note is in the room (ui/src/muster_ui_backend.cpp joinRoom). - §15, the headless entry: its heading gains 2026-10-23. The 2026-10-23 run is "a harness making the same module calls", since the 2026-10-22 harness is not on disk to compare. The locked screen is given as the reason for 2026-10-22 only. - §15's Linux-only list: voice-self-test.sh passed on the C++ UI's runner on both fleets at 08dfcb3. The Nim UI's runner has not run it, and ui-parity.sh voice has not run for either UI. - §17 S11: macOS 26.5.2 (sw_vers), not 26.5. - §18: the Linux-only item names what is still not run there instead of "the rest of §15's Linux-only checks". The re-send path is labelled for the maintainer, as his review lists it with the proof question, older clients and the version. - Lines from 762099e and 175c7cf that ran past the doc's width are rewrapped. docs/labbook/voice-notes-audio-stack.md: the plugin's dlopen/dlsym/dlclose link has run on Linux. That covers the linux-amd64 package of the lgx variants run (39776903474), the suite's miniaudio tests and the CI runner at 08dfcb3, though which library supplied them is not read. §7's libopus note stops saying the tests linked the pin "that way": run-suite's code says so, and the version is unread. "Not found out yet" scopes linux-amd64 to the plugin links too. room_graph.nim:23 and voice_live.nim:479: the comments say the maintainer decides the proof, as §10 and §18 now do. These are word swaps in comments, and no line moves. Not changed: the root CLAUDE.md layout line ("until the operator decides") and module/src/audio/CLAUDE.md's "on Linux, not yet run there". Both wait on the person's go-ahead for a CLAUDE.md edit. TDD-Exempt: documentation and two code comments only; the runs it records are the evidence logs (voice_device_e2e.log, hosts-08dfcb3.log, ci-selftest-08dfcb3.log, run 40030788377's arm suite job log), not tests added here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d the libopus they linked Run 40030788377's two module suite jobs finished after 762099e and 1598563 were written: - ubuntu-latest and ubuntu-24.04-arm each gave 284 of 285, four at a time. The one failure was room_graph_scale_test's own timing beside the other tests (its folds 5,658 ms and 4,021 ms, against a 4,000 ms bound); re-run alone it passed (1,197 ms and 1,497 ms). - voice_codec_test §1 printed "libopus 1.5.2, preskip 312" on both arches, read from the run's kept logs (suite-logs-ubuntu-latest, suite-logs-ubuntu-24.04-arm). So the suite links the shipping 1.5.2 on Linux, which §12, §15, §18 and the labbook said was not read. To tell load from a regression, room_graph_scale_test ran alone on this Mac, three times on this branch and three on main's 67701f8: folds 554-605 ms against 555-562 ms, the whole reduceRoomGraph 617-646 ms against 590-592 ms, about 6% slower here. §12 says so, and that main has not run on those runners. Also two lines the doc commits left, since agents may not edit CLAUDE.md files: the root CLAUDE.md's layout line now says the maintainer decides the proof question, as §10 and §18 do, and module/src/audio/CLAUDE.md says voice_codec_test has run on Linux and printed 1.5.2. TDD-Exempt: documentation only; the numbers are read from run 40030788377's logs and from run-suite.sh room_graph_scale on this Mac, quoted above. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main moved three PRs past this branch's base 67701f8 (corpetty#341, corpetty#342, corpetty#343: Send money fixes, Pay with Monero Wallet filling its Send tab, the pebbles for 0.3.3), which left PR 340 conflicting. The one conflict was ui/tests/gallery/FakeBackend.qml, which both sides had regenerated with FileShare's props and slots; it is generated from ui/src/muster_ui.rep, which main does not touch, so it is regenerated here (python3 ui/tools/gen-fake-backend.py) and comes out byte-identical to this branch's. Main.qml, Room.qml, RoomThread.qml, RoomComposer.qml, MusterCard.qml and CardProposal.qml merged on their own. TDD-Exempt: a merge of main; the regenerated fake backend is checked by gen-fake-backend.py --check, and the merged QML by the gallery fixtures run after it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as voice_status pumps first
§12 said a Record on a nix-built runner with no device library answers {ok}, voice_status
says `starting`, and "the next pump" ends it with no-device. voice_status pumps before it
answers (voice_live.nim voiceStatus), so the recording ends at the first status read after
the start thread returns, and `starting` shows only to a read that lands before that. The
review reply was corrected the same way; §12 now matches it. Read from the code, not run
on Linux, as §12 already says.
TDD-Exempt: documentation only; one paragraph of §12 reworded to match voice_live.nim's voiceStatus.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review and for the Linux runs. Every point has an answer at Should fix before merge
Worth fixing
Linux
Still open, from your list
Yours to decide (§18)
§18 also keeps four smaller questions open that are not put to you: which room holds the draft, whether to coarsen The updated PR description lists them. |
From an outside contributor. This PR adds voice notes: a short recorded clip posted in a room, sealed like a text message, and the same size on the wire whatever its length. The design doc is the first commit, and the rest of the PR follows it:
docs/design/voice-notes.md. §18 lists the decisions and the questions for you.Since the review at
bbae7d8(27 commits and a merge ofmain, up to04369ce):voice/no longer switches the room's proof off, but a note still does until §18's proof question is settled.08dfcb3and again at04369ce, with the C++ UI's runners on ubuntu-latest, on both fleets. Its earlier failure was the script misreading its logs.Item-by-item replies follow in the review thread.
What a person sees
silent, with a note that says why. The button stays. This was seen in Basecamp's window atbbae7d8, before the start thread (§11).How it works
muster_module. The UI only triggers record, stop, send, discard, play and play-stop, and pollsvoice_status(§8). No voice method returns audio, and no hosted method returns the audio of a note this member's view holds. §10 says what is left: an older client in the room, a copy of a note's bytes posted as junk (below), and bytes a member posts in a message body.voice_record_startandvoice_playanswer at once. Until the device runs,voice_statussaysrecording.state: startingfor a recording andplaying.starting: truefor a playback, andready.capture/ready.playbackreadunknown.no-device.device-start-slow, and is let go.voice/<nid>/<author>/<i>/2. The session pads everyvoice/frame to 90,112 B of plaintext, 90,153 B on the wire. So a 1 s note and a 60 s note are the same size to the network and the store nodes, though they still see that a note was sent, to which topic and when (§3–§5). The registry carries this as the protects claim "A voice note's length does not show in its size" (requirement FS-9). Its test isprobe_voice_fixed_size_stepper.broken. Forged, unsigned, cross-room, edited and squatted parts drop out. Any order or duplication folds the same, and a later joiner folds no earlier note (F-16) (§6). Amessage/whose body claimsvoice-notestays plain text (§7).Event, so it is not made here (§6, §15).coordinate_proof. It refusesvoice-notes-presentonly when the room's authentic view holds a part the fold keeps. An entry undervoice/that is no note's part refuses nothing, and leaves with the proof whole, like any raw entry. That means a copy of a note's bytes leaves too, when this member's view holds none of that note's parts (§10;voice_live_test§26). Any note, even a deliberately broken one, still refuses until §18's proof question is decided.publishAuthored. It now re-seals the member's ownafter-signed copy of an event the log already holds, not the log's kept copy, for every author-signed kind.authorship_test§12 holds it for a re-announced account disclosure.AudioIoseam, with synthetic, WAV-file and null implementations for tests, and no mocks (§9). On Linux, reading the code, a nix-built runner most likely finds no device and says so (no-device). It never records zeros (§12).voice_status,voice_record_start,voice_record_stop,voice_draft_send,voice_draft_discard,voice_play(note_id)andvoice_play_stop. Thread rows carry a note in a separatevoicefield, as metadata only.ui/and the Nim one inui-nim/. Their parity run has not happened (below).CardVoiceNote.qml,RoomVoiceNote.qml, the mic inRoomComposer, and the microphone strip inMain.qml.Room.qmlpolls every 250 ms only while a device starts or records, or a note plays. A held draft is read on Main's 1 s tick.MUSTER_AUTOVOICE/MUSTER_AUTOVOICEPLAYautopilots.run-suite.shcounts a SKIP only for a test in its E2E list. Any other test that printsSKIP <name>:fails.run-suite.sh(both OSes) andgrade-specs.sh(which links for Linux only) take the builder pin's libopus 1.5.2, the version the.lgxships. Both read the pin throughmodule/tools/builder-nixpkgs.sh.scripts/lib/ui_log.pyreads the C++ UI's lines out of ui-host's quoted relay and rejoins the lines ui-host cut. In the CI logs it was built on, that was every voice line and 974 of 976 longer SELFTEST lines.scripts/tests/voice-self-test-replay.pychecks it, and the self-test's verdict, on lines cut verbatim from a Linux run.voice_live_test§23's raw-log refusal is inverted, since junk now refuses nothing.voice_device_e2ewait for a start to land.room-voice-recordgallery fixture checks the strip and the poll.ui/tests/gallery/FakeBackend.qml, which was already stale onmain. It also fixes the thread's fallback for a card kind MusterCard doesn't know:RoomThreaddrew nothing, and now it shows the message's text.What ran
On macOS 26.5.2, Apple Silicon:
08dfcb3:module/tests/run-suite.sh, 285 passed, 0 failed. The five commits after it change only docs, the claims registry and the QML generated from it, and the wording of two code comments. Atfd59219,voice_live_test,room_graph_test,room_graph_live_test,room_graph_scale_testand the host probe pass, and so do the registry gates.08dfcb3(voice_device_e2e, opt-in), through the start thread. It was launched from a shell whose app is already allowed the microphone, so no prompt stood (§11):08dfcb3. They ran logosctl 0.3.2 onlogos.dev, driven by a scratch harness (not in the tree) that makes the calls the UI's slots make.silentwith the macOS note, and B's thread still held one note.bbae7d8, before the start thread, on throwaway profiles onlogos.dev:open -n -a) and the mic button was pressed.logos_hostsurvivedvoice_record_start. tccd denied the microphone without a prompt, so the capture was zeros, Send refused it assilentwith the Basecamp note, and nothing was posted.main(a5e0149, fix(monero): three Send money bugs — a stray Mark received, a used address, the wallet button #341–chore(pebbles): track Muster 0.3.3 #343). Its only conflict wasui/tests/gallery/FakeBackend.qml. That file is generated from the.rep, whichmaindoesn't touch, so it was regenerated, and came out byte-identical to the branch's. After the merge:componentson one design system, androom-news, which trips on a sidebar setting left on this Mac.main's newcard-mark-receivedpasses..lgxbuilds.04369ceplus the fork's workflow commits, on both fleets (the self-test job of run 40042776538). B's sink held 143,184 and 143,136 samples, and C's zeros were refused assilent.04369ce: the module and UI.lgxbuilds on darwin-arm64, linux-amd64 and linux-arm64, and the merge job.Main.qmland the generatedFakeBackend.qml(no module), on Qt 6.9.2 (offscreen, software), on both design systems, with 0 QML warnings. That includes the strip and the starting state.gen-claims-qml --checkandgen-fake-backend --checkall pass. ui-lint's checks were run as a copy against Qt 6.9.2's qmllint, since there is no runner here, and found 0 new findings on both design systems.ui-nim/tests/autovoice/run.shpasses against a fake module.On Linux:
bbae7d8: the module suite gave 285 passed, 0 failed, linking nixpkgs' libopus 1.6.1. Thelinux-amd64package loads with its ownlibopus.so.0(§12).08dfcb3plus the fork's workflow commits (run 40030788377):room_graph_scale_test's own timing budget beside the other tests (its folds took 5,658 ms and 4,021 ms against 4,000), and it passed alone. On this Mac, alone, three runs each, its folds took 554–605 ms on this branch and 555–562 ms onmainat67701f8, this branch's base, andreduceRoomGraphtook 617–646 ms against 590–592 ms, about 6% longer.mainhas not run on those CI runners (§12).voice_codec_testprintedlibopus 1.5.2on both arches.scripts/voice-self-test.shran three of the C++ UI's standalone runners on one ubuntu-latest machine: A sends, B plays, and C, on the null device, must be refused. It gave SUCCESS onlogos.devand onMUSTER_FLEET=local.silent, and B's thread held only A's note.Not verified
recordinghas run only in the self-test's runners on Linux, where C's null-device start went throughstartingfirst.coordinate_conversations,coordinate_flow,coordinate_graph,coordinate_provenance,coordinate_leave) are not.make build-nim, thenMUSTER_UI=nim scripts/voice-self-test.sh, has not run. Neither hasscripts/ui-parity.sh voice, for either UI.scripts/grade-specs.sh. It has not run, since there is no exophial here. Its libopus block ran on its own.The full list is §15.
Asked of you (§18)
derived-exo-<id>. The probes are already in stepper form.coordinate_messagesandcoordinate_proof. Otherwise a voice spec's own probe takes those checks over.coordinate_proof: should the proof export be named as an exception, or should a proof form carry voice parts as digests? Until then a single note switches the room's proof off for good.device-start-slow, or to a start thread at all, since macOS did not prompt for a Basecamp 0.3.2 opened the way the Dock opens it (one run, §11).main.The commits use the
voice-notesscope until there is an exo id, and the PR's diff againstmaintouches nothing under.pebbles/. Each commit carries a Tested-Behavior or TDD-Exempt trailer.🤖 Generated with Claude Code