Joining voice on the voice-chat preset was unreliable: audio arrived after several seconds of the route flipping back and forth, sometimes not at all, and VoiceOver went quiet while it happened. Device logs show why. A graph with voice processing enabled reports a successful start and is then torn down within a second, roughly three times in four; every configuration without voice processing — both microphone presets, and voice chat with processing off — comes up first time and runs indefinitely. With voice processing the input and output are one IO unit, and it only stays up while the input is part of the render chain. The input node carried a tap and no connection, which leaves it out of that chain. Route the input through a silent mixer so it is genuinely rendered. The rest of this is the amplifier rather than the cause, and each part of it turned one failed start into a storm: The stall watchdog rebuilt on every missed tick, without bound. That converted a graph that could not start into endless session reconfiguration, which is what the user heard and what hid the reason from the log. It now backs off after each failed attempt and stops after four, logging VC_WATCHDOG exhausted, so a transient freeze still recovers and a graph that will not start fails visibly. Nothing waited for a graph to start before judging it dead. Enabling voice processing rebuilds both halves of the IO, which posts a configuration change and reads as not running for several hundred milliseconds, so the configuration-change handler and the watchdog both tore down graphs that were about to run. A settling window holds them off for two seconds. A route change forced a full rebuild, and every rebuild moves the route, so one notification produced the next. Route changes now take the non-forcing path, which rebuilds a stopped graph and leaves a healthy one alone; the hardware test it uses reads the input node's format, not AVAudioSession, whose reported rate and channel count do not settle until after the graph has started. The input side is built once per session instead of being added when voice is joined, so joining and leaving voice set a stream id rather than replacing the graph, and a mono voice-chat apply no longer clears a stereo capsule configuration it never applied. Every rebuild now logs its cause, and VC_START/VC_START_CHECK record whether the graph survived its start. The first-attempt failure is not fixed and is recorded in PROGRESS.md as a release gate: capture still comes up on a watchdog rebuild rather than immediately. The changed logic sits on AVAudioSession and AVAudioEngine, which the net10.0 test project cannot reference, so the behaviour is covered by the existing source assertions; verification is on device.
Apple clients
VoiceCat.Mac and VoiceCat.iOS are .NET 10 AppKit and UIKit clients over the shared managed
core. The ReplayKit upload extension under native/apple/broadcast remains Swift because it
runs under the extension memory limit and writes the versioned shared audio ring.
Build on macOS with Xcode and the pinned .NET workloads:
./scripts/build-native.ps1
./scripts/build-native-ios.sh
dotnet restore clients/apple/VoiceCat.Apple.slnx
dotnet build clients/apple/VoiceCat.Apple.slnx -c Debug --no-restore
Use publish-macos.sh --dry-run to validate a local ad-hoc macOS bundle. The dry-run build does
not enable hardened runtime because ad-hoc signatures have no Team ID and cannot satisfy macOS
library validation. The script normalizes nested signatures and installs the verified bundle at
both VoiceCat.Mac/bin/Release/net10.0-macos27.0/osx-arm64/VoiceCat.app and
VoiceCat.Mac/bin/Release/distribution/VoiceCat.app. Distribution builds remain hardened and
require VOICECAT_CODESIGN_IDENTITY; optional notarization uses APPLE_ID, APPLE_TEAM_ID, and
APPLE_APP_PASSWORD.
For a physical iOS device, use build-ios-device.sh and deploy-ios-device.sh. The host and
ReplayKit extension require signing profiles with App Group group.me.iamtalon.voicecat.
Hardware validation must cover VoiceOver, background and lock behavior, interruptions, route
changes, Bluetooth, ReplayKit, and iOS 27 ScreenCaptureKit audio.
For iOS voice stability, leave a call joined with the microphone active for at least 30 minutes
and confirm speech stays clear and VC_AUDIO reports no growing feedDrops. While still joined,
toggle Wi-Fi off and on, switch between Wi-Fi and cellular, and confirm the app stays open,
reconnects, and restores the voice session. Repeat with mono, stereo, and voice processing.
The iOS remote-user manual gate must also cover Users → user detail → independent microphone and screen-audio gain/mute controls, microphone receive noise reduction, the no-active-stream state, and Private Chats → conversation → User/audio settings. Repeat the navigation with VoiceOver and disconnect the remote user while its detail and conversation views are open.
App Store builds use the same device builder with --configuration Release. Set
VOICECAT_BUILD_NUMBER, VOICECAT_DISPLAY_VERSION, VOICECAT_DEVELOPMENT_TEAM, the host
VOICECAT_CODESIGN_KEY/VOICECAT_CODESIGN_PROVISION pair, and the extension
VOICECAT_BROADCAST_CODESIGN_KEY/VOICECAT_BROADCAST_CODESIGN_PROVISION pair. The host and
extension profiles must both be App Store Connect profiles and include the shared App Group.
The complete TestFlight workflow is documented in docs/apple-ios-release.md. The shared ring
contract is documented in docs/broadcast-ring-format.md.