Toggling speaker output flipped the route back and forth indefinitely. Two
loops, both of which made a rebuild produce the condition for the next one.
Reconfiguring the session moves the route, and moving the route is reported
back through RouteChangeNotification. Forcing the speaker takes a headset out
of the route, which arrives as OldDeviceUnavailable, and releasing it brings
the headset back as NewDeviceAvailable; neither is among the reasons the
handler filters, so each rebuild answered its own echo with another rebuild.
Nothing compared the reported route against the route the live graph was
actually built on.
Record that route at the end of Apply, once the session is configured, and
rebuild only when a reported change differs from it; notifications that arrive
while Apply is still running describe the change Apply is itself making and are
ignored outright. The decision is AudioRouteWatcher in VoiceCat.Core, which is
platform-agnostic and tested, following ControlPathWatcher; the route identity
it compares is supplied by the caller, on iOS the UIDs of the current route's
ports. A graph whose route is unchanged but broken is still the stall
watchdog's to catch.
The port override was also re-asserted on every Apply, so where the system
wanted to hand output back to a connected headset each rebuild forced it to the
speaker again and the resulting route change drove the next rebuild. It is now
the one-shot request it should always have been, issued by the toggle alone;
the DefaultToSpeaker category option is the part that persists across rebuilds.
Also updates the route test from 724f7e9, which asserted the voice-chat preset
clearing the speaker flag and the absence of the port override. Both were
deliberately removed when speaker output became orthogonal to the preset, and
the test should have been updated with them.
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.