Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4ffde2752 |
+12
-16
@@ -8,10 +8,8 @@ This project follows a Conventional Commits style workflow.
|
||||
|
||||
The v0.3.0 milestone transitions Chanora from an internal-beta voice
|
||||
prototype to a cross-platform baseline client with event-driven UI,
|
||||
visible per-client audio state, non-self client info parity, and
|
||||
documented host Rust workspace plus Flutter validation gates. Android
|
||||
target compile/install/smoke evidence remains blocked locally pending the
|
||||
required NDK compiler and an authorized ADB target.
|
||||
per-user audio controls, non-self client info parity, and CI-hardened
|
||||
Android / iOS / macOS / Linux builds.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -19,9 +17,10 @@ required NDK compiler and an authorized ADB target.
|
||||
deltas (client join/leave/move/update, channel add/remove/update)
|
||||
flow through a typed `ProtocolDelta` enum and update the Flutter UI
|
||||
in real time. Channel switching is instant.
|
||||
- **Per-client audio state visibility.** Client rows surface
|
||||
muted/deafened state in avatar badges. Per-user volume UI, persistence,
|
||||
and mixer wiring remain tracked as follow-up work.
|
||||
- **Per-user volume controls.** Each client in the snapshot gets an
|
||||
independent volume slider persisted in the bridge layer. Avatar
|
||||
badges show muted/deafened state. Volume adjustments take effect
|
||||
immediately on the audio mix.
|
||||
- **Non-self client info parity with Qint.** The Info tab now populates
|
||||
connection metadata (name, description, created, last connected,
|
||||
connections, transfer, ping deviation) for other clients via an
|
||||
@@ -30,10 +29,9 @@ required NDK compiler and an authorized ADB target.
|
||||
- **Ping deviation in client profiles.** `ping_deviation_milliseconds`
|
||||
propagated from protocol DTO through bridge API to Dart, with a
|
||||
conditional l10n row in the client info sheet (en + zh).
|
||||
- **Apple CoreML Silero VAD scaffolding/assets** for iOS / macOS when
|
||||
the private `silero-coreml` SwiftPM package is available. Product
|
||||
`VoiceActivity` remains reserved/disabled per DEC-030 until a later
|
||||
baseline enables and verifies it.
|
||||
- **Apple CoreML Silero VAD** as the preferred voice activity detector
|
||||
on iOS / macOS when the private `silero-coreml` SwiftPM submodule is
|
||||
available. WebRTC VAD remains the runtime fallback.
|
||||
- **TeamSpeak address resolver** (`chanora_resolver`) for DNS SRV
|
||||
lookups and `ts3server://` URI handling.
|
||||
- **Per-ABI Android APK splitting.** `flutter build apk
|
||||
@@ -52,9 +50,8 @@ required NDK compiler and an authorized ADB target.
|
||||
- **iOS / macOS audio lifecycle hardened.** Voice unit restart-in-place,
|
||||
serialized lifecycle events, WebRTC VAD on iOS, unblocked connect-time
|
||||
audio startup.
|
||||
- **Linux native audio path promoted** with ONNX Runtime VAD assets
|
||||
bundled for future `VoiceActivity` work. Desktop voice I/O works on
|
||||
PipeWire / PulseAudio; product `VoiceActivity` remains disabled.
|
||||
- **Linux native audio path promoted** with ONNX Runtime bundled for
|
||||
VAD. Desktop voice I/O works on PipeWire / PulseAudio.
|
||||
- **Android audio routing** uses `MODE_IN_COMMUNICATION`, proper
|
||||
startup permission flow, and system back-button integration.
|
||||
- **`SnapshotChanged` event removed.** Replaced by the typed delta
|
||||
@@ -62,8 +59,7 @@ required NDK compiler and an authorized ADB target.
|
||||
Flutter).
|
||||
- **Prefetch crate renamed** from the PoC-era name to
|
||||
`chanora_prefetch`. All docs, specs, and code updated.
|
||||
- **Flutter app version/build bumped to `0.3.0+100`.** Rust workspace
|
||||
packages remain versioned separately at `0.2.0-beta.1`.
|
||||
- **Build number bumped to 76.**
|
||||
- **Flutter bridge regenerated** for `flutter_rust_bridge` 2.12.0.
|
||||
|
||||
### Fixed
|
||||
|
||||
Generated
+1
-1
@@ -532,7 +532,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "chanora_resolver"
|
||||
version = "0.2.0-beta.1"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hickory-resolver",
|
||||
|
||||
@@ -14,10 +14,10 @@ Flutter UI + Rust Core + tsclientlib
|
||||
|
||||
## Status
|
||||
|
||||
Chanora is currently a baseline-candidate Flutter + Rust workspace. It is not production-ready and is not approved for public or store release.
|
||||
Chanora is currently in early planning and baseline-candidate design.
|
||||
|
||||
```text
|
||||
Current documentation baseline: v0.9.x document set
|
||||
Current documentation baseline: v0.9.2
|
||||
Current status: Baseline Candidate
|
||||
Implementation status: Not production-ready
|
||||
```
|
||||
@@ -25,7 +25,7 @@ Implementation status: Not production-ready
|
||||
The current engineering focus is:
|
||||
|
||||
- defining the system and software architecture;
|
||||
- hardening the Flutter + Rust application structure;
|
||||
- preparing the Flutter + Rust application structure;
|
||||
- validating TeamSpeak-compatible protocol integration through `tsclientlib`;
|
||||
- defining cross-platform audio behavior;
|
||||
- preparing release, verification, security, privacy, and legal gates.
|
||||
@@ -49,7 +49,7 @@ Current platform policy:
|
||||
| iOS / iPadOS runtime target | iOS 16+ while Apple CoreML Silero VAD is linked |
|
||||
| macOS runtime target | macOS 13+ while Apple CoreML Silero VAD is linked |
|
||||
| App Store Connect upload gate | Xcode 26+ with iOS 26 / iPadOS 26 SDK+ for upload on or after 2026-04-28 |
|
||||
| Android runtime target | Android API 28+ per DEC-004, SysRS-288, SRS-187, and Gradle `minSdk = 28` |
|
||||
| Android runtime target | Android API 24+ unless Flutter, plugin, audio, or product constraints require raising it |
|
||||
| Google Play target API | Target the Google Play-required API level on upload date |
|
||||
|
||||
The App Store / Play Store upload gates are release requirements. They are separate from local development and internal testing requirements.
|
||||
@@ -230,7 +230,7 @@ docs/
|
||||
aspice-swe2-swe3-integration-note.md
|
||||
```
|
||||
|
||||
Implementation source folders are present in this workspace. The current high-level structure is:
|
||||
Implementation source folders may be added later. A likely structure is:
|
||||
|
||||
```text
|
||||
apps/
|
||||
@@ -248,7 +248,7 @@ crates/
|
||||
chanora_bridge/
|
||||
```
|
||||
|
||||
The exact implementation layout may continue to evolve as maintainability reviews split or merge Modules, but the repository scaffold exists.
|
||||
The exact implementation layout should be finalized when the repository scaffold is created.
|
||||
|
||||
---
|
||||
|
||||
@@ -395,7 +395,9 @@ docs/governance/git-commit-message-convention.md
|
||||
|
||||
## Development
|
||||
|
||||
Common local commands include:
|
||||
Implementation commands will be added after the repository scaffold is finalized.
|
||||
|
||||
Expected future commands may include:
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
@@ -405,7 +407,7 @@ cargo clippy
|
||||
cargo fmt
|
||||
```
|
||||
|
||||
Android runtime success also requires an available Android NDK toolchain and an authorized device or emulator for build/install/smoke verification.
|
||||
Do not treat these as authoritative until the actual Flutter/Rust workspace has been created.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,24 +6,6 @@ import AVFoundation
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
private var iosAudioLifecycleChannel: FlutterMethodChannel?
|
||||
private var iosPlatformChannel: FlutterMethodChannel?
|
||||
private var iosAudioSessionChannel: FlutterMethodChannel?
|
||||
|
||||
/// Tracks whether a voice channel is currently active.
|
||||
///
|
||||
/// The AVAudioSession is intentionally not configured for VoIP at
|
||||
/// app launch — that would interrupt other apps' audio (Spotify,
|
||||
/// Apple Music, podcasts) the moment the user opens Chanora, even
|
||||
/// when they're just reading chat. Production VoIP apps (Telegram
|
||||
/// group calls, Signal, Discord, Element) only switch the session
|
||||
/// to `.playAndRecord` + `.voiceChat` when the user actually joins
|
||||
/// a voice channel. See `docs/architecture/sad.md` and the
|
||||
/// `chanora/ios_audio_session` MethodChannel contract.
|
||||
///
|
||||
/// This flag gates lifecycle handlers (interruption-ended,
|
||||
/// media-services-reset) so we only rebuild the VoIP session if a
|
||||
/// call is actually in progress. When false, those handlers leave
|
||||
/// the session in the inactive `.ambient` baseline.
|
||||
private var voiceSessionActive: Bool = false
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
@@ -33,27 +15,91 @@ import AVFoundation
|
||||
ChanoraSileroSelfTest.run()
|
||||
}
|
||||
|
||||
// AVAudioSession lifecycle policy (DEC-2026-06-08, supersedes
|
||||
// the launch-time .playAndRecord setup):
|
||||
// Configure the iOS AVAudioSession **category + mode** at
|
||||
// app-launch time, but DEFER setActive(true) until the scene
|
||||
// is foregrounded. Calling setActive in didFinishLaunching is
|
||||
// racy on iOS 17+ devices: if the user launches the app from a
|
||||
// cold state, the UIApplication isn't yet `.active` and
|
||||
// setActive returns `AVAudioSessionErrorCodeCannotStartPlaying`
|
||||
// (561017449) — the iOS audio policy server refuses to grant
|
||||
// the audio session because the app is not yet considered the
|
||||
// foreground priority owner. Symptom in production builds:
|
||||
// 'AVAudioSession setup failed: Error 561017449 "Session
|
||||
// activation failed"' in NSLog, after which the audio engine
|
||||
// is unusable until the user backgrounds + foregrounds the
|
||||
// app.
|
||||
//
|
||||
// At launch we set the category to .ambient and leave the
|
||||
// session INACTIVE — matching the Telegram / Signal / Discord /
|
||||
// Element / Jitsi pattern and Apple's guidance that "a VoIP
|
||||
// app's audio session should not be active" while idle.
|
||||
// Configuring .playAndRecord + .voiceChat at launch stops other
|
||||
// apps' music (Spotify, Apple Music, podcasts) the moment the
|
||||
// user opens Chanora, even when they are just reading text chat.
|
||||
//
|
||||
// VoIP configuration is engaged on voice-channel join via the
|
||||
// `chanora/ios_audio_session` MethodChannel, driven from Dart
|
||||
// by the BridgeEvent::AudioStarted / AudioStopped lifecycle.
|
||||
// The category itself can be set whenever; only the active
|
||||
// state needs to be deferred. We listen for
|
||||
// didBecomeActiveNotification and activate then. Most
|
||||
// production iOS voice apps (Discord, Zoom, FaceTime) follow
|
||||
// this same shape.
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
|
||||
logAudioSessionState(context: "launch-ambient")
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .voiceChat,
|
||||
// Mode rationale (May 2026, .voiceChat reinstated):
|
||||
//
|
||||
// We previously used .default mode after discovering that
|
||||
// .voiceChat routed output through iOS's in-call audio
|
||||
// channel, which made speaker output barely audible. That
|
||||
// bug was caused by cpal's RemoteIO unit binding to a stale
|
||||
// physical transducer — after migrating to coreaudio-rs +
|
||||
// kAudioUnitSubType_VoiceProcessingIO (see
|
||||
// crates/chanora_audio/src/ios_voice_unit.rs) the route
|
||||
// binding is correct under either mode because VPIO re-binds
|
||||
// on overrideOutputAudioPort.
|
||||
//
|
||||
// .voiceChat advantages over .default:
|
||||
// * Tells iOS this is a VoIP session — other apps' audio
|
||||
// is properly ducked/paused instead of competing.
|
||||
// * Enables correct Bluetooth HFP negotiation without
|
||||
// manual workarounds.
|
||||
// * iOS treats the audio session as a "call" for priority
|
||||
// purposes (won't be interrupted by notification sounds).
|
||||
// * System-level CallKit integration (lock-screen controls).
|
||||
//
|
||||
// .defaultToSpeaker ensures output goes to the main speaker
|
||||
// (not the earpiece) by default when no headphones are
|
||||
// connected, compensating for the in-call channel's tendency
|
||||
// to route to the earpiece.
|
||||
//
|
||||
// References:
|
||||
// * https://github.com/twilio/video-quickstart-ios/issues/522
|
||||
// * https://stackoverflow.com/questions/79834998 (Daily.co)
|
||||
//
|
||||
// Options:
|
||||
// .defaultToSpeaker : route output to the main speaker
|
||||
// (not the earpiece) by default
|
||||
// when no headphones are connected.
|
||||
// .allowBluetoothHFP : permit Bluetooth Hands-Free
|
||||
// Profile headsets as both input
|
||||
// and output.
|
||||
// .allowBluetoothA2DP : permit higher-quality A2DP
|
||||
// output-only Bluetooth devices.
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
||||
)
|
||||
// Match VPIO / Opus frame cadence to reduce callback pressure.
|
||||
try session.setPreferredIOBufferDuration(0.02)
|
||||
try session.setPreferredSampleRate(48000.0)
|
||||
logAudioSessionState(context: "setCategory")
|
||||
} catch {
|
||||
NSLog("chanora_flutter: AVAudioSession .ambient baseline failed: \(error)")
|
||||
NSLog("chanora_flutter: AVAudioSession setCategory failed: \(error)")
|
||||
}
|
||||
|
||||
// Activate the session once the app is actually foreground. The
|
||||
// notification fires immediately after the cold-launch settles,
|
||||
// and again on every resume-from-background — both safe
|
||||
// moments to call setActive(true). Repeated activation while
|
||||
// already-active is a no-op per the docs.
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(activateAudioSession),
|
||||
name: UIApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleRouteChange(_:)),
|
||||
@@ -78,60 +124,37 @@ import AVFoundation
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
/// Activate the VoIP audio session. Called from Dart via the
|
||||
/// `chanora/ios_audio_session` channel when a voice channel join
|
||||
/// reaches the `BridgeEvent::AudioStarted` stage. Configures
|
||||
/// .playAndRecord + .voiceChat with .mixWithOthers so other apps
|
||||
/// (Spotify, podcasts) can keep playing alongside the voice
|
||||
/// channel — matching the Telegram group-call UX. Idempotent:
|
||||
/// repeated calls while already active are a no-op.
|
||||
private func activateVoiceSession() {
|
||||
/// Called by `didBecomeActiveNotification` (cold-launch settle +
|
||||
/// every resume-from-background). Activates the AVAudioSession.
|
||||
/// Repeated activation is a no-op when the session is already
|
||||
/// active so this is safe to call on every foreground.
|
||||
@objc private func activateAudioSession() {
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .voiceChat,
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
|
||||
)
|
||||
try session.setPreferredIOBufferDuration(0.02)
|
||||
try session.setPreferredSampleRate(48000.0)
|
||||
try session.setActive(true, options: [])
|
||||
voiceSessionActive = true
|
||||
logAudioSessionState(context: "activateVoiceSession")
|
||||
let ins = session.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
|
||||
try AVAudioSession.sharedInstance().setActive(true, options: [])
|
||||
NSLog("chanora_flutter: AVAudioSession activated on foreground")
|
||||
// Read back the ACTUAL session state. preferredSampleRate /
|
||||
// preferredIOBufferDuration are hints; iOS may pick something
|
||||
// else depending on hardware + currently-engaged effects.
|
||||
// Without these we can't tell whether VPIO is running at
|
||||
// 48 kHz mono (what our render callback assumes) or at e.g.
|
||||
// 44.1 kHz (which would explain the user's broken playback
|
||||
// \u2014 our render callback would be writing samples at the
|
||||
// wrong rate, causing pitch + timing artifacts).
|
||||
logAudioSessionState(context: "setActive")
|
||||
let s = AVAudioSession.sharedInstance()
|
||||
let ins = s.currentRoute.inputs.map { $0.portType.rawValue }.joined(separator: ",")
|
||||
NSLog(
|
||||
"chanora_flutter: voice session active: " +
|
||||
"sampleRate=\(session.sampleRate) " +
|
||||
"ioBufferDuration=\(String(format: "%.4f", session.ioBufferDuration)) " +
|
||||
"inputs=[\(ins)] outputVolume=\(session.outputVolume)"
|
||||
"chanora_flutter: AVAudioSession actual: " +
|
||||
"sampleRate=\(s.sampleRate) " +
|
||||
"ioBufferDuration=\(String(format: "%.4f", s.ioBufferDuration)) " +
|
||||
"inputs=[\(ins)] " +
|
||||
"outputVolume=\(s.outputVolume)"
|
||||
)
|
||||
} catch {
|
||||
NSLog("chanora_flutter: activateVoiceSession failed: \(error)")
|
||||
NSLog("chanora_flutter: AVAudioSession setActive failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Deactivate the VoIP audio session and return to the idle
|
||||
/// .ambient baseline. Called from Dart on `BridgeEvent::AudioStopped`
|
||||
/// (intentional leave, disconnect, or connection lost).
|
||||
/// `.notifyOthersOnDeactivation` lets other audio apps know they
|
||||
/// can resume — best-effort: Apple Music / Podcasts resume
|
||||
/// reliably, Spotify is not guaranteed.
|
||||
private func deactivateVoiceSession() {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setActive(false, options: [.notifyOthersOnDeactivation])
|
||||
} catch {
|
||||
NSLog("chanora_flutter: deactivateVoiceSession setActive(false) failed: \(error)")
|
||||
}
|
||||
do {
|
||||
try session.setCategory(.ambient, mode: .default)
|
||||
} catch {
|
||||
NSLog("chanora_flutter: deactivateVoiceSession setCategory(.ambient) failed: \(error)")
|
||||
}
|
||||
voiceSessionActive = false
|
||||
logAudioSessionState(context: "deactivateVoiceSession")
|
||||
}
|
||||
|
||||
/// Reads back the actual AVAudioSession state and logs it for
|
||||
/// SDD-098 compliance. Called after both setCategory and setActive
|
||||
/// to verify that the session accepted the requested configuration.
|
||||
@@ -196,30 +219,25 @@ import AVFoundation
|
||||
}
|
||||
|
||||
@objc private func handleMediaServicesReset(_ notification: Notification) {
|
||||
NSLog("chanora_flutter: media services reset voiceActive=\(voiceSessionActive)")
|
||||
if voiceSessionActive {
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .voiceChat,
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP, .mixWithOthers]
|
||||
)
|
||||
try session.setPreferredIOBufferDuration(0.02)
|
||||
try session.setPreferredSampleRate(48000.0)
|
||||
try session.setActive(true, options: [])
|
||||
logAudioSessionState(context: "mediaServicesWereReset-voip")
|
||||
} catch {
|
||||
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
|
||||
}
|
||||
} else {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default)
|
||||
logAudioSessionState(context: "mediaServicesWereReset-ambient")
|
||||
} catch {
|
||||
NSLog("chanora_flutter: AVAudioSession media-services reset ambient restore failed: \(error)")
|
||||
}
|
||||
NSLog("chanora_flutter: media services reset")
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .voiceChat,
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
||||
)
|
||||
try session.setPreferredIOBufferDuration(0.02)
|
||||
try session.setPreferredSampleRate(48000.0)
|
||||
try session.setActive(true, options: [])
|
||||
logAudioSessionState(context: "mediaServicesWereReset")
|
||||
} catch {
|
||||
NSLog("chanora_flutter: AVAudioSession media-services reset rebuild failed: \(error)")
|
||||
}
|
||||
// P1: After rebuilding the session, send the current route class to
|
||||
// Rust so it can recompute the processing policy and reset the
|
||||
// AudioUnit. The Rust side handles this via ios_handle_media_services_reset
|
||||
// which calls ios_restart_voice_unit.
|
||||
let routeClass = classifyAudioRoute(AVAudioSession.sharedInstance().currentRoute)
|
||||
NSLog("chanora_flutter: media services reset complete, route=\(routeClass)")
|
||||
iosAudioLifecycleChannel?.invokeMethod("handleMediaServicesReset", arguments: routeClass)
|
||||
@@ -251,26 +269,6 @@ import AVFoundation
|
||||
name: "chanora/ios_platform",
|
||||
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||
)
|
||||
iosAudioSessionChannel = FlutterMethodChannel(
|
||||
name: "chanora/ios_audio_session",
|
||||
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||
)
|
||||
iosAudioSessionChannel?.setMethodCallHandler { [weak self] call, result in
|
||||
guard let self = self else {
|
||||
result(FlutterError(code: "delegate_gone", message: "AppDelegate deallocated", details: nil))
|
||||
return
|
||||
}
|
||||
switch call.method {
|
||||
case "activateVoiceSession":
|
||||
self.activateVoiceSession()
|
||||
result(nil)
|
||||
case "deactivateVoiceSession":
|
||||
self.deactivateVoiceSession()
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
iosPlatformChannel?.setMethodCallHandler { call, result in
|
||||
switch call.method {
|
||||
case "getMicrophonePermissionState":
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<true/>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
|
||||
@@ -22,10 +22,8 @@ import 'l10n/generated/app_localizations.dart';
|
||||
import 'services/android_permissions_service.dart';
|
||||
import 'services/app_bootstrap.dart';
|
||||
import 'services/audio_lifecycle_service.dart';
|
||||
import 'services/ios_audio_session_controller.dart';
|
||||
import 'services/channel_join_error_mapper.dart';
|
||||
import 'services/connection_phase_state.dart';
|
||||
import 'services/hard_mute_owners.dart';
|
||||
import 'services/ios_permissions_service.dart';
|
||||
import 'services/macos_permissions_service.dart';
|
||||
import 'services/prefetch_debouncer.dart';
|
||||
@@ -340,9 +338,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
bool _inChannel = false;
|
||||
rust.BridgeTransmitMode _transmitMode = rust.BridgeTransmitMode.ptt;
|
||||
bool _hardMute = false;
|
||||
HardMuteOwners _hardMuteOwners = const HardMuteOwners();
|
||||
bool get _hardMuteByPermission => _hardMuteOwners.permission;
|
||||
bool get _hardMuteByTalkPower => _hardMuteOwners.talkPower;
|
||||
bool _hardMuteByPermission = false;
|
||||
bool _hardMuteByTalkPower = false;
|
||||
bool _permissionHardMuteClearInFlight = false;
|
||||
int _releaseTailMs = 200;
|
||||
BigInt? _currentVoiceChannelId;
|
||||
@@ -592,8 +589,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
await rust.setHardMute(muted: false);
|
||||
if (!mounted || !_hardMuteByPermission) return;
|
||||
setState(() {
|
||||
_hardMuteOwners = _hardMuteOwners.copyWith(permission: false);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
_hardMute = false;
|
||||
_hardMuteByPermission = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
@@ -768,10 +765,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_resetConnectionUiState(phase: ConnectionPhase.disconnected);
|
||||
});
|
||||
case rust.BridgeEvent_AudioStarted():
|
||||
unawaited(iosAudioSessionController.activate());
|
||||
_ensureStatsTimer();
|
||||
case rust.BridgeEvent_AudioStopped():
|
||||
unawaited(iosAudioSessionController.deactivate());
|
||||
_statsTimer?.cancel();
|
||||
_statsTimer = null;
|
||||
case rust.BridgeEvent_PttCapability(
|
||||
@@ -800,8 +795,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_voiceStateInitialized = true;
|
||||
_inChannel = inChannel;
|
||||
_transmitMode = transmitMode;
|
||||
_hardMuteOwners = _hardMuteOwners.withBridgeManualMute(mute);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
_hardMute = mute;
|
||||
if (!mute) _hardMuteByPermission = false;
|
||||
_releaseTailMs = releaseTailMs;
|
||||
_currentVoiceChannelId = currentChannelId;
|
||||
_pendingVoiceChannelId = pendingTargetChannelId;
|
||||
@@ -1273,8 +1268,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
await rust.setHardMute(muted: true);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hardMuteOwners = _hardMuteOwners.copyWith(permission: true);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
_hardMute = true;
|
||||
_hardMuteByPermission = true;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
@@ -1289,8 +1284,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
await rust.setHardMute(muted: false);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hardMuteOwners = _hardMuteOwners.copyWith(permission: false);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
_hardMute = false;
|
||||
_hardMuteByPermission = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1358,14 +1353,13 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
final next = !_hardMute;
|
||||
final previousInputMuted = _inputMuted;
|
||||
final previousHardMute = _hardMute;
|
||||
final previousHardMuteOwners = _hardMuteOwners;
|
||||
final previousPermissionMute = _hardMuteByPermission;
|
||||
setState(() {
|
||||
_inputMuted = next;
|
||||
_hardMuteOwners = _hardMuteOwners.copyWith(
|
||||
manual: next,
|
||||
permission: next ? false : null,
|
||||
);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
_hardMute = next;
|
||||
if (next) {
|
||||
_hardMuteByPermission = false;
|
||||
}
|
||||
});
|
||||
try {
|
||||
// Hard-mute is two coordinated effects:
|
||||
@@ -1384,7 +1378,7 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
setState(() {
|
||||
_inputMuted = previousInputMuted;
|
||||
_hardMute = previousHardMute;
|
||||
_hardMuteOwners = previousHardMuteOwners;
|
||||
_hardMuteByPermission = previousPermissionMute;
|
||||
});
|
||||
_showUiError('hard mute', e);
|
||||
}
|
||||
@@ -1654,7 +1648,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
_inputMuted = false;
|
||||
_outputMuted = false;
|
||||
_hardMute = false;
|
||||
_hardMuteOwners = const HardMuteOwners();
|
||||
_hardMuteByPermission = false;
|
||||
_hardMuteByTalkPower = false;
|
||||
_inChannel = false;
|
||||
_currentVoiceChannelId = null;
|
||||
_pendingVoiceChannelId = null;
|
||||
@@ -2085,8 +2080,8 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
|
||||
if (!own.talkPowerOk && !_hardMuteByTalkPower) {
|
||||
final talkPowerEpoch = _connectionEpoch;
|
||||
_hardMuteOwners = _hardMuteOwners.copyWith(talkPower: true);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
_hardMuteByTalkPower = true;
|
||||
_hardMute = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted ||
|
||||
_connectionEpoch != talkPowerEpoch ||
|
||||
@@ -2099,14 +2094,15 @@ class _BetaHomeState extends State<_BetaHome> with WidgetsBindingObserver {
|
||||
});
|
||||
} else if (own.talkPowerOk && _hardMuteByTalkPower) {
|
||||
final talkPowerEpoch = _connectionEpoch;
|
||||
_hardMuteOwners = _hardMuteOwners.copyWith(talkPower: false);
|
||||
_hardMute = _hardMuteOwners.effective;
|
||||
if (!_hardMuteOwners.effective) {
|
||||
_hardMuteByTalkPower = false;
|
||||
if (!_hardMuteByPermission) {
|
||||
_hardMute = false;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted ||
|
||||
_connectionEpoch != talkPowerEpoch ||
|
||||
!_serverReachable ||
|
||||
_hardMuteOwners.effective) {
|
||||
_hardMuteByTalkPower ||
|
||||
_hardMuteByPermission) {
|
||||
return;
|
||||
}
|
||||
unawaited(rust.setHardMute(muted: false));
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
class HardMuteOwners {
|
||||
const HardMuteOwners({
|
||||
this.manual = false,
|
||||
this.permission = false,
|
||||
this.talkPower = false,
|
||||
});
|
||||
|
||||
final bool manual;
|
||||
final bool permission;
|
||||
final bool talkPower;
|
||||
|
||||
bool get effective => manual || permission || talkPower;
|
||||
|
||||
HardMuteOwners withBridgeManualMute(bool muted) {
|
||||
return copyWith(
|
||||
manual: muted && (manual || !permission && !talkPower),
|
||||
);
|
||||
}
|
||||
|
||||
HardMuteOwners copyWith({
|
||||
bool? manual,
|
||||
bool? permission,
|
||||
bool? talkPower,
|
||||
}) {
|
||||
return HardMuteOwners(
|
||||
manual: manual ?? this.manual,
|
||||
permission: permission ?? this.permission,
|
||||
talkPower: talkPower ?? this.talkPower,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
const iosAudioSessionChannelName = 'chanora/ios_audio_session';
|
||||
|
||||
/// Controls the iOS AVAudioSession VoIP lifecycle from Dart.
|
||||
///
|
||||
/// The Swift `AppDelegate` configures the session to `.ambient` at
|
||||
/// launch and leaves it inactive. The session is only switched to
|
||||
/// `.playAndRecord` + `.voiceChat` (with `.mixWithOthers`) while a
|
||||
/// voice channel is actually active. This controller is the Dart
|
||||
/// side of that contract — call [activate] when the Rust engine
|
||||
/// emits `BridgeEvent::AudioStarted` and [deactivate] on
|
||||
/// `BridgeEvent::AudioStopped`.
|
||||
///
|
||||
/// On non-iOS platforms both methods are no-ops; the platforms
|
||||
/// handle their own session lifecycle elsewhere (Android via
|
||||
/// `AndroidAudioLifecycleController`, macOS via
|
||||
/// `MacOSAudioLifecycle`, desktop has no exclusive session).
|
||||
class IosAudioSessionController {
|
||||
IosAudioSessionController({
|
||||
MethodChannel? channel,
|
||||
bool? isIos,
|
||||
}) : _channel = channel ?? const MethodChannel(iosAudioSessionChannelName),
|
||||
_isIos = isIos ?? Platform.isIOS;
|
||||
|
||||
final MethodChannel _channel;
|
||||
final bool _isIos;
|
||||
|
||||
Future<void> activate() async {
|
||||
if (!_isIos) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('activateVoiceSession');
|
||||
} on PlatformException {
|
||||
// Swift side logs the failure via NSLog; surfacing the
|
||||
// exception to the event handler would be noise. The Rust
|
||||
// engine remains alive and will produce silence until the
|
||||
// next route change or a manual leave/rejoin.
|
||||
} on MissingPluginException {
|
||||
// Test hosts and mispackaged builds may not have registered
|
||||
// the iOS channel. Keep event dispatch alive rather than
|
||||
// surfacing an unhandled async error.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deactivate() async {
|
||||
if (!_isIos) return;
|
||||
try {
|
||||
await _channel.invokeMethod<void>('deactivateVoiceSession');
|
||||
} on PlatformException {
|
||||
// Same rationale as activate(): the Swift side logs.
|
||||
// Worst case the session stays in .playAndRecord until the
|
||||
// app is backgrounded — at which point iOS reclaims the
|
||||
// session automatically.
|
||||
} on MissingPluginException {
|
||||
// Same rationale as activate(): missing channel should not
|
||||
// break bridge event handling.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Default singleton used by [main.dart] event dispatch. Tests
|
||||
/// should construct their own [IosAudioSessionController] with a
|
||||
/// mocked channel rather than mutating this instance.
|
||||
final iosAudioSessionController = IosAudioSessionController();
|
||||
@@ -318,15 +318,6 @@ class _VoicePttButtonState extends State<VoicePttButton> {
|
||||
playVoicePttHaptic(held);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_pressed) {
|
||||
_pressed = false;
|
||||
widget.onHeldChanged(false);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<true/>
|
||||
<false/>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/hard_mute_owners.dart';
|
||||
|
||||
void main() {
|
||||
group('HardMuteOwners', () {
|
||||
test('manual mute survives talk-power block and restore', () {
|
||||
const owners = HardMuteOwners(manual: true);
|
||||
|
||||
final blocked = owners.copyWith(talkPower: true);
|
||||
expect(blocked.effective, isTrue);
|
||||
|
||||
final restored = blocked.copyWith(talkPower: false);
|
||||
expect(restored.manual, isTrue);
|
||||
expect(restored.talkPower, isFalse);
|
||||
expect(restored.effective, isTrue);
|
||||
});
|
||||
|
||||
test('effective mute is the union of independent owners', () {
|
||||
expect(const HardMuteOwners().effective, isFalse);
|
||||
expect(const HardMuteOwners(manual: true).effective, isTrue);
|
||||
expect(const HardMuteOwners(permission: true).effective, isTrue);
|
||||
expect(const HardMuteOwners(talkPower: true).effective, isTrue);
|
||||
});
|
||||
|
||||
test('bridge mute does not convert talk-power owner into manual owner', () {
|
||||
const owners = HardMuteOwners(talkPower: true);
|
||||
|
||||
final synced = owners.withBridgeManualMute(true);
|
||||
|
||||
expect(synced.manual, isFalse);
|
||||
expect(synced.talkPower, isTrue);
|
||||
expect(synced.effective, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/services/ios_audio_session_controller.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('IosAudioSessionController', () {
|
||||
const channel = MethodChannel(iosAudioSessionChannelName);
|
||||
final messenger = TestDefaultBinaryMessengerBinding
|
||||
.instance.defaultBinaryMessenger;
|
||||
|
||||
tearDown(() {
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
});
|
||||
|
||||
test('channel name matches Swift contract', () {
|
||||
expect(iosAudioSessionChannelName, 'chanora/ios_audio_session');
|
||||
});
|
||||
|
||||
test('activate invokes activateVoiceSession on iOS', () async {
|
||||
final calls = <MethodCall>[];
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return null;
|
||||
});
|
||||
|
||||
final controller = IosAudioSessionController(
|
||||
channel: channel,
|
||||
isIos: true,
|
||||
);
|
||||
await controller.activate();
|
||||
|
||||
expect(calls.map((c) => c.method), ['activateVoiceSession']);
|
||||
expect(calls.single.arguments, isNull);
|
||||
});
|
||||
|
||||
test('deactivate invokes deactivateVoiceSession on iOS', () async {
|
||||
final calls = <MethodCall>[];
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return null;
|
||||
});
|
||||
|
||||
final controller = IosAudioSessionController(
|
||||
channel: channel,
|
||||
isIos: true,
|
||||
);
|
||||
await controller.deactivate();
|
||||
|
||||
expect(calls.map((c) => c.method), ['deactivateVoiceSession']);
|
||||
expect(calls.single.arguments, isNull);
|
||||
});
|
||||
|
||||
test('activate is a no-op on non-iOS platforms', () async {
|
||||
var invoked = false;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
invoked = true;
|
||||
return null;
|
||||
});
|
||||
|
||||
final controller = IosAudioSessionController(
|
||||
channel: channel,
|
||||
isIos: false,
|
||||
);
|
||||
await controller.activate();
|
||||
await controller.deactivate();
|
||||
|
||||
expect(invoked, isFalse);
|
||||
});
|
||||
|
||||
test('activate swallows PlatformException so engine keeps running',
|
||||
() async {
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
throw PlatformException(code: 'avaudiosession_failed');
|
||||
});
|
||||
|
||||
final controller = IosAudioSessionController(
|
||||
channel: channel,
|
||||
isIos: true,
|
||||
);
|
||||
|
||||
await expectLater(controller.activate(), completes);
|
||||
await expectLater(controller.deactivate(), completes);
|
||||
});
|
||||
|
||||
test('activate swallows MissingPluginException when channel is absent',
|
||||
() async {
|
||||
final controller = IosAudioSessionController(
|
||||
channel: channel,
|
||||
isIos: true,
|
||||
);
|
||||
|
||||
await expectLater(controller.activate(), completes);
|
||||
await expectLater(controller.deactivate(), completes);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chanora_flutter/l10n/generated/app_localizations.dart';
|
||||
import 'package:chanora_flutter/widgets/voice_compact.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('touch PTT releases when disposed while held', (tester) async {
|
||||
final heldChanges = <bool>[];
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: AppL10n.localizationsDelegates,
|
||||
supportedLocales: AppL10n.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: VoicePttButton(
|
||||
active: false,
|
||||
onHeldChanged: heldChanges.add,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final center = tester.getCenter(find.byType(VoicePttButton));
|
||||
final gesture = await tester.startGesture(center);
|
||||
await tester.pump();
|
||||
|
||||
expect(heldChanges, [true]);
|
||||
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold()));
|
||||
|
||||
expect(heldChanges, [true, false]);
|
||||
|
||||
await gesture.cancel();
|
||||
|
||||
expect(heldChanges, [true, false]);
|
||||
});
|
||||
}
|
||||
@@ -18,7 +18,7 @@ chanora_diagnostics = { path = "../../crates/chanora_diagnostics" }
|
||||
chanora_prefetch = { path = "../../crates/chanora_prefetch" }
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
tokio = { version = "1", features = ["sync", "rt", "macros", "time"] }
|
||||
tokio = { version = "1", features = ["sync", "rt", "macros"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Used by integration tests to inspect the bookmark DB row layout
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
use chanora_audio::{AudioRoute, PttBackendDescriptor};
|
||||
use chanora_protocol::MessageTarget;
|
||||
|
||||
/// Privacy-safe snapshot of the active PTT capability.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PttDescriptorSnapshot {
|
||||
/// Stable capability level name.
|
||||
pub level: String,
|
||||
/// Stable backend identifier.
|
||||
pub backend_id: String,
|
||||
/// Coarse bound input class; empty when no binding is active.
|
||||
pub bound_input_class: String,
|
||||
}
|
||||
|
||||
impl From<PttBackendDescriptor> for PttDescriptorSnapshot {
|
||||
fn from(desc: PttBackendDescriptor) -> Self {
|
||||
Self {
|
||||
level: desc.level.as_str().to_string(),
|
||||
backend_id: desc.backend_id.to_string(),
|
||||
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted PTT binding state exposed to callers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PersistedPttBinding {
|
||||
/// Stable input category string (`""`, `"keyboard"`, or
|
||||
/// `"mouse-side-button"`).
|
||||
pub input_class: String,
|
||||
/// Display-only key label; empty when no binding is active.
|
||||
pub key_label: String,
|
||||
}
|
||||
|
||||
impl PersistedPttBinding {
|
||||
pub(crate) fn empty() -> Self {
|
||||
Self {
|
||||
input_class: String::new(),
|
||||
key_label: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// High-level lifecycle event surfaced to subscribers.
|
||||
///
|
||||
/// This is the minimal set needed for A.6 (reconnect banner). The
|
||||
/// full event catalogue lands in A.4.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SessionEvent {
|
||||
/// Initial connect succeeded, or reconnect attempt succeeded.
|
||||
Connected {
|
||||
/// Server name reported in the snapshot.
|
||||
server_name: String,
|
||||
},
|
||||
/// Connection lost; the supervisor will retry.
|
||||
Lost {
|
||||
/// Reason classification from the protocol layer.
|
||||
reason: String,
|
||||
},
|
||||
/// Supervisor is sleeping before its next reconnect attempt.
|
||||
Reconnecting {
|
||||
/// 1-based attempt counter for the current outage.
|
||||
attempt: u32,
|
||||
/// Seconds the supervisor will sleep before this attempt.
|
||||
delay_secs: u32,
|
||||
},
|
||||
/// Supervisor gave up after `attempt` failed retries (or the
|
||||
/// user explicitly disconnected mid-outage).
|
||||
Disconnected {
|
||||
/// Reason classification from the protocol layer.
|
||||
reason: String,
|
||||
},
|
||||
/// Audio engine started (e.g. after a successful reconnect with
|
||||
/// reattachment).
|
||||
AudioStarted,
|
||||
/// Audio engine stopped (e.g. before a reconnect cycle, or by
|
||||
/// explicit user action).
|
||||
AudioStopped,
|
||||
/// Detected desktop Push-to-Talk capability (gen2 v0.9.3 /
|
||||
/// DEC-023..028). Published when the audio engine starts or
|
||||
/// when the active backend transitions (for example macOS
|
||||
/// permission state change). Carries only the privacy-safe
|
||||
/// descriptor — capability level, backend identifier, bound
|
||||
/// input class — per SRS-202 / DEC-027.
|
||||
PttCapability {
|
||||
/// Stable level name from `PttCapabilityLevel::as_str()`.
|
||||
level: String,
|
||||
/// Stable backend identifier (e.g. `"focused"`).
|
||||
backend_id: String,
|
||||
/// Coarse bound input class (e.g. `"keyboard"`); empty when
|
||||
/// no binding is active.
|
||||
bound_input_class: String,
|
||||
},
|
||||
/// Voice subsystem state snapshot (SDD-094). Emitted on
|
||||
/// `voice_join` / `voice_leave`, transmit-mode changes,
|
||||
/// hard-mute toggles, and release-tail edits.
|
||||
VoiceState {
|
||||
/// True when the user has joined a voice channel via
|
||||
/// `voice_join` and the audio engine is running.
|
||||
in_channel: bool,
|
||||
/// Active transmit mode encoded as
|
||||
/// [`chanora_audio::TransmitMode::as_u8`].
|
||||
transmit_mode: u8,
|
||||
/// True when the hard-mute clamp is engaged.
|
||||
mute: bool,
|
||||
/// Current release-tail in milliseconds (0..=500).
|
||||
release_tail_ms: u32,
|
||||
/// Last confirmed authoritative channel id from the
|
||||
/// `channel_join` reducer projection.
|
||||
current_channel_id: Option<u64>,
|
||||
/// Non-authoritative pending target channel id from the
|
||||
/// reducer projection.
|
||||
pending_target_channel_id: Option<u64>,
|
||||
/// Whether the reducer currently allows a new join intent.
|
||||
can_join: bool,
|
||||
/// Whether the reducer currently allows leave intent.
|
||||
can_leave: bool,
|
||||
/// Join projection synchronization state.
|
||||
join_sync_state: VoiceJoinSyncState,
|
||||
/// Last stable sanitized join error code, if any.
|
||||
join_error_code: Option<VoiceJoinErrorCode>,
|
||||
},
|
||||
/// iOS audio-session interruption state (SDD-101). Emitted when
|
||||
/// interruption begins and when it ends (with the platform hint
|
||||
/// indicating whether audio should resume).
|
||||
InterruptionState {
|
||||
/// True when interruption began, false when interruption ended.
|
||||
began: bool,
|
||||
/// Platform-provided resume hint. For begin events this is false.
|
||||
should_resume: bool,
|
||||
},
|
||||
/// A text message was received from the server.
|
||||
ChatMessage {
|
||||
/// Client id of the sender.
|
||||
sender_id: u64,
|
||||
/// Nickname of the sender.
|
||||
sender_name: String,
|
||||
/// Message content.
|
||||
message: String,
|
||||
/// Target scope (server/channel/private/poke).
|
||||
target: MessageTarget,
|
||||
},
|
||||
/// Human-readable TeamSpeak-style server activity.
|
||||
ServerActivity {
|
||||
/// Activity line text.
|
||||
message: String,
|
||||
},
|
||||
/// Audio route changed (speaker/earpiece/BT/wired headset).
|
||||
AudioRouteChanged {
|
||||
/// New audio output route.
|
||||
route: AudioRoute,
|
||||
},
|
||||
/// A client moved to a different channel.
|
||||
ClientMoved {
|
||||
/// Unique client identifier.
|
||||
client_id: u64,
|
||||
/// Destination channel.
|
||||
new_channel_id: u64,
|
||||
},
|
||||
/// A new client connected.
|
||||
ClientJoined {
|
||||
/// Unique client identifier.
|
||||
client_id: u64,
|
||||
/// Channel the client joined.
|
||||
channel_id: u64,
|
||||
/// Display nickname.
|
||||
name: String,
|
||||
/// Microphone muted state.
|
||||
input_muted: bool,
|
||||
/// Speaker muted state.
|
||||
output_muted: bool,
|
||||
/// Whether this is a server query (bot) client.
|
||||
is_server_query: bool,
|
||||
/// Client's talk power value.
|
||||
talk_power: i32,
|
||||
/// Whether the server granted temporary talk power.
|
||||
talk_power_granted: bool,
|
||||
},
|
||||
/// A client disconnected.
|
||||
ClientLeft {
|
||||
/// Unique client identifier.
|
||||
client_id: u64,
|
||||
/// Display nickname at time of disconnect.
|
||||
name: String,
|
||||
},
|
||||
/// Client properties changed.
|
||||
ClientUpdated {
|
||||
/// Unique client identifier.
|
||||
client_id: u64,
|
||||
/// Microphone muted state.
|
||||
input_muted: bool,
|
||||
/// Speaker muted state.
|
||||
output_muted: bool,
|
||||
/// Whether this is a server query (bot) client.
|
||||
is_server_query: bool,
|
||||
/// Client's talk power value.
|
||||
talk_power: i32,
|
||||
/// Whether the server granted temporary talk power.
|
||||
talk_power_granted: bool,
|
||||
},
|
||||
/// A new channel appeared.
|
||||
ChannelAdded {
|
||||
/// Unique channel identifier.
|
||||
id: u64,
|
||||
/// Parent channel ID.
|
||||
parent: u64,
|
||||
/// Channel name.
|
||||
name: String,
|
||||
/// Predecessor channel ID within the same parent (TeamSpeak
|
||||
/// linked-list ordering hint). Zero means first child.
|
||||
order: i64,
|
||||
/// Whether the channel requires a password.
|
||||
has_password: bool,
|
||||
/// Talk power required to speak, or `None` when unrestricted.
|
||||
needed_talk_power: Option<i32>,
|
||||
},
|
||||
/// A channel was deleted.
|
||||
ChannelRemoved {
|
||||
/// Channel identifier.
|
||||
id: u64,
|
||||
},
|
||||
/// Channel properties changed.
|
||||
ChannelUpdated {
|
||||
/// Unique channel identifier.
|
||||
id: u64,
|
||||
/// Channel name.
|
||||
name: String,
|
||||
/// Whether the channel requires a password.
|
||||
has_password: bool,
|
||||
/// Talk power required to speak, or `None` when unrestricted.
|
||||
needed_talk_power: Option<i32>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Bridge-safe mirror of channel-join projection sync state.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum VoiceJoinSyncState {
|
||||
/// Reducer is ready to accept channel actions.
|
||||
Ready,
|
||||
/// Reducer is synchronizing against an initial snapshot.
|
||||
SynchronizingInitialSnapshot,
|
||||
/// Reducer is synchronizing after reconnect.
|
||||
SynchronizingReconnect,
|
||||
}
|
||||
|
||||
/// Bridge-safe mirror of stable channel-join error codes.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum VoiceJoinErrorCode {
|
||||
/// Duplicate same-target join intent was coalesced.
|
||||
DuplicateSameTargetCoalesced,
|
||||
/// A different target was requested while one is already pending.
|
||||
JoinAlreadyPendingDifferentTarget,
|
||||
/// Join denied by server policy/permission.
|
||||
JoinDenied,
|
||||
/// Join failed due to protocol-level error.
|
||||
JoinProtocolFailure,
|
||||
/// Join failed due to transport/network error.
|
||||
JoinNetworkFailure,
|
||||
/// Join timed out awaiting confirmation.
|
||||
JoinTimeout,
|
||||
/// Pending join was superseded by user leave.
|
||||
JoinSupersededByLeave,
|
||||
/// Stale join outcome was ignored.
|
||||
JoinStaleOutcomeIgnored,
|
||||
/// Authoritative membership reconciled to different channel.
|
||||
JoinReconciledDifferentChannel,
|
||||
/// Join command was rejected before send acceptance.
|
||||
JoinCommandRejectedBeforeSend,
|
||||
/// Join intent rejected while reducer synchronizing.
|
||||
JoinCannotStartWhileSynchronizing,
|
||||
}
|
||||
|
||||
/// Coarse OS-reported network state. Populated by the Flutter side
|
||||
/// via `connectivity_plus`; on platforms where no signal is wired
|
||||
/// we stay at `Unknown` forever and the supervisor falls back to
|
||||
/// pure watchdog/backoff behaviour.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NetworkState {
|
||||
/// No signal seen yet — treat as ambiguous; don't change behaviour.
|
||||
Unknown,
|
||||
/// OS reports at least one network with internet capability.
|
||||
Online,
|
||||
/// OS reports no networks available.
|
||||
Offline,
|
||||
}
|
||||
+341
-145
@@ -52,8 +52,6 @@ use chanora_state::channel_join::{
|
||||
ConnectionEpoch, JoinFailureKind,
|
||||
};
|
||||
|
||||
mod events;
|
||||
mod network_diagnostics;
|
||||
pub mod ptt;
|
||||
|
||||
pub use chanora_audio::{
|
||||
@@ -71,11 +69,46 @@ pub use chanora_protocol::{
|
||||
MessageTarget, ProtocolError, ServerActivity, ServerSnapshot,
|
||||
};
|
||||
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};
|
||||
pub use events::{
|
||||
NetworkState, PersistedPttBinding, PttDescriptorSnapshot, SessionEvent, VoiceJoinErrorCode,
|
||||
VoiceJoinSyncState,
|
||||
};
|
||||
use network_diagnostics::NetworkDiagnostics;
|
||||
|
||||
/// Privacy-safe snapshot of the active PTT capability.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PttDescriptorSnapshot {
|
||||
/// Stable capability level name.
|
||||
pub level: String,
|
||||
/// Stable backend identifier.
|
||||
pub backend_id: String,
|
||||
/// Coarse bound input class; empty when no binding is active.
|
||||
pub bound_input_class: String,
|
||||
}
|
||||
|
||||
impl From<PttBackendDescriptor> for PttDescriptorSnapshot {
|
||||
fn from(desc: PttBackendDescriptor) -> Self {
|
||||
Self {
|
||||
level: desc.level.as_str().to_string(),
|
||||
backend_id: desc.backend_id.to_string(),
|
||||
bound_input_class: desc.bound_input_class.unwrap_or("").to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted PTT binding state exposed to callers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PersistedPttBinding {
|
||||
/// Stable input category string (`""`, `"keyboard"`, or
|
||||
/// `"mouse-side-button"`).
|
||||
pub input_class: String,
|
||||
/// Display-only key label; empty when no binding is active.
|
||||
pub key_label: String,
|
||||
}
|
||||
|
||||
impl PersistedPttBinding {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
input_class: String::new(),
|
||||
key_label: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can arise during top-level orchestration.
|
||||
#[derive(Debug, Error)]
|
||||
@@ -114,11 +147,291 @@ pub enum CoreError {
|
||||
Ptt(#[from] ptt::PttControllerError),
|
||||
}
|
||||
|
||||
/// High-level lifecycle event surfaced to subscribers.
|
||||
///
|
||||
/// This is the minimal set needed for A.6 (reconnect banner). The
|
||||
/// full event catalogue lands in A.4.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SessionEvent {
|
||||
/// Initial connect succeeded, or reconnect attempt succeeded.
|
||||
Connected {
|
||||
/// Server name reported in the snapshot.
|
||||
server_name: String,
|
||||
},
|
||||
/// Connection lost; the supervisor will retry.
|
||||
Lost {
|
||||
/// Reason classification from the protocol layer.
|
||||
reason: String,
|
||||
},
|
||||
/// Supervisor is sleeping before its next reconnect attempt.
|
||||
Reconnecting {
|
||||
/// 1-based attempt counter for the current outage.
|
||||
attempt: u32,
|
||||
/// Seconds the supervisor will sleep before this attempt.
|
||||
delay_secs: u32,
|
||||
},
|
||||
/// Supervisor gave up after `attempt` failed retries (or the
|
||||
/// user explicitly disconnected mid-outage).
|
||||
Disconnected {
|
||||
/// Reason classification from the protocol layer.
|
||||
reason: String,
|
||||
},
|
||||
/// Audio engine started (e.g. after a successful reconnect with
|
||||
/// reattachment).
|
||||
AudioStarted,
|
||||
/// Audio engine stopped (e.g. before a reconnect cycle, or by
|
||||
/// explicit user action).
|
||||
AudioStopped,
|
||||
/// Detected desktop Push-to-Talk capability (gen2 v0.9.3 /
|
||||
/// DEC-023..028). Published when the audio engine starts or
|
||||
/// when the active backend transitions (for example macOS
|
||||
/// permission state change). Carries only the privacy-safe
|
||||
/// descriptor — capability level, backend identifier, bound
|
||||
/// input class — per SRS-202 / DEC-027.
|
||||
PttCapability {
|
||||
/// Stable level name from `PttCapabilityLevel::as_str()`.
|
||||
level: String,
|
||||
/// Stable backend identifier (e.g. `"focused"`).
|
||||
backend_id: String,
|
||||
/// Coarse bound input class (e.g. `"keyboard"`); empty when
|
||||
/// no binding is active.
|
||||
bound_input_class: String,
|
||||
},
|
||||
/// Voice subsystem state snapshot (SDD-094). Emitted on
|
||||
/// `voice_join` / `voice_leave`, transmit-mode changes,
|
||||
/// hard-mute toggles, and release-tail edits.
|
||||
VoiceState {
|
||||
/// True when the user has joined a voice channel via
|
||||
/// `voice_join` and the audio engine is running.
|
||||
in_channel: bool,
|
||||
/// Active transmit mode encoded as
|
||||
/// [`chanora_audio::TransmitMode::as_u8`].
|
||||
transmit_mode: u8,
|
||||
/// True when the hard-mute clamp is engaged.
|
||||
mute: bool,
|
||||
/// Current release-tail in milliseconds (0..=500).
|
||||
release_tail_ms: u32,
|
||||
/// Last confirmed authoritative channel id from the
|
||||
/// `channel_join` reducer projection.
|
||||
current_channel_id: Option<u64>,
|
||||
/// Non-authoritative pending target channel id from the
|
||||
/// reducer projection.
|
||||
pending_target_channel_id: Option<u64>,
|
||||
/// Whether the reducer currently allows a new join intent.
|
||||
can_join: bool,
|
||||
/// Whether the reducer currently allows leave intent.
|
||||
can_leave: bool,
|
||||
/// Join projection synchronization state.
|
||||
join_sync_state: VoiceJoinSyncState,
|
||||
/// Last stable sanitized join error code, if any.
|
||||
join_error_code: Option<VoiceJoinErrorCode>,
|
||||
},
|
||||
/// iOS audio-session interruption state (SDD-101). Emitted when
|
||||
/// interruption begins and when it ends (with the platform hint
|
||||
/// indicating whether audio should resume).
|
||||
InterruptionState {
|
||||
/// True when interruption began, false when interruption ended.
|
||||
began: bool,
|
||||
/// Platform-provided resume hint. For begin events this is false.
|
||||
should_resume: bool,
|
||||
},
|
||||
/// A text message was received from the server.
|
||||
ChatMessage {
|
||||
/// Client id of the sender.
|
||||
sender_id: u64,
|
||||
/// Nickname of the sender.
|
||||
sender_name: String,
|
||||
/// Message content.
|
||||
message: String,
|
||||
/// Target scope (server/channel/private/poke).
|
||||
target: MessageTarget,
|
||||
},
|
||||
/// Human-readable TeamSpeak-style server activity.
|
||||
ServerActivity {
|
||||
/// Activity line text.
|
||||
message: String,
|
||||
},
|
||||
/// Audio route changed (speaker/earpiece/BT/wired headset).
|
||||
AudioRouteChanged {
|
||||
/// New audio output route.
|
||||
route: AudioRoute,
|
||||
},
|
||||
/// A client moved to a different channel.
|
||||
ClientMoved {
|
||||
/// Unique client identifier.
|
||||
client_id: u64,
|
||||
/// Destination channel.
|
||||
new_channel_id: u64,
|
||||
},
|
||||
/// A new client connected.
|
||||
ClientJoined {
|
||||
/// Unique client identifier.
|
||||
client_id: u64,
|
||||
/// Channel the client joined.
|
||||
channel_id: u64,
|
||||
/// Display nickname.
|
||||
name: String,
|
||||
/// Microphone muted state.
|
||||
input_muted: bool,
|
||||
/// Speaker muted state.
|
||||
output_muted: bool,
|
||||
/// Whether this is a server query (bot) client.
|
||||
is_server_query: bool,
|
||||
/// Client's talk power value.
|
||||
talk_power: i32,
|
||||
/// Whether the server granted temporary talk power.
|
||||
talk_power_granted: bool,
|
||||
},
|
||||
/// A client disconnected.
|
||||
ClientLeft {
|
||||
/// Unique client identifier.
|
||||
client_id: u64,
|
||||
/// Display nickname at time of disconnect.
|
||||
name: String,
|
||||
},
|
||||
/// Client properties changed.
|
||||
ClientUpdated {
|
||||
/// Unique client identifier.
|
||||
client_id: u64,
|
||||
/// Microphone muted state.
|
||||
input_muted: bool,
|
||||
/// Speaker muted state.
|
||||
output_muted: bool,
|
||||
/// Whether this is a server query (bot) client.
|
||||
is_server_query: bool,
|
||||
/// Client's talk power value.
|
||||
talk_power: i32,
|
||||
/// Whether the server granted temporary talk power.
|
||||
talk_power_granted: bool,
|
||||
},
|
||||
/// A new channel appeared.
|
||||
ChannelAdded {
|
||||
/// Unique channel identifier.
|
||||
id: u64,
|
||||
/// Parent channel ID.
|
||||
parent: u64,
|
||||
/// Channel name.
|
||||
name: String,
|
||||
/// Predecessor channel ID within the same parent (TeamSpeak
|
||||
/// linked-list ordering hint). Zero means first child.
|
||||
order: i64,
|
||||
/// Whether the channel requires a password.
|
||||
has_password: bool,
|
||||
/// Talk power required to speak, or `None` when unrestricted.
|
||||
needed_talk_power: Option<i32>,
|
||||
},
|
||||
/// A channel was deleted.
|
||||
ChannelRemoved {
|
||||
/// Channel identifier.
|
||||
id: u64,
|
||||
},
|
||||
/// Channel properties changed.
|
||||
ChannelUpdated {
|
||||
/// Unique channel identifier.
|
||||
id: u64,
|
||||
/// Channel name.
|
||||
name: String,
|
||||
/// Whether the channel requires a password.
|
||||
has_password: bool,
|
||||
/// Talk power required to speak, or `None` when unrestricted.
|
||||
needed_talk_power: Option<i32>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Bridge-safe mirror of channel-join projection sync state.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum VoiceJoinSyncState {
|
||||
/// Reducer is ready to accept channel actions.
|
||||
Ready,
|
||||
/// Reducer is synchronizing against an initial snapshot.
|
||||
SynchronizingInitialSnapshot,
|
||||
/// Reducer is synchronizing after reconnect.
|
||||
SynchronizingReconnect,
|
||||
}
|
||||
|
||||
/// Bridge-safe mirror of stable channel-join error codes.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum VoiceJoinErrorCode {
|
||||
/// Duplicate same-target join intent was coalesced.
|
||||
DuplicateSameTargetCoalesced,
|
||||
/// A different target was requested while one is already pending.
|
||||
JoinAlreadyPendingDifferentTarget,
|
||||
/// Join denied by server policy/permission.
|
||||
JoinDenied,
|
||||
/// Join failed due to protocol-level error.
|
||||
JoinProtocolFailure,
|
||||
/// Join failed due to transport/network error.
|
||||
JoinNetworkFailure,
|
||||
/// Join timed out awaiting confirmation.
|
||||
JoinTimeout,
|
||||
/// Pending join was superseded by user leave.
|
||||
JoinSupersededByLeave,
|
||||
/// Stale join outcome was ignored.
|
||||
JoinStaleOutcomeIgnored,
|
||||
/// Authoritative membership reconciled to different channel.
|
||||
JoinReconciledDifferentChannel,
|
||||
/// Join command was rejected before send acceptance.
|
||||
JoinCommandRejectedBeforeSend,
|
||||
/// Join intent rejected while reducer synchronizing.
|
||||
JoinCannotStartWhileSynchronizing,
|
||||
}
|
||||
|
||||
/// Coarse OS-reported network state. Populated by the Flutter side
|
||||
/// via `connectivity_plus`; on platforms where no signal is wired
|
||||
/// we stay at `Unknown` forever and the supervisor falls back to
|
||||
/// pure watchdog/backoff behaviour.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NetworkState {
|
||||
/// No signal seen yet — treat as ambiguous; don't change behaviour.
|
||||
Unknown,
|
||||
/// OS reports at least one network with internet capability.
|
||||
Online,
|
||||
/// OS reports no networks available.
|
||||
Offline,
|
||||
}
|
||||
|
||||
/// Channel capacity for the broadcast events. Generous because
|
||||
/// reconnect cycles emit several events per attempt; if subscribers
|
||||
/// fall behind we'd rather skip than block the supervisor.
|
||||
const EVENT_CHANNEL_CAPACITY: usize = 64;
|
||||
|
||||
/// Network diagnostics snapshot collected across connection lifetimes.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct NetworkDiagnostics {
|
||||
/// Total count of connects (including the initial one).
|
||||
connect_count: u64,
|
||||
/// Count of disconnects (graceful + loss).
|
||||
disconnect_count: u64,
|
||||
/// Recent loss reasons (last 8, ring buffer).
|
||||
loss_reasons: Vec<String>,
|
||||
}
|
||||
|
||||
impl NetworkDiagnostics {
|
||||
fn record_connect(&mut self) {
|
||||
self.connect_count = self.connect_count.saturating_add(1);
|
||||
}
|
||||
fn record_loss(&mut self, reason: &str) {
|
||||
self.disconnect_count = self.disconnect_count.saturating_add(1);
|
||||
if self.loss_reasons.len() >= 8 {
|
||||
self.loss_reasons.remove(0);
|
||||
}
|
||||
self.loss_reasons.push(reason.to_string());
|
||||
}
|
||||
fn summary(&self) -> String {
|
||||
let mut s = format!(
|
||||
"connects: {}\ndisconnects: {}\n",
|
||||
self.connect_count, self.disconnect_count
|
||||
);
|
||||
if !self.loss_reasons.is_empty() {
|
||||
s.push_str(&format!(
|
||||
"loss_reasons: [{}]\n",
|
||||
self.loss_reasons.join(", ")
|
||||
));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
struct SupervisorInner {
|
||||
/// Optional cached AudioEngineConfig — set when start_audio is
|
||||
/// first called, used to re-create the engine after a reconnect.
|
||||
@@ -164,10 +477,6 @@ struct ConnectedState {
|
||||
local_output_muted: bool,
|
||||
}
|
||||
|
||||
async fn take_disconnect_state<T>(inner: &Arc<Mutex<Option<T>>>) -> Option<T> {
|
||||
inner.lock().await.take()
|
||||
}
|
||||
|
||||
fn normalize_channel_password(password: Option<String>) -> Option<String> {
|
||||
password
|
||||
.map(|p| p.trim().to_string())
|
||||
@@ -1569,7 +1878,8 @@ impl ChanoraSession {
|
||||
|
||||
/// Disconnect from the server. No-op if not connected.
|
||||
pub async fn disconnect(&self) -> Result<(), CoreError> {
|
||||
if let Some(mut state) = take_disconnect_state(&self.inner).await {
|
||||
let mut guard = self.inner.lock().await;
|
||||
if let Some(mut state) = guard.take() {
|
||||
// Signal the supervisor to exit (cancels any backoff sleep).
|
||||
if let Some(tx) = state.cancel_tx.take() {
|
||||
let _ = tx.send(());
|
||||
@@ -1585,7 +1895,7 @@ impl ChanoraSession {
|
||||
// Wait for the supervisor to wind down so we don't race
|
||||
// a redial against the explicit disconnect.
|
||||
if let Some(handle) = state.supervisor.take() {
|
||||
await_supervisor_shutdown(handle, SUPERVISOR_SHUTDOWN_TIMEOUT).await;
|
||||
let _ = handle.await;
|
||||
}
|
||||
let _ = self.events_tx.send(SessionEvent::Disconnected {
|
||||
reason: "user requested".to_string(),
|
||||
@@ -1624,22 +1934,6 @@ const WATCHDOG_PROBE_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
/// Number of consecutive watchdog failures before the supervisor
|
||||
/// declares the connection lost.
|
||||
const WATCHDOG_MAX_MISSES: u32 = 3;
|
||||
const SUPERVISOR_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
|
||||
async fn await_supervisor_shutdown(mut handle: JoinHandle<()>, timeout_duration: Duration) {
|
||||
if tokio::time::timeout(timeout_duration, &mut handle)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!(
|
||||
target: "chanora_core",
|
||||
timeout_ms = timeout_duration.as_millis() as u64,
|
||||
"supervisor did not stop before shutdown timeout"
|
||||
);
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
}
|
||||
}
|
||||
|
||||
struct SupervisorContext {
|
||||
state_arc: Arc<Mutex<Option<ConnectedState>>>,
|
||||
@@ -1695,77 +1989,27 @@ fn spawn_event_forwarders(
|
||||
let mut rx = delta_rx;
|
||||
while let Some(delta) = rx.recv().await {
|
||||
let event = match delta {
|
||||
ProtocolDelta::ClientMoved {
|
||||
client_id,
|
||||
new_channel_id,
|
||||
} => SessionEvent::ClientMoved {
|
||||
client_id,
|
||||
new_channel_id,
|
||||
},
|
||||
ProtocolDelta::ClientJoined {
|
||||
client_id,
|
||||
channel_id,
|
||||
name,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
} => SessionEvent::ClientJoined {
|
||||
client_id,
|
||||
channel_id,
|
||||
name,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
},
|
||||
ProtocolDelta::ClientMoved { client_id, new_channel_id } => {
|
||||
SessionEvent::ClientMoved { client_id, new_channel_id }
|
||||
}
|
||||
ProtocolDelta::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
||||
SessionEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
||||
}
|
||||
ProtocolDelta::ClientLeft { client_id, name } => {
|
||||
SessionEvent::ClientLeft { client_id, name }
|
||||
}
|
||||
ProtocolDelta::ClientUpdated {
|
||||
client_id,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
} => SessionEvent::ClientUpdated {
|
||||
client_id,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
},
|
||||
ProtocolDelta::ChannelAdded {
|
||||
id,
|
||||
parent,
|
||||
name,
|
||||
order,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
} => SessionEvent::ChannelAdded {
|
||||
id,
|
||||
parent,
|
||||
name,
|
||||
order,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
},
|
||||
ProtocolDelta::ChannelRemoved { id } => SessionEvent::ChannelRemoved { id },
|
||||
ProtocolDelta::ChannelUpdated {
|
||||
id,
|
||||
name,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
} => SessionEvent::ChannelUpdated {
|
||||
id,
|
||||
name,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
},
|
||||
ProtocolDelta::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
||||
SessionEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
||||
}
|
||||
ProtocolDelta::ChannelAdded { id, parent, name, order, has_password, needed_talk_power } => {
|
||||
SessionEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power }
|
||||
}
|
||||
ProtocolDelta::ChannelRemoved { id } => {
|
||||
SessionEvent::ChannelRemoved { id }
|
||||
}
|
||||
ProtocolDelta::ChannelUpdated { id, name, has_password, needed_talk_power } => {
|
||||
SessionEvent::ChannelUpdated { id, name, has_password, needed_talk_power }
|
||||
}
|
||||
};
|
||||
let _ = ev_tx.send(event);
|
||||
}
|
||||
@@ -2389,54 +2633,6 @@ mod tests {
|
||||
s.disconnect().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnect_state_take_releases_inner_lock_before_teardown() {
|
||||
let inner = Arc::new(Mutex::new(Some(())));
|
||||
|
||||
let state = super::take_disconnect_state(&inner).await;
|
||||
|
||||
assert_eq!(state, Some(()));
|
||||
assert!(inner.try_lock().is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn supervisor_join_returns_after_shutdown_timeout() {
|
||||
let handle = tokio::spawn(async {
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
super::await_supervisor_shutdown(handle, Duration::from_millis(10)).await;
|
||||
|
||||
assert!(start.elapsed() < Duration::from_millis(100));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn supervisor_shutdown_timeout_aborts_pending_task() {
|
||||
struct DropNotice(Option<tokio::sync::oneshot::Sender<()>>);
|
||||
|
||||
impl Drop for DropNotice {
|
||||
fn drop(&mut self) {
|
||||
if let Some(tx) = self.0.take() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
|
||||
let handle = tokio::spawn(async move {
|
||||
let _notice = DropNotice(Some(dropped_tx));
|
||||
std::future::pending::<()>().await;
|
||||
});
|
||||
|
||||
super::await_supervisor_shutdown(handle, Duration::from_millis(10)).await;
|
||||
|
||||
tokio::time::timeout(Duration::from_millis(100), dropped_rx)
|
||||
.await
|
||||
.expect("pending supervisor task should be aborted")
|
||||
.expect("drop notice should be delivered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_detects_in_channel_move() {
|
||||
use chanora_protocol::{ChannelInfo, ClientInfo};
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Network diagnostics snapshot collected across connection lifetimes.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub(crate) struct NetworkDiagnostics {
|
||||
/// Total count of connects (including the initial one).
|
||||
connect_count: u64,
|
||||
/// Count of disconnects (graceful + loss).
|
||||
disconnect_count: u64,
|
||||
/// Recent loss reasons (last 8, ring buffer).
|
||||
loss_reasons: VecDeque<String>,
|
||||
}
|
||||
|
||||
impl NetworkDiagnostics {
|
||||
pub(crate) fn record_connect(&mut self) {
|
||||
self.connect_count = self.connect_count.saturating_add(1);
|
||||
}
|
||||
|
||||
pub(crate) fn record_loss(&mut self, reason: &str) {
|
||||
self.disconnect_count = self.disconnect_count.saturating_add(1);
|
||||
if self.loss_reasons.len() >= 8 {
|
||||
self.loss_reasons.pop_front();
|
||||
}
|
||||
self.loss_reasons.push_back(reason.to_string());
|
||||
}
|
||||
|
||||
pub(crate) fn summary(&self) -> String {
|
||||
let mut s = format!(
|
||||
"connects: {}\ndisconnects: {}\n",
|
||||
self.connect_count, self.disconnect_count
|
||||
);
|
||||
if !self.loss_reasons.is_empty() {
|
||||
s.push_str(&format!(
|
||||
"loss_reasons: [{}]\n",
|
||||
self.loss_reasons
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn network_diagnostics_keeps_last_eight_loss_reasons() {
|
||||
let mut diagnostics = NetworkDiagnostics::default();
|
||||
|
||||
for i in 0..10 {
|
||||
diagnostics.record_loss(&format!("loss-{i}"));
|
||||
}
|
||||
|
||||
assert_eq!(diagnostics.disconnect_count, 10);
|
||||
assert_eq!(diagnostics.loss_reasons.len(), 8);
|
||||
assert_eq!(
|
||||
diagnostics.loss_reasons.front().map(String::as_str),
|
||||
Some("loss-2")
|
||||
);
|
||||
assert_eq!(
|
||||
diagnostics.loss_reasons.back().map(String::as_str),
|
||||
Some("loss-9")
|
||||
);
|
||||
assert!(diagnostics.summary().contains(
|
||||
"loss_reasons: [loss-2, loss-3, loss-4, loss-5, loss-6, loss-7, loss-8, loss-9]"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crossbeam::queue::ArrayQueue;
|
||||
|
||||
/// Fixed-capacity PCM handoff from the Android render producer task to
|
||||
/// the Oboe output callback.
|
||||
pub(crate) struct AndroidRenderRing {
|
||||
frames: Arc<ArrayQueue<[f32; 2]>>,
|
||||
}
|
||||
|
||||
impl AndroidRenderRing {
|
||||
pub(crate) fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
frames: Arc::new(ArrayQueue::new((capacity / 2).max(1))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn producer(&self) -> AndroidRenderRingProducer {
|
||||
AndroidRenderRingProducer {
|
||||
frames: Arc::clone(&self.frames),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn consumer(&self) -> AndroidRenderRingConsumer {
|
||||
AndroidRenderRingConsumer {
|
||||
frames: Arc::clone(&self.frames),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct AndroidRenderRingProducer {
|
||||
frames: Arc<ArrayQueue<[f32; 2]>>,
|
||||
}
|
||||
|
||||
impl AndroidRenderRingProducer {
|
||||
pub(crate) fn push_frame_lossy(&self, samples: &[f32]) {
|
||||
for frame in samples.chunks_exact(2) {
|
||||
let stereo_frame = [frame[0], frame[1]];
|
||||
if self.frames.push(stereo_frame).is_err() {
|
||||
let _ = self.frames.pop();
|
||||
let _ = self.frames.push(stereo_frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct AndroidRenderRingConsumer {
|
||||
frames: Arc<ArrayQueue<[f32; 2]>>,
|
||||
}
|
||||
|
||||
impl AndroidRenderRingConsumer {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn drain_into_zero_filling(&self, out: &mut [f32]) {
|
||||
let mut chunks = out.chunks_exact_mut(2);
|
||||
for frame_out in &mut chunks {
|
||||
let frame = self.frames.pop().unwrap_or([0.0, 0.0]);
|
||||
frame_out.copy_from_slice(&frame);
|
||||
}
|
||||
for sample in chunks.into_remainder() {
|
||||
*sample = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn drain_stereo_into_zero_filling(&self, out: &mut [(f32, f32)]) {
|
||||
for frame_out in out {
|
||||
let frame = self.frames.pop().unwrap_or([0.0, 0.0]);
|
||||
*frame_out = (frame[0], frame[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn producer_drops_oldest_samples_when_ring_is_full() {
|
||||
let ring = AndroidRenderRing::new(4);
|
||||
let producer = ring.producer();
|
||||
let consumer = ring.consumer();
|
||||
|
||||
producer.push_frame_lossy(&[1.0, 2.0, 3.0, 4.0]);
|
||||
producer.push_frame_lossy(&[5.0, 6.0]);
|
||||
|
||||
let mut out = [0.0; 4];
|
||||
consumer.drain_into_zero_filling(&mut out);
|
||||
|
||||
assert_eq!(out, [3.0, 4.0, 5.0, 6.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflow_after_partial_consumer_drain_preserves_stereo_pairing() {
|
||||
let ring = AndroidRenderRing::new(4);
|
||||
let producer = ring.producer();
|
||||
let consumer = ring.consumer();
|
||||
|
||||
producer.push_frame_lossy(&[1.0, 10.0, 2.0, 20.0]);
|
||||
|
||||
let mut odd_out = [9.0];
|
||||
consumer.drain_into_zero_filling(&mut odd_out);
|
||||
assert_eq!(odd_out, [0.0]);
|
||||
|
||||
producer.push_frame_lossy(&[3.0, 30.0]);
|
||||
|
||||
let mut out = [0.0; 4];
|
||||
consumer.drain_into_zero_filling(&mut out);
|
||||
|
||||
assert_eq!(out, [2.0, 20.0, 3.0, 30.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumer_zero_fills_tail_on_underrun() {
|
||||
let ring = AndroidRenderRing::new(4);
|
||||
let producer = ring.producer();
|
||||
let consumer = ring.consumer();
|
||||
|
||||
producer.push_frame_lossy(&[0.25, -0.25]);
|
||||
|
||||
let mut out = [9.0; 4];
|
||||
consumer.drain_into_zero_filling(&mut out);
|
||||
|
||||
assert_eq!(out, [0.25, -0.25, 0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ use crate::mobile_voice_backend::{
|
||||
BackendEventTx, EffectEngagement, EffectEngine, InputPresetChoice, MobileVoiceAudioBackend,
|
||||
SharingModeChoice, VoiceAudioParams,
|
||||
};
|
||||
use chanora_protocol::OutPacket;
|
||||
use tsclientlib::audio::AudioHandler;
|
||||
|
||||
use crate::{engine::SessionAudioId, AudioError};
|
||||
@@ -85,11 +86,40 @@ use crate::processor::AudioProcessor;
|
||||
|
||||
const RENDER_REF_SLOTS: usize = 4;
|
||||
const RENDER_REF_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES;
|
||||
const ANDROID_RENDER_PULL_SAMPLES: usize = crate::frame::FRAME_20MS_SAMPLES * 2;
|
||||
const ANDROID_RENDER_RING_CAPACITY: usize = ANDROID_RENDER_PULL_SAMPLES * 5;
|
||||
|
||||
type RenderReferenceBuffer =
|
||||
crate::render_reference::RenderReferenceBuffer<RENDER_REF_SAMPLES, RENDER_REF_SLOTS>;
|
||||
struct RenderReferenceBuffer {
|
||||
buf: Box<[[f32; RENDER_REF_SAMPLES]; RENDER_REF_SLOTS]>,
|
||||
write_idx: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl RenderReferenceBuffer {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
buf: Box::new([[0.0_f32; RENDER_REF_SAMPLES]; RENDER_REF_SLOTS]),
|
||||
write_idx: std::sync::atomic::AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
fn write(&self, frame: &[f32; RENDER_REF_SAMPLES]) {
|
||||
let idx = self.write_idx.load(Ordering::Relaxed);
|
||||
unsafe {
|
||||
let slot = &self.buf[idx] as *const [f32; RENDER_REF_SAMPLES]
|
||||
as *mut [f32; RENDER_REF_SAMPLES];
|
||||
(*slot).copy_from_slice(frame);
|
||||
}
|
||||
self.write_idx
|
||||
.store((idx + 1) % RENDER_REF_SLOTS, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn read_latest(&self) -> [f32; RENDER_REF_SAMPLES] {
|
||||
let wi = self.write_idx.load(Ordering::Relaxed);
|
||||
let ri = (wi + RENDER_REF_SLOTS - 1) % RENDER_REF_SLOTS;
|
||||
self.buf[ri]
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl Send for RenderReferenceBuffer {}
|
||||
unsafe impl Sync for RenderReferenceBuffer {}
|
||||
|
||||
// --- Capture state for Oboe input callback (SDD-111 / SDD-120) ----
|
||||
//
|
||||
@@ -108,8 +138,9 @@ struct AndroidCaptureState {
|
||||
encoder: OpusEncoder,
|
||||
pcm_accum: Vec<i16>,
|
||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||
@@ -133,7 +164,7 @@ struct AndroidCaptureState {
|
||||
|
||||
impl AndroidCaptureState {
|
||||
fn new(
|
||||
voice_out_tx: mpsc::Sender<chanora_protocol::OutPacket>,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
@@ -157,12 +188,9 @@ impl AndroidCaptureState {
|
||||
encoder,
|
||||
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: crate::opus_voice::start_out_packet_worker(
|
||||
voice_out_tx,
|
||||
frames_sent.clone(),
|
||||
"android",
|
||||
)?,
|
||||
voice_out_tx,
|
||||
transmit_active,
|
||||
frames_sent,
|
||||
mic_gain,
|
||||
voice_activity_selector,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||
@@ -193,10 +221,8 @@ impl AndroidCaptureState {
|
||||
self.audio_processing_stats
|
||||
.record_callback_frames(samples.len() as u64);
|
||||
if self.input_sample_rate_hz != crate::frame::SAMPLE_RATE_HZ {
|
||||
self.resample_capture_to_48k(samples);
|
||||
let resampled = std::mem::take(&mut self.resample_scratch);
|
||||
let resampled = self.resample_capture_to_48k(samples);
|
||||
self.ingest_48k_i16(&resampled);
|
||||
self.resample_scratch = resampled;
|
||||
return;
|
||||
}
|
||||
self.ingest_48k_i16(samples);
|
||||
@@ -215,7 +241,6 @@ impl AndroidCaptureState {
|
||||
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||
let frame = self.pending_10ms;
|
||||
self.process_10ms_capture_frame(&frame);
|
||||
self.encode_complete_20ms_frames();
|
||||
self.pending_10ms_len = 0;
|
||||
}
|
||||
}
|
||||
@@ -225,10 +250,6 @@ impl AndroidCaptureState {
|
||||
return;
|
||||
}
|
||||
|
||||
self.encode_complete_20ms_frames();
|
||||
}
|
||||
|
||||
fn encode_complete_20ms_frames(&mut self) {
|
||||
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
||||
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
|
||||
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
|
||||
@@ -237,6 +258,7 @@ impl AndroidCaptureState {
|
||||
Ok(len) => {
|
||||
crate::opus_voice::send_voip_frame(
|
||||
&self.voice_out_tx,
|
||||
&self.frames_sent,
|
||||
&self.opus_out,
|
||||
len,
|
||||
|| {
|
||||
@@ -264,18 +286,35 @@ impl AndroidCaptureState {
|
||||
}
|
||||
}
|
||||
|
||||
fn resample_capture_to_48k(&mut self, samples: &[i16]) -> usize {
|
||||
let result = crate::capture_resampler::resample_capture_to_48k(
|
||||
samples,
|
||||
self.input_sample_rate_hz,
|
||||
&mut self.resample_pos,
|
||||
&mut self.resample_last,
|
||||
&mut self.resample_scratch,
|
||||
);
|
||||
if result.dropped {
|
||||
self.audio_processing_stats.increment_callback_xrun();
|
||||
fn resample_capture_to_48k(&mut self, samples: &[i16]) -> Vec<i16> {
|
||||
if samples.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
result.output_len
|
||||
self.resample_scratch.clear();
|
||||
let ratio = self.input_sample_rate_hz as f64 / crate::frame::SAMPLE_RATE_HZ as f64;
|
||||
let mut pos = self.resample_pos;
|
||||
while pos < samples.len() as f64 {
|
||||
let i = pos.floor() as isize;
|
||||
let frac = pos - i as f64;
|
||||
let a = if i <= 0 {
|
||||
self.resample_last as f64
|
||||
} else {
|
||||
samples[(i - 1) as usize] as f64
|
||||
};
|
||||
let b = if i < samples.len() as isize {
|
||||
samples[i as usize] as f64
|
||||
} else {
|
||||
a
|
||||
};
|
||||
let value = (a + frac * (b - a))
|
||||
.round()
|
||||
.clamp(i16::MIN as f64, i16::MAX as f64) as i16;
|
||||
self.resample_scratch.push(value);
|
||||
pos += ratio;
|
||||
}
|
||||
self.resample_pos = pos - samples.len() as f64;
|
||||
self.resample_last = *samples.last().unwrap_or(&self.resample_last);
|
||||
self.resample_scratch.clone()
|
||||
}
|
||||
|
||||
fn set_input_sample_rate_hz(&mut self, sample_rate_hz: u32) {
|
||||
@@ -354,9 +393,15 @@ impl AndroidCaptureState {
|
||||
self.fallback_warned_backend = None;
|
||||
match vad_backend {
|
||||
crate::VadBackend::SileroOnnx => {
|
||||
self.silero_vad_worker = None;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
let path = crate::vad::silero_model_bundle_path();
|
||||
self.silero_vad_worker =
|
||||
crate::vad::silero_onnx::SileroOnnxVadWorker::try_new(&path);
|
||||
if self.silero_vad_worker.is_none() {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
"android: Silero VAD model not found at {path}; falling back to WebRTC VAD"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
self.silero_vad_worker = None;
|
||||
@@ -375,38 +420,20 @@ impl AndroidCaptureState {
|
||||
speech: true,
|
||||
}
|
||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
match crate::vad::callback_vad_worker_policy(
|
||||
voice_activity_mode,
|
||||
vad_backend,
|
||||
self.silero_vad_worker.is_some(),
|
||||
) {
|
||||
crate::vad::VadWorkerPolicy::UseWorker => {
|
||||
let worker = self
|
||||
.silero_vad_worker
|
||||
.as_ref()
|
||||
.expect("policy checked worker");
|
||||
let enqueued = worker.try_send(capture_seq, &frame);
|
||||
if !worker.is_stale(capture_seq) {
|
||||
let p = worker.latest_probability();
|
||||
crate::vad::VadOutput {
|
||||
probability: p,
|
||||
speech: p >= 0.5,
|
||||
}
|
||||
} else if enqueued {
|
||||
crate::vad::VadOutput {
|
||||
probability: 0.0,
|
||||
speech: false,
|
||||
}
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(vad_backend);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
&mut self.vad_detector,
|
||||
&frame,
|
||||
)
|
||||
if let Some(worker) = self.silero_vad_worker.as_ref() {
|
||||
let enqueued = worker.try_send(capture_seq, &frame);
|
||||
if !worker.is_stale(capture_seq) {
|
||||
let p = worker.latest_probability();
|
||||
crate::vad::VadOutput {
|
||||
probability: p,
|
||||
speech: p >= 0.5,
|
||||
}
|
||||
}
|
||||
crate::vad::VadWorkerPolicy::UseFallback => {
|
||||
} else if enqueued {
|
||||
crate::vad::VadOutput {
|
||||
probability: 0.0,
|
||||
speech: false,
|
||||
}
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(vad_backend);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
@@ -414,10 +441,10 @@ impl AndroidCaptureState {
|
||||
&frame,
|
||||
)
|
||||
}
|
||||
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
||||
probability: 1.0,
|
||||
speech: true,
|
||||
},
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(vad_backend);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
}
|
||||
} else {
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
@@ -445,19 +472,21 @@ impl AndroidCaptureState {
|
||||
return;
|
||||
}
|
||||
|
||||
if crate::capture_accumulator::append_processed_i16_bounded(
|
||||
&mut self.pcm_accum,
|
||||
&frame,
|
||||
self.mic_gain,
|
||||
) {
|
||||
self.audio_processing_stats.increment_callback_xrun();
|
||||
let gain = self.mic_gain;
|
||||
if (gain - 1.0).abs() < f32::EPSILON {
|
||||
self.pcm_accum
|
||||
.extend(frame.iter().copied().map(crate::frame::f32_to_i16));
|
||||
} else {
|
||||
self.pcm_accum.extend(frame.iter().copied().map(|s| {
|
||||
let scaled = (crate::frame::f32_to_i16(s) as f32) * gain;
|
||||
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct InputCallback {
|
||||
state: Arc<Mutex<AndroidCaptureState>>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
event_tx: BackendEventTx,
|
||||
}
|
||||
|
||||
@@ -469,13 +498,9 @@ impl AudioInputCallback for InputCallback {
|
||||
_stream: &mut dyn AudioInputStreamSafe,
|
||||
frames: &[i16],
|
||||
) -> DataCallbackResult {
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| match self.state.try_lock() {
|
||||
Ok(mut s) => s.ingest_i16(frames),
|
||||
Err(std::sync::TryLockError::WouldBlock) => {
|
||||
self.audio_processing_stats.increment_callback_xrun();
|
||||
}
|
||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||||
warn!(target: "chanora_audio", "android: capture state poisoned: {e}");
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||
if let Ok(mut s) = self.state.lock() {
|
||||
s.ingest_i16(frames);
|
||||
}
|
||||
}));
|
||||
DataCallbackResult::Continue
|
||||
@@ -497,7 +522,8 @@ impl AudioInputCallback for InputCallback {
|
||||
// writes stereo f32 directly to the Oboe output buffer.
|
||||
|
||||
struct OutputCallback {
|
||||
pcm_consumer: crate::android_render_ring::AndroidRenderRingConsumer,
|
||||
handler: AudioHandler<SessionAudioId>,
|
||||
event_consumer: crate::audio_event_queue::AudioEventConsumer,
|
||||
output_gain: Arc<AtomicU32>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
event_tx: BackendEventTx,
|
||||
@@ -516,39 +542,47 @@ impl AudioOutputCallback for OutputCallback {
|
||||
frames: &mut [(f32, f32)],
|
||||
) -> DataCallbackResult {
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||
self.pcm_consumer.drain_stereo_into_zero_filling(frames);
|
||||
let buf: &mut [f32] =
|
||||
bytemuck::cast_slice_mut::<(f32, f32), f32>(frames);
|
||||
for s in buf.iter_mut() {
|
||||
*s = 0.0;
|
||||
}
|
||||
for cmd in self.event_consumer.drain_controls() {
|
||||
match cmd {
|
||||
AudioCommand::SetVolume(id, vol) => {
|
||||
if let Some(q) = self.handler.get_mut_queues().get_mut(&id) {
|
||||
q.volume = vol;
|
||||
}
|
||||
}
|
||||
AudioCommand::RemoveClient(id) => {
|
||||
self.handler.get_mut_queues().remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for pkt in self.event_consumer.drain_packets(50) {
|
||||
if let Err(e) = self.handler.handle_packet(pkt.client_id, pkt.data) {
|
||||
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = self.handler.fill_buffer(buf);
|
||||
let gain = f32::from_bits(self.output_gain.load(Ordering::Relaxed));
|
||||
let muted = self.output_muted.load(Ordering::Relaxed);
|
||||
if muted {
|
||||
for frame in frames.iter_mut() {
|
||||
*frame = (0.0, 0.0);
|
||||
for s in buf.iter_mut() {
|
||||
*s = 0.0;
|
||||
}
|
||||
} else if gain != 1.0 {
|
||||
for (left, right) in frames.iter_mut() {
|
||||
*left *= gain;
|
||||
*right *= gain;
|
||||
for s in buf.iter_mut() {
|
||||
*s *= gain;
|
||||
}
|
||||
}
|
||||
let mut sum_squares = 0.0_f32;
|
||||
for (left, right) in frames.iter() {
|
||||
sum_squares += left * left + right * right;
|
||||
}
|
||||
let sample_count = frames.len() * 2;
|
||||
let dbfs = if sample_count == 0 {
|
||||
-120.0
|
||||
} else {
|
||||
let rms = (sum_squares / sample_count as f32).sqrt();
|
||||
if rms <= 0.000_001 {
|
||||
-120.0
|
||||
} else {
|
||||
20.0 * rms.log10()
|
||||
}
|
||||
};
|
||||
self.audio_processing_stats
|
||||
.update_render(dbfs, frames.len() as u32);
|
||||
.update_render(crate::frame::dbfs(buf), frames.len() as u32);
|
||||
|
||||
for (left, right) in frames.iter() {
|
||||
self.pending_render_ref[self.pending_render_ref_len] = (left + right) * 0.5;
|
||||
for chunk in buf.chunks_exact(2) {
|
||||
self.pending_render_ref[self.pending_render_ref_len] = (chunk[0] + chunk[1]) * 0.5;
|
||||
self.pending_render_ref_len += 1;
|
||||
if self.pending_render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||
self.render_reference.write(&self.pending_render_ref);
|
||||
@@ -580,7 +614,6 @@ impl AudioOutputCallback for OutputCallback {
|
||||
pub struct AndroidVoiceUnit {
|
||||
input: Option<AudioStreamAsync<OboeInput, InputCallback>>,
|
||||
output: Option<AudioStreamAsync<OboeOutput, OutputCallback>>,
|
||||
render_producer_shutdown: Arc<AtomicBool>,
|
||||
|
||||
// Recorded achieved values (SDD-112).
|
||||
input_perf: AchievedPerformanceMode,
|
||||
@@ -602,13 +635,11 @@ pub struct AndroidVoiceUnit {
|
||||
|
||||
#[derive(Default)]
|
||||
struct HardwareEffectHandles {
|
||||
aec: Option<AndroidGlobalObject>,
|
||||
ns: Option<AndroidGlobalObject>,
|
||||
agc: Option<AndroidGlobalObject>,
|
||||
aec: Option<jni::objects::GlobalRef>,
|
||||
ns: Option<jni::objects::GlobalRef>,
|
||||
agc: Option<jni::objects::GlobalRef>,
|
||||
}
|
||||
|
||||
type AndroidGlobalObject = jni::refs::Global<jni::objects::JObject<'static>>;
|
||||
|
||||
impl AndroidVoiceUnit {
|
||||
/// Open the input + output streams (SDD-111 + SDD-112) and,
|
||||
/// once a session id is available, attach SDD-113 hardware
|
||||
@@ -675,7 +706,6 @@ impl AndroidVoiceUnit {
|
||||
|
||||
let input_cb = InputCallback {
|
||||
state: capture_state.clone(),
|
||||
audio_processing_stats: audio_processing_stats.clone(),
|
||||
event_tx: event_tx.clone(),
|
||||
};
|
||||
let input_builder = input_builder.set_callback(input_cb);
|
||||
@@ -692,12 +722,7 @@ impl AndroidVoiceUnit {
|
||||
error = ?e,
|
||||
"android: primary input stream open failed; entering fallback ladder"
|
||||
);
|
||||
match Self::open_input_fallback(
|
||||
cfg,
|
||||
&event_tx,
|
||||
capture_state.clone(),
|
||||
audio_processing_stats.clone(),
|
||||
) {
|
||||
match Self::open_input_fallback(cfg, &event_tx, capture_state.clone()) {
|
||||
Ok(s) => Some(s),
|
||||
Err(fallback_err) => {
|
||||
warn!(
|
||||
@@ -764,10 +789,9 @@ impl AndroidVoiceUnit {
|
||||
|
||||
let render_ref_for_output = render_ref_buf.clone();
|
||||
let event_queue = params.event_producer.queue();
|
||||
let render_ring =
|
||||
crate::android_render_ring::AndroidRenderRing::new(ANDROID_RENDER_RING_CAPACITY);
|
||||
let output_cb = OutputCallback {
|
||||
pcm_consumer: render_ring.consumer(),
|
||||
handler: params.handler,
|
||||
event_consumer: AudioEventQueue::consumer(&event_queue),
|
||||
output_gain: params.output_gain.clone(),
|
||||
output_muted: params.output_muted.clone(),
|
||||
event_tx: event_tx.clone(),
|
||||
@@ -789,7 +813,8 @@ impl AndroidVoiceUnit {
|
||||
Self::open_output_fallback(
|
||||
cfg,
|
||||
&event_tx,
|
||||
render_ring.consumer(),
|
||||
AudioHandler::new(),
|
||||
AudioEventQueue::consumer(&event_queue),
|
||||
params.output_gain.clone(),
|
||||
params.output_muted.clone(),
|
||||
audio_processing_stats.clone(),
|
||||
@@ -797,11 +822,6 @@ impl AndroidVoiceUnit {
|
||||
)?
|
||||
}
|
||||
};
|
||||
let render_producer_shutdown = Self::spawn_render_producer(
|
||||
params.handler,
|
||||
AudioEventQueue::consumer(&event_queue),
|
||||
render_ring.producer(),
|
||||
);
|
||||
|
||||
let output_frames_per_burst = output_stream.get_frames_per_burst();
|
||||
if output_frames_per_burst > 0 {
|
||||
@@ -958,7 +978,6 @@ impl AndroidVoiceUnit {
|
||||
Ok(Self {
|
||||
input: input_stream,
|
||||
output: Some(output_stream),
|
||||
render_producer_shutdown,
|
||||
input_perf,
|
||||
input_share,
|
||||
output_perf,
|
||||
@@ -976,7 +995,6 @@ impl AndroidVoiceUnit {
|
||||
cfg: &AndroidVoiceStreamConfig,
|
||||
event_tx: &BackendEventTx,
|
||||
capture_state: Arc<Mutex<AndroidCaptureState>>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
) -> Result<AudioStreamAsync<OboeInput, InputCallback>, BackendError> {
|
||||
// SDD-112 items 6 & 7: explore (preset × sharing) independently
|
||||
// via the pure helpers in `mobile_voice_backend`. Primary
|
||||
@@ -1013,7 +1031,6 @@ impl AndroidVoiceUnit {
|
||||
};
|
||||
let cb = InputCallback {
|
||||
state: capture_state.clone(),
|
||||
audio_processing_stats: audio_processing_stats.clone(),
|
||||
event_tx: event_tx.clone(),
|
||||
};
|
||||
let builder = AudioStreamBuilder::default()
|
||||
@@ -1049,14 +1066,16 @@ impl AndroidVoiceUnit {
|
||||
fn open_output_fallback(
|
||||
cfg: &AndroidVoiceStreamConfig,
|
||||
event_tx: &BackendEventTx,
|
||||
pcm_consumer: crate::android_render_ring::AndroidRenderRingConsumer,
|
||||
handler: AudioHandler<SessionAudioId>,
|
||||
event_consumer: crate::audio_event_queue::AudioEventConsumer,
|
||||
output_gain: Arc<AtomicU32>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
render_reference: Arc<RenderReferenceBuffer>,
|
||||
) -> Result<AudioStreamAsync<OboeOutput, OutputCallback>, BackendError> {
|
||||
let cb = OutputCallback {
|
||||
pcm_consumer,
|
||||
handler,
|
||||
event_consumer,
|
||||
output_gain,
|
||||
output_muted,
|
||||
event_tx: event_tx.clone(),
|
||||
@@ -1081,50 +1100,6 @@ impl AndroidVoiceUnit {
|
||||
.map_err(|e| BackendError::OpenFailed(format!("output fallback: {e:?}")))
|
||||
}
|
||||
|
||||
fn spawn_render_producer(
|
||||
mut handler: AudioHandler<SessionAudioId>,
|
||||
event_consumer: crate::audio_event_queue::AudioEventConsumer,
|
||||
pcm_producer: crate::android_render_ring::AndroidRenderRingProducer,
|
||||
) -> Arc<AtomicBool> {
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let shutdown_for_task = shutdown.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut pull_scratch = vec![0.0_f32; ANDROID_RENDER_PULL_SAMPLES];
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if shutdown_for_task.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
for cmd in event_consumer.drain_controls() {
|
||||
match cmd {
|
||||
AudioCommand::SetVolume(id, vol) => {
|
||||
if let Some(q) = handler.get_mut_queues().get_mut(&id) {
|
||||
q.volume = vol;
|
||||
}
|
||||
}
|
||||
AudioCommand::RemoveClient(id) => {
|
||||
handler.get_mut_queues().remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for pkt in event_consumer.drain_packets(50) {
|
||||
if let Err(e) = handler.handle_packet(pkt.client_id, pkt.data) {
|
||||
debug!(target: "chanora_audio", error = %e, "decode failed");
|
||||
}
|
||||
}
|
||||
|
||||
pull_scratch.fill(0.0);
|
||||
let _ = handler.fill_buffer(&mut pull_scratch);
|
||||
pcm_producer.push_frame_lossy(&pull_scratch);
|
||||
}
|
||||
});
|
||||
shutdown
|
||||
}
|
||||
|
||||
/// Clone of the event sender, for JNI focus / SCO listeners
|
||||
/// registered on the engine's behalf.
|
||||
pub fn event_sender(&self) -> BackendEventTx {
|
||||
@@ -1176,7 +1151,6 @@ impl MobileVoiceAudioBackend for AndroidVoiceUnit {
|
||||
fn close(&mut self) -> Result<(), BackendError> {
|
||||
// SDD-115 reverse order: release hardware effects FIRST,
|
||||
// then close streams.
|
||||
self.render_producer_shutdown.store(true, Ordering::Relaxed);
|
||||
release_hardware_effects(&mut self.hw_effects);
|
||||
self.stop().ok();
|
||||
// Dropping the Option drops the underlying AudioStreamAsync
|
||||
@@ -1234,7 +1208,6 @@ impl Drop for AndroidVoiceUnit {
|
||||
// Wrap in catch_unwind so a panic during Drop cannot unwind
|
||||
// into the JVM (SDD-115 callback safety).
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| {
|
||||
self.render_producer_shutdown.store(true, Ordering::Relaxed);
|
||||
release_hardware_effects(&mut self.hw_effects);
|
||||
// SDD-116: clear the diagnostics slot on Drop too.
|
||||
clear_android_audio_diagnostics();
|
||||
@@ -1314,71 +1287,62 @@ fn attach_hardware_effects_inner(
|
||||
session_id: AudioSessionId,
|
||||
effects: &crate::AudioEffects,
|
||||
) -> HardwareEffectHandles {
|
||||
with_android_env("hardware effects", |env| {
|
||||
let mut handles = HardwareEffectHandles::default();
|
||||
if effects.aec {
|
||||
handles.aec = create_effect(
|
||||
env,
|
||||
"android/media/audiofx/AcousticEchoCanceler",
|
||||
session_id,
|
||||
"AEC",
|
||||
);
|
||||
}
|
||||
if effects.noise_suppression {
|
||||
handles.ns = create_effect(
|
||||
env,
|
||||
"android/media/audiofx/NoiseSuppressor",
|
||||
session_id,
|
||||
"NS",
|
||||
);
|
||||
}
|
||||
if effects.agc {
|
||||
handles.agc = create_effect(
|
||||
env,
|
||||
"android/media/audiofx/AutomaticGainControl",
|
||||
session_id,
|
||||
"AGC",
|
||||
);
|
||||
}
|
||||
handles
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn with_android_env<R>(
|
||||
operation: &str,
|
||||
op: impl for<'local> FnOnce(&mut jni::Env<'local>) -> R,
|
||||
) -> Option<R> {
|
||||
let ctx = ndk_context::android_context();
|
||||
if ctx.vm().is_null() {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
operation,
|
||||
"android: ndk_context vm null; JNI call skipped"
|
||||
"android: ndk_context vm null; cannot bind hardware effects (software fallback engages)"
|
||||
);
|
||||
return None;
|
||||
return HardwareEffectHandles::default();
|
||||
}
|
||||
|
||||
let jvm = unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) };
|
||||
match jvm.attach_current_thread(|env| Ok::<R, jni::errors::Error>(op(env))) {
|
||||
Ok(value) => Some(value),
|
||||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, operation, "android: attach_current_thread failed");
|
||||
None
|
||||
warn!(target: "chanora_audio", error = %e, "android: JavaVM::from_raw failed; effects not bound");
|
||||
return HardwareEffectHandles::default();
|
||||
}
|
||||
};
|
||||
let mut env = match jvm.attach_current_thread() {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, "android: attach_current_thread failed; effects not bound");
|
||||
return HardwareEffectHandles::default();
|
||||
}
|
||||
};
|
||||
|
||||
let mut handles = HardwareEffectHandles::default();
|
||||
if effects.aec {
|
||||
handles.aec = create_effect(
|
||||
&mut env,
|
||||
"android/media/audiofx/AcousticEchoCanceler",
|
||||
session_id,
|
||||
"AEC",
|
||||
);
|
||||
}
|
||||
if effects.noise_suppression {
|
||||
handles.ns = create_effect(
|
||||
&mut env,
|
||||
"android/media/audiofx/NoiseSuppressor",
|
||||
session_id,
|
||||
"NS",
|
||||
);
|
||||
}
|
||||
if effects.agc {
|
||||
handles.agc = create_effect(
|
||||
&mut env,
|
||||
"android/media/audiofx/AutomaticGainControl",
|
||||
session_id,
|
||||
"AGC",
|
||||
);
|
||||
}
|
||||
handles
|
||||
}
|
||||
|
||||
/// SDD-113 item 3: probe the static `isAvailable()` on each effect
|
||||
/// class before calling `create(int)`. Returns `false` on any JNI
|
||||
/// failure so the caller engages the software fallback.
|
||||
fn effect_is_available(env: &mut jni::Env<'_>, class: &jni::objects::JClass, label: &str) -> bool {
|
||||
match env.call_static_method(
|
||||
class,
|
||||
jni::jni_str!("isAvailable"),
|
||||
jni::jni_sig!("()Z"),
|
||||
&[],
|
||||
) {
|
||||
fn effect_is_available(env: &mut jni::JNIEnv, class: &jni::objects::JClass, label: &str) -> bool {
|
||||
match env.call_static_method(class, "isAvailable", "()Z", &[]) {
|
||||
Ok(v) => match v.z() {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
@@ -1396,14 +1360,14 @@ fn effect_is_available(env: &mut jni::Env<'_>, class: &jni::objects::JClass, lab
|
||||
}
|
||||
|
||||
fn create_effect(
|
||||
env: &mut jni::Env<'_>,
|
||||
env: &mut jni::JNIEnv,
|
||||
fqcn: &str,
|
||||
session_id: AudioSessionId,
|
||||
label: &str,
|
||||
) -> Option<AndroidGlobalObject> {
|
||||
) -> Option<jni::objects::GlobalRef> {
|
||||
use jni::objects::JValue;
|
||||
// Class.create(int) -> ClassInstance|null
|
||||
let class = match env.find_class(jni::strings::JNIString::new(fqcn)) {
|
||||
let class = match env.find_class(fqcn) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, effect = label, "android: find_class failed; effect not bound — software fallback engages");
|
||||
@@ -1419,18 +1383,10 @@ fn create_effect(
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let create_sig = match jni::signature::RuntimeMethodSignature::from_str(format!("(I)L{fqcn};"))
|
||||
{
|
||||
Ok(sig) => sig,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, effect = label, "android: create() signature parse failed");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let inst = match env.call_static_method(
|
||||
&class,
|
||||
jni::jni_str!("create"),
|
||||
create_sig.method_signature(),
|
||||
"create",
|
||||
&format!("(I)L{fqcn};"),
|
||||
&[JValue::Int(session_id)],
|
||||
) {
|
||||
Ok(v) => match v.l() {
|
||||
@@ -1455,8 +1411,8 @@ fn create_effect(
|
||||
// setEnabled(true) -> int (success code)
|
||||
if let Err(e) = env.call_method(
|
||||
&inst,
|
||||
jni::jni_str!("setEnabled"),
|
||||
jni::jni_sig!("(Z)I"),
|
||||
"setEnabled",
|
||||
"(Z)I",
|
||||
&[JValue::Bool(jni::sys::JNI_TRUE)],
|
||||
) {
|
||||
let _ = env.exception_clear();
|
||||
@@ -1492,28 +1448,34 @@ fn release_hardware_effects_inner(handles: &mut HardwareEffectHandles) {
|
||||
if aec.is_none() && ns.is_none() && agc.is_none() {
|
||||
return;
|
||||
}
|
||||
let _ = with_android_env("release hardware effects", |env| {
|
||||
for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] {
|
||||
if let Some(g) = effect {
|
||||
let _ = env.call_method(
|
||||
g.as_obj(),
|
||||
jni::jni_str!("setEnabled"),
|
||||
jni::jni_sig!("(Z)I"),
|
||||
&[jni::objects::JValue::Bool(jni::sys::JNI_FALSE)],
|
||||
);
|
||||
env.exception_clear();
|
||||
let _ = env.call_method(
|
||||
g.as_obj(),
|
||||
jni::jni_str!("release"),
|
||||
jni::jni_sig!("()V"),
|
||||
&[],
|
||||
);
|
||||
env.exception_clear();
|
||||
drop(g);
|
||||
info!(target: "chanora_audio", effect = label, "android: hardware effect released");
|
||||
}
|
||||
let ctx = ndk_context::android_context();
|
||||
if ctx.vm().is_null() {
|
||||
return;
|
||||
}
|
||||
// SAFETY: vm is non-null and owned for process lifetime via JNI_OnLoad.
|
||||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut env = match jvm.attach_current_thread() {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for (effect, label) in [(aec, "AEC"), (ns, "NS"), (agc, "AGC")] {
|
||||
if let Some(g) = effect {
|
||||
let _ = env.call_method(
|
||||
g.as_obj(),
|
||||
"setEnabled",
|
||||
"(Z)I",
|
||||
&[jni::objects::JValue::Bool(jni::sys::JNI_FALSE)],
|
||||
);
|
||||
let _ = env.exception_clear();
|
||||
let _ = env.call_method(g.as_obj(), "release", "()V", &[]);
|
||||
let _ = env.exception_clear();
|
||||
drop(g);
|
||||
info!(target: "chanora_audio", effect = label, "android: hardware effect released");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Process-global BackendEvent sender for JNI callbacks --------
|
||||
@@ -1588,7 +1550,7 @@ pub fn chanora_android_stop_voice_service() -> bool {
|
||||
fn call_voice_service_static(method: &str) -> bool {
|
||||
use jni::objects::{JObject, JValue};
|
||||
let ctx = ndk_context::android_context();
|
||||
if ctx.context().is_null() {
|
||||
if ctx.vm().is_null() || ctx.context().is_null() {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
method,
|
||||
@@ -1596,41 +1558,54 @@ fn call_voice_service_static(method: &str) -> bool {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
with_android_env("voice foreground service", |env| {
|
||||
// SAFETY: ndk_context::context() is the application Context
|
||||
// jobject; valid global ref for process lifetime.
|
||||
let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
|
||||
let class = match load_app_class(env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
match env.call_static_method(
|
||||
&class,
|
||||
jni::strings::JNIString::new(method),
|
||||
jni::jni_sig!("(Landroid/content/Context;)V"),
|
||||
&[JValue::Object(&context_obj)],
|
||||
) {
|
||||
Ok(_) => {
|
||||
info!(target: "chanora_audio", method, "android: voice foreground service call dispatched");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
env.exception_clear();
|
||||
warn!(target: "chanora_audio", error = %e, method, "android: foreground service static call failed");
|
||||
false
|
||||
}
|
||||
// SAFETY: vm/context populated by chanora_bridge::android_init at
|
||||
// JNI_OnLoad + initChanoraContext; both pointers are valid for
|
||||
// the process lifetime.
|
||||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, method, "android: JavaVM::from_raw failed");
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let mut env = match jvm.attach_current_thread() {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, method, "android: attach_current_thread failed");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// SAFETY: ndk_context::context() is the application Context
|
||||
// jobject; valid global ref for process lifetime.
|
||||
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
||||
let class = match load_app_class(&mut env, &context_obj, ANDROID_VOICE_FG_SERVICE_FQCN) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
match env.call_static_method(
|
||||
&class,
|
||||
method,
|
||||
"(Landroid/content/Context;)V",
|
||||
&[JValue::Object(&context_obj)],
|
||||
) {
|
||||
Ok(_) => {
|
||||
info!(target: "chanora_audio", method, "android: voice foreground service call dispatched");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = env.exception_clear();
|
||||
warn!(target: "chanora_audio", error = %e, method, "android: foreground service static call failed");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_app_class<'local>(
|
||||
env: &mut jni::Env<'local>,
|
||||
env: &mut jni::JNIEnv<'local>,
|
||||
context_obj: &jni::objects::JObject<'local>,
|
||||
slash_name: &str,
|
||||
) -> Option<jni::objects::JClass<'local>> {
|
||||
match env.find_class(jni::strings::JNIString::new(slash_name)) {
|
||||
match env.find_class(slash_name) {
|
||||
Ok(c) => return Some(c),
|
||||
Err(e) => {
|
||||
let _ = env.exception_clear();
|
||||
@@ -1641,8 +1616,8 @@ fn load_app_class<'local>(
|
||||
let loader = match env
|
||||
.call_method(
|
||||
context_obj,
|
||||
jni::jni_str!("getClassLoader"),
|
||||
jni::jni_sig!("()Ljava/lang/ClassLoader;"),
|
||||
"getClassLoader",
|
||||
"()Ljava/lang/ClassLoader;",
|
||||
&[],
|
||||
)
|
||||
.and_then(|v| v.l())
|
||||
@@ -1667,20 +1642,13 @@ fn load_app_class<'local>(
|
||||
match env
|
||||
.call_method(
|
||||
&loader,
|
||||
jni::jni_str!("loadClass"),
|
||||
jni::jni_sig!("(Ljava/lang/String;)Ljava/lang/Class;"),
|
||||
"loadClass",
|
||||
"(Ljava/lang/String;)Ljava/lang/Class;",
|
||||
&[jni::objects::JValue::Object(&class_name_obj)],
|
||||
)
|
||||
.and_then(|v| v.l())
|
||||
{
|
||||
Ok(class_obj) => match env.cast_local::<jni::objects::JClass>(class_obj) {
|
||||
Ok(class) => Some(class),
|
||||
Err(e) => {
|
||||
env.exception_clear();
|
||||
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass returned non-Class object");
|
||||
None
|
||||
}
|
||||
},
|
||||
Ok(class_obj) => Some(jni::objects::JClass::from(class_obj)),
|
||||
Err(e) => {
|
||||
let _ = env.exception_clear();
|
||||
warn!(target: "chanora_audio", error = %e, class = %dotted_name, "android: ClassLoader.loadClass failed");
|
||||
@@ -1711,7 +1679,7 @@ fn load_app_class<'local>(
|
||||
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusController_publishFocusChange<
|
||||
'local,
|
||||
>(
|
||||
_env: jni::EnvUnowned<'local>,
|
||||
_env: jni::JNIEnv<'local>,
|
||||
_class: jni::objects::JClass<'local>,
|
||||
state: jni::sys::jint,
|
||||
) {
|
||||
@@ -1749,7 +1717,7 @@ pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidAudioFocusContro
|
||||
pub extern "system" fn Java_app_chanora_chanora_1flutter_AndroidBluetoothScoController_publishScoStateChange<
|
||||
'local,
|
||||
>(
|
||||
_env: jni::EnvUnowned<'local>,
|
||||
_env: jni::JNIEnv<'local>,
|
||||
_class: jni::objects::JClass<'local>,
|
||||
state: jni::sys::jint,
|
||||
) {
|
||||
@@ -1798,7 +1766,7 @@ pub fn chanora_android_stop_bluetooth_sco() -> bool {
|
||||
fn call_static_void_context(fqcn: &str, method: &str) -> bool {
|
||||
use jni::objects::{JObject, JValue};
|
||||
let ctx = ndk_context::android_context();
|
||||
if ctx.context().is_null() {
|
||||
if ctx.vm().is_null() || ctx.context().is_null() {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
class = fqcn,
|
||||
@@ -1807,29 +1775,39 @@ fn call_static_void_context(fqcn: &str, method: &str) -> bool {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
with_android_env("static context call", |env| {
|
||||
let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
|
||||
let class = match load_app_class(env, &context_obj, fqcn) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
match env.call_static_method(
|
||||
&class,
|
||||
jni::strings::JNIString::new(method),
|
||||
jni::jni_sig!("(Landroid/content/Context;)V"),
|
||||
&[JValue::Object(&context_obj)],
|
||||
) {
|
||||
Ok(_) => {
|
||||
info!(target: "chanora_audio", class = fqcn, method, "android: dispatched");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
env.exception_clear();
|
||||
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: static call failed");
|
||||
false
|
||||
}
|
||||
let jvm = match unsafe { jni::JavaVM::from_raw(ctx.vm() as *mut _) } {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: JavaVM::from_raw failed");
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let mut env = match jvm.attach_current_thread() {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: attach_current_thread failed");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
||||
let class = match load_app_class(&mut env, &context_obj, fqcn) {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
match env.call_static_method(
|
||||
&class,
|
||||
method,
|
||||
"(Landroid/content/Context;)V",
|
||||
&[JValue::Object(&context_obj)],
|
||||
) {
|
||||
Ok(_) => {
|
||||
info!(target: "chanora_audio", class = fqcn, method, "android: dispatched");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = env.exception_clear();
|
||||
warn!(target: "chanora_audio", error = %e, class = fqcn, method, "android: static call failed");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
pub(crate) fn append_processed_i16_bounded(
|
||||
pcm_accum: &mut Vec<i16>,
|
||||
frame: &[f32],
|
||||
gain: f32,
|
||||
) -> bool {
|
||||
if (gain - 1.0).abs() < f32::EPSILON {
|
||||
for src in frame.iter().copied() {
|
||||
if pcm_accum.len() == pcm_accum.capacity() {
|
||||
return true;
|
||||
}
|
||||
pcm_accum.push(crate::frame::f32_to_i16(src));
|
||||
}
|
||||
} else {
|
||||
for src in frame.iter().copied() {
|
||||
if pcm_accum.len() == pcm_accum.capacity() {
|
||||
return true;
|
||||
}
|
||||
let scaled = (crate::frame::f32_to_i16(src) as f32) * gain;
|
||||
pcm_accum.push(scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn append_i16_bounded(pcm_accum: &mut Vec<i16>, frame: &[i16]) -> bool {
|
||||
for src in frame.iter().copied() {
|
||||
if pcm_accum.len() == pcm_accum.capacity() {
|
||||
return true;
|
||||
}
|
||||
pcm_accum.push(src);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{append_i16_bounded, append_processed_i16_bounded};
|
||||
|
||||
#[test]
|
||||
fn append_processed_i16_bounded_does_not_grow_when_full() {
|
||||
let frame = [0.25_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES / 2);
|
||||
let warmed_capacity = accum.capacity();
|
||||
let warmed_ptr = accum.as_ptr();
|
||||
|
||||
let dropped = append_processed_i16_bounded(&mut accum, &frame, 1.0);
|
||||
|
||||
assert!(dropped);
|
||||
assert_eq!(accum.len(), warmed_capacity);
|
||||
assert_eq!(accum.capacity(), warmed_capacity);
|
||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_processed_i16_bounded_preserves_expected_10ms_append() {
|
||||
let frame = [0.25_f32; crate::frame::FRAME_10MS_SAMPLES];
|
||||
let mut accum = Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2);
|
||||
let warmed_capacity = accum.capacity();
|
||||
let warmed_ptr = accum.as_ptr();
|
||||
|
||||
let dropped = append_processed_i16_bounded(&mut accum, &frame, 1.0);
|
||||
|
||||
assert!(!dropped);
|
||||
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES);
|
||||
assert_eq!(accum.capacity(), warmed_capacity);
|
||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_i16_bounded_does_not_grow_when_preroll_exceeds_capacity() {
|
||||
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES / 2);
|
||||
let warmed_capacity = accum.capacity();
|
||||
let warmed_ptr = accum.as_ptr();
|
||||
|
||||
let dropped = append_i16_bounded(&mut accum, &frame);
|
||||
|
||||
assert!(dropped);
|
||||
assert_eq!(accum.len(), warmed_capacity);
|
||||
assert_eq!(accum.capacity(), warmed_capacity);
|
||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_i16_bounded_preserves_expected_10ms_append() {
|
||||
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||
let mut accum = Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2);
|
||||
let warmed_capacity = accum.capacity();
|
||||
let warmed_ptr = accum.as_ptr();
|
||||
|
||||
let dropped = append_i16_bounded(&mut accum, &frame);
|
||||
|
||||
assert!(!dropped);
|
||||
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES);
|
||||
assert_eq!(accum.capacity(), warmed_capacity);
|
||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_i16_bounded_preserves_full_vad_preroll_window() {
|
||||
let frame = [7_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||
let mut accum = Vec::with_capacity(crate::frame::FRAME_10MS_SAMPLES * 16);
|
||||
let warmed_capacity = accum.capacity();
|
||||
let warmed_ptr = accum.as_ptr();
|
||||
|
||||
for _ in 0..16 {
|
||||
assert!(!append_i16_bounded(&mut accum, &frame));
|
||||
}
|
||||
|
||||
assert_eq!(accum.len(), crate::frame::FRAME_10MS_SAMPLES * 16);
|
||||
assert_eq!(accum.capacity(), warmed_capacity);
|
||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||
assert!(append_i16_bounded(&mut accum, &frame));
|
||||
assert_eq!(accum.len(), warmed_capacity);
|
||||
assert_eq!(accum.capacity(), warmed_capacity);
|
||||
assert_eq!(accum.as_ptr(), warmed_ptr);
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
pub(crate) struct CaptureResampleResult {
|
||||
pub(crate) output_len: usize,
|
||||
pub(crate) dropped: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn resample_capture_to_48k(
|
||||
samples: &[i16],
|
||||
input_sample_rate_hz: u32,
|
||||
resample_pos: &mut f64,
|
||||
resample_last: &mut i16,
|
||||
scratch: &mut Vec<i16>,
|
||||
) -> CaptureResampleResult {
|
||||
scratch.clear();
|
||||
if samples.is_empty() {
|
||||
return CaptureResampleResult {
|
||||
output_len: 0,
|
||||
dropped: false,
|
||||
};
|
||||
}
|
||||
|
||||
let ratio = input_sample_rate_hz.max(1) as f64 / crate::frame::SAMPLE_RATE_HZ as f64;
|
||||
let mut pos = *resample_pos;
|
||||
let mut dropped = false;
|
||||
while pos < samples.len() as f64 {
|
||||
let i = pos.floor() as isize;
|
||||
let frac = pos - i as f64;
|
||||
let a = if i <= 0 {
|
||||
*resample_last as f64
|
||||
} else {
|
||||
samples[(i - 1) as usize] as f64
|
||||
};
|
||||
let b = if i < samples.len() as isize {
|
||||
samples[i as usize] as f64
|
||||
} else {
|
||||
a
|
||||
};
|
||||
let value = (a + frac * (b - a))
|
||||
.round()
|
||||
.clamp(i16::MIN as f64, i16::MAX as f64) as i16;
|
||||
if scratch.len() < scratch.capacity() {
|
||||
scratch.push(value);
|
||||
} else {
|
||||
dropped = true;
|
||||
}
|
||||
pos += ratio;
|
||||
}
|
||||
*resample_pos = pos - samples.len() as f64;
|
||||
*resample_last = *samples.last().unwrap_or(resample_last);
|
||||
CaptureResampleResult {
|
||||
output_len: scratch.len(),
|
||||
dropped,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod android_voice_unit_resampler_tests {
|
||||
use super::resample_capture_to_48k;
|
||||
|
||||
#[test]
|
||||
fn android_voice_unit_resampler_reuses_scratch_without_capacity_growth() {
|
||||
let samples: Vec<i16> = (0..882).map(|i| i as i16).collect();
|
||||
let mut pos = 0.0;
|
||||
let mut last = 0_i16;
|
||||
let mut scratch = Vec::with_capacity(960);
|
||||
|
||||
let first = resample_capture_to_48k(&samples, 44_100, &mut pos, &mut last, &mut scratch);
|
||||
let first_len = first.output_len;
|
||||
assert_eq!(first_len, 960);
|
||||
assert!(!first.dropped);
|
||||
assert_eq!(scratch.len(), first_len);
|
||||
let warmed_capacity = scratch.capacity();
|
||||
let warmed_ptr = scratch.as_ptr();
|
||||
|
||||
for _ in 0..8 {
|
||||
let result =
|
||||
resample_capture_to_48k(&samples, 44_100, &mut pos, &mut last, &mut scratch);
|
||||
let len = result.output_len;
|
||||
assert_eq!(len, scratch.len());
|
||||
assert!(len >= 959 && len <= 960);
|
||||
assert!(!result.dropped);
|
||||
assert_eq!(scratch.capacity(), warmed_capacity);
|
||||
assert_eq!(scratch.as_ptr(), warmed_ptr);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn android_voice_unit_resampler_truncates_oversized_burst_without_capacity_growth() {
|
||||
let samples: Vec<i16> = (0..4_800).map(|i| i as i16).collect();
|
||||
let mut pos = 0.0;
|
||||
let mut last = 0_i16;
|
||||
let mut scratch = Vec::with_capacity(960);
|
||||
let warmed_capacity = scratch.capacity();
|
||||
let warmed_ptr = scratch.as_ptr();
|
||||
|
||||
let result = resample_capture_to_48k(&samples, 48_000, &mut pos, &mut last, &mut scratch);
|
||||
|
||||
assert_eq!(result.output_len, warmed_capacity);
|
||||
assert!(result.dropped);
|
||||
assert_eq!(scratch.len(), warmed_capacity);
|
||||
assert_eq!(scratch.capacity(), warmed_capacity);
|
||||
assert_eq!(scratch.as_ptr(), warmed_ptr);
|
||||
assert_eq!(pos, 0.0);
|
||||
assert_eq!(last, *samples.last().unwrap());
|
||||
}
|
||||
}
|
||||
@@ -314,15 +314,6 @@ mod tests {
|
||||
rec.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ios_raw_debug_wav_does_not_push_from_realtime_callback() {
|
||||
let src = include_str!("ios_raw_unit.rs");
|
||||
assert!(
|
||||
!src.contains("push_raw_mic") && !src.contains("push_processed_mic"),
|
||||
"ios raw callbacks must not call WavDebugRecorder push_*_mic until it has a preallocated handoff"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_header_is_44_bytes() {
|
||||
// Write to a temp file to test the header.
|
||||
|
||||
@@ -981,6 +981,7 @@ impl AudioEngine {
|
||||
|
||||
Ok(Self {
|
||||
transmit_gate,
|
||||
frames_sent,
|
||||
frames_received,
|
||||
output_gain,
|
||||
output_muted,
|
||||
@@ -1084,25 +1085,11 @@ impl AudioEngine {
|
||||
audio_processing_stats: audio_processing_stats.clone(),
|
||||
};
|
||||
let mut android_voice_unit =
|
||||
match crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params) {
|
||||
Ok(unit) => unit,
|
||||
Err(e) => {
|
||||
Self::rollback_android_startup_resources(&mut audio_mode_stack);
|
||||
return Err(AudioError::Backend(format!(
|
||||
"android: failed to open Oboe voice unit: {e}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
crate::android_voice_unit::AndroidVoiceUnit::open(&cfg_av, params).map_err(|e| {
|
||||
AudioError::Backend(format!("android: failed to open Oboe voice unit: {e}"))
|
||||
})?;
|
||||
|
||||
if let Err(e) = android_voice_unit.start() {
|
||||
if let Err(close_err) = android_voice_unit.close() {
|
||||
warn!(
|
||||
target: "chanora_audio",
|
||||
error = %close_err,
|
||||
"android: AndroidVoiceUnit::close failed during startup rollback"
|
||||
);
|
||||
}
|
||||
Self::rollback_android_startup_resources(&mut audio_mode_stack);
|
||||
return Err(AudioError::Backend(format!(
|
||||
"android: failed to start Oboe voice unit: {e}"
|
||||
)));
|
||||
@@ -1339,43 +1326,6 @@ impl AudioEngine {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn rollback_android_startup_resources(audio_mode_stack: &mut crate::mode_stack::ModeStack) {
|
||||
crate::android_voice_unit::chanora_android_stop_bluetooth_sco();
|
||||
crate::android_voice_unit::chanora_android_abandon_audio_focus();
|
||||
match release_android_audio_mode_for_startup_rollback(audio_mode_stack) {
|
||||
crate::mode_stack::ModeRelease::LastRelease { prior } => {
|
||||
match android_set_audio_mode(prior) {
|
||||
Ok(()) => info!(
|
||||
target: "chanora_audio",
|
||||
restored_mode = prior,
|
||||
"android: AudioManager mode restored during startup rollback"
|
||||
),
|
||||
Err(e) => warn!(
|
||||
target: "chanora_audio",
|
||||
error = %e,
|
||||
restored_mode = prior,
|
||||
"android: failed to restore AudioManager mode during startup rollback"
|
||||
),
|
||||
}
|
||||
}
|
||||
crate::mode_stack::ModeRelease::StillHeld => {
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
"android: audio mode still held during startup rollback"
|
||||
);
|
||||
}
|
||||
crate::mode_stack::ModeRelease::AlreadyReleased => {}
|
||||
}
|
||||
|
||||
if crate::android_voice_unit::chanora_android_stop_voice_service() {
|
||||
info!(
|
||||
target: "chanora_audio",
|
||||
"android: voice foreground service stopped during startup rollback"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the engine. Idempotent.
|
||||
pub fn stop(&mut self) {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
@@ -1770,33 +1720,6 @@ impl Drop for AudioEngine {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(test, target_os = "android"))]
|
||||
fn release_android_audio_mode_for_startup_rollback(
|
||||
audio_mode_stack: &mut crate::mode_stack::ModeStack,
|
||||
) -> crate::mode_stack::ModeRelease {
|
||||
audio_mode_stack.release()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn android_startup_rollback_releases_acquired_mode_snapshot() {
|
||||
let mut stack = crate::mode_stack::ModeStack::new();
|
||||
let _ = stack.acquire(7);
|
||||
|
||||
let release = release_android_audio_mode_for_startup_rollback(&mut stack);
|
||||
|
||||
assert_eq!(
|
||||
release,
|
||||
crate::mode_stack::ModeRelease::LastRelease { prior: 7 }
|
||||
);
|
||||
assert_eq!(stack.refcount(), 0);
|
||||
assert_eq!(stack.snapshot(), None);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Capture pipeline ----------
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
@@ -1839,12 +1762,9 @@ fn try_open_capture(
|
||||
in_sample_rate,
|
||||
in_channels,
|
||||
mic_gain,
|
||||
crate::opus_voice::start_out_packet_worker(
|
||||
voice_out_tx,
|
||||
frames_sent.clone(),
|
||||
"cpal-capture",
|
||||
)?,
|
||||
voice_out_tx,
|
||||
transmit_active,
|
||||
frames_sent,
|
||||
audio_processing_stats,
|
||||
)));
|
||||
|
||||
@@ -1879,10 +1799,11 @@ struct CaptureState {
|
||||
/// Linux ALSA defaults).
|
||||
resample_last: f32,
|
||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
/// The PTT transmission gate. Read once per outbound frame; the
|
||||
/// CaptureState never mutates this flag.
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
/// Pre-allocated mono downmix buffer. Resized in-place each
|
||||
/// callback; `clear()` retains capacity. SDD-094 realtime-thread
|
||||
/// invariant: this avoids the heap allocation that the prior fix
|
||||
@@ -1915,7 +1836,8 @@ struct CaptureState {
|
||||
/// Time-based gating is robust to cpal buffer-size and sample-rate
|
||||
/// changes that a fixed callback-count would not be.
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
const LEVEL_METER_INTERVAL: std::time::Duration = std::time::Duration::from_millis(33);
|
||||
const LEVEL_METER_INTERVAL: std::time::Duration =
|
||||
std::time::Duration::from_millis(33);
|
||||
|
||||
#[cfg(not(any(target_os = "ios", target_os = "macos", target_os = "android")))]
|
||||
impl CaptureState {
|
||||
@@ -1924,8 +1846,9 @@ impl CaptureState {
|
||||
in_sample_rate: u32,
|
||||
in_channels: usize,
|
||||
mic_gain: f32,
|
||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
audio_processing_stats: Arc<crate::SharedAudioProcessingStats>,
|
||||
) -> Self {
|
||||
Self {
|
||||
@@ -1939,6 +1862,7 @@ impl CaptureState {
|
||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx,
|
||||
transmit_active,
|
||||
frames_sent,
|
||||
mono_scratch: Vec::with_capacity(4096),
|
||||
frame_scratch: Vec::with_capacity(FRAME_SAMPLES),
|
||||
audio_processing_stats,
|
||||
@@ -2035,6 +1959,7 @@ impl CaptureState {
|
||||
Ok(len) => {
|
||||
crate::opus_voice::send_voip_frame(
|
||||
&self.voice_out_tx,
|
||||
&self.frames_sent,
|
||||
&self.opus_out,
|
||||
len,
|
||||
|| {
|
||||
@@ -2448,13 +2373,6 @@ impl std::fmt::Display for AudioModeError {
|
||||
#[cfg(target_os = "android")]
|
||||
impl std::error::Error for AudioModeError {}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
impl From<jni::errors::Error> for AudioModeError {
|
||||
fn from(value: jni::errors::Error) -> Self {
|
||||
Self::JniAttachFailed(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// JNI helper shared by `android_get_audio_mode` and
|
||||
/// `android_set_audio_mode`: attach to the current thread and return
|
||||
/// the `AudioManager` jobject. Centralised so SDD-108's two platform
|
||||
@@ -2462,10 +2380,7 @@ impl From<jni::errors::Error> for AudioModeError {
|
||||
#[cfg(target_os = "android")]
|
||||
fn android_audio_manager_call<F, R>(op: F) -> Result<R, AudioModeError>
|
||||
where
|
||||
F: for<'local> FnOnce(
|
||||
&mut jni::Env<'local>,
|
||||
&jni::objects::JObject<'local>,
|
||||
) -> Result<R, AudioModeError>
|
||||
F: FnOnce(&mut jni::JNIEnv, &jni::objects::JObject) -> Result<R, AudioModeError>
|
||||
+ std::panic::UnwindSafe,
|
||||
{
|
||||
use jni::objects::{JObject, JString, JValue};
|
||||
@@ -2482,40 +2397,42 @@ where
|
||||
// at a live JavaVM* set by our bridge_init JNI hook. The
|
||||
// unsafe block contains only the cast required by
|
||||
// `JavaVM::from_raw`.
|
||||
let jvm = unsafe { jni::JavaVM::from_raw(vm_ptr as *mut _) };
|
||||
jvm.attach_current_thread(|env| -> Result<R, AudioModeError> {
|
||||
let context_obj = unsafe { JObject::from_raw(env, ctx.context() as jni::sys::jobject) };
|
||||
let jvm = unsafe { jni::JavaVM::from_raw(vm_ptr as *mut _) }
|
||||
.map_err(|e| AudioModeError::JniAttachFailed(format!("jvm from_raw: {e}")))?;
|
||||
let mut env = jvm
|
||||
.attach_current_thread()
|
||||
.map_err(|e| AudioModeError::JniAttachFailed(format!("attach: {e}")))?;
|
||||
|
||||
let service_name: JString =
|
||||
env.new_string("audio")
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "new_string",
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
let service_name_obj = JObject::from(service_name);
|
||||
let audio_manager = env
|
||||
.call_method(
|
||||
&context_obj,
|
||||
jni::jni_str!("getSystemService"),
|
||||
jni::jni_sig!("(Ljava/lang/String;)Ljava/lang/Object;"),
|
||||
&[JValue::Object(&service_name_obj)],
|
||||
)
|
||||
let context_obj = unsafe { JObject::from_raw(ctx.context() as jni::sys::jobject) };
|
||||
|
||||
let service_name: JString =
|
||||
env.new_string("audio")
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getSystemService",
|
||||
method: "new_string",
|
||||
detail: e.to_string(),
|
||||
})?
|
||||
.l()
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getSystemService",
|
||||
detail: format!("obj cast: {e}"),
|
||||
})?;
|
||||
if audio_manager.is_null() {
|
||||
return Err(AudioModeError::Other(
|
||||
"AudioManager service is null".to_string(),
|
||||
));
|
||||
}
|
||||
op(env, &audio_manager)
|
||||
})
|
||||
let audio_manager = env
|
||||
.call_method(
|
||||
&context_obj,
|
||||
"getSystemService",
|
||||
"(Ljava/lang/String;)Ljava/lang/Object;",
|
||||
&[JValue::Object(&service_name.into())],
|
||||
)
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getSystemService",
|
||||
detail: e.to_string(),
|
||||
})?
|
||||
.l()
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getSystemService",
|
||||
detail: format!("obj cast: {e}"),
|
||||
})?;
|
||||
if audio_manager.is_null() {
|
||||
return Err(AudioModeError::Other(
|
||||
"AudioManager service is null".to_string(),
|
||||
));
|
||||
}
|
||||
op(&mut env, &audio_manager)
|
||||
});
|
||||
match result {
|
||||
Ok(inner) => inner,
|
||||
@@ -2534,21 +2451,16 @@ where
|
||||
#[cfg(target_os = "android")]
|
||||
pub fn android_get_audio_mode() -> Result<i32, AudioModeError> {
|
||||
android_audio_manager_call(|env, audio_manager| {
|
||||
env.call_method(
|
||||
audio_manager,
|
||||
jni::jni_str!("getMode"),
|
||||
jni::jni_sig!("()I"),
|
||||
&[],
|
||||
)
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getMode",
|
||||
detail: e.to_string(),
|
||||
})?
|
||||
.i()
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getMode",
|
||||
detail: format!("int cast: {e}"),
|
||||
})
|
||||
env.call_method(audio_manager, "getMode", "()I", &[])
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getMode",
|
||||
detail: e.to_string(),
|
||||
})?
|
||||
.i()
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "getMode",
|
||||
detail: format!("int cast: {e}"),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2562,16 +2474,11 @@ pub fn android_get_audio_mode() -> Result<i32, AudioModeError> {
|
||||
pub fn android_set_audio_mode(mode: i32) -> Result<(), AudioModeError> {
|
||||
use jni::objects::JValue;
|
||||
android_audio_manager_call(move |env, audio_manager| {
|
||||
env.call_method(
|
||||
audio_manager,
|
||||
jni::jni_str!("setMode"),
|
||||
jni::jni_sig!("(I)V"),
|
||||
&[JValue::Int(mode)],
|
||||
)
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "setMode",
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
env.call_method(audio_manager, "setMode", "(I)V", &[JValue::Int(mode)])
|
||||
.map_err(|e| AudioModeError::MethodCallFailed {
|
||||
method: "setMode",
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -42,11 +42,13 @@ mod inner {
|
||||
use coreaudio::audio_unit::render_callback::{self, data};
|
||||
use coreaudio::audio_unit::IOType;
|
||||
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::mobile_voice_backend::VoiceAudioParams;
|
||||
use crate::processor::AudioProcessor;
|
||||
use crate::AudioError;
|
||||
use chanora_protocol::OutPacket;
|
||||
|
||||
const SAMPLE_RATE_HZ: f64 = 48_000.0;
|
||||
|
||||
@@ -61,10 +63,43 @@ mod inner {
|
||||
/// If the capture callback runs before the render callback has written
|
||||
/// a frame it reads zeros (silence reference), which is safe — Sonora
|
||||
/// AEC3 simply skips cancellation for that frame.
|
||||
type RenderReferenceBuffer = crate::render_reference::RenderReferenceBuffer<480, 4>;
|
||||
type RenderReferenceFrameAccumulator =
|
||||
crate::render_reference::RenderReferenceFrameAccumulator<480>;
|
||||
const RAW_RENDER_SCRATCH_FRAMES: usize = 1024;
|
||||
struct RenderReferenceBuffer {
|
||||
buf: Box<[[f32; 480]; 4]>,
|
||||
write_idx: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl RenderReferenceBuffer {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
buf: Box::new([[0.0; 480]; 4]),
|
||||
write_idx: std::sync::atomic::AtomicUsize::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Write one 10 ms render-reference frame. Realtime-safe.
|
||||
fn write(&self, frame: &[f32; 480]) {
|
||||
let idx = self.write_idx.load(Ordering::Relaxed);
|
||||
// SAFETY: only one writer (render callback); torn reads
|
||||
// are bounded to one frame of AEC degradation.
|
||||
unsafe {
|
||||
let slot = &self.buf[idx] as *const [f32; 480] as *mut [f32; 480];
|
||||
(*slot).copy_from_slice(frame);
|
||||
}
|
||||
self.write_idx.store((idx + 1) % 4, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Read the most recently completed render-reference frame.
|
||||
fn read_latest(&self) -> [f32; 480] {
|
||||
let wi = self.write_idx.load(Ordering::Relaxed);
|
||||
let ri = (wi + 3) % 4;
|
||||
self.buf[ri]
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: accessed from two audio callback threads; data races are
|
||||
// bounded to one frame of AEC quality degradation.
|
||||
unsafe impl Send for RenderReferenceBuffer {}
|
||||
unsafe impl Sync for RenderReferenceBuffer {}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// Capture pipeline state //
|
||||
@@ -74,9 +109,10 @@ mod inner {
|
||||
encoder: OpusEncoder,
|
||||
pcm_accum: Vec<i16>,
|
||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||
@@ -92,6 +128,7 @@ mod inner {
|
||||
pending_10ms: [i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||
pending_10ms_len: usize,
|
||||
fallback_warned_backend: Option<crate::VadBackend>,
|
||||
wav_recorder: Option<Arc<crate::debug_wav::WavDebugRecorder>>,
|
||||
}
|
||||
|
||||
impl RawCaptureState {
|
||||
@@ -109,13 +146,10 @@ mod inner {
|
||||
encoder,
|
||||
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: crate::opus_voice::start_out_packet_worker(
|
||||
params.voice_out_tx.clone(),
|
||||
params.frames_sent.clone(),
|
||||
"ios-raw",
|
||||
)?,
|
||||
voice_out_tx: params.voice_out_tx.clone(),
|
||||
transmit_active: params.transmit_active.clone(),
|
||||
output_muted: params.output_muted.clone(),
|
||||
frames_sent: params.frames_sent.clone(),
|
||||
mic_gain: params.mic_gain,
|
||||
voice_activity_selector: params.voice_activity_selector.clone(),
|
||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||
@@ -132,6 +166,7 @@ mod inner {
|
||||
pending_10ms: [0_i16; crate::frame::FRAME_10MS_SAMPLES],
|
||||
pending_10ms_len: 0,
|
||||
fallback_warned_backend: None,
|
||||
wav_recorder: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -169,7 +204,6 @@ mod inner {
|
||||
if self.pending_10ms_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||
let frame = self.pending_10ms;
|
||||
self.process_10ms_capture_frame(&frame);
|
||||
self.encode_complete_20ms_frames();
|
||||
self.pending_10ms_len = 0;
|
||||
}
|
||||
}
|
||||
@@ -179,10 +213,7 @@ mod inner {
|
||||
return;
|
||||
}
|
||||
|
||||
self.encode_complete_20ms_frames();
|
||||
}
|
||||
|
||||
fn encode_complete_20ms_frames(&mut self) {
|
||||
// Encode complete 20 ms Opus frames.
|
||||
while self.pcm_accum.len() >= crate::frame::FRAME_20MS_SAMPLES {
|
||||
let mut frame = [0i16; crate::frame::FRAME_20MS_SAMPLES];
|
||||
frame.copy_from_slice(&self.pcm_accum[..crate::frame::FRAME_20MS_SAMPLES]);
|
||||
@@ -192,6 +223,7 @@ mod inner {
|
||||
Ok(len) => {
|
||||
crate::opus_voice::send_voip_frame(
|
||||
&self.voice_out_tx,
|
||||
&self.frames_sent,
|
||||
&self.opus_out,
|
||||
len,
|
||||
|| {
|
||||
@@ -221,18 +253,20 @@ mod inner {
|
||||
}
|
||||
let input_dbfs = crate::frame::dbfs(&frame);
|
||||
|
||||
// Debug WAV mic taps are intentionally unavailable on iOS raw
|
||||
// realtime callbacks until WavDebugRecorder supports a
|
||||
// preallocated handoff path; the current recorder push path
|
||||
// allocates per frame.
|
||||
// WAV tap: raw mic (before processing).
|
||||
if let Some(ref rec) = self.wav_recorder {
|
||||
rec.push_raw_mic(&frame);
|
||||
}
|
||||
|
||||
// Feed render reference to WebRTC APM before capture so AEC can adapt.
|
||||
let render_ref = self.render_reference.read_latest();
|
||||
self.webrtc_apm_processor.process_render(&render_ref);
|
||||
self.webrtc_apm_processor.process_capture(&mut frame);
|
||||
|
||||
// Processed-mic debug WAV capture is disabled for the same
|
||||
// realtime allocation reason as the raw-mic tap above.
|
||||
// WAV tap: processed mic (after WebRTC APM).
|
||||
if let Some(ref rec) = self.wav_recorder {
|
||||
rec.push_processed_mic(&frame);
|
||||
}
|
||||
|
||||
let voice_activity_mode = self
|
||||
.voice_activity_selector
|
||||
@@ -266,9 +300,14 @@ mod inner {
|
||||
self.current_vad_backend = vad_backend;
|
||||
self.fallback_warned_backend = None;
|
||||
if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
self.silero_coreml_worker = None;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
self.silero_coreml_worker =
|
||||
crate::vad::apple_coreml::AppleCoreMlVadWorker::try_new();
|
||||
if self.silero_coreml_worker.is_none() {
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
} else {
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
}
|
||||
} else {
|
||||
self.silero_coreml_worker = None;
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
@@ -286,38 +325,20 @@ mod inner {
|
||||
speech: true,
|
||||
}
|
||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
match crate::vad::callback_vad_worker_policy(
|
||||
voice_activity_mode,
|
||||
vad_backend,
|
||||
self.silero_coreml_worker.is_some(),
|
||||
) {
|
||||
crate::vad::VadWorkerPolicy::UseWorker => {
|
||||
let worker = self
|
||||
.silero_coreml_worker
|
||||
.as_ref()
|
||||
.expect("policy checked worker");
|
||||
let enqueued = worker.try_send(capture_seq, &frame);
|
||||
if !worker.is_stale(capture_seq) {
|
||||
let p = worker.latest_probability();
|
||||
crate::vad::VadOutput {
|
||||
probability: p,
|
||||
speech: p >= 0.5,
|
||||
}
|
||||
} else if enqueued {
|
||||
crate::vad::VadOutput {
|
||||
probability: 0.0,
|
||||
speech: false,
|
||||
}
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(vad_backend);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
&mut self.vad_detector,
|
||||
&frame,
|
||||
)
|
||||
if let Some(worker) = self.silero_coreml_worker.as_ref() {
|
||||
let enqueued = worker.try_send(capture_seq, &frame);
|
||||
if !worker.is_stale(capture_seq) {
|
||||
let p = worker.latest_probability();
|
||||
crate::vad::VadOutput {
|
||||
probability: p,
|
||||
speech: p >= 0.5,
|
||||
}
|
||||
}
|
||||
crate::vad::VadWorkerPolicy::UseFallback => {
|
||||
} else if enqueued {
|
||||
crate::vad::VadOutput {
|
||||
probability: 0.0,
|
||||
speech: false,
|
||||
}
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(vad_backend);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
@@ -325,10 +346,13 @@ mod inner {
|
||||
&frame,
|
||||
)
|
||||
}
|
||||
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
||||
probability: 1.0,
|
||||
speech: true,
|
||||
},
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(vad_backend);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
&mut self.vad_detector,
|
||||
&frame,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
@@ -354,12 +378,15 @@ mod inner {
|
||||
return;
|
||||
}
|
||||
|
||||
if crate::capture_accumulator::append_processed_i16_bounded(
|
||||
&mut self.pcm_accum,
|
||||
&frame,
|
||||
self.mic_gain,
|
||||
) {
|
||||
self.audio_processing_stats.increment_callback_xrun();
|
||||
let gain = self.mic_gain;
|
||||
if (gain - 1.0).abs() < f32::EPSILON {
|
||||
self.pcm_accum
|
||||
.extend(frame.iter().copied().map(crate::frame::f32_to_i16));
|
||||
} else {
|
||||
self.pcm_accum.extend(frame.iter().copied().map(|s| {
|
||||
let scaled = crate::frame::f32_to_i16(s) as f32 * gain;
|
||||
scaled.clamp(i16::MIN as f32, i16::MAX as f32) as i16
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -419,9 +446,7 @@ mod inner {
|
||||
})
|
||||
.map_err(|e| AudioError::Backend(format!("remoteio input cb: {e}")))?;
|
||||
|
||||
let mut scratch = [0.0_f32; RAW_RENDER_SCRATCH_FRAMES * 2];
|
||||
let mut mono = [0.0_f32; RAW_RENDER_SCRATCH_FRAMES];
|
||||
let mut render_ref_accum = RenderReferenceFrameAccumulator::new();
|
||||
let mut scratch: Vec<f32> = Vec::with_capacity(2048);
|
||||
let handler = params.handler.clone();
|
||||
let output_gain = params.output_gain.clone();
|
||||
let output_muted = params.output_muted.clone();
|
||||
@@ -430,10 +455,9 @@ mod inner {
|
||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
let out = args.data.buffer;
|
||||
let n = out.len();
|
||||
let process_n = n.min(RAW_RENDER_SCRATCH_FRAMES);
|
||||
let stereo_n = process_n * 2;
|
||||
if n > RAW_RENDER_SCRATCH_FRAMES {
|
||||
stats_render.increment_callback_xrun();
|
||||
let stereo_n = n * 2;
|
||||
if scratch.len() < stereo_n {
|
||||
scratch.resize(stereo_n, 0.0);
|
||||
}
|
||||
scratch[..stereo_n].fill(0.0);
|
||||
|
||||
@@ -451,26 +475,22 @@ mod inner {
|
||||
}
|
||||
|
||||
// INV_012: copy render reference BEFORE playout.
|
||||
let mono_n = n.min(480);
|
||||
let mut ref_frame = [0.0_f32; 480];
|
||||
crate::voice_render::downmix_stereo_f32_to_mono_f32(
|
||||
&scratch[..stereo_n],
|
||||
&mut mono[..process_n],
|
||||
&mut ref_frame[..mono_n],
|
||||
);
|
||||
render_ref_accum.push_mono_samples(&mono[..process_n], |frame| {
|
||||
render_ref_buf.write(frame);
|
||||
});
|
||||
render_ref_buf.write(&ref_frame);
|
||||
|
||||
let gain = f32::from_bits(output_gain.load(Ordering::Relaxed));
|
||||
let muted = output_muted.load(Ordering::Relaxed);
|
||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_interleaved_i16(
|
||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_mono_i16(
|
||||
&scratch[..stereo_n],
|
||||
&mut out[..process_n],
|
||||
1,
|
||||
out,
|
||||
gain,
|
||||
muted,
|
||||
);
|
||||
if process_n < n {
|
||||
out[process_n..].fill(0);
|
||||
}
|
||||
if mix_stats.clipped_samples > 0 {
|
||||
stats_render.add_clipped_samples(mix_stats.clipped_samples);
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
//! * AVAudioSession category / mode configuration — Swift owns the
|
||||
//! session (it must be set up before Flutter loads).
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use audiopus::coder::Encoder as OpusEncoder;
|
||||
@@ -75,10 +75,12 @@ use coreaudio::audio_unit::render_callback::{self, data};
|
||||
use coreaudio::audio_unit::IOType;
|
||||
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
|
||||
use crossbeam::queue::ArrayQueue;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use crate::mobile_voice_backend::VoiceAudioParams;
|
||||
use crate::AudioError;
|
||||
use chanora_protocol::OutPacket;
|
||||
|
||||
/// Sample rate every layer above us assumes. Matches the Opus
|
||||
/// encoder rate, the `tsclientlib::AudioHandler` mix rate, and the
|
||||
@@ -103,15 +105,6 @@ const INPUT_BUS: Element = Element::Input;
|
||||
/// when the VAD gate opens (VAD_004 / pre_roll_ms=160).
|
||||
const PRE_ROLL_FRAMES: usize = 16;
|
||||
|
||||
/// Enough room for the 160 ms VAD pre-roll plus a few jitter frames, without
|
||||
/// growing inside the input callback.
|
||||
const CAPTURE_ACCUM_CAPACITY_SAMPLES: usize = crate::frame::FRAME_10MS_SAMPLES * 20;
|
||||
|
||||
/// Fixed iOS render scratch capacity. Larger callback requests are truncated
|
||||
/// to this capacity and the remaining output is silence.
|
||||
#[cfg_attr(not(target_os = "ios"), allow(dead_code))]
|
||||
const IOS_RENDER_SCRATCH_FRAMES: usize = 4096;
|
||||
|
||||
/// Capture pipeline state owned by the VPIO input callback. The
|
||||
/// AudioUnit hands us 48 kHz signed-int16 mono PCM directly (no
|
||||
/// downmix or resample needed — VPIO's hardware-side mix-down
|
||||
@@ -139,9 +132,10 @@ struct IosCaptureState {
|
||||
/// jitter without reallocating.
|
||||
pcm_accum: Vec<i16>,
|
||||
opus_out: [u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: crate::opus_voice::EncodedVoiceFrameSender,
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
transmit_active: Arc<AtomicBool>,
|
||||
output_muted: Arc<AtomicBool>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
mic_gain: f32,
|
||||
voice_activity_selector: Option<Arc<crate::TransmitModeSelector>>,
|
||||
vad_detector: crate::vad::WebRtcFallbackVad,
|
||||
@@ -160,6 +154,7 @@ struct IosCaptureState {
|
||||
pre_roll_count: usize,
|
||||
pre_roll_flushed: bool,
|
||||
capture_frame_seq: u64,
|
||||
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
|
||||
}
|
||||
|
||||
impl IosCaptureState {
|
||||
@@ -167,20 +162,20 @@ impl IosCaptureState {
|
||||
/// Encoder configuration is the same as cpal-side
|
||||
/// `try_open_capture` (engine.rs) so audio quality is platform-
|
||||
/// neutral.
|
||||
fn new(params: &VoiceAudioParams) -> Result<Self, AudioError> {
|
||||
fn new(
|
||||
params: &VoiceAudioParams,
|
||||
wav_recorder: Arc<Mutex<Option<Arc<crate::debug_wav::WavDebugRecorder>>>>,
|
||||
) -> Result<Self, AudioError> {
|
||||
let encoder = crate::opus_voice::new_voip_encoder("ios VPIO")?;
|
||||
|
||||
Ok(Self {
|
||||
encoder,
|
||||
pcm_accum: Vec::with_capacity(CAPTURE_ACCUM_CAPACITY_SAMPLES),
|
||||
pcm_accum: Vec::with_capacity(crate::frame::FRAME_20MS_SAMPLES * 2),
|
||||
opus_out: [0u8; crate::opus_voice::MAX_OPUS_FRAME],
|
||||
voice_out_tx: crate::opus_voice::start_out_packet_worker(
|
||||
params.voice_out_tx.clone(),
|
||||
params.frames_sent.clone(),
|
||||
"ios-vpio",
|
||||
)?,
|
||||
voice_out_tx: params.voice_out_tx.clone(),
|
||||
transmit_active: params.transmit_active.clone(),
|
||||
output_muted: params.output_muted.clone(),
|
||||
frames_sent: params.frames_sent.clone(),
|
||||
mic_gain: params.mic_gain,
|
||||
voice_activity_selector: params.voice_activity_selector.clone(),
|
||||
vad_detector: crate::vad::WebRtcFallbackVad::default(),
|
||||
@@ -198,6 +193,7 @@ impl IosCaptureState {
|
||||
pre_roll_count: 0,
|
||||
pre_roll_flushed: false,
|
||||
capture_frame_seq: 0,
|
||||
wav_recorder,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -271,6 +267,7 @@ impl IosCaptureState {
|
||||
Ok(len) => {
|
||||
crate::opus_voice::send_voip_frame(
|
||||
&self.voice_out_tx,
|
||||
&self.frames_sent,
|
||||
&self.opus_out,
|
||||
len,
|
||||
|| {
|
||||
@@ -301,9 +298,25 @@ impl IosCaptureState {
|
||||
}
|
||||
let input_dbfs = crate::frame::dbfs(&frame);
|
||||
|
||||
// WAV tap: raw mic (before processing, DIAG_002).
|
||||
if let Ok(guard) = self.wav_recorder.try_lock() {
|
||||
if let Some(rec) = guard.as_ref() {
|
||||
rec.push_raw_mic(&frame);
|
||||
}
|
||||
}
|
||||
|
||||
// Read config once per frame (try_lock: non-blocking, falls back to
|
||||
// last-known values if the lock is contended — safe to miss one frame).
|
||||
let (run_ns, run_agc, run_hpf, vad_backend, vad_hangover, debug_wav_dump_enabled) = self
|
||||
let (
|
||||
run_ns,
|
||||
run_agc,
|
||||
run_hpf,
|
||||
vad_backend,
|
||||
vad_hangover,
|
||||
debug_wav_dump_enabled,
|
||||
route,
|
||||
processing_backend,
|
||||
) = self
|
||||
.audio_processing_config
|
||||
.try_lock()
|
||||
.map(|cfg| {
|
||||
@@ -319,6 +332,8 @@ impl IosCaptureState {
|
||||
cfg.vad_backend,
|
||||
cfg.vad_hangover_ms,
|
||||
cfg.debug_wav_dump_enabled,
|
||||
cfg.route,
|
||||
cfg.processing_backend,
|
||||
)
|
||||
})
|
||||
.unwrap_or((
|
||||
@@ -328,13 +343,10 @@ impl IosCaptureState {
|
||||
crate::VadBackend::WebrtcVad,
|
||||
crate::voice_activity::VAD_HANGOVER_MS,
|
||||
false,
|
||||
crate::AudioRoute::Unknown,
|
||||
crate::AudioBackend::PlatformVoiceProcessing,
|
||||
));
|
||||
|
||||
// VPIO realtime callbacks cannot use WavDebugRecorder today: its push
|
||||
// path allocates per frame. Debug WAV capture is intentionally disabled
|
||||
// here until the recorder can hand off preallocated frames.
|
||||
let _ = debug_wav_dump_enabled;
|
||||
|
||||
let voice_activity_mode = self
|
||||
.voice_activity_selector
|
||||
.as_ref()
|
||||
@@ -347,13 +359,32 @@ impl IosCaptureState {
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
}
|
||||
|
||||
// Switch VAD backend only while VoiceActivity mode is active.
|
||||
if let Ok(mut recorder_guard) = self.wav_recorder.try_lock() {
|
||||
if debug_wav_dump_enabled {
|
||||
if recorder_guard.is_none() {
|
||||
*recorder_guard = Some(crate::debug_wav::WavDebugRecorder::start(
|
||||
route,
|
||||
processing_backend,
|
||||
));
|
||||
}
|
||||
} else if let Some(recorder) = recorder_guard.take() {
|
||||
recorder.stop();
|
||||
}
|
||||
}
|
||||
|
||||
if voice_activity_mode && vad_backend != self.current_vad_backend {
|
||||
self.current_vad_backend = vad_backend;
|
||||
self.fallback_warned_backend = None;
|
||||
if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
self.silero_coreml_worker = None;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
self.silero_coreml_worker =
|
||||
crate::vad::apple_coreml::AppleCoreMlVadWorker::try_new();
|
||||
if self.silero_coreml_worker.is_none() {
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
self.audio_processing_stats.set_vad_fallback_active(true);
|
||||
} else {
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
}
|
||||
} else {
|
||||
self.silero_coreml_worker = None;
|
||||
self.audio_processing_stats.set_vad_fallback_active(false);
|
||||
@@ -406,38 +437,20 @@ impl IosCaptureState {
|
||||
speech: true,
|
||||
}
|
||||
} else if vad_backend == crate::VadBackend::SileroOnnx {
|
||||
match crate::vad::callback_vad_worker_policy(
|
||||
voice_activity_mode,
|
||||
vad_backend,
|
||||
self.silero_coreml_worker.is_some(),
|
||||
) {
|
||||
crate::vad::VadWorkerPolicy::UseWorker => {
|
||||
let worker = self
|
||||
.silero_coreml_worker
|
||||
.as_ref()
|
||||
.expect("policy checked worker");
|
||||
let enqueued = worker.try_send(capture_seq, &frame);
|
||||
if !worker.is_stale(capture_seq) {
|
||||
let p = worker.latest_probability();
|
||||
crate::vad::VadOutput {
|
||||
probability: p,
|
||||
speech: p >= 0.5,
|
||||
}
|
||||
} else if enqueued {
|
||||
crate::vad::VadOutput {
|
||||
probability: 0.0,
|
||||
speech: false,
|
||||
}
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
&mut self.vad_detector,
|
||||
&frame,
|
||||
)
|
||||
if let Some(worker) = self.silero_coreml_worker.as_ref() {
|
||||
let enqueued = worker.try_send(capture_seq, &frame);
|
||||
if !worker.is_stale(capture_seq) {
|
||||
let p = worker.latest_probability();
|
||||
crate::vad::VadOutput {
|
||||
probability: p,
|
||||
speech: p >= 0.5,
|
||||
}
|
||||
}
|
||||
crate::vad::VadWorkerPolicy::UseFallback => {
|
||||
} else if enqueued {
|
||||
crate::vad::VadOutput {
|
||||
probability: 0.0,
|
||||
speech: false,
|
||||
}
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(
|
||||
@@ -445,10 +458,10 @@ impl IosCaptureState {
|
||||
&frame,
|
||||
)
|
||||
}
|
||||
crate::vad::VadWorkerPolicy::NotModelBacked => crate::vad::VadOutput {
|
||||
probability: 1.0,
|
||||
speech: true,
|
||||
},
|
||||
} else {
|
||||
used_fallback_vad = true;
|
||||
self.mark_vad_fallback_active(crate::VadBackend::SileroOnnx);
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
}
|
||||
} else {
|
||||
crate::vad::VoiceActivityDetector::process_10ms(&mut self.vad_detector, &frame)
|
||||
@@ -471,6 +484,13 @@ impl IosCaptureState {
|
||||
transmit_active,
|
||||
);
|
||||
|
||||
// WAV tap: processed mic (after Rust DSP, DIAG_002).
|
||||
if let Ok(guard) = self.wav_recorder.try_lock() {
|
||||
if let Some(rec) = guard.as_ref() {
|
||||
rec.push_processed_mic(&frame);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to i16 for accumulation.
|
||||
let mut pcm_frame = [0_i16; crate::frame::FRAME_10MS_SAMPLES];
|
||||
if (self.mic_gain - 1.0).abs() < f32::EPSILON {
|
||||
@@ -508,13 +528,7 @@ impl IosCaptureState {
|
||||
let pre_roll_to_emit = self.pre_roll_count.saturating_sub(1);
|
||||
for i in 0..pre_roll_to_emit {
|
||||
let idx = (oldest + i) % PRE_ROLL_FRAMES;
|
||||
if crate::capture_accumulator::append_i16_bounded(
|
||||
&mut self.pcm_accum,
|
||||
&self.pre_roll_buf[idx],
|
||||
) {
|
||||
self.audio_processing_stats.increment_callback_xrun();
|
||||
break;
|
||||
}
|
||||
self.pcm_accum.extend_from_slice(&self.pre_roll_buf[idx]);
|
||||
}
|
||||
} else if !transmit_active {
|
||||
// Gate closed — reset the flush flag so pre-roll fires again
|
||||
@@ -526,9 +540,7 @@ impl IosCaptureState {
|
||||
return;
|
||||
}
|
||||
|
||||
if crate::capture_accumulator::append_i16_bounded(&mut self.pcm_accum, &pcm_frame) {
|
||||
self.audio_processing_stats.increment_callback_xrun();
|
||||
}
|
||||
self.pcm_accum.extend_from_slice(&pcm_frame);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,7 +694,9 @@ impl IosVoiceUnit {
|
||||
Element::Output,
|
||||
Some(&ducking_config),
|
||||
) {
|
||||
tracing::debug!("vpio set OtherAudioDuckingConfiguration failed (older OS?): {e}");
|
||||
tracing::debug!(
|
||||
"vpio set OtherAudioDuckingConfiguration failed (older OS?): {e}"
|
||||
);
|
||||
}
|
||||
|
||||
// Note: we keep VPIO's voice processing chain ENABLED
|
||||
@@ -734,7 +748,18 @@ impl IosVoiceUnit {
|
||||
// scratch are owned by the closure — no Mutex needed
|
||||
// because the input callback is the sole writer/reader on
|
||||
// the audio thread.
|
||||
let mut capture_state = IosCaptureState::new(¶ms)?;
|
||||
let wav_recorder = Arc::new(Mutex::new({
|
||||
let cfg = params.audio_processing_config.lock().unwrap().clone();
|
||||
if cfg.debug_wav_dump_enabled {
|
||||
Some(crate::debug_wav::WavDebugRecorder::start(
|
||||
cfg.route,
|
||||
cfg.processing_backend,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}));
|
||||
let mut capture_state = IosCaptureState::new(¶ms, wav_recorder.clone())?;
|
||||
|
||||
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
// VPIO with our pinned stream format delivers
|
||||
@@ -854,8 +879,11 @@ impl IosVoiceUnit {
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut pull_scratch: Vec<f32> = vec![0.0; PULL_SAMPLES];
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_millis(20));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
let mut interval =
|
||||
tokio::time::interval(std::time::Duration::from_millis(20));
|
||||
interval.set_missed_tick_behavior(
|
||||
tokio::time::MissedTickBehavior::Delay,
|
||||
);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if producer_shutdown_for_task.load(Ordering::Relaxed) {
|
||||
@@ -881,16 +909,16 @@ impl IosVoiceUnit {
|
||||
|
||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
let render_callback::Args {
|
||||
data, num_frames, ..
|
||||
data,
|
||||
num_frames,
|
||||
..
|
||||
} = args;
|
||||
let out: &mut [i16] = data.buffer;
|
||||
let out_channels = data.channels;
|
||||
let needed = num_frames * out_channels;
|
||||
|
||||
if pcm_ring_consumer.len() < PREBUFFER_SAMPLES {
|
||||
for sample in &mut out[..needed] {
|
||||
*sample = 0;
|
||||
}
|
||||
for sample in &mut out[..needed] { *sample = 0; }
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -911,8 +939,10 @@ impl IosVoiceUnit {
|
||||
let mono = (l_lim + r_lim) * 0.5;
|
||||
out[base] = (mono.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||
} else {
|
||||
out[base] = (l_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||
out[base + 1] = (r_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||
out[base] =
|
||||
(l_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||
out[base + 1] =
|
||||
(r_lim.clamp(-1.0, 1.0) * i16::MAX as f32) as i16;
|
||||
}
|
||||
written_frames += 1;
|
||||
}
|
||||
@@ -921,18 +951,17 @@ impl IosVoiceUnit {
|
||||
let remaining = num_frames - written_frames;
|
||||
for f in 0..remaining {
|
||||
let base = (written_frames + f) * out_channels;
|
||||
for c in 0..out_channels {
|
||||
out[base + c] = 0;
|
||||
}
|
||||
for c in 0..out_channels { out[base + c] = 0; }
|
||||
}
|
||||
}
|
||||
|
||||
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
||||
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
||||
let gain = f32::from_bits(
|
||||
output_gain_for_render.load(Ordering::Relaxed),
|
||||
);
|
||||
let muted =
|
||||
output_muted_for_render.load(Ordering::Relaxed);
|
||||
if muted {
|
||||
for sample in &mut out[..needed] {
|
||||
*sample = 0;
|
||||
}
|
||||
for sample in &mut out[..needed] { *sample = 0; }
|
||||
} else if gain != 1.0 {
|
||||
for sample in &mut out[..needed] {
|
||||
*sample = (((*sample as f32) * gain)
|
||||
@@ -943,7 +972,9 @@ impl IosVoiceUnit {
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
|
||||
.map_err(|e| AudioError::Backend(format!(
|
||||
"audio unit set render callback: {e}"
|
||||
)))?;
|
||||
}
|
||||
|
||||
// iOS path: direct fill_buffer in callback. iOS VPIO
|
||||
@@ -953,112 +984,127 @@ impl IosVoiceUnit {
|
||||
// producer-task path above.
|
||||
#[cfg(target_os = "ios")]
|
||||
{
|
||||
let mut scratch_stereo: Vec<f32> = vec![0.0; IOS_RENDER_SCRATCH_FRAMES * 2];
|
||||
let handler_for_render = params.handler.clone();
|
||||
let output_gain_for_render = params.output_gain.clone();
|
||||
let output_muted_for_render = params.output_muted.clone();
|
||||
let audio_processing_stats_for_render = params.audio_processing_stats.clone();
|
||||
// Level meter decimation: the render callback fires ~93
|
||||
// times/sec, but the bridge consumer reads at ~30 Hz.
|
||||
let mut render_level_decimation: u32 = 0;
|
||||
// Debug WAV render-reference capture is intentionally unavailable
|
||||
// on iOS VPIO callbacks until WavDebugRecorder supports a
|
||||
// preallocated handoff; its current push path allocates per frame.
|
||||
// Diagnostic counters sampled every 100 callbacks.
|
||||
let mut cb_count: u64 = 0;
|
||||
let mut last_num_frames: usize = 0;
|
||||
let mut num_frames_changes: u64 = 0;
|
||||
let mut callbacks_with_audio: u64 = 0;
|
||||
let mut callbacks_with_silence: u64 = 0;
|
||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
let render_callback::Args {
|
||||
data, num_frames, ..
|
||||
} = args;
|
||||
let out: &mut [i16] = data.buffer;
|
||||
let out_channels = data.channels;
|
||||
let process_frames = num_frames.min(IOS_RENDER_SCRATCH_FRAMES);
|
||||
if process_frames < num_frames {
|
||||
let mut scratch_stereo: Vec<f32> = vec![0.0; 4096 * 2];
|
||||
let handler_for_render = params.handler.clone();
|
||||
let output_gain_for_render = params.output_gain.clone();
|
||||
let output_muted_for_render = params.output_muted.clone();
|
||||
let audio_processing_stats_for_render = params.audio_processing_stats.clone();
|
||||
// Level meter decimation: the render callback fires ~93
|
||||
// times/sec, but the bridge consumer reads at ~30 Hz.
|
||||
let mut render_level_decimation: u32 = 0;
|
||||
unit.set_render_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
|
||||
let render_callback::Args {
|
||||
data,
|
||||
num_frames,
|
||||
..
|
||||
} = args;
|
||||
let out: &mut [i16] = data.buffer;
|
||||
let out_channels = data.channels;
|
||||
// AudioHandler produces 48 kHz stereo f32 (= num_frames * 2 floats).
|
||||
let needed = num_frames * 2;
|
||||
if scratch_stereo.len() < needed {
|
||||
scratch_stereo.resize(needed, 0.0);
|
||||
}
|
||||
// Zero the live slice. AudioHandler::fill_buffer is
|
||||
// additive (does NOT clear); residual values from
|
||||
// earlier callbacks (when scratch was bigger) would
|
||||
// leak through otherwise.
|
||||
scratch_stereo[..needed].fill(0.0);
|
||||
match handler_for_render.try_lock() {
|
||||
Ok(mut h) => {
|
||||
let _ = h.fill_buffer(&mut scratch_stereo[..needed]);
|
||||
}
|
||||
Err(std::sync::TryLockError::WouldBlock) => {
|
||||
audio_processing_stats_for_render.increment_callback_xrun();
|
||||
// scratch_stereo is already zeroed above.
|
||||
}
|
||||
// AudioHandler produces 48 kHz stereo f32 (= frames * 2 floats).
|
||||
let needed = process_frames * 2;
|
||||
// Zero the live slice. AudioHandler::fill_buffer is
|
||||
// additive (does NOT clear); residual values from
|
||||
// earlier callbacks (when scratch was bigger) would
|
||||
// leak through otherwise.
|
||||
scratch_stereo[..needed].fill(0.0);
|
||||
match handler_for_render.try_lock() {
|
||||
Ok(mut h) => {
|
||||
let _ = h.fill_buffer(&mut scratch_stereo[..needed]);
|
||||
}
|
||||
Err(std::sync::TryLockError::WouldBlock) => {
|
||||
audio_processing_stats_for_render.increment_callback_xrun();
|
||||
// scratch_stereo is already zeroed above.
|
||||
}
|
||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||||
// Never panic on the realtime IO thread.
|
||||
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}");
|
||||
}
|
||||
Err(std::sync::TryLockError::Poisoned(e)) => {
|
||||
// Never panic on the realtime IO thread.
|
||||
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}");
|
||||
}
|
||||
// Peak limiter — multi-client mixes can sum past 0 dBFS;
|
||||
// without this the downmix helper would hard-clip to i16::MAX.
|
||||
crate::voice_render::limit_peak_inplace(&mut scratch_stereo[..needed], 0.99);
|
||||
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
||||
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_interleaved_i16(
|
||||
&scratch_stereo[..needed],
|
||||
out,
|
||||
out_channels,
|
||||
gain,
|
||||
muted,
|
||||
}
|
||||
// Peak limiter — multi-client mixes can sum past 0 dBFS;
|
||||
// without this the downmix helper would hard-clip to i16::MAX.
|
||||
crate::voice_render::limit_peak_inplace(&mut scratch_stereo[..needed], 0.99);
|
||||
let gain = f32::from_bits(output_gain_for_render.load(Ordering::Relaxed));
|
||||
let muted = output_muted_for_render.load(Ordering::Relaxed);
|
||||
let mix_stats = crate::voice_render::downmix_stereo_f32_to_interleaved_i16(
|
||||
&scratch_stereo[..needed],
|
||||
out,
|
||||
out_channels,
|
||||
gain,
|
||||
muted,
|
||||
);
|
||||
if mix_stats.clipped_samples > 0 {
|
||||
audio_processing_stats_for_render.add_clipped_samples(mix_stats.clipped_samples);
|
||||
}
|
||||
render_level_decimation = render_level_decimation.wrapping_add(1);
|
||||
if render_level_decimation % 3 == 0 {
|
||||
audio_processing_stats_for_render.update_render(
|
||||
crate::frame::dbfs(&scratch_stereo[..needed]),
|
||||
num_frames as u32,
|
||||
);
|
||||
if mix_stats.clipped_samples > 0 {
|
||||
audio_processing_stats_for_render
|
||||
.add_clipped_samples(mix_stats.clipped_samples);
|
||||
}
|
||||
render_level_decimation = render_level_decimation.wrapping_add(1);
|
||||
if render_level_decimation % 3 == 0 {
|
||||
audio_processing_stats_for_render.update_render(
|
||||
crate::frame::dbfs(&scratch_stereo[..needed]),
|
||||
num_frames as u32,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Track audio-vs-silence for the diagnostic.
|
||||
if mix_stats.peak_i16 > 0 {
|
||||
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
|
||||
} else {
|
||||
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
|
||||
// Muted output writes intentional silence (peak_i16 == 0 by
|
||||
// design), not a starved render path. Gate on !muted to avoid
|
||||
// counting deliberate silence as an output underrun.
|
||||
if !muted {
|
||||
audio_processing_stats_for_render.increment_output_underrun();
|
||||
if let Ok(guard) = wav_recorder_for_render.try_lock() {
|
||||
if let Some(rec) = guard.as_ref() {
|
||||
if !render_recorder_active {
|
||||
render_ref_len = 0;
|
||||
render_ref_accum.fill(0.0);
|
||||
render_recorder_active = true;
|
||||
}
|
||||
let mut idx = 0;
|
||||
while idx + 1 < needed {
|
||||
let mono = (scratch_stereo[idx] + scratch_stereo[idx + 1]) * 0.5;
|
||||
render_ref_accum[render_ref_len] = mono;
|
||||
render_ref_len += 1;
|
||||
idx += 2;
|
||||
if render_ref_len == crate::frame::FRAME_10MS_SAMPLES {
|
||||
rec.push_render_reference(&render_ref_accum);
|
||||
render_ref_len = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
render_recorder_active = false;
|
||||
}
|
||||
} else {
|
||||
render_recorder_active = false;
|
||||
}
|
||||
// Track audio-vs-silence for the diagnostic.
|
||||
if mix_stats.peak_i16 > 0 {
|
||||
callbacks_with_audio = callbacks_with_audio.wrapping_add(1);
|
||||
} else {
|
||||
callbacks_with_silence = callbacks_with_silence.wrapping_add(1);
|
||||
// Muted output writes intentional silence (peak_i16 == 0 by
|
||||
// design), not a starved render path. Gate on !muted to avoid
|
||||
// counting deliberate silence as an output underrun.
|
||||
if !muted {
|
||||
audio_processing_stats_for_render.increment_output_underrun();
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic sampling.
|
||||
if last_num_frames != 0 && last_num_frames != num_frames {
|
||||
num_frames_changes = num_frames_changes.wrapping_add(1);
|
||||
}
|
||||
last_num_frames = num_frames;
|
||||
cb_count = cb_count.wrapping_add(1);
|
||||
if cb_count.is_multiple_of(100) {
|
||||
debug!(
|
||||
target: "chanora_audio",
|
||||
cb = cb_count,
|
||||
num_frames,
|
||||
frames_changes = num_frames_changes,
|
||||
callbacks_with_audio,
|
||||
callbacks_with_silence,
|
||||
peak_out_i16 = mix_stats.peak_i16,
|
||||
gain,
|
||||
"ios audio unit render callback diagnostic sample (direct fill_buffer)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
|
||||
// Diagnostic sampling.
|
||||
if last_num_frames != 0 && last_num_frames != num_frames {
|
||||
num_frames_changes = num_frames_changes.wrapping_add(1);
|
||||
}
|
||||
last_num_frames = num_frames;
|
||||
cb_count = cb_count.wrapping_add(1);
|
||||
if cb_count.is_multiple_of(100) {
|
||||
debug!(
|
||||
target: "chanora_audio",
|
||||
cb = cb_count,
|
||||
num_frames,
|
||||
frames_changes = num_frames_changes,
|
||||
callbacks_with_audio,
|
||||
callbacks_with_silence,
|
||||
peak_out_i16 = mix_stats.peak_i16,
|
||||
gain,
|
||||
"ios audio unit render callback diagnostic sample (direct fill_buffer)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.map_err(|e| AudioError::Backend(format!("audio unit set render callback: {e}")))?;
|
||||
} // end #[cfg(target_os = "ios")] block
|
||||
|
||||
// Finalise the unit — allocates internal buffers per the
|
||||
|
||||
@@ -28,17 +28,10 @@
|
||||
|
||||
#![warn(missing_docs)]
|
||||
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
mod android_render_ring;
|
||||
#[cfg(any(target_os = "android", test))]
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
mod audio_event_queue;
|
||||
pub mod audio_processing;
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
mod capture_accumulator;
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
mod capture_resampler;
|
||||
pub mod debug_wav;
|
||||
mod engine;
|
||||
pub mod frame;
|
||||
@@ -49,11 +42,6 @@ pub mod processor;
|
||||
pub mod ptt;
|
||||
pub mod ptt_backends;
|
||||
pub mod release_tail;
|
||||
#[cfg_attr(
|
||||
not(any(target_os = "android", target_os = "ios", test)),
|
||||
allow(dead_code)
|
||||
)]
|
||||
pub(crate) mod render_reference;
|
||||
pub mod route_policy;
|
||||
pub mod transmit_mode;
|
||||
pub mod transmit_selector;
|
||||
|
||||
@@ -3,18 +3,15 @@ use audiopus::{
|
||||
Application as OpusApp, Bitrate as OpusBitrate, Channels as OpusChannels,
|
||||
SampleRate as OpusSampleRate,
|
||||
};
|
||||
use crossbeam::queue::ArrayQueue;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info, warn};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use chanora_protocol::{AudioData, CodecType, OutAudio, OutPacket};
|
||||
|
||||
use crate::AudioError;
|
||||
|
||||
pub(crate) const MAX_OPUS_FRAME: usize = 1275;
|
||||
const VOICE_FRAME_QUEUE_CAPACITY: usize = 64;
|
||||
|
||||
const VOIP_BITRATE_BPS: i32 = 32_000;
|
||||
const VOIP_COMPLEXITY: u8 = 10;
|
||||
@@ -57,129 +54,10 @@ pub(crate) fn tune_voip_encoder(encoder: &mut OpusEncoder, context: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) struct EncodedVoiceFrame {
|
||||
data: [u8; MAX_OPUS_FRAME],
|
||||
len: usize,
|
||||
}
|
||||
|
||||
pub(crate) struct EncodedVoiceFrameSender {
|
||||
queue: Arc<ArrayQueue<EncodedVoiceFrame>>,
|
||||
open: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
enum EncodedVoiceFrameSendError {
|
||||
Full,
|
||||
Closed,
|
||||
}
|
||||
|
||||
impl EncodedVoiceFrameSender {
|
||||
fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
queue: Arc::new(ArrayQueue::new(capacity)),
|
||||
open: Arc::new(AtomicBool::new(true)),
|
||||
}
|
||||
}
|
||||
|
||||
fn worker_queue(&self) -> Arc<ArrayQueue<EncodedVoiceFrame>> {
|
||||
Arc::clone(&self.queue)
|
||||
}
|
||||
|
||||
fn worker_open_flag(&self) -> Arc<AtomicBool> {
|
||||
Arc::clone(&self.open)
|
||||
}
|
||||
|
||||
fn push(&self, frame: EncodedVoiceFrame) -> Result<(), EncodedVoiceFrameSendError> {
|
||||
if !self.open.load(Ordering::Relaxed) {
|
||||
return Err(EncodedVoiceFrameSendError::Closed);
|
||||
}
|
||||
self.queue
|
||||
.push(frame)
|
||||
.map_err(|_| EncodedVoiceFrameSendError::Full)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn start_out_packet_worker(
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
context: &'static str,
|
||||
) -> Result<EncodedVoiceFrameSender, AudioError> {
|
||||
start_out_packet_worker_with_spawner(voice_out_tx, frames_sent, context, |name, worker| {
|
||||
std::thread::Builder::new()
|
||||
.name(name)
|
||||
.spawn(worker)
|
||||
.map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
fn start_out_packet_worker_with_spawner<S>(
|
||||
voice_out_tx: mpsc::Sender<OutPacket>,
|
||||
frames_sent: Arc<AtomicU32>,
|
||||
context: &'static str,
|
||||
spawn: S,
|
||||
) -> Result<EncodedVoiceFrameSender, AudioError>
|
||||
where
|
||||
S: FnOnce(String, Box<dyn FnOnce() + Send + 'static>) -> std::io::Result<()>,
|
||||
{
|
||||
let tx = EncodedVoiceFrameSender::new(VOICE_FRAME_QUEUE_CAPACITY);
|
||||
let rx = tx.worker_queue();
|
||||
let worker_open = tx.worker_open_flag();
|
||||
spawn(
|
||||
format!("chanora-{context}-voice-packets"),
|
||||
Box::new(move || {
|
||||
loop {
|
||||
let Some(frame) = rx.pop() else {
|
||||
if Arc::strong_count(&rx) == 1 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
continue;
|
||||
};
|
||||
let packet = OutAudio::new(&AudioData::C2S {
|
||||
id: 0,
|
||||
codec: CodecType::OpusVoice,
|
||||
data: frame.as_slice(),
|
||||
});
|
||||
match voice_out_tx.try_send(packet) {
|
||||
Ok(()) => {
|
||||
frames_sent.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Full(_)) => {
|
||||
warn!(target: "chanora_audio", context = %context, "voice_out queue full; dropping frame");
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => {
|
||||
debug!(target: "chanora_audio", context = %context, "voice_out closed; voice packet worker stopping");
|
||||
worker_open.store(false, Ordering::Relaxed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.map_err(|e| {
|
||||
tx.open.store(false, Ordering::Relaxed);
|
||||
AudioError::Backend(format!("voice packet worker spawn ({context}): {e}"))
|
||||
})?;
|
||||
Ok(tx)
|
||||
}
|
||||
|
||||
impl EncodedVoiceFrame {
|
||||
fn try_from_opus(opus_out: &[u8], len: usize) -> Option<Self> {
|
||||
if len > opus_out.len() || len > MAX_OPUS_FRAME {
|
||||
return None;
|
||||
}
|
||||
let mut data = [0u8; MAX_OPUS_FRAME];
|
||||
data[..len].copy_from_slice(&opus_out[..len]);
|
||||
Some(Self { data, len })
|
||||
}
|
||||
|
||||
fn as_slice(&self) -> &[u8] {
|
||||
&self.data[..self.len]
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode-scope send helper for a freshly encoded Opus voice frame.
|
||||
pub(crate) fn send_voip_frame<F, G>(
|
||||
voice_out_tx: &EncodedVoiceFrameSender,
|
||||
voice_out_tx: &mpsc::Sender<OutPacket>,
|
||||
frames_sent: &AtomicU32,
|
||||
opus_out: &[u8],
|
||||
len: usize,
|
||||
on_full: F,
|
||||
@@ -188,77 +66,16 @@ pub(crate) fn send_voip_frame<F, G>(
|
||||
F: FnOnce(),
|
||||
G: FnOnce(),
|
||||
{
|
||||
let Some(frame) = EncodedVoiceFrame::try_from_opus(opus_out, len) else {
|
||||
on_full();
|
||||
return;
|
||||
};
|
||||
match voice_out_tx.push(frame) {
|
||||
Ok(()) => {}
|
||||
Err(EncodedVoiceFrameSendError::Full) => on_full(),
|
||||
Err(EncodedVoiceFrameSendError::Closed) => on_closed(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encoded_voice_frame_copies_into_fixed_storage() {
|
||||
let source = [7u8; MAX_OPUS_FRAME];
|
||||
|
||||
let frame = EncodedVoiceFrame::try_from_opus(&source, MAX_OPUS_FRAME).unwrap();
|
||||
|
||||
assert_eq!(frame.as_slice().len(), MAX_OPUS_FRAME);
|
||||
assert!(frame.as_slice().iter().all(|byte| *byte == 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_voice_frame_rejects_lengths_beyond_fixed_storage() {
|
||||
let source = [0u8; MAX_OPUS_FRAME];
|
||||
|
||||
assert!(EncodedVoiceFrame::try_from_opus(&source, MAX_OPUS_FRAME + 1).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_voice_frame_sender_reports_full_without_blocking() {
|
||||
let sender = EncodedVoiceFrameSender::new(1);
|
||||
let source = [3u8; MAX_OPUS_FRAME];
|
||||
let first = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
|
||||
let second = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
|
||||
|
||||
assert!(sender.push(first).is_ok());
|
||||
assert!(sender.push(second).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_voice_frame_sender_reports_closed_without_queueing() {
|
||||
let sender = EncodedVoiceFrameSender::new(1);
|
||||
sender.open.store(false, Ordering::Relaxed);
|
||||
let source = [3u8; MAX_OPUS_FRAME];
|
||||
let frame = EncodedVoiceFrame::try_from_opus(&source, 4).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
sender.push(frame),
|
||||
Err(EncodedVoiceFrameSendError::Closed)
|
||||
));
|
||||
assert_eq!(sender.queue.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_voice_frame_sender_reports_spawn_failure() {
|
||||
let (voice_out_tx, _voice_out_rx) = mpsc::channel(1);
|
||||
let frames_sent = Arc::new(AtomicU32::new(0));
|
||||
|
||||
let result = start_out_packet_worker_with_spawner(
|
||||
voice_out_tx,
|
||||
frames_sent,
|
||||
"test",
|
||||
|_name, _worker| Err(std::io::Error::other("spawn failed")),
|
||||
);
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(AudioError::Backend(message)) if message.contains("spawn failed"))
|
||||
);
|
||||
let packet = OutAudio::new(&AudioData::C2S {
|
||||
id: 0,
|
||||
codec: CodecType::OpusVoice,
|
||||
data: &opus_out[..len],
|
||||
});
|
||||
match voice_out_tx.try_send(packet) {
|
||||
Ok(()) => {
|
||||
frames_sent.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(mpsc::error::TrySendError::Full(_)) => on_full(),
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => on_closed(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
use core::fmt;
|
||||
|
||||
use crate::ptt::{AudioTransmitGate, PttBackendDescriptor};
|
||||
use thiserror::Error;
|
||||
|
||||
mod focused;
|
||||
|
||||
@@ -109,51 +108,35 @@ impl fmt::Display for PttInputClass {
|
||||
}
|
||||
|
||||
/// Errors raised by a desktop PTT backend.
|
||||
#[derive(Debug, Error)]
|
||||
#[derive(Debug)]
|
||||
pub enum PttBackendError {
|
||||
/// The OS rejected the backend initialisation (e.g. Raw Input
|
||||
/// registration failed, event tap creation failed).
|
||||
#[error("init failed: {0}")]
|
||||
Init(String),
|
||||
/// The user-granted permission required for global capture is
|
||||
/// not granted (typically macOS Input Monitoring / Accessibility).
|
||||
#[error("permission denied")]
|
||||
PermissionDenied,
|
||||
/// The display server or compositor does not expose the
|
||||
/// expected interface (typically a non-tested Linux compositor).
|
||||
#[error("unsupported environment")]
|
||||
UnsupportedEnvironment,
|
||||
/// Caller submitted a binding whose `platform_key` cannot be
|
||||
/// parsed in the active OS.
|
||||
#[error("invalid binding: {0}")]
|
||||
InvalidBinding(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ptt_backend_error_display_strings_stay_stable() {
|
||||
assert_eq!(
|
||||
PttBackendError::Init("rawinput".into()).to_string(),
|
||||
"init failed: rawinput"
|
||||
);
|
||||
assert_eq!(
|
||||
PttBackendError::PermissionDenied.to_string(),
|
||||
"permission denied"
|
||||
);
|
||||
assert_eq!(
|
||||
PttBackendError::UnsupportedEnvironment.to_string(),
|
||||
"unsupported environment"
|
||||
);
|
||||
assert_eq!(
|
||||
PttBackendError::InvalidBinding("bad key".into()).to_string(),
|
||||
"invalid binding: bad key"
|
||||
);
|
||||
impl fmt::Display for PttBackendError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Init(s) => write!(f, "init failed: {s}"),
|
||||
Self::PermissionDenied => f.write_str("permission denied"),
|
||||
Self::UnsupportedEnvironment => f.write_str("unsupported environment"),
|
||||
Self::InvalidBinding(s) => write!(f, "invalid binding: {s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PttBackendError {}
|
||||
|
||||
/// Cross-platform desktop PTT backend (SDD-081).
|
||||
///
|
||||
/// All implementations call exactly the audio transmit gate's
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
use std::array;
|
||||
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
const NO_LATEST_SLOT: usize = usize::MAX;
|
||||
|
||||
struct Slot<const SAMPLES: usize> {
|
||||
version: AtomicUsize,
|
||||
samples: [AtomicU32; SAMPLES],
|
||||
#[cfg(test)]
|
||||
bump_after_first_sample_read: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl<const SAMPLES: usize> Slot<SAMPLES> {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
version: AtomicUsize::new(0),
|
||||
samples: array::from_fn(|_| AtomicU32::new(0.0_f32.to_bits())),
|
||||
#[cfg(test)]
|
||||
bump_after_first_sample_read: std::sync::atomic::AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RenderReferenceFrameAccumulator<const SAMPLES: usize> {
|
||||
pending: [f32; SAMPLES],
|
||||
pending_len: usize,
|
||||
}
|
||||
|
||||
impl<const SAMPLES: usize> RenderReferenceFrameAccumulator<SAMPLES> {
|
||||
pub(crate) fn new() -> Self {
|
||||
assert!(
|
||||
SAMPLES > 0,
|
||||
"RenderReferenceFrameAccumulator requires at least one sample"
|
||||
);
|
||||
Self {
|
||||
pending: [0.0; SAMPLES],
|
||||
pending_len: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn push_mono_samples(
|
||||
&mut self,
|
||||
mut samples: &[f32],
|
||||
mut publish: impl FnMut(&[f32; SAMPLES]),
|
||||
) {
|
||||
while !samples.is_empty() {
|
||||
let needed = SAMPLES - self.pending_len;
|
||||
let take = needed.min(samples.len());
|
||||
self.pending[self.pending_len..self.pending_len + take]
|
||||
.copy_from_slice(&samples[..take]);
|
||||
self.pending_len += take;
|
||||
samples = &samples[take..];
|
||||
|
||||
if self.pending_len == SAMPLES {
|
||||
publish(&self.pending);
|
||||
self.pending_len = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn pending_len(&self) -> usize {
|
||||
self.pending_len
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RenderReferenceBuffer<const SAMPLES: usize, const SLOTS: usize> {
|
||||
slots: Box<[Slot<SAMPLES>; SLOTS]>,
|
||||
write_idx: AtomicUsize,
|
||||
latest_slot: AtomicUsize,
|
||||
}
|
||||
|
||||
impl<const SAMPLES: usize, const SLOTS: usize> RenderReferenceBuffer<SAMPLES, SLOTS> {
|
||||
pub(crate) fn new() -> Arc<Self> {
|
||||
assert!(
|
||||
SLOTS > 0,
|
||||
"RenderReferenceBuffer requires at least one slot"
|
||||
);
|
||||
Arc::new(Self {
|
||||
slots: Box::new(array::from_fn(|_| Slot::new())),
|
||||
write_idx: AtomicUsize::new(0),
|
||||
latest_slot: AtomicUsize::new(NO_LATEST_SLOT),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn write(&self, frame: &[f32; SAMPLES]) {
|
||||
let idx = self.write_idx.load(Ordering::Relaxed) % SLOTS;
|
||||
let slot = &self.slots[idx];
|
||||
// The acquire half keeps payload stores after the odd in-progress marker.
|
||||
let version = slot.version.fetch_add(1, Ordering::AcqRel);
|
||||
debug_assert_eq!(version & 1, 0, "single writer should only enter even slots");
|
||||
for (sample, value) in slot.samples.iter().zip(frame.iter().copied()) {
|
||||
sample.store(value.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
slot.version
|
||||
.store(version.wrapping_add(2) & !1, Ordering::Release);
|
||||
self.latest_slot.store(idx, Ordering::Release);
|
||||
self.write_idx.store((idx + 1) % SLOTS, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub(crate) fn read_latest(&self) -> [f32; SAMPLES] {
|
||||
let mut out = [0.0_f32; SAMPLES];
|
||||
self.read_latest_into(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn read_latest_into(&self, out: &mut [f32; SAMPLES]) {
|
||||
let idx = self.latest_slot.load(Ordering::Acquire);
|
||||
if idx == NO_LATEST_SLOT {
|
||||
out.fill(0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
let slot = &self.slots[idx];
|
||||
let before = slot.version.load(Ordering::Acquire);
|
||||
if before & 1 == 1 {
|
||||
out.fill(0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
for (dst, sample) in out.iter_mut().zip(slot.samples.iter()) {
|
||||
*dst = f32::from_bits(sample.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
for (idx, (dst, sample)) in out.iter_mut().zip(slot.samples.iter()).enumerate() {
|
||||
*dst = f32::from_bits(sample.load(Ordering::Relaxed));
|
||||
if idx == 0
|
||||
&& slot
|
||||
.bump_after_first_sample_read
|
||||
.swap(false, Ordering::Relaxed)
|
||||
{
|
||||
slot.version.fetch_add(2, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
let after = slot.version.load(Ordering::Acquire);
|
||||
if before != after || after & 1 == 1 {
|
||||
out.fill(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn mark_latest_slot_in_progress_for_test(&self) {
|
||||
let idx = self.latest_slot.load(Ordering::Acquire);
|
||||
assert_ne!(idx, NO_LATEST_SLOT);
|
||||
self.slots[idx].version.fetch_or(1, Ordering::Release);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn bump_latest_slot_version_after_first_sample_for_test(&self) {
|
||||
let idx = self.latest_slot.load(Ordering::Acquire);
|
||||
assert_ne!(idx, NO_LATEST_SLOT);
|
||||
self.slots[idx]
|
||||
.bump_after_first_sample_read
|
||||
.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{RenderReferenceBuffer, RenderReferenceFrameAccumulator};
|
||||
|
||||
#[test]
|
||||
fn render_reference_reads_zero_before_first_publish() {
|
||||
let buffer = RenderReferenceBuffer::<4, 2>::new();
|
||||
|
||||
assert_eq!(buffer.read_latest(), [0.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_reference_reader_gets_latest_complete_frame() {
|
||||
let buffer = RenderReferenceBuffer::<4, 3>::new();
|
||||
|
||||
buffer.write(&[1.0, 2.0, 3.0, 4.0]);
|
||||
buffer.write(&[5.0, 6.0, 7.0, 8.0]);
|
||||
|
||||
assert_eq!(buffer.read_latest(), [5.0, 6.0, 7.0, 8.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_reference_writes_wrap_without_returning_stale_frame() {
|
||||
let buffer = RenderReferenceBuffer::<2, 2>::new();
|
||||
|
||||
buffer.write(&[1.0, 2.0]);
|
||||
buffer.write(&[3.0, 4.0]);
|
||||
buffer.write(&[5.0, 6.0]);
|
||||
|
||||
assert_eq!(buffer.read_latest(), [5.0, 6.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_reference_accumulator_publishes_only_complete_frames() {
|
||||
let mut accum = RenderReferenceFrameAccumulator::<4>::new();
|
||||
let mut frames = Vec::new();
|
||||
|
||||
accum.push_mono_samples(&[1.0, 2.0], |frame| frames.push(*frame));
|
||||
|
||||
assert!(frames.is_empty());
|
||||
assert_eq!(accum.pending_len(), 2);
|
||||
|
||||
accum.push_mono_samples(&[3.0, 4.0, 5.0, 6.0, 7.0], |frame| frames.push(*frame));
|
||||
|
||||
assert_eq!(frames, vec![[1.0, 2.0, 3.0, 4.0]]);
|
||||
assert_eq!(accum.pending_len(), 3);
|
||||
|
||||
accum.push_mono_samples(&[8.0], |frame| frames.push(*frame));
|
||||
|
||||
assert_eq!(frames, vec![[1.0, 2.0, 3.0, 4.0], [5.0, 6.0, 7.0, 8.0]]);
|
||||
assert_eq!(accum.pending_len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_reference_reader_rejects_in_progress_slot() {
|
||||
let buffer = RenderReferenceBuffer::<2, 1>::new();
|
||||
|
||||
buffer.write(&[1.0, 2.0]);
|
||||
buffer.mark_latest_slot_in_progress_for_test();
|
||||
|
||||
assert_eq!(buffer.read_latest(), [0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_reference_reader_rejects_stale_slot_changed_during_read() {
|
||||
let buffer = RenderReferenceBuffer::<2, 1>::new();
|
||||
|
||||
buffer.write(&[1.0, 2.0]);
|
||||
|
||||
buffer.bump_latest_slot_version_after_first_sample_for_test();
|
||||
|
||||
assert_eq!(buffer.read_latest(), [0.0, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "RenderReferenceBuffer requires at least one slot")]
|
||||
fn render_reference_rejects_zero_slots() {
|
||||
let _ = RenderReferenceBuffer::<2, 0>::new();
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{OnceLock, RwLock};
|
||||
|
||||
use crate::frame::{f32_to_i16, i16_to_f32};
|
||||
use crate::{AudioError, VadBackend};
|
||||
use crate::AudioError;
|
||||
use resampler::{Downsampler48to16, INPUT_FRAME_10MS};
|
||||
|
||||
#[cfg(not(target_os = "ios"))]
|
||||
@@ -108,36 +108,6 @@ pub fn process_i16_10ms(detector: &mut dyn VoiceActivityDetector, samples: &[i16
|
||||
detector.process_10ms(&frame)
|
||||
}
|
||||
|
||||
/// Callback-side policy for optional model-backed VAD workers.
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub(crate) enum VadWorkerPolicy {
|
||||
/// Keep using the already-available model worker.
|
||||
UseWorker,
|
||||
/// No worker may be constructed on the callback thread; use WebRTC fallback.
|
||||
UseFallback,
|
||||
/// This backend does not need a model worker.
|
||||
NotModelBacked,
|
||||
}
|
||||
|
||||
/// Decide whether a realtime callback may use a model-backed VAD worker.
|
||||
///
|
||||
/// Model/worker construction is intentionally absent from this policy: if a
|
||||
/// worker is not already present, callbacks must stay nonblocking and fall back.
|
||||
pub(crate) fn callback_vad_worker_policy(
|
||||
voice_activity_mode: bool,
|
||||
backend: VadBackend,
|
||||
worker_available: bool,
|
||||
) -> VadWorkerPolicy {
|
||||
if !voice_activity_mode || backend != VadBackend::SileroOnnx {
|
||||
return VadWorkerPolicy::NotModelBacked;
|
||||
}
|
||||
if worker_available {
|
||||
VadWorkerPolicy::UseWorker
|
||||
} else {
|
||||
VadWorkerPolicy::UseFallback
|
||||
}
|
||||
}
|
||||
|
||||
static SILERO_MODEL_PATH_OVERRIDE: OnceLock<RwLock<Option<String>>> = OnceLock::new();
|
||||
static SILERO_MODEL_EPOCH: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
@@ -282,32 +252,4 @@ mod tests {
|
||||
assert_eq!(silero_model_bundle_path(), path.to_string_lossy());
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_policy_uses_existing_model_worker_only() {
|
||||
assert_eq!(
|
||||
callback_vad_worker_policy(true, VadBackend::SileroOnnx, true),
|
||||
VadWorkerPolicy::UseWorker
|
||||
);
|
||||
assert_eq!(
|
||||
callback_vad_worker_policy(true, VadBackend::SileroOnnx, false),
|
||||
VadWorkerPolicy::UseFallback
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn callback_policy_keeps_disabled_and_webrtc_paths_worker_free() {
|
||||
assert_eq!(
|
||||
callback_vad_worker_policy(false, VadBackend::SileroOnnx, false),
|
||||
VadWorkerPolicy::NotModelBacked
|
||||
);
|
||||
assert_eq!(
|
||||
callback_vad_worker_policy(true, VadBackend::Disabled, false),
|
||||
VadWorkerPolicy::NotModelBacked
|
||||
);
|
||||
assert_eq!(
|
||||
callback_vad_worker_policy(true, VadBackend::WebrtcVad, false),
|
||||
VadWorkerPolicy::NotModelBacked
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/// Diagnostics returned by render downmix helpers.
|
||||
#[cfg(any(target_os = "ios", test))]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(crate) struct RenderDownmixStats {
|
||||
/// Peak absolute sample magnitude after i16 conversion.
|
||||
@@ -8,7 +7,47 @@ pub(crate) struct RenderDownmixStats {
|
||||
pub clipped_samples: u64,
|
||||
}
|
||||
|
||||
/// Downmix interleaved stereo f32 samples into mono i16 samples.
|
||||
///
|
||||
/// The helper is allocation-free and safe for realtime render callbacks.
|
||||
/// If the stereo source is shorter than expected, the remainder of `out`
|
||||
/// is filled with silence.
|
||||
#[cfg(any(target_os = "ios", test))]
|
||||
pub(crate) fn downmix_stereo_f32_to_mono_i16(
|
||||
stereo: &[f32],
|
||||
out: &mut [i16],
|
||||
gain: f32,
|
||||
muted: bool,
|
||||
) -> RenderDownmixStats {
|
||||
if muted {
|
||||
out.fill(0);
|
||||
return RenderDownmixStats::default();
|
||||
}
|
||||
|
||||
let available_frames = stereo.len() / 2;
|
||||
if available_frames < out.len() {
|
||||
out.fill(0);
|
||||
}
|
||||
|
||||
let mut peak = 0_u16;
|
||||
let mut clipped_samples = 0_u64;
|
||||
for (dst, lr) in out.iter_mut().zip(stereo.chunks_exact(2)) {
|
||||
let mono = (lr[0] + lr[1]) * 0.5 * gain;
|
||||
let clamped = mono.clamp(-1.0, 1.0);
|
||||
if (mono - clamped).abs() > f32::EPSILON {
|
||||
clipped_samples = clipped_samples.saturating_add(1);
|
||||
}
|
||||
let sample = (clamped * i16::MAX as f32) as i16;
|
||||
*dst = sample;
|
||||
peak = peak.max(sample.unsigned_abs());
|
||||
}
|
||||
|
||||
RenderDownmixStats {
|
||||
peak_i16: peak.min(i16::MAX as u16) as i16,
|
||||
clipped_samples,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn downmix_stereo_f32_to_interleaved_i16(
|
||||
stereo: &[f32],
|
||||
out: &mut [i16],
|
||||
@@ -83,7 +122,10 @@ pub(crate) fn limit_peak_inplace(samples: &mut [f32], threshold: f32) -> f32 {
|
||||
if threshold <= 0.0 || !threshold.is_finite() {
|
||||
return 1.0;
|
||||
}
|
||||
let peak = samples.iter().map(|s| s.abs()).fold(0.0_f32, f32::max);
|
||||
let peak = samples
|
||||
.iter()
|
||||
.map(|s| s.abs())
|
||||
.fold(0.0_f32, f32::max);
|
||||
if peak <= threshold {
|
||||
return 1.0;
|
||||
}
|
||||
@@ -103,7 +145,7 @@ mod tests {
|
||||
let stereo = [1.0_f32, 1.0, 0.25, -0.25, -2.0, -2.0];
|
||||
let mut out = [0_i16; 3];
|
||||
|
||||
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 2.0, false);
|
||||
let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 2.0, false);
|
||||
|
||||
assert_eq!(out[0], i16::MAX);
|
||||
assert_eq!(out[1], 0);
|
||||
@@ -117,7 +159,7 @@ mod tests {
|
||||
let stereo = [1.0_f32, 1.0, -1.0, -1.0];
|
||||
let mut out = [123_i16; 2];
|
||||
|
||||
let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 1.0, true);
|
||||
let stats = downmix_stereo_f32_to_mono_i16(&stereo, &mut out, 1.0, true);
|
||||
|
||||
assert_eq!(out, [0, 0]);
|
||||
assert_eq!(stats, RenderDownmixStats::default());
|
||||
@@ -192,7 +234,7 @@ mod tests {
|
||||
let mut scratch = [1.0_f32, 1.0, -0.5, -0.5, 0.8, 0.8];
|
||||
limit_peak_inplace(&mut scratch, 0.95);
|
||||
let mut out = [0_i16; 3];
|
||||
let stats = downmix_stereo_f32_to_interleaved_i16(&scratch, &mut out, 1, 1.0, false);
|
||||
let stats = downmix_stereo_f32_to_mono_i16(&scratch, &mut out, 1.0, false);
|
||||
assert_eq!(stats.clipped_samples, 0);
|
||||
assert!(stats.peak_i16 < i16::MAX);
|
||||
}
|
||||
|
||||
@@ -1972,77 +1972,27 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
|
||||
route: route.into(),
|
||||
}
|
||||
}
|
||||
chanora_core::SessionEvent::ClientMoved {
|
||||
client_id,
|
||||
new_channel_id,
|
||||
} => BridgeEvent::ClientMoved {
|
||||
client_id,
|
||||
new_channel_id,
|
||||
},
|
||||
chanora_core::SessionEvent::ClientJoined {
|
||||
client_id,
|
||||
channel_id,
|
||||
name,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
} => BridgeEvent::ClientJoined {
|
||||
client_id,
|
||||
channel_id,
|
||||
name,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
},
|
||||
chanora_core::SessionEvent::ClientMoved { client_id, new_channel_id } => {
|
||||
BridgeEvent::ClientMoved { client_id, new_channel_id }
|
||||
}
|
||||
chanora_core::SessionEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
||||
BridgeEvent::ClientJoined { client_id, channel_id, name, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
||||
}
|
||||
chanora_core::SessionEvent::ClientLeft { client_id, name } => {
|
||||
BridgeEvent::ClientLeft { client_id, name }
|
||||
}
|
||||
chanora_core::SessionEvent::ClientUpdated {
|
||||
client_id,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
} => BridgeEvent::ClientUpdated {
|
||||
client_id,
|
||||
input_muted,
|
||||
output_muted,
|
||||
is_server_query,
|
||||
talk_power,
|
||||
talk_power_granted,
|
||||
},
|
||||
chanora_core::SessionEvent::ChannelAdded {
|
||||
id,
|
||||
parent,
|
||||
name,
|
||||
order,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
} => BridgeEvent::ChannelAdded {
|
||||
id,
|
||||
parent,
|
||||
name,
|
||||
order,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
},
|
||||
chanora_core::SessionEvent::ChannelRemoved { id } => BridgeEvent::ChannelRemoved { id },
|
||||
chanora_core::SessionEvent::ChannelUpdated {
|
||||
id,
|
||||
name,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
} => BridgeEvent::ChannelUpdated {
|
||||
id,
|
||||
name,
|
||||
has_password,
|
||||
needed_talk_power,
|
||||
},
|
||||
chanora_core::SessionEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted } => {
|
||||
BridgeEvent::ClientUpdated { client_id, input_muted, output_muted, is_server_query, talk_power, talk_power_granted }
|
||||
}
|
||||
chanora_core::SessionEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power } => {
|
||||
BridgeEvent::ChannelAdded { id, parent, name, order, has_password, needed_talk_power }
|
||||
}
|
||||
chanora_core::SessionEvent::ChannelRemoved { id } => {
|
||||
BridgeEvent::ChannelRemoved { id }
|
||||
}
|
||||
chanora_core::SessionEvent::ChannelUpdated { id, name, has_password, needed_talk_power } => {
|
||||
BridgeEvent::ChannelUpdated { id, name, has_password, needed_talk_power }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use thiserror::Error;
|
||||
@@ -712,7 +712,7 @@ impl DiagnosticExport {
|
||||
/// diagnostic export and state-sync replay verification.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProtocolEventRecorder {
|
||||
events: VecDeque<String>,
|
||||
events: Vec<String>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
@@ -720,20 +720,17 @@ impl ProtocolEventRecorder {
|
||||
/// Create a recorder with the given ring-buffer capacity.
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
events: VecDeque::with_capacity(capacity),
|
||||
events: Vec::with_capacity(capacity),
|
||||
capacity,
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, ts: &str, kind: &str, detail: &str) {
|
||||
if self.capacity == 0 {
|
||||
return;
|
||||
}
|
||||
let s = format!("[{ts}] {kind}: {detail}");
|
||||
if self.events.len() >= self.capacity {
|
||||
self.events.pop_front();
|
||||
self.events.remove(0);
|
||||
}
|
||||
self.events.push_back(s);
|
||||
self.events.push(s);
|
||||
}
|
||||
|
||||
/// Record a successful connection.
|
||||
@@ -780,12 +777,12 @@ impl ProtocolEventRecorder {
|
||||
|
||||
/// Drain all recorded events and reset the buffer.
|
||||
pub fn drain(&mut self) -> Vec<String> {
|
||||
self.events.drain(..).collect()
|
||||
std::mem::take(&mut self.events)
|
||||
}
|
||||
|
||||
/// Snapshot all recorded events without clearing the buffer.
|
||||
pub fn snapshot(&self) -> Vec<String> {
|
||||
self.events.iter().cloned().collect()
|
||||
self.events.clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1106,13 +1103,4 @@ mod tests {
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(drained, first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_event_zero_capacity_drops_events() {
|
||||
let mut recorder = ProtocolEventRecorder::new(0);
|
||||
recorder.record_connected("Server");
|
||||
|
||||
assert!(recorder.snapshot().is_empty());
|
||||
assert!(recorder.drain().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,9 +46,6 @@ use crate::ProtocolError;
|
||||
const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750);
|
||||
const INBOUND_VOICE_SEND_TIMEOUT: Duration = Duration::from_millis(40);
|
||||
const PROFILE_REFRESH_RESULT_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const OUTBOUND_VOICE_PACKETS_PER_TICK: usize = 8;
|
||||
const DISCONNECT_REPLY_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
const DISCONNECT_EVENT_DRAIN_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
type PendingMoves = HashMap<
|
||||
MessageHandle,
|
||||
@@ -87,30 +84,6 @@ async fn send_with_timeout<T: Send>(
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_voice_packets_for_tick<T, E>(
|
||||
voice_out_rx: &mut mpsc::Receiver<T>,
|
||||
max_packets: usize,
|
||||
mut send: impl FnMut(T) -> Result<(), E>,
|
||||
) -> usize {
|
||||
let mut drained = 0;
|
||||
for _ in 0..max_packets {
|
||||
let packet = match voice_out_rx.try_recv() {
|
||||
Ok(packet) => packet,
|
||||
Err(_) => break,
|
||||
};
|
||||
let _ = send(packet);
|
||||
drained += 1;
|
||||
}
|
||||
drained
|
||||
}
|
||||
|
||||
async fn bounded_drain_stream<S>(stream: S, timeout_duration: Duration)
|
||||
where
|
||||
S: futures::Stream,
|
||||
{
|
||||
let _ = tokio::time::timeout(timeout_duration, stream.for_each(|_| future::ready(()))).await;
|
||||
}
|
||||
|
||||
/// Pick the TeamSpeak `client_version`/platform/signature triple
|
||||
/// (sourced from `ReSpeak/tsdeclarations/Versions.csv`, baked into
|
||||
/// `tsproto-types` at vendor-time) that best matches the *runtime*
|
||||
@@ -368,21 +341,8 @@ impl ProtocolClient {
|
||||
/// Disconnect cleanly. Blocks until the task exits.
|
||||
pub async fn disconnect(self) {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let request_path = async {
|
||||
if self.tx.send(Request::Disconnect(tx)).await.is_ok() {
|
||||
let _ = rx.await;
|
||||
}
|
||||
};
|
||||
|
||||
if tokio::time::timeout(DISCONNECT_REPLY_TIMEOUT, request_path)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!(
|
||||
target: "chanora_protocol",
|
||||
timeout_ms = DISCONNECT_REPLY_TIMEOUT.as_millis() as u64,
|
||||
"disconnect request did not complete before timeout"
|
||||
);
|
||||
if self.tx.send(Request::Disconnect(tx)).await.is_ok() {
|
||||
let _ = rx.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -716,14 +676,12 @@ async fn connection_task(
|
||||
|
||||
// Main loop: pump events, service requests, forward voice.
|
||||
loop {
|
||||
// 1. Send a bounded batch of outbound voice packets first — they're
|
||||
// time-sensitive, but control requests must still make progress.
|
||||
drain_voice_packets_for_tick(&mut voice_out_rx, OUTBOUND_VOICE_PACKETS_PER_TICK, |pkt| {
|
||||
// 1. Drain any outbound voice packets first — they're time-sensitive.
|
||||
while let Ok(pkt) = voice_out_rx.try_recv() {
|
||||
if let Err(e) = con.send_audio(pkt) {
|
||||
warn!(target: "chanora_protocol", error = %e, "send_audio failed");
|
||||
}
|
||||
Ok::<(), ()>(())
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Advance event stream by at most one event with a small timeout.
|
||||
let pump = async {
|
||||
@@ -731,19 +689,21 @@ async fn connection_task(
|
||||
tokio::time::timeout(Duration::from_millis(20), ev_stream.next()).await
|
||||
};
|
||||
match pump.await {
|
||||
Ok(Some(Ok(item))) => match item {
|
||||
StreamItem::Audio(buf) => {
|
||||
handle_audio_stream_item(&channels.voice_in, &mut voice_activity, buf).await;
|
||||
Ok(Some(Ok(item))) => {
|
||||
match item {
|
||||
StreamItem::Audio(buf) => {
|
||||
handle_audio_stream_item(&channels.voice_in, &mut voice_activity, buf).await;
|
||||
}
|
||||
other => handle_non_audio_stream_item(
|
||||
&con,
|
||||
other,
|
||||
&channels.chat,
|
||||
&channels.activity,
|
||||
&channels.delta,
|
||||
&mut pending_moves,
|
||||
),
|
||||
}
|
||||
other => handle_non_audio_stream_item(
|
||||
&con,
|
||||
other,
|
||||
&channels.chat,
|
||||
&channels.activity,
|
||||
&channels.delta,
|
||||
&mut pending_moves,
|
||||
),
|
||||
},
|
||||
}
|
||||
Ok(Some(Err(e))) => {
|
||||
warn!(target: "chanora_protocol", error = %e, "event error");
|
||||
// Some errors are transient; treat persistent ones
|
||||
@@ -847,7 +807,7 @@ async fn connection_task(
|
||||
}
|
||||
Ok(Request::Disconnect(reply)) => {
|
||||
let _ = con.disconnect(DisconnectOptions::new());
|
||||
bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
|
||||
con.events().for_each(|_| future::ready(())).await;
|
||||
let _ = reply.send(());
|
||||
info!(target: "chanora_protocol", "clean disconnect");
|
||||
exit!(DisconnectReason::UserRequested);
|
||||
@@ -855,7 +815,7 @@ async fn connection_task(
|
||||
Err(mpsc::error::TryRecvError::Empty) => {}
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => {
|
||||
let _ = con.disconnect(DisconnectOptions::new());
|
||||
bounded_drain_stream(con.events(), DISCONNECT_EVENT_DRAIN_TIMEOUT).await;
|
||||
con.events().for_each(|_| future::ready(())).await;
|
||||
info!(target: "chanora_protocol", "handle dropped; implicit disconnect");
|
||||
exit!(DisconnectReason::UserRequested);
|
||||
}
|
||||
@@ -967,9 +927,7 @@ fn handle_non_audio_stream_item(
|
||||
let mapped = match target {
|
||||
tsclientlib::MessageTarget::Server => MessageTarget::Server,
|
||||
tsclientlib::MessageTarget::Channel => MessageTarget::Channel,
|
||||
tsclientlib::MessageTarget::Client(id) => {
|
||||
MessageTarget::Client(id.0 as u64)
|
||||
}
|
||||
tsclientlib::MessageTarget::Client(id) => MessageTarget::Client(id.0 as u64),
|
||||
tsclientlib::MessageTarget::Poke(id) => MessageTarget::Poke(id.0 as u64),
|
||||
};
|
||||
let _ = chat_tx.try_send(ChatMessage {
|
||||
@@ -1167,15 +1125,7 @@ async fn fetch_client_profile(
|
||||
) -> Result<ClientProfile, ProtocolError> {
|
||||
let target_id = TsClientId(client_id as u16);
|
||||
|
||||
let (
|
||||
database_id,
|
||||
uid_b64,
|
||||
has_optional,
|
||||
has_connection,
|
||||
is_own,
|
||||
needs_server_groups,
|
||||
needs_channel_groups,
|
||||
) = {
|
||||
let (database_id, uid_b64, has_optional, has_connection, is_own, needs_server_groups, needs_channel_groups) = {
|
||||
let state = con
|
||||
.get_state()
|
||||
.map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?;
|
||||
@@ -1264,9 +1214,15 @@ async fn fetch_client_profile(
|
||||
}
|
||||
|
||||
let db_info = if refresh_plan.needs_client_db_info {
|
||||
request_client_db_info(con, database_id, channels, pending_moves, voice_activity)
|
||||
.await
|
||||
.ok()
|
||||
request_client_db_info(
|
||||
con,
|
||||
database_id,
|
||||
channels,
|
||||
pending_moves,
|
||||
voice_activity,
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -1333,18 +1289,10 @@ async fn fetch_client_profile(
|
||||
.or_else(|| db_info.as_ref().map(|info| info.created.unix_timestamp())),
|
||||
last_connected_unix_seconds: optional
|
||||
.map(|info| info.last_connected.unix_timestamp())
|
||||
.or_else(|| {
|
||||
db_info
|
||||
.as_ref()
|
||||
.map(|info| info.last_connected.unix_timestamp())
|
||||
}),
|
||||
.or_else(|| db_info.as_ref().map(|info| info.last_connected.unix_timestamp())),
|
||||
connections_total: optional
|
||||
.map(|info| u64::from(info.connections_total))
|
||||
.or_else(|| {
|
||||
db_info
|
||||
.as_ref()
|
||||
.map(|info| u64::from(info.connections_total))
|
||||
}),
|
||||
.or_else(|| db_info.as_ref().map(|info| u64::from(info.connections_total))),
|
||||
online_seconds: connection
|
||||
.and_then(|info| info.connected_time.map(|duration| duration.whole_seconds())),
|
||||
idle_milliseconds: connection.map(|info| duration_millis(info.idle_time)),
|
||||
@@ -1377,10 +1325,14 @@ async fn fetch_client_profile(
|
||||
.or_else(|| db_info.as_ref().map(|info| info.bytes_uploaded_total)),
|
||||
packet_loss_client_to_server_total: net_stats
|
||||
.map(|s| s.get_packetloss())
|
||||
.or_else(|| connection.map(|info| info.client_to_server_packetloss_total)),
|
||||
.or_else(|| {
|
||||
connection.map(|info| info.client_to_server_packetloss_total)
|
||||
}),
|
||||
packet_loss_server_to_client_total: net_stats
|
||||
.map(|s| s.get_packetloss_s2c_total())
|
||||
.or_else(|| connection.and_then(|info| info.server_to_client_packetloss_total)),
|
||||
.or_else(|| {
|
||||
connection.and_then(|info| info.server_to_client_packetloss_total)
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1924,12 +1876,10 @@ const _: () = {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
bounded_drain_stream, client_profile_refresh_plan, drain_voice_packets_for_tick,
|
||||
is_server_query_client_type, send_with_timeout, server_socket_from_config,
|
||||
sort_channels_tree_by, std_duration_millis, ConnectConfig, ProtocolClient, Request,
|
||||
SendTimeoutError, DISCONNECT_REPLY_TIMEOUT,
|
||||
client_profile_refresh_plan, is_server_query_client_type, send_with_timeout,
|
||||
server_socket_from_config, sort_channels_tree_by, std_duration_millis,
|
||||
ConnectConfig, SendTimeoutError,
|
||||
};
|
||||
use futures::stream;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tsproto_types::ClientType;
|
||||
@@ -2172,83 +2122,6 @@ mod tests {
|
||||
|
||||
assert_eq!(result, Err(SendTimeoutError::Timeout(2)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnect_request_send_is_bounded_when_request_channel_is_full() {
|
||||
let (tx, _rx) = mpsc::channel(1);
|
||||
let (reply_tx, _reply_rx) = tokio::sync::oneshot::channel();
|
||||
tx.send(Request::Snapshot(reply_tx))
|
||||
.await
|
||||
.expect("seed first request");
|
||||
|
||||
let (disconnect_tx, _disconnect_rx) = tokio::sync::oneshot::channel();
|
||||
let result = send_with_timeout(
|
||||
&tx,
|
||||
Request::Disconnect(disconnect_tx),
|
||||
Duration::from_millis(10),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(SendTimeoutError::Timeout(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn protocol_client_disconnect_returns_when_request_channel_is_full() {
|
||||
let (tx, _rx) = mpsc::channel(1);
|
||||
let (snapshot_tx, _snapshot_rx) = tokio::sync::oneshot::channel();
|
||||
tx.send(Request::Snapshot(snapshot_tx))
|
||||
.await
|
||||
.expect("seed first request");
|
||||
let (voice_out_tx, _voice_out_rx) = mpsc::channel(1);
|
||||
let (_voice_in_tx, voice_in_rx) = mpsc::channel(1);
|
||||
let (_lost_tx, lost_rx) = tokio::sync::oneshot::channel();
|
||||
let (_chat_tx, chat_rx) = mpsc::channel(1);
|
||||
let (_activity_tx, activity_rx) = mpsc::channel(1);
|
||||
let (_delta_tx, delta_rx) = mpsc::channel(1);
|
||||
let client = ProtocolClient {
|
||||
tx,
|
||||
voice_out_tx,
|
||||
voice_in_rx: std::sync::Mutex::new(Some(voice_in_rx)),
|
||||
lost_rx: std::sync::Mutex::new(Some(lost_rx)),
|
||||
chat_rx: std::sync::Mutex::new(Some(chat_rx)),
|
||||
activity_rx: std::sync::Mutex::new(Some(activity_rx)),
|
||||
delta_rx: std::sync::Mutex::new(Some(delta_rx)),
|
||||
};
|
||||
|
||||
tokio::time::timeout(
|
||||
DISCONNECT_REPLY_TIMEOUT + Duration::from_millis(100),
|
||||
client.disconnect(),
|
||||
)
|
||||
.await
|
||||
.expect("disconnect should not wait indefinitely for request channel capacity");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn voice_drain_stops_at_per_tick_budget() {
|
||||
let (tx, mut rx) = mpsc::channel(8);
|
||||
for value in 0_u8..5 {
|
||||
tx.send(value).await.expect("seed voice packet");
|
||||
}
|
||||
|
||||
let mut sent = Vec::new();
|
||||
let drained = drain_voice_packets_for_tick(&mut rx, 2, |value| {
|
||||
sent.push(value);
|
||||
Ok::<(), ()>(())
|
||||
});
|
||||
|
||||
assert_eq!(drained, 2);
|
||||
assert_eq!(sent, vec![0, 1]);
|
||||
assert_eq!(rx.len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disconnect_stream_drain_returns_after_timeout() {
|
||||
let start = tokio::time::Instant::now();
|
||||
|
||||
bounded_drain_stream(stream::pending::<()>(), Duration::from_millis(10)).await;
|
||||
|
||||
assert!(start.elapsed() < Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
fn forward_delta(
|
||||
@@ -2331,7 +2204,9 @@ fn forward_delta(
|
||||
old: PropertyValue::Channel(channel),
|
||||
..
|
||||
} => {
|
||||
let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved { id: channel.id.0 });
|
||||
let _ = delta_tx.try_send(ProtocolDelta::ChannelRemoved {
|
||||
id: channel.id.0,
|
||||
});
|
||||
}
|
||||
Event::PropertyChanged {
|
||||
id: PropertyId::Channel(channel_id),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
[package]
|
||||
name = "chanora_resolver"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -346,7 +346,9 @@ pub fn reduce(state: &mut Option<ServerState>, event: StateEvent) -> Reduction {
|
||||
if removed_channel {
|
||||
deltas.push(Delta::ChannelRemoved(id));
|
||||
}
|
||||
Reduction { deltas }
|
||||
Reduction {
|
||||
deltas,
|
||||
}
|
||||
}
|
||||
_ => Reduction { deltas: vec![] },
|
||||
},
|
||||
@@ -410,7 +412,14 @@ pub fn reduce_reconnect_snapshot(
|
||||
state: &mut Option<ServerState>,
|
||||
snap: ServerSnapshot,
|
||||
) -> Reduction {
|
||||
reduce(state, StateEvent::Snapshot(snap))
|
||||
let normalized = normalize_snapshot(snap);
|
||||
*state = Some(ServerState::from_snapshot(normalized.clone()));
|
||||
Reduction {
|
||||
deltas: vec![
|
||||
Delta::ConnectionStateChanged(ConnectionState::Ready),
|
||||
Delta::SnapshotApplied(normalized),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -42,7 +42,7 @@ Chanora is a Flutter application with a Rust core. Flutter owns the user-facing
|
||||
| Flutter service layer | `apps/chanora_flutter/lib/services/` | Permission flows, lifecycle policy, host prefetch debounce, link trust, state mapping, platform back intent | Flutter app shell, generated bridge APIs, platform plugins |
|
||||
| Flutter widget layer | `apps/chanora_flutter/lib/widgets/` | Connect UI, channel tree, chat, voice controls, settings, diagnostics surfaces | Flutter services, generated DTOs, design tokens |
|
||||
| Bridge layer | `crates/chanora_bridge`, `apps/chanora_flutter/lib/src/rust/` | Typed Flutter/Rust boundary and generated bindings | Rust core, Flutter generated code |
|
||||
| Rust core | `core/chanora_core` | Connection lifecycle, orchestration, reconnect behavior, storage coordination, voice state, bridge-facing event DTOs | Protocol, audio, storage, diagnostics, state, resolver/prefetch |
|
||||
| Rust core | `core/chanora_core` | Connection lifecycle, orchestration, reconnect behavior, storage coordination, voice state | Protocol, audio, storage, diagnostics, state, resolver/prefetch |
|
||||
| Protocol adapter | `crates/chanora_protocol` | Isolate `tsclientlib`, expose typed protocol DTOs/errors | Rust core, external compatible server |
|
||||
| State sync | `crates/chanora_state` | Snapshot/delta model, channel join helpers, reducer behavior | Rust core, protocol DTOs |
|
||||
| Audio subsystem | `crates/chanora_audio` | Capture/playback, Opus, DSP, PTT, voice activity reservation, platform units | Rust core, platform APIs, protocol audio path |
|
||||
@@ -68,8 +68,6 @@ Flutter UI/widgets/services
|
||||
|
||||
The bridge is the trust and type boundary between Flutter and Rust. Flutter must not directly depend on protocol-library internals. Rust core must not expose platform-specific storage or audio details to UI code except through stable DTOs and capability fields.
|
||||
|
||||
Current Core locality note: the public Core Interface remains available through `chanora_core::*` re-exports, while branch `simplify-project-review` has started moving internal Core responsibilities into focused Modules (`events.rs`, `network_diagnostics.rs`). This is an internal maintainability split, not a public Interface change.
|
||||
|
||||
## 6. Runtime Flow Architecture
|
||||
|
||||
### 6.1 Connect Flow
|
||||
@@ -182,8 +180,6 @@ This SAD derives only from `docs/srs.md`. The broad SRS group-to-component alloc
|
||||
| SAD item numbering from historical status references is not reconstructed in this baseline | Existing references such as `SAD-043` and `SAD-046` are not itemized here | Treat this as a DV baseline SAD; add itemized SAD IDs in a follow-up if process requires strict ID-level review |
|
||||
| Some architecture views are textual rather than C4 diagrams | Reviewers may request visual C4 views | Record as documentation hardening, not a blocker for DV baseline if textual views are accepted |
|
||||
| Release/platform architecture evidence is incomplete | Public release remains blocked | Controlled by release-readiness and waiver records |
|
||||
| Android runtime verification is not automatic in local reviews | Android permission/audio/lifecycle regressions can pass Rust-only tests | Require `adb devices -l` with a connected device/emulator and Android smoke evidence before claiming Android runtime success |
|
||||
| Protocol voice packet re-export is an intentional exception to full protocol isolation | Future changes may accidentally widen the protocol/audio Seam | Document and keep the voice wire exception narrow, or move packet construction fully into `chanora_protocol` |
|
||||
|
||||
## 14. DV Conclusion
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ This Software Detailed Design defines the module-level design details needed for
|
||||
| SDD-MOD-005 Voice UI | `voice_bar.dart`, `voice_compact.dart`, `voice_settings*.dart`, `voice_level_meter.dart`, `ptt_capability_badge.dart` | Voice controls, processing settings, metering, PTT capability | Flutter widget layer |
|
||||
| SDD-MOD-006 Platform services | `android_permissions_service.dart`, `ios_permissions_service.dart`, `audio_lifecycle_service.dart`, `back_intent_*`, `link_trust_service.dart` | Permission, lifecycle, navigation, route/link trust behavior | Flutter service layer |
|
||||
| SDD-MOD-007 Bridge API | `crates/chanora_bridge/src/api.rs`, generated Dart/Rust bridge files | Typed command/event boundary | Bridge layer |
|
||||
| SDD-MOD-008 Rust core supervisor | `core/chanora_core/src/lib.rs`, `events.rs`, `network_diagnostics.rs`, `ptt.rs` | Connection orchestration, reconnect, bridge-facing event DTOs, network diagnostics, PTT state, storage coordination | Rust core |
|
||||
| SDD-MOD-008 Rust core supervisor | `core/chanora_core/src/lib.rs`, `ptt.rs` | Connection orchestration, reconnect, PTT state, storage coordination | Rust core |
|
||||
| SDD-MOD-009 Protocol adapter | `crates/chanora_protocol/src/` | `tsclientlib` isolation, DTO/error mapping | Protocol adapter |
|
||||
| SDD-MOD-010 State sync | `crates/chanora_state/src/lib.rs`, `channel_join.rs` | Snapshot/delta model, reducer, channel join support | State sync |
|
||||
| SDD-MOD-011 Audio subsystem | `crates/chanora_audio/src/` | Audio capture/playback, DSP, Opus, PTT, mode stack, platform units | Audio subsystem |
|
||||
@@ -50,7 +50,6 @@ Design rules:
|
||||
|---|---|
|
||||
| Connection lifecycle | Rust core owns connect/disconnect/reconnect decisions and suppresses reconnect after user disconnect |
|
||||
| Backoff | Reconnect uses exponential backoff as described in implementation status, capped at 60 seconds |
|
||||
| Core internal Modules | `lib.rs` remains the public Interface and orchestration entry point; `events.rs` owns public event/bridge-facing DTOs re-exported by `lib.rs`; `network_diagnostics.rs` owns private connect/loss counters and the last-loss ring buffer |
|
||||
| Server resolution | Resolver performs SRV/TSDNS/DNS fallback; prefetch cache may warm but must not be required for connect success |
|
||||
| Snapshot mapping | Rust state and bridge DTOs are mapped into Flutter view models by `snapshot_state_mapper.dart` |
|
||||
| Channel join | Channel join logic and errors are represented through Rust state/protocol handling and Flutter error mapper service |
|
||||
@@ -154,7 +153,6 @@ Design rules:
|
||||
| Some module designs are summarized rather than API-by-API | May be insufficient for final process audit | Use this as DV baseline; deepen high-risk modules before final release gate |
|
||||
| Android Keystore-backed DEK is not implemented | Limits storage/security design claims | Controlled by waiver and release-readiness records |
|
||||
| Full event replay tooling and live reducer integration evidence are absent | Limits state verification design beyond reducer unit behavior | Controlled as P1 gap and runtime-integration follow-up |
|
||||
| Android runtime smoke is blocked when no device/emulator is attached | Android permission/audio/lifecycle paths cannot be claimed from Rust tests alone | Require `adb devices -l` and Android smoke evidence before closing Android verification claims |
|
||||
|
||||
## 13. DV Conclusion
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ This index lists the documents required for DV review and identifies their curre
|
||||
| Release | `docs/release/release-readiness-go-nogo-record.md` | No-Go for public/store release |
|
||||
| Release | `docs/release/platform-release-policy.md` | Baseline candidate |
|
||||
| Governance | `docs/governance/traceability-matrix.md` | DV baseline candidate |
|
||||
| Governance | `docs/governance/maintainability-review-2026-06-08.md` | Working-branch maintainability and fail-safe review |
|
||||
| Security/privacy/legal | `docs/security/security-privacy-legal-guideline.md` | Baseline candidate |
|
||||
| Privacy | `docs/privacy/privacy-policy.md` | Engineering baseline candidate |
|
||||
| Legal | `docs/legal/trademark-and-attribution-review.md` | DEC-012 open |
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# Chanora Maintainability Review — 2026-06-08
|
||||
|
||||
**Document status:** Working-branch review record
|
||||
**Branch:** `simplify-project-review`
|
||||
**Scope:** Project-wide simplification, fail-safe, and verification review
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
This record captures the current maintainability review so implementation, verification, and release documents do not drift behind the code. It focuses on unnecessary Modules, shallow Interfaces, duplicate Implementations, built-in replacement opportunities, and fail-safe gaps that need explicit evidence before release claims.
|
||||
|
||||
## 2. Changes Already Applied on the Branch
|
||||
|
||||
| Area | Files | Maintainability result |
|
||||
|---|---|---|
|
||||
| Core event DTO locality | `core/chanora_core/src/events.rs`, `core/chanora_core/src/lib.rs` | Public Core event DTOs moved out of the oversized Core integration Module while preserving the public `chanora_core::*` Interface through re-exports. |
|
||||
| Core network diagnostics locality | `core/chanora_core/src/network_diagnostics.rs`, `core/chanora_core/src/lib.rs` | Private network diagnostic ring-buffer state and its regression test now live next to the Implementation they protect. |
|
||||
| Bounded queues | `core/chanora_core/src/network_diagnostics.rs`, `crates/chanora_diagnostics/src/lib.rs` | Replaced `Vec + remove(0)` queue behaviour with `VecDeque`, reducing custom queue code and avoiding O(n) front removal. |
|
||||
| PTT backend errors | `crates/chanora_audio/src/ptt_backends/mod.rs` | Replaced manual `Display` / `Error` Implementation with existing `thiserror::Error`; regression test keeps user-facing strings stable. |
|
||||
| Render downmix | `crates/chanora_audio/src/voice_render.rs`, `crates/chanora_audio/src/ios_raw_unit.rs` | Removed duplicate mono-i16 downmix loop by using the interleaved helper with one output channel. |
|
||||
| State reducer | `crates/chanora_state/src/lib.rs` | Reused the main snapshot reducer for reconnect snapshots instead of duplicating normalization and delta construction. |
|
||||
| Workspace metadata | `crates/chanora_resolver/Cargo.toml`, `Cargo.lock` | Resolver inherits workspace package metadata, improving release metadata Locality. |
|
||||
| Flutter voice fail-safes | `apps/chanora_flutter/lib/main.dart`, `apps/chanora_flutter/lib/widgets/voice_compact.dart`, `apps/chanora_flutter/lib/services/ios_audio_session_controller.dart` | Commit `d835394` preserves independent mute owners, releases touch PTT on disposal while held, and catches iOS audio-session `MissingPluginException` / activation failures so they do not become unhandled async errors. |
|
||||
| Rust realtime callback hardening | `crates/chanora_audio/src/android_voice_unit.rs`, `crates/chanora_audio/src/ios_raw_unit.rs`, `crates/chanora_audio/src/engine.rs` | Commit `8606eb4` hardens Android/iOS realtime callback paths. The current branch also migrates the Android-only JNI paths to `jni-rs` 0.22 so the supported ARM64 Android debug build compiles. Full lock-free audio-handler/config/debug-recorder redesign remains follow-up work. |
|
||||
|
||||
## 3. Remaining Simplification Opportunities
|
||||
|
||||
| Recommendation | Candidate files | Strength | Notes |
|
||||
|---|---|---|---|
|
||||
| Continue splitting Core internals by responsibility | `core/chanora_core/src/lib.rs` | Strong | Next slices should be reconnect/session, voice projection, storage helpers, and diagnostics export. Keep public re-exports stable. |
|
||||
| Make Bridge depend on Core rather than Audio where possible | `crates/chanora_bridge/Cargo.toml`, `crates/chanora_bridge/src/api.rs`, `core/chanora_core/src/lib.rs` | Worth exploring | The Bridge currently has a direct audio edge. Apply the deletion test before removing it. |
|
||||
| Decide whether prefetch deserves a crate-level Seam | `crates/chanora_prefetch/src/lib.rs`, `crates/chanora_resolver/src/lib.rs`, `core/chanora_core/src/lib.rs` | Worth exploring | Prefetch is a small TTL cache and fire-and-forget resolver Adapter. Merge into resolver if it is resolver policy; merge into Core if it is app orchestration policy. |
|
||||
| Consolidate protocol/core/bridge event catalogues | `crates/chanora_protocol/src/dto.rs`, `core/chanora_core/src/events.rs`, `crates/chanora_bridge/src/api.rs` | Worth exploring | Protocol-owned deltas and Core-owned lifecycle events are currently mirrored through multiple DTO layers. |
|
||||
| Reduce bridge DTO mirror boilerplate | `crates/chanora_bridge/src/api.rs` | Worth exploring | Verify Flutter Rust Bridge support before deleting mirrors. If mirrors remain required, centralize conversion patterns and keep field order aligned with Core DTOs. |
|
||||
| Clarify protocol voice packet exception | `crates/chanora_protocol/src/lib.rs`, `crates/chanora_audio/Cargo.toml` | Worth exploring | The protocol crate documents `tsclientlib` isolation but deliberately re-exports voice packet types for audio. Document this as an explicit voice wire Seam or move packet construction fully into protocol. |
|
||||
| Remove shallow audio helpers only after public API check | `crates/chanora_audio/src/processor/noop.rs`, `crates/chanora_audio/src/processor/platform.rs`, `crates/chanora_audio/src/frame.rs` | Speculative | These Modules are shallow, but deletion must wait until external/public API expectations are checked. |
|
||||
| Redesign remaining audio shared state outside realtime callbacks | `crates/chanora_audio/src/engine.rs`, platform voice units, debug recorder/config paths | Strong follow-up | The focused callback hardening is complete, but a full lock-free `AudioHandler` / config / debug-recorder redesign should be planned separately and verified on device. |
|
||||
| Review protocol/core disconnect and control-plane bounds | `core/chanora_core/src/lib.rs`, `crates/chanora_protocol/src/adapter.rs` | Strong follow-up | Unless closed by a later code slice, sustained voice traffic and broken transport should be reviewed for bounded control request and disconnect progress. |
|
||||
|
||||
## 4. Fail-Safe Gaps That Need Evidence
|
||||
|
||||
| Gap | Risk | Required evidence before release claim |
|
||||
|---|---|---|
|
||||
| Android Keystore-backed DEK remains deferred | Android identity/bookmark encryption has weaker fail-safe properties than final target secure-storage design. | Android secure-storage audit or waiver; explicit release-readiness limitation. |
|
||||
| Android permission/audio lifecycle needs deeper route exercise | Build/install/launch smoke now passes on the emulator, but full permission-flow and audio-route lifecycle behavior still need an interactive scenario or device test before release. | Device/emulator scenario covering permission request/denial/grant, voice controls, foreground service, audio focus, and route/SCO transitions. |
|
||||
| iOS device runtime verification not executed in this review | The iOS audio-session error path is hardened, but VoiceProcessingIO/session ordering and runtime audio behavior still need device evidence. | iOS device or simulator build/run plus audio-session smoke evidence before iOS runtime success is claimed. |
|
||||
| VAD / VoiceActivity wording drift | VAD assets, tests, and scaffolding exist, but product-enabled `VoiceActivity` remains reserved/disabled per DEC-030. | Release, README, and verification wording must distinguish scaffolding/assets/tests from shipped product behavior. |
|
||||
| Protocol voice packet re-export is an intentional exception | Future maintainers may assume complete protocol isolation and accidentally widen the Seam. | Architecture note in SAD/SDD or a decision-register entry. |
|
||||
| Bridge DTO mirror drift | Field additions can be missed across Core, Bridge, and Dart generated DTOs. | Bridge generation check plus Flutter analyze/test after bridge DTO changes. |
|
||||
| Full live reducer integration remains separate from reducer unit coverage | State reducer tests are strong, but runtime UI still has snapshot/probe paths. | SWE.5 integration run proving live protocol events fold through the intended state path, or explicit P1 deferral. |
|
||||
| Event replay tooling remains absent | Replay-based diagnosis and regression reproduction are limited. | Event replay tool implementation or waiver. |
|
||||
|
||||
## 5. Verification Policy for Future Code Changes
|
||||
|
||||
| Change type | Required verification |
|
||||
|---|---|
|
||||
| Rust-only change | `cargo fmt --all`, `cargo check --workspace`, `cargo test --workspace` |
|
||||
| Bridge DTO/API change | Rust verification plus bridge generation check, `flutter analyze`, and `flutter test --exclude-tags e2e` in `apps/chanora_flutter` |
|
||||
| Android platform/audio/permission change | Rust/Flutter verification plus NDK target compilation, `adb devices -l`, Android build/install, and a device or emulator smoke test |
|
||||
| Documentation-only change | Read affected docs and ensure cross-links/document index stay current; code tests are not required unless docs describe a code change just made |
|
||||
|
||||
## 6. Android ADB Status for This Review
|
||||
|
||||
`adb devices -l` now reports an authorized emulator target:
|
||||
|
||||
```text
|
||||
emulator-5554 device product:sdk_gphone64_arm64 model:sdk_gphone64_arm64 device:emu64a transport_id:1
|
||||
```
|
||||
|
||||
Android default debug build still fails because SDD-118 excludes `armeabi-v7a`; use a supported ABI target. ARM64 debug build/install/launch smoke evidence from 2026-06-08:
|
||||
|
||||
```text
|
||||
flutter build apk --debug --target-platform android-arm64
|
||||
✓ Built build/app/outputs/flutter-apk/app-debug.apk
|
||||
|
||||
adb -s emulator-5554 install -r apps/chanora_flutter/build/app/outputs/flutter-apk/app-debug.apk
|
||||
Success
|
||||
|
||||
adb -s emulator-5554 shell am start -W -n app.chanora.chanora_flutter/.MainActivity
|
||||
Status: ok
|
||||
LaunchState: COLD
|
||||
Activity: app.chanora.chanora_flutter/.MainActivity
|
||||
TotalTime: 6514
|
||||
|
||||
adb -s emulator-5554 shell pidof app.chanora.chanora_flutter
|
||||
7287
|
||||
```
|
||||
|
||||
`dumpsys window app.chanora.chanora_flutter` showed `MainActivity` visible with `isReadyForDisplay()=true`, and `dumpsys activity top` showed `app.chanora.chanora_flutter/.MainActivity` resumed with window focus. This is build/install/launch smoke evidence only; permission-flow success and audio-lifecycle success are not claimed by this review.
|
||||
|
||||
## 7. Release and Documentation Alignment Notes
|
||||
|
||||
- Android minimum runtime baseline is API 28 (Android 9.0) per SysRS-288, SRS-187, DEC-004, and the Gradle `minSdk = 28` configuration. Documents must not revive the older API 24 baseline.
|
||||
- Flutter app version/build is `0.3.0+100` in `apps/chanora_flutter/pubspec.yaml`. Rust workspace package version remains `0.2.0-beta.1`. Release documents must distinguish these values instead of treating them as one candidate version.
|
||||
- The v0.3.0 changelog entry may mention VAD assets/backends only as implementation scaffolding; product-enabled `VoiceActivity` remains disabled/coming-soon until DEC-030 is superseded and runtime verification exists.
|
||||
- README wording must describe the existing Flutter/Rust workspace and app scaffold, not a future scaffold that has not been created.
|
||||
|
||||
## 8. Git Policy
|
||||
|
||||
No commit is created automatically. Commit only on explicit user demand, after reviewing `git status`, `git diff`, and recent log output.
|
||||
@@ -11,14 +11,12 @@ This register records product and engineering decisions referenced by the DV doc
|
||||
|
||||
| Decision | State | DV impact |
|
||||
|---|---|---|
|
||||
| DEC-004 Android minimum runtime API 28 | Accepted by requirements baseline | Android release, verification, and README wording must use API 28 rather than the earlier API 24 recommendation |
|
||||
| DEC-012 legal/trademark/OSS review | Open | Blocks public/store release |
|
||||
| DEC-020 dual license MIT OR Apache-2.0 | Accepted per README | Supports license posture; dependency notices still require review |
|
||||
| DEC-027 desktop mouse side-button PTT | Accepted by requirements baseline | Verification must not over-claim unsupported platform input classes |
|
||||
| DEC-030 VAD deferral | Accepted as deferral | VAD scaffolding, assets, and tests may exist, but product `VoiceActivity` remains disabled/coming-soon until a later baseline enables and verifies it |
|
||||
| DEC-030 VAD deferral | Accepted as deferral | `VoiceActivity` remains disabled/coming-soon |
|
||||
| DEC-032 Android CMake patch exit path | Active tracking | Patched dependency requires reevaluation |
|
||||
| DEC-033 macOS VPIO ducking configuration | Accepted | Write `kAUVoiceIOProperty_OtherAudioDuckingConfiguration` with `mEnableAdvancedDucking=0` (disables dynamic voice-activity-driven ducking) and `mDuckingLevel=Min` (= 10) to minimise the ducking of other apps' audio during a voice session; property is macOS 14+ only, the macOS 13 set fails silently (debug log) and VPIO uses its default behaviour; matches the iOS `.voiceChat` baseline on macOS 14+ |
|
||||
| DEC-034 Android runtime verification gate | Active tracking | Android target compilation, install, and runtime smoke are blocked locally until `aarch64-linux-android-clang` is available and `adb devices -l` shows an authorized target; release docs must not claim Android runtime success |
|
||||
|
||||
## 3. DV Rule
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
# Chanora Implementation Status — 2026-05-28
|
||||
|
||||
**Workspace version:** `v0.2.0-beta.1`
|
||||
**Flutter app version/build:** `0.3.0+100`
|
||||
**CHANGELOG latest:** `v0.3.0`
|
||||
**Build status:** Host Rust workspace evidence shows all 9 crates compile cleanly. This does not claim Android target success; Android target compile/install/smoke evidence remains blocked locally as noted below.
|
||||
**CHANGELOG latest:** `v1.0.0-rc.1`
|
||||
**Build status:** All 9 crates compile cleanly.
|
||||
|
||||
---
|
||||
|
||||
@@ -19,9 +18,9 @@
|
||||
| Localization (en + zh-Hans) | `l10n/generated/app_localizations_en.dart` + `app_localizations_zh.dart`, `l10n.yaml` |
|
||||
| Flutter/Rust bridge | `chanora_bridge` crate (2152-line `api.rs`), generated `frb_generated.rs`, Dart side generated |
|
||||
| Protocol adapter | `chanora_protocol` — `tsclientlib` isolated behind `ProtocolClient`, typed DTOs, `ProtocolError` catalogue |
|
||||
| Connection lifecycle | `chanora_core` — supervisor task, exponential backoff reconnect (1s→60s), user-disconnect suppresses reconnect; branch `simplify-project-review` has started splitting the previous large `lib.rs` into focused internal Modules (`events.rs`, `network_diagnostics.rs`) while preserving public re-exports |
|
||||
| Connection lifecycle | `chanora_core` (2682-line `lib.rs`) — supervisor task, exponential backoff reconnect (1s→60s), user-disconnect suppresses reconnect |
|
||||
| State sync reducer unit | `chanora_state` — `ConnectionState`, `channel_join`, snapshot/delta reducers, reconnect handling, deterministic ordering, malformed duplicate normalization, channel-delete/client cleanup, and reducer unit tests. Runtime core integration still uses snapshot/probe refresh paths and remains separate validation work. |
|
||||
| Audio subsystem | `chanora_audio` — Opus encode/decode, HPF/NS/AEC3/AGC2 DSP, PTT backends (Windows/macOS/Linux/focused), iOS VoiceProcessingIO, Android Oboe, jitter buffer via `tsclientlib::audio::AudioHandler`, mixer, mute/deaf gates, release-tail timer, and VAD scaffolding/assets. Product `VoiceActivity` remains disabled per DEC-030. |
|
||||
| Audio subsystem | `chanora_audio` — Opus encode/decode, HPF/NS/AEC3/AGC2 DSP, PTT backends (Windows/macOS/Linux/focused), iOS VoiceProcessingIO, Android Oboe, jitter buffer via `tsclientlib::audio::AudioHandler`, mixer, mute/deaf gates, release-tail timer, VAD |
|
||||
| Push-to-talk | Per-platform backends: Windows Raw Input + hook fallback, macOS Event Tap, Linux freedesktop portal, focused fallback; `PttCapabilityLevel` (L0–L3); missed-key-up watchdog |
|
||||
| Voice controls UI | `voice_bar`, `voice_compact`, `voice_haptics`, `voice_level_meter`, `voice_platform`, `ptt_capability_badge`, `talk_power_warning` |
|
||||
| Storage (non-secret) | `chanora_storage` — `BookmarkRepository` (SQLite/rusqlite bundled, schema v2), ChaCha20-Poly1305 encrypted passwords |
|
||||
@@ -46,7 +45,7 @@
|
||||
| Link trust | `link_trust_service.dart` |
|
||||
| About dialog | Non-affiliation statement, dual-license declaration, NOTICE pointer |
|
||||
| CI | GitHub Actions on every push |
|
||||
| Workspace compiles | Host Rust workspace evidence shows all 9 crates build cleanly; Android target compilation remains blocked locally as noted below. |
|
||||
| Workspace compiles | All 9 crates build cleanly |
|
||||
|
||||
### Partial / Scaffold Only
|
||||
|
||||
@@ -55,18 +54,17 @@
|
||||
| Event replay tooling | Reducer tests cover the state-sync contract, but standalone replay-file tooling remains a P1 verification gap. |
|
||||
| Reducer runtime integration evidence | The standalone reducer is unit-tested, but `chanora_core` still refreshes UI state through snapshot/probe paths rather than folding all live protocol events through `chanora_state::reduce`. |
|
||||
| Silero VAD | `assets/models/silero_vad.onnx` bundled but DEC-030 defers VAD to P1; `TransmitMode::VoiceActivity` is reserved and disabled in this baseline. |
|
||||
| macOS build | Source-buildable only; no public release artifact is approved. |
|
||||
| Windows build | Source-buildable only; no public release artifact is approved. |
|
||||
| iOS build | Source-buildable/unsigned validation only; no TestFlight/App Store release artifact is approved. |
|
||||
| macOS build | Not in `v1.0.0-rc.1` release artifacts (source-buildable only per `staged-release-plan.md`). |
|
||||
| Windows build | Same — source-buildable, not in rc.1 release artifacts. |
|
||||
| iOS build | Same — source-buildable, not in rc.1 release artifacts. |
|
||||
|
||||
### Not Done (P0 blockers remaining)
|
||||
|
||||
| Item | Status |
|
||||
|---|---|
|
||||
| DEC-012 legal/trademark/OSS review | Explicitly open. Public release is blocked. |
|
||||
| DEC-012 legal/trademark/OSS review | Explicitly open — `v1.0.0-rc.1` is the candidate awaiting sign-off. Public release is blocked. |
|
||||
| Android Keystore-backed DEK | Deferred to v1.1. Android still uses file-fallback for the Data Encryption Key. |
|
||||
| Android target compile/install/smoke evidence | Blocked locally until the Android NDK compiler `aarch64-linux-android-clang` is available and `adb devices -l` shows an authorized device or emulator. |
|
||||
| iOS `AVAudioSession.Mode.voiceChat` | Implemented in `apps/chanora_flutter/ios/Runner/AppDelegate.swift` with call-scoped activation (idle `.ambient` baseline; VoIP `.playAndRecord` + `.voiceChat` + `.mixWithOthers` engaged only on `BridgeEvent::AudioStarted` via `chanora/ios_audio_session` MethodChannel). Release readiness still requires device audio validation and candidate evidence attachment. |
|
||||
| iOS `AVAudioSession.Mode.voiceChat` | Implemented in `apps/chanora_flutter/ios/Runner/AppDelegate.swift`; release readiness still requires device audio validation and candidate evidence attachment. |
|
||||
| Candidate state-sync evidence attachment | Reducer tests exist and pass locally; release readiness still needs candidate CI/run IDs and runtime integration evidence attached before public release approval. |
|
||||
|
||||
---
|
||||
@@ -94,7 +92,7 @@
|
||||
| Recent servers persistence (SRS-085) | Not confirmed in storage crate |
|
||||
| UI settings persistence (SRS-087) | Implemented for current P1 scope using `shared_preferences`: host, nickname, permission explanation flag, and theme mode (`system` / `light` / `dark`). SQLite-backed UI settings remain a future hardening option if multi-profile or transactional settings are introduced. |
|
||||
| Event replay tool (SRS-061, SRS-098) | No replay infrastructure found |
|
||||
| Network diagnostics (SRS-100) | Core tracks connect/disconnect counts and last-loss reasons in `network_diagnostics.rs`; export/integration evidence still needs release-candidate attachment |
|
||||
| Network diagnostics (SRS-100) | Not found in diagnostics export |
|
||||
| Side navigation rail for medium layout (SRS-153) | Not confirmed |
|
||||
| Keyboard focus traversal (SRS-160) | Not confirmed |
|
||||
| Android audio focus / BT route changes (SRS-112) | Partial — `MODE_IN_COMMUNICATION` done; full focus/BT handling not confirmed |
|
||||
|
||||
@@ -20,7 +20,6 @@ A waiver records a known gap that reviewers may accept for a limited decision sc
|
||||
| DV-WVR-006 | Desktop and iOS artifacts are source-buildable or unsigned only | `docs/implementation-status-2026-05-28.md`, `docs/release/ios-build.md` | Blocks packaged public release claims | Internal validation from source/unsigned builds only | Signed/notarized/package artifacts exist and hashes are recorded |
|
||||
| DV-WVR-007 | Silero VAD asset bundled while `VoiceActivity` is deferred | `docs/implementation-status-2026-05-28.md` | Risk that UI/release wording overstates VAD availability | DV may pass if VoiceActivity remains disabled/coming-soon | VAD implementation allocated in a later baseline or asset/wording reconciled |
|
||||
| DV-WVR-008 | Artifact hashes, tag, and candidate run IDs are not recorded in release record | `docs/release/release-readiness-go-nogo-record.md` | Blocks final release approval and reproducibility | DV documentation review only | Candidate build run records, tag, commit SHA, and artifact hashes are recorded |
|
||||
| DV-WVR-009 | Android target compile and runtime smoke blocked locally | `docs/governance/maintainability-review-2026-06-08.md`, `docs/release/release-readiness-go-nogo-record.md` | Blocks Android runtime, permission-flow, and audio-lifecycle success claims | Documentation review only; internal validation must keep Android limitation stated | Android NDK compiler `aarch64-linux-android-clang` is available, `adb devices -l` shows an authorized target, and Android build/install/smoke evidence is attached |
|
||||
|
||||
## 3. Waiver Review Rules
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# iOS / macOS Export Compliance
|
||||
|
||||
Status: TestFlight workaround applied. Production submission requires the steps below.
|
||||
|
||||
## Current state (2026-06-08)
|
||||
|
||||
Both `apps/chanora_flutter/ios/Runner/Info.plist` and `apps/chanora_flutter/macos/Runner/Info.plist` declare:
|
||||
|
||||
```xml
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
```
|
||||
|
||||
This is a **temporary workaround** to unblock TestFlight uploads. It is NOT correct for public App Store release because Chanora ships non-exempt encryption (see "Why Chanora is not exempt" below). The workaround is acceptable for internal testing under TestFlight while the BIS self-classification and Apple export-compliance documentation are being prepared.
|
||||
|
||||
## Why Chanora is not exempt
|
||||
|
||||
Chanora is a **closed-source proprietary** application that uses encryption beyond Apple's "standard system encryption" exemption:
|
||||
|
||||
1. `chacha20poly1305` in `crates/chanora_storage` — AEAD encryption of the local Data Encryption Key (DEK) for secret storage.
|
||||
2. `tsclientlib` in `crates/chanora_protocol` and `crates/chanora_audio` — implements TeamSpeak's custom Ed25519 / P-256 / EAX cryptographic handshake. This is NOT standard IETF TLS.
|
||||
3. `rustls` in `crates/chanora_protocol` and `crates/chanora_resolver` — ships its own TLS implementation rather than using the system stack.
|
||||
|
||||
Apple's exemption (HTTPS via system networking only) does not apply. Because Chanora is closed-source, the "publicly available source code" path (§734.3(b)(3) + §742.15(b)) is also unavailable — that route is reserved for open-source distributions whose cryptographic source is publicly accessible without restriction.
|
||||
|
||||
Chanora's path is therefore the **mass-market self-classification** route under §740.17(b)(1) with Note 3 to Category 5, Part 2 of the CCL — closed-source consumer apps with standard published algorithms qualify as mass-market and self-classify under **ECCN 5D992.c**. `ITSAppUsesNonExemptEncryption` must be `<true/>` for any public release, and `ITSEncryptionExportComplianceCode` must accompany it.
|
||||
|
||||
## The submission flow (in order)
|
||||
|
||||
### Step 1: File the BIS annual self-classification report
|
||||
|
||||
US Department of Commerce, Bureau of Industry and Security (BIS), requires an annual self-classification report for mass-market encryption software under EAR §740.17(b)(1). Since Chanora is **closed-source** but distributed as a mass-market consumer app with standard cryptographic algorithms, it qualifies for self-classification under the **mass-market note (Note 3 to Category 5, Part 2 of the CCL)**, which reclassifies it from ECCN 5D002.c.1 to **ECCN 5D992.c**.
|
||||
|
||||
There is **no fee**, **no review**, **no pre-approval needed**. You export immediately under (b)(1) and file the report within 30 days.
|
||||
|
||||
The format is a **CSV file** per Supplement No. 8 to Part 742, not a prose email body. Send it as an attachment:
|
||||
|
||||
**Recipients** (CC both):
|
||||
- `crypt@bis.doc.gov`
|
||||
- `enc@nsa.gov`
|
||||
|
||||
**Subject**: `Self-Classification Report — Chanora — [YEAR]`
|
||||
|
||||
**CSV template** (fill in `[BRACKETS]`):
|
||||
|
||||
```csv
|
||||
Submitter Name,Submitter Email,Submitter Phone,Submitter Address,Item Type,Item Name,Item Version,ECCN,Authorization Type,Encryption Algorithm,Key Length,Item Description
|
||||
[YOUR NAME],[YOU@EMAIL.COM],[PHONE],[ADDRESS],Mass Market Encryption Software,Chanora,[VERSION e.g. v1.0.0-rc.1],5D992.c,MMKT,ChaCha20-Poly1305; Ed25519; P-256 ECDH; EAX-AES; TLS 1.2/1.3,256,Cross-platform voice communication client for TeamSpeak-compatible servers. Cryptographic algorithms are all published standards; the application is closed-source proprietary software distributed via mass-market channels (Apple App Store).
|
||||
```
|
||||
|
||||
**Notes on the CSV**:
|
||||
- **Authorization Type** `MMKT` signals the mass-market self-classification under Note 3.
|
||||
- **ECCN** `5D992.c` — the mass-market note reclassifies closed-source mass-market software with standard crypto from 5D002.c.1 to 5D992.c, removing it from "EI" and "NS" controls.
|
||||
- The CSV is a **single row** for one product. For multiple products, add one row per product.
|
||||
- Send it **once per calendar year** (BIS deadline: February 1 for prior calendar year).
|
||||
- Keep the sent-mail receipt — Apple may ask to see proof during the App Store review.
|
||||
- BIS does not reply. Filing the report is the compliance event; no approval or confirmation is needed.
|
||||
|
||||
### Step 2: App Store Connect — encryption questionnaire
|
||||
|
||||
Path: `App Store Connect → My Apps → Chanora → App Information → Encryption` (or during version submission, the same questionnaire appears).
|
||||
|
||||
Answer the questions as follows for Chanora:
|
||||
|
||||
1. "Does your app use encryption?" → **Yes**
|
||||
2. "Does your app qualify for any of the exemptions provided in Category 5, Part 2 of the U.S. Export Administration Regulations?" → **No**
|
||||
(We do use non-exempt crypto — see "Why Chanora is not exempt".)
|
||||
3. "Does your app implement any encryption algorithms that are proprietary or not accepted as standard by international standard bodies (IEEE, IETF, ITU, etc.)?" → **No**
|
||||
(ChaCha20-Poly1305 is RFC 8439; Ed25519 is RFC 8032; P-256 is FIPS 186-4. TeamSpeak's handshake uses standard primitives even though the orchestration is custom.)
|
||||
4. "Does your app implement any standard encryption algorithms instead of, or in addition to, using or accessing the encryption in Apple's operating system?" → **Yes**
|
||||
5. "Is your app made available on the French App Store?" → answer per your distribution plan (Yes if you want EU/FR availability).
|
||||
|
||||
Apple will then ask you to upload supporting documentation. Upload:
|
||||
- A PDF copy of your sent email from Step 1 (showing the date sent and both recipients), with the CSV attachment shown or attached.
|
||||
- A short cover sheet identifying: product name (Chanora), ECCN (`5D992.c`), authorization basis (`§740.17(b)(1)` mass-market self-classification under Note 3 to Category 5, Part 2 of the CCL), and the date the report was sent to BIS.
|
||||
|
||||
### Step 3: Receive the code from Apple
|
||||
|
||||
After review (typical turnaround: a few business days to ~2 weeks), Apple sends an email with the export compliance code. The code is a UUID, lowercase, 36 characters, e.g. `aeafaa12-a017-48c8-a29a-bdd3061f93e3` (this is Keybase's actual code, used here as a format example only).
|
||||
|
||||
The code is **per-app, not per-version**. Once issued it can be reused for every subsequent build until your encryption usage materially changes.
|
||||
|
||||
### Step 4: Revert the workaround and patch in the code
|
||||
|
||||
Edit `apps/chanora_flutter/ios/Runner/Info.plist`:
|
||||
|
||||
```xml
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<true/>
|
||||
<key>ITSEncryptionExportComplianceCode</key>
|
||||
<string>PASTE_APPLE_ISSUED_UUID_HERE</string>
|
||||
```
|
||||
|
||||
Do the same for `apps/chanora_flutter/macos/Runner/Info.plist`.
|
||||
|
||||
Optionally — to avoid committing the literal UUID to git — use a build variable instead (matches the `procivis/one-wallet` pattern):
|
||||
|
||||
```xml
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<true/>
|
||||
<key>ITSEncryptionExportComplianceCode</key>
|
||||
<string>$(CHANORA_IOS_EXPORT_COMPLIANCE_CODE)</string>
|
||||
```
|
||||
|
||||
Then set `CHANORA_IOS_EXPORT_COMPLIANCE_CODE` as a build setting (in `Runner.xcodeproj` user-defined settings, in a non-tracked `.xcconfig`, or via the CI/Fastlane environment).
|
||||
|
||||
Validate after editing:
|
||||
|
||||
```bash
|
||||
plutil -lint apps/chanora_flutter/ios/Runner/Info.plist
|
||||
plutil -lint apps/chanora_flutter/macos/Runner/Info.plist
|
||||
```
|
||||
|
||||
### Step 5: Submit and confirm
|
||||
|
||||
Re-upload the build via Xcode → Organizer → Distribute App, or via Fastlane / CI. App Store Connect should no longer reject the build with the "Invalid Export Compliance Code" error.
|
||||
|
||||
If you use Fastlane, you can also pass `uses_non_exempt_encryption: true` to `upload_to_testflight` / `upload_to_app_store` as belt-and-suspenders; the Info.plist value is authoritative.
|
||||
|
||||
## Annual maintenance
|
||||
|
||||
- Every January, re-send the BIS self-classification CSV (Step 1) with the current year and the latest released version. Deadline: **February 1** for the prior calendar year. Keep the receipt.
|
||||
- The Apple-issued code does NOT need re-issuance unless Chanora adds a fundamentally new encryption capability (e.g. end-to-end encrypted DMs would qualify; bumping an existing AEAD library version would not).
|
||||
- If you add E2EE for direct messages, change the at-rest encryption algorithm, or introduce proprietary (non-standard) cryptography, the mass-market classification may no longer apply — you would need to either re-self-classify or file a CCATS (Commodity Classification) request via SNAP-R, and Apple will need updated documentation and a new code.
|
||||
|
||||
## References
|
||||
|
||||
- Apple — `ITSAppUsesNonExemptEncryption` key: <https://developer.apple.com/documentation/bundleresources/information-property-list/itsappusesnonexemptencryption>
|
||||
- Apple — `ITSEncryptionExportComplianceCode` key: <https://developer.apple.com/documentation/bundleresources/information-property-list/itsencryptionexportcompliancecode>
|
||||
- Apple — Complying with encryption export regulations: <https://developer.apple.com/documentation/security/complying-with-encryption-export-regulations>
|
||||
- Apple — App Store Connect Help, export compliance overview: <https://developer.apple.com/help/app-store-connect/manage-app-information/overview-of-export-compliance/>
|
||||
- Apple — App Store Connect Help, determine and upload encryption documentation: <https://developer.apple.com/help/app-store-connect/manage-app-information/determine-and-upload-app-encryption-documentation/>
|
||||
- BIS — Encryption FAQs: <https://www.bis.doc.gov/index.php/policy-guidance/encryption>
|
||||
- BIS — EAR §740.17 (License Exception ENC, self-classification): <https://www.ecfr.gov/current/title-15/subtitle-B/chapter-VII/subchapter-C/part-740/section-740.17>
|
||||
- BIS — Supplement No. 8 to Part 742 (self-classification report CSV format): <https://www.ecfr.gov/current/title-15/subtitle-B/chapter-VII/subchapter-C/part-742>
|
||||
- BIS — Note 3 to Category 5, Part 2 of the CCL (mass-market note): <https://www.ecfr.gov/current/title-15/subtitle-B/chapter-VII/subchapter-C/part-774>
|
||||
- Real-world examples (closed-source iOS apps with `ITSEncryptionExportComplianceCode`):
|
||||
- Keybase: <https://github.com/keybase/client/blob/master/shared/ios/Keybase/Info.plist>
|
||||
- Status.im: <https://github.com/status-im/status-mobile/blob/develop/ios/StatusIm/Info.plist>
|
||||
- Cryptomator: <https://github.com/cryptomator/ios/blob/develop/Cryptomator/Info.plist>
|
||||
- Build-variable pattern (avoid committing UUID to git):
|
||||
- Procivis One Wallet: <https://github.com/procivis/one-wallet/blob/main/ios/Wallet/Info.plist>
|
||||
|
||||
## Decision log
|
||||
|
||||
| Date | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| 2026-06-08 | Temporarily set `ITSAppUsesNonExemptEncryption=<false/>` on both iOS and macOS Info.plist | Unblock TestFlight internal testing while BIS / Apple paperwork is being prepared. Re-do per Steps 1-5 above before any public App Store release. |
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Document status:** DV meeting baseline candidate
|
||||
**Date:** 2026-05-29
|
||||
**Candidate:** documentation/DV evidence over Rust workspace version `0.2.0-beta.1`; Flutter app version/build `0.3.0+100`
|
||||
**Candidate:** `v1.0.0-rc.1` evidence over workspace version `0.2.0-beta.1`
|
||||
**Decision:** No-Go for public/store release; Conditional Go only for documentation review and continued internal DV validation
|
||||
|
||||
## 1. Decision Summary
|
||||
@@ -23,11 +23,10 @@ The phrase `Conditional Go` in this record is restricted to document-baseline re
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Workspace version | `0.2.0-beta.1` |
|
||||
| Flutter app version/build | `0.3.0+100` from `apps/chanora_flutter/pubspec.yaml` |
|
||||
| CHANGELOG latest candidate | `v0.3.0` baseline entry; public release version not reached |
|
||||
| Build number | `100` from `apps/chanora_flutter/pubspec.yaml` |
|
||||
| CHANGELOG latest candidate | `v1.0.0-rc.1` |
|
||||
| Build number | `76` from `apps/chanora_flutter/pubspec.yaml` |
|
||||
| Commit SHA | To be recorded from the candidate build job before release approval |
|
||||
| Git tag | To be recorded if a candidate is tagged for release validation |
|
||||
| Git tag | To be recorded if `v1.0.0-rc.1` is tagged for release validation |
|
||||
| Artifact hashes | Not recorded in current workspace; required before release approval |
|
||||
| Release owner | Product / Release Operations |
|
||||
| Verification owner | Software QA with System Engineering support |
|
||||
@@ -55,7 +54,6 @@ Current implementation status is summarized in `docs/implementation-status-2026-
|
||||
| Privacy policy baseline | Baseline candidate | Requires owner/legal review before public/store release |
|
||||
| Security/privacy evidence | Partial | Blocks strong secure-storage and diagnostic claims until audits attach evidence |
|
||||
| Android secure-storage DEK | Deferred to v1.1 | Requires waiver for internal testing; limits release claim |
|
||||
| Android target compile/install/smoke | Blocked locally | Missing Android NDK compiler `aarch64-linux-android-clang` and no authorized ADB target block Android runtime claims |
|
||||
| iOS release build/signing | Unsigned verification only | Blocks TestFlight/App Store release |
|
||||
| macOS signing/notarization | Not complete | Blocks macOS public binary release |
|
||||
| Windows/Linux packaging | Source-buildable only for candidate | Blocks packaged public desktop release claims |
|
||||
@@ -72,7 +70,6 @@ Current implementation status is summarized in `docs/implementation-status-2026-
|
||||
| iOS unsigned build | CI defined | Attach latest passing candidate run; add signing evidence before release |
|
||||
| Compatible-server demo | Evidence not attached in this record | Run and attach demo notes/logs |
|
||||
| Audio send/receive and processing demo | Evidence not attached in this record | Run and attach platform evidence |
|
||||
| Android target build/install/smoke | Blocked locally | Install/fix Android NDK compiler, connect/authorize a device or emulator, then attach build/install/smoke evidence |
|
||||
| Diagnostics redaction/export demo | Evidence not attached in this record | Run and attach export review |
|
||||
| Platform secure-storage audit | Partial | Attach per-platform audit or waiver |
|
||||
| PTT capability evidence | Partial | Attach per-platform `PttCapabilityLevel` and backend record |
|
||||
@@ -81,7 +78,7 @@ Current implementation status is summarized in `docs/implementation-status-2026-
|
||||
|
||||
| Platform | Current readiness | Release decision |
|
||||
|---|---|---|
|
||||
| Android | Core platform implementation present; Android Keystore-backed DEK deferred; local target compile/install/smoke blocked by missing `aarch64-linux-android-clang` and no authorized ADB target | Conditional internal validation only after Android build/install/smoke evidence or explicit waiver |
|
||||
| Android | Core platform implementation present; Android Keystore-backed DEK deferred | Conditional internal validation only |
|
||||
| iOS | Unsigned build path present; signing and store pipeline incomplete | No-Go for store release |
|
||||
| Windows | Source-buildable; smoke procedure exists | No-Go for packaged release until smoke/signing evidence exists |
|
||||
| macOS | Source-buildable; public artifact not in candidate | No-Go for packaged release until signing/notarization evidence exists |
|
||||
|
||||
+1
-1
@@ -1317,7 +1317,7 @@ Therefore:
|
||||
- Analysis: Feasible with current Flutter + Rust Core architecture; refine in SAD/SDD as needed.
|
||||
- Owner: Software Team
|
||||
|
||||
**SRS-110**: The iOS software shall integrate with AVAudioSession or equivalent platform audio session behavior for foreground voice sessions. The session shall be configured for VoIP (`.playAndRecord` + `.voiceChat` + `.mixWithOthers`) only while a voice channel is active, and shall return to a non-disruptive idle state (`.ambient`, inactive, with `.notifyOthersOnDeactivation`) at all other times so that other apps' audio (music, podcasts, navigation) is preserved when the user opens Chanora to read text chat.
|
||||
**SRS-110**: The iOS software shall integrate with AVAudioSession or equivalent platform audio session behavior for foreground voice sessions.
|
||||
|
||||
- Type: Platform / iOS
|
||||
- Stage: P0 / MVP
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
# Core Internal Split Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Improve `chanora_core` maintainability by moving stable event DTOs and network diagnostic state out of the oversized `lib.rs` while preserving the public crate Interface.
|
||||
|
||||
**Architecture:** Keep `lib.rs` as the public Interface and session orchestration entry point. Move event-facing DTOs to `events.rs` and private network diagnostic ring-buffer state to `network_diagnostics.rs`; re-export public event types from `lib.rs` so downstream callers keep using `chanora_core::SessionEvent` and related names unchanged.
|
||||
|
||||
**Tech Stack:** Rust 2021, Tokio broadcast/watch channels, `thiserror`, existing Cargo workspace tests.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Move Core Event DTOs
|
||||
|
||||
**Files:**
|
||||
- Create: `core/chanora_core/src/events.rs`
|
||||
- Modify: `core/chanora_core/src/lib.rs`
|
||||
- Verify: `cargo test -p chanora_core`
|
||||
|
||||
- [ ] **Step 1: Preserve current public Interface with tests**
|
||||
|
||||
Run: `cargo test -p chanora_core`
|
||||
Expected: PASS. Existing bridge-facing tests and compile checks prove the current public event names are valid.
|
||||
|
||||
- [ ] **Step 2: Create `events.rs` with the moved public event DTOs**
|
||||
|
||||
Move these exact public types from `lib.rs` to `events.rs`:
|
||||
- `PttDescriptorSnapshot`
|
||||
- `PersistedPttBinding`
|
||||
- `SessionEvent`
|
||||
- `VoiceJoinSyncState`
|
||||
- `VoiceJoinErrorCode`
|
||||
- `NetworkState`
|
||||
|
||||
Use local imports in `events.rs` for `chanora_audio::{AudioRoute, PttBackendDescriptor}` and `chanora_protocol::MessageTarget`.
|
||||
|
||||
- [ ] **Step 3: Re-export moved types from `lib.rs`**
|
||||
|
||||
Add `mod events;` and `pub use events::{...};` for all moved public types. Remove the original definitions from `lib.rs`.
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `cargo test -p chanora_core`
|
||||
Expected: PASS with no public Interface break.
|
||||
|
||||
### Task 2: Move Core Network Diagnostics State
|
||||
|
||||
**Files:**
|
||||
- Create: `core/chanora_core/src/network_diagnostics.rs`
|
||||
- Modify: `core/chanora_core/src/lib.rs`
|
||||
- Verify: `cargo test -p chanora_core network_diagnostics`
|
||||
|
||||
- [ ] **Step 1: Move `NetworkDiagnostics` into a private module**
|
||||
|
||||
Move the private `NetworkDiagnostics` struct and its methods from `lib.rs` into `network_diagnostics.rs`. Keep methods `pub(crate)` because `ChanoraSession` records connection/loss events and exports summaries.
|
||||
|
||||
- [ ] **Step 2: Move the regression test with the module**
|
||||
|
||||
Move `network_diagnostics_keeps_last_eight_loss_reasons` from the `lib.rs` test module into `network_diagnostics.rs` so the behaviour test lives next to the Implementation it protects.
|
||||
|
||||
- [ ] **Step 3: Import the private module from `lib.rs`**
|
||||
|
||||
Add `mod network_diagnostics;` and `use network_diagnostics::NetworkDiagnostics;`. Remove `VecDeque` from the `lib.rs` imports.
|
||||
|
||||
- [ ] **Step 4: Run targeted and workspace verification**
|
||||
|
||||
Run: `cargo test -p chanora_core network_diagnostics`
|
||||
Expected: PASS.
|
||||
|
||||
Run: `cargo test --workspace`
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Format and Check Workspace
|
||||
|
||||
**Files:**
|
||||
- Modify: Rust files touched above only, except existing formatter-only churn may remain from prior `cargo fmt --all`.
|
||||
|
||||
- [ ] **Step 1: Format Rust code**
|
||||
|
||||
Run: `cargo fmt --all`
|
||||
Expected: no command output.
|
||||
|
||||
- [ ] **Step 2: Compile workspace**
|
||||
|
||||
Run: `cargo check --workspace`
|
||||
Expected: finishes successfully.
|
||||
|
||||
- [ ] **Step 3: Inspect diff**
|
||||
|
||||
Run: `git diff --stat`
|
||||
Expected: new `events.rs` and `network_diagnostics.rs`; smaller `core/chanora_core/src/lib.rs`; no public API renames.
|
||||
|
||||
---
|
||||
|
||||
Self-review: This plan covers the recommended Core split first slice, avoids public Interface changes, has no placeholders, and keeps testing tied to the moved Implementations.
|
||||
@@ -1,608 +0,0 @@
|
||||
# Maintainability Continuation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Continue the current maintainability review with safe simplifications, full code-review remediation, current documentation, explicit fail-safe evidence, and Android runtime verification status.
|
||||
|
||||
**Current status:** Task 0 and the audio-realtime portion of Task 0.1 have landed in commits `d835394` and `8606eb4`. Task 0.2 is a documentation/status alignment slice only; it must not claim Android or iOS runtime success.
|
||||
|
||||
**Architecture:** Treat the existing uncommitted maintainability changes as the baseline slice. Fix safety and hidden-bug findings before broad Module splits. Preserve public Rust Core, Bridge, Protocol, Audio, and Flutter responsibilities while applying only tested simplifications and risk-reducing refactors. Record larger seam decisions as follow-up findings unless a huge Module must be split to make a safety fix testable.
|
||||
|
||||
**Tech Stack:** Rust 2021 Cargo workspace, Flutter/Dart 3.11, Flutter Rust Bridge 2.12, Android ADB, Markdown governance and verification documents.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
Implementation should keep the following responsibilities stable:
|
||||
|
||||
- `core/chanora_core/src/lib.rs`: public Core API, session orchestration, public re-exports, and integration-facing methods.
|
||||
- `core/chanora_core/src/events.rs`: Core public event DTOs re-exported by `lib.rs`.
|
||||
- `core/chanora_core/src/network_diagnostics.rs`: private bounded network diagnostic history and its local regression tests.
|
||||
- `crates/chanora_audio/src/voice_render.rs`: shared render/downmix helpers.
|
||||
- `crates/chanora_audio/src/ios_raw_unit.rs`, `ios_voice_unit.rs`, `android_voice_unit.rs`, `engine.rs`: platform/audio backends; avoid broad rewrites without device evidence.
|
||||
- `crates/chanora_audio/src/ptt_backends/mod.rs`: PTT backend descriptor and error definitions.
|
||||
- `crates/chanora_state/src/lib.rs`: snapshot reducers and state deltas.
|
||||
- `crates/chanora_protocol/src/adapter.rs`: protocol adapter event ordering and DTO projection.
|
||||
- `crates/chanora_bridge/src/api.rs`: bridge-facing API and DTO mapping; generated files are not manually edited.
|
||||
- `crates/chanora_diagnostics/src/lib.rs`: bounded diagnostics, redaction, and export data.
|
||||
- `docs/governance/maintainability-review-2026-06-08.md`: working review record and fail-safe gap log.
|
||||
- `docs/governance/document-index.md`: navigation index for review records.
|
||||
- `docs/architecture/sad.md`, `docs/architecture/sdd.md`: architecture updates for seams and implementation boundaries.
|
||||
- `docs/implementation-status-2026-05-28.md`: implementation status updates.
|
||||
- `docs/verification/swe4-unit-verification-plan.md`, `docs/verification/swe5-software-integration-verification-plan.md`: verification evidence and requirements updates.
|
||||
|
||||
## Review Findings To Remediate First
|
||||
|
||||
The full review added these priority fixes before the original maintainability cleanup sequence:
|
||||
|
||||
- Flutter privacy fail-safe: talk-power recovery must not clear a user/manual mute. Fixed in commit `d835394`.
|
||||
- Flutter stuck-transmit fail-safe: touch PTT must release when disposed while held. Fixed in commit `d835394`.
|
||||
- Flutter/iOS fail-safe: iOS audio-session activation errors must be caught, and activation should be moved before Rust VoiceProcessingIO startup where the current app flow allows. Missing-plugin/error hardening fixed in commit `d835394`; iOS device runtime verification remains required.
|
||||
- Rust realtime safety: Android and iOS raw render-reference buffers must not use unsynchronized mutable aliasing. Callback-path hardening fixed in commit `8606eb4`; the full lock-free `AudioHandler` / config / debug-recorder redesign remains a follow-up.
|
||||
- Rust realtime safety: Android input callback must not block on a mutex, and non-48 kHz capture should not allocate/clone per callback. Focused callback-path hardening fixed in commit `8606eb4`; Android target compilation and runtime verification remain blocked locally until the missing NDK compiler and an authorized ADB target are available.
|
||||
- Rust control-plane safety: disconnect and protocol control requests must remain bounded under broken transport or sustained voice traffic.
|
||||
- Governance correctness: README/release/VAD/Android API/product-decision docs must not contradict code or verification status.
|
||||
|
||||
## Task 0: Fix Flutter Privacy and Stuck-Transmit Fail-Safes
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/chanora_flutter/lib/main.dart`
|
||||
- Modify: `apps/chanora_flutter/lib/widgets/voice_compact.dart`
|
||||
- Modify: `apps/chanora_flutter/lib/services/ios_audio_session_controller.dart`
|
||||
- Test: `apps/chanora_flutter/test/widgets/voice_compact_test.dart`
|
||||
- Test: existing Flutter tests under `apps/chanora_flutter/test/`
|
||||
|
||||
- [ ] **Step 1: Add failing touch PTT disposal regression test**
|
||||
|
||||
Create or update `apps/chanora_flutter/test/widgets/voice_compact_test.dart` with a widget test that presses the touch PTT button, replaces the widget without sending pointer-up, and expects the callback sequence `[true, false]`.
|
||||
|
||||
- [ ] **Step 2: Run touch PTT test and verify RED**
|
||||
|
||||
Run from `apps/chanora_flutter`: `flutter test test/widgets/voice_compact_test.dart`
|
||||
|
||||
Expected before production fix: FAIL because disposal does not emit `false`.
|
||||
|
||||
- [ ] **Step 3: Implement touch PTT release-on-dispose**
|
||||
|
||||
Add `dispose()` to the touch PTT button state so an active press calls `widget.onHeldChanged(false)` exactly once before disposal.
|
||||
|
||||
- [ ] **Step 4: Run touch PTT test and verify GREEN**
|
||||
|
||||
Run from `apps/chanora_flutter`: `flutter test test/widgets/voice_compact_test.dart`
|
||||
|
||||
Expected after fix: PASS.
|
||||
|
||||
- [ ] **Step 5: Add or preserve mute-owner regression coverage**
|
||||
|
||||
If an existing pure reducer seam is available, add a failing test for manual mute true -> talk power blocked -> talk power restored. If no testable seam exists, first extract the smallest voice mute owner helper from `main.dart` and test it directly.
|
||||
|
||||
- [ ] **Step 6: Implement independent mute owners**
|
||||
|
||||
Ensure talk-power recovery clears only the talk-power owner and does not clear manual/user mute or permission mute. Effective hard mute is the OR of manual, permission, and talk-power owners.
|
||||
|
||||
- [ ] **Step 7: Harden iOS audio-session controller errors**
|
||||
|
||||
Add tests for `MissingPluginException` in `ios_audio_session_controller_test.dart`, then catch `MissingPluginException` or `Object` so activation/deactivation failures do not become unhandled async errors.
|
||||
|
||||
- [ ] **Step 8: Run focused Flutter verification**
|
||||
|
||||
Run from `apps/chanora_flutter`: `flutter test test/widgets/voice_compact_test.dart test/services/ios_audio_session_controller_test.dart && flutter analyze`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Commit Flutter fail-safe slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add apps/chanora_flutter/lib/main.dart apps/chanora_flutter/lib/widgets/voice_compact.dart apps/chanora_flutter/lib/services/ios_audio_session_controller.dart apps/chanora_flutter/test/widgets/voice_compact_test.dart apps/chanora_flutter/test/services/ios_audio_session_controller_test.dart
|
||||
git commit -m "fix(voice): preserve mute owners and release touch ptt"
|
||||
```
|
||||
|
||||
Expected: one commit containing only Flutter fail-safe fixes and tests.
|
||||
|
||||
## Task 0.1: Fix Rust Realtime and Control-Plane Safety Findings
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/chanora_audio/src/android_voice_unit.rs`
|
||||
- Modify: `crates/chanora_audio/src/ios_raw_unit.rs`
|
||||
- Modify: `crates/chanora_audio/src/engine.rs`
|
||||
- Modify: `crates/chanora_protocol/src/adapter.rs`
|
||||
- Modify: `core/chanora_core/src/lib.rs`
|
||||
- Test: Rust tests in affected crates
|
||||
|
||||
- [ ] **Step 1: Add failing bounded-buffer regression for render-reference handoff**
|
||||
|
||||
Add host-testable unit coverage around the render-reference buffer behavior so a writer can publish a frame and a reader can read a complete latest frame without unsynchronized mutation.
|
||||
|
||||
- [ ] **Step 2: Replace unsafe shared mutable render-reference buffers**
|
||||
|
||||
Replace unsynchronized mutable aliasing in Android and iOS raw render-reference buffers with a realtime-safe handoff such as an `ArrayQueue` of complete frames or a documented atomic double-buffer. Do not add mutex locking to realtime callbacks.
|
||||
|
||||
- [ ] **Step 3: Add failing protocol progress regression where feasible**
|
||||
|
||||
Add or isolate a test proving control requests are not starved by sustained voice packet drain.
|
||||
|
||||
- [ ] **Step 4: Bound voice draining and disconnect shutdown**
|
||||
|
||||
Cap voice packet draining per protocol loop and make disconnect/shutdown bounded so UI/Core locks are not held across unbounded transport waits.
|
||||
|
||||
- [ ] **Step 5: Run focused Rust verification**
|
||||
|
||||
Run: `cargo test -p chanora_audio -p chanora_protocol -p chanora_core`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit Rust safety slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add crates/chanora_audio/src/android_voice_unit.rs crates/chanora_audio/src/ios_raw_unit.rs crates/chanora_audio/src/engine.rs crates/chanora_protocol/src/adapter.rs core/chanora_core/src/lib.rs
|
||||
git commit -m "fix(audio): harden realtime and protocol fail-safes"
|
||||
```
|
||||
|
||||
Expected: one commit containing only Rust safety fixes and tests.
|
||||
|
||||
## Task 0.2: Align Review Findings With Specs, Plans, and Governance Docs
|
||||
|
||||
**Status:** In progress / documentation-only alignment. Do not commit from this task unless explicitly requested.
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/specs/2026-06-08-maintainability-continuation-design.md`
|
||||
- Modify: `docs/superpowers/plans/2026-06-08-maintainability-continuation.md`
|
||||
- Modify: `docs/governance/maintainability-review-2026-06-08.md`
|
||||
- Modify: `README.md`
|
||||
- Modify if needed: `CHANGELOG.md`
|
||||
- Modify if needed: `docs/governance/product-decision-register.md`
|
||||
- Modify if needed: `docs/release/release-readiness-go-nogo-record.md`
|
||||
- Modify if needed: `docs/release/dv-waiver-register.md`
|
||||
- Modify if needed: `docs/verification/verification-master-plan.md`
|
||||
- Modify if needed: `docs/verification/sys4-system-integration-verification-plan.md`
|
||||
|
||||
- [x] **Step 1: Record full-review findings in maintainability review**
|
||||
|
||||
Update `docs/governance/maintainability-review-2026-06-08.md` with the full code-review findings, fixed items, blocked items, and follow-up Module split candidates.
|
||||
|
||||
- [x] **Step 2: Fix stale platform/release claims**
|
||||
|
||||
Update Android minimum runtime claims to API 28 where code and requirements require it. Update release metadata so Flutter app version/build and Rust workspace version are clearly distinguished.
|
||||
|
||||
- [x] **Step 3: Clarify VAD/VoiceActivity status**
|
||||
|
||||
Document the difference between VAD scaffolding/assets/tests and product-enabled VoiceActivity behavior. Do not claim runtime VoiceActivity is shipped unless verified.
|
||||
|
||||
- [x] **Step 4: Promote Android runtime verification blocker**
|
||||
|
||||
Add Android ADB/build/install/smoke as a blocker or waiver in governing release/verification docs when no authorized target is connected.
|
||||
|
||||
- [ ] **Step 5: Commit plan/spec/governance alignment slice**
|
||||
|
||||
Skipped in this subagent run because the instruction for Task 0.2 explicitly says not to commit.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-06-08-maintainability-continuation-design.md docs/superpowers/plans/2026-06-08-maintainability-continuation.md docs/governance/maintainability-review-2026-06-08.md README.md CHANGELOG.md docs/governance/product-decision-register.md docs/release/release-readiness-go-nogo-record.md docs/release/dv-waiver-register.md docs/verification/verification-master-plan.md docs/verification/sys4-system-integration-verification-plan.md
|
||||
git commit -m "docs: align review findings and verification gates"
|
||||
```
|
||||
|
||||
Expected: one documentation/governance commit, with unavailable optional files omitted only if unchanged.
|
||||
|
||||
## Task 1: Verify Current Branch Baseline
|
||||
|
||||
**Files:**
|
||||
- Read: `docs/governance/maintainability-review-2026-06-08.md`
|
||||
- Read: `docs/superpowers/plans/2026-06-08-core-internal-split.md`
|
||||
- Inspect: all currently modified files from `git status --short`
|
||||
- Modify: none unless verification exposes a small mechanical fix
|
||||
|
||||
- [ ] **Step 1: Inspect current status**
|
||||
|
||||
Run: `git status --short`
|
||||
|
||||
Expected: output includes the existing maintainability branch changes and no staged files from unrelated work.
|
||||
|
||||
- [ ] **Step 2: Inspect current diff summary**
|
||||
|
||||
Run: `git diff --stat`
|
||||
|
||||
Expected: diff remains focused on Core split, Audio simplifications, State/Protocol/Bridge/Diagnostics cleanup, and documentation updates.
|
||||
|
||||
- [ ] **Step 3: Verify Rust formatting**
|
||||
|
||||
Run: `cargo fmt --all --check`
|
||||
|
||||
Expected: PASS with no output. If it fails, run `cargo fmt --all`, inspect the resulting diff, and include formatter-only changes in the smallest relevant commit.
|
||||
|
||||
- [ ] **Step 4: Verify Rust compilation**
|
||||
|
||||
Run: `cargo check --workspace`
|
||||
|
||||
Expected: PASS for the full workspace.
|
||||
|
||||
- [ ] **Step 5: Verify Rust tests**
|
||||
|
||||
Run: `cargo test --workspace`
|
||||
|
||||
Expected: PASS for the full workspace.
|
||||
|
||||
- [ ] **Step 6: Verify Flutter analysis**
|
||||
|
||||
Run from `apps/chanora_flutter`: `flutter analyze`
|
||||
|
||||
Expected: PASS with no new analyzer errors.
|
||||
|
||||
- [ ] **Step 7: Verify Flutter tests**
|
||||
|
||||
Run from `apps/chanora_flutter`: `flutter test --exclude-tags e2e`
|
||||
|
||||
Expected: PASS for non-e2e Flutter tests.
|
||||
|
||||
- [ ] **Step 8: Check Android device availability**
|
||||
|
||||
Run: `adb devices -l`
|
||||
|
||||
Expected if a target is connected: at least one `device` row. Expected if no target is connected: only the header and no `device` row; record Android runtime verification as blocked in `docs/governance/maintainability-review-2026-06-08.md`.
|
||||
|
||||
- [ ] **Step 9: Commit verified existing slice**
|
||||
|
||||
Only after Steps 1-8 have been completed or any blocked Android status has been documented, stage the smallest coherent existing slice.
|
||||
|
||||
Recommended first slice if tests pass:
|
||||
|
||||
```bash
|
||||
git add core/chanora_core/src/lib.rs core/chanora_core/src/events.rs core/chanora_core/src/network_diagnostics.rs docs/superpowers/plans/2026-06-08-core-internal-split.md
|
||||
git commit -m "refactor(core): split event and diagnostics internals"
|
||||
```
|
||||
|
||||
Expected: one commit containing only the Core split and its plan.
|
||||
|
||||
## Task 2: Commit Existing Built-In and Helper Reuse Simplifications
|
||||
|
||||
**Files:**
|
||||
- Modify or stage: `crates/chanora_audio/src/ptt_backends/mod.rs`
|
||||
- Modify or stage: `crates/chanora_audio/src/voice_render.rs`
|
||||
- Modify or stage: `crates/chanora_audio/src/ios_raw_unit.rs`
|
||||
- Modify or stage: `crates/chanora_diagnostics/src/lib.rs`
|
||||
- Modify or stage: `crates/chanora_state/src/lib.rs`
|
||||
- Modify or stage: `crates/chanora_resolver/Cargo.toml`
|
||||
- Modify or stage: `Cargo.lock`
|
||||
|
||||
- [ ] **Step 1: Inspect simplification diffs**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff -- crates/chanora_audio/src/ptt_backends/mod.rs crates/chanora_audio/src/voice_render.rs crates/chanora_audio/src/ios_raw_unit.rs crates/chanora_diagnostics/src/lib.rs crates/chanora_state/src/lib.rs crates/chanora_resolver/Cargo.toml Cargo.lock
|
||||
```
|
||||
|
||||
Expected: diffs show built-in/helper reuse only: `thiserror::Error`, `VecDeque`, render helper reuse, reducer reuse, and workspace metadata inheritance.
|
||||
|
||||
- [ ] **Step 2: Run focused Rust tests for changed areas**
|
||||
|
||||
Run: `cargo test -p chanora_audio -p chanora_diagnostics -p chanora_state -p chanora_resolver`
|
||||
|
||||
Expected: PASS for all listed crates.
|
||||
|
||||
- [ ] **Step 3: Run workspace Rust verification**
|
||||
|
||||
Run: `cargo check --workspace && cargo test --workspace`
|
||||
|
||||
Expected: PASS for workspace compile and tests.
|
||||
|
||||
- [ ] **Step 4: Commit built-in/helper reuse slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add crates/chanora_audio/src/ptt_backends/mod.rs crates/chanora_audio/src/voice_render.rs crates/chanora_audio/src/ios_raw_unit.rs crates/chanora_diagnostics/src/lib.rs crates/chanora_state/src/lib.rs crates/chanora_resolver/Cargo.toml Cargo.lock
|
||||
git commit -m "refactor: reuse built-ins and shared helpers"
|
||||
```
|
||||
|
||||
Expected: one commit containing only the built-in/helper reuse simplifications.
|
||||
|
||||
## Task 3: Review Remaining Audio Platform Diffs Before Committing
|
||||
|
||||
**Files:**
|
||||
- Inspect: `crates/chanora_audio/src/android_voice_unit.rs`
|
||||
- Inspect: `crates/chanora_audio/src/engine.rs`
|
||||
- Inspect: `crates/chanora_audio/src/ios_voice_unit.rs`
|
||||
- Modify: `docs/governance/maintainability-review-2026-06-08.md` if Android runtime verification is blocked or audio fail-safe evidence changes
|
||||
|
||||
- [ ] **Step 1: Inspect platform-audio diffs**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff -- crates/chanora_audio/src/android_voice_unit.rs crates/chanora_audio/src/engine.rs crates/chanora_audio/src/ios_voice_unit.rs
|
||||
```
|
||||
|
||||
Expected: diffs are understandable as local simplification or fail-safe improvements. If a diff changes platform runtime behavior and no Android/iOS device evidence is available, keep it separate from non-platform commits.
|
||||
|
||||
- [ ] **Step 2: Run audio crate tests**
|
||||
|
||||
Run: `cargo test -p chanora_audio`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Check Android runtime availability**
|
||||
|
||||
Run: `adb devices -l`
|
||||
|
||||
Expected if connected: at least one target row with `device`. Expected if blocked: no target row.
|
||||
|
||||
- [ ] **Step 4: Run Android build when a target is available**
|
||||
|
||||
Run from `apps/chanora_flutter`: `flutter build apk --debug`
|
||||
|
||||
Expected: PASS and a debug APK is produced.
|
||||
|
||||
- [ ] **Step 5: Run Android install and smoke when a target is available**
|
||||
|
||||
Run from `apps/chanora_flutter`: `flutter install`
|
||||
|
||||
Expected: PASS and app installs on the connected target.
|
||||
|
||||
Manual smoke expectations:
|
||||
|
||||
- App launches without crash.
|
||||
- Permission UI can be reached.
|
||||
- Audio device/permission screen does not crash.
|
||||
- Voice controls remain responsive.
|
||||
|
||||
- [ ] **Step 6: Record blocked Android evidence if no target is available**
|
||||
|
||||
Modify `docs/governance/maintainability-review-2026-06-08.md` Section 6 so it includes the exact `adb devices -l` result and states Android runtime verification is blocked until a device or emulator is connected.
|
||||
|
||||
- [ ] **Step 7: Commit platform-audio slice**
|
||||
|
||||
If Android runtime was verified, run:
|
||||
|
||||
```bash
|
||||
git add crates/chanora_audio/src/android_voice_unit.rs crates/chanora_audio/src/engine.rs crates/chanora_audio/src/ios_voice_unit.rs docs/governance/maintainability-review-2026-06-08.md
|
||||
git commit -m "refactor(audio): simplify platform voice internals"
|
||||
```
|
||||
|
||||
If Android runtime was blocked, run:
|
||||
|
||||
```bash
|
||||
git add crates/chanora_audio/src/android_voice_unit.rs crates/chanora_audio/src/engine.rs crates/chanora_audio/src/ios_voice_unit.rs docs/governance/maintainability-review-2026-06-08.md
|
||||
git commit -m "refactor(audio): simplify platform voice internals"
|
||||
```
|
||||
|
||||
Expected: commit message is the same, but the maintainability review explicitly records the blocked Android runtime evidence.
|
||||
|
||||
## Task 4: Review Protocol and Bridge Diffs as One Boundary Slice
|
||||
|
||||
**Files:**
|
||||
- Inspect: `crates/chanora_protocol/src/adapter.rs`
|
||||
- Inspect: `crates/chanora_bridge/src/api.rs`
|
||||
- Modify: `docs/architecture/sad.md`
|
||||
- Modify: `docs/architecture/sdd.md`
|
||||
- Modify: `docs/governance/maintainability-review-2026-06-08.md`
|
||||
|
||||
- [ ] **Step 1: Inspect protocol/bridge diffs**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff -- crates/chanora_protocol/src/adapter.rs crates/chanora_bridge/src/api.rs
|
||||
```
|
||||
|
||||
Expected: diffs preserve protocol isolation and bridge DTO shape unless bridge generation and Flutter tests are included.
|
||||
|
||||
- [ ] **Step 2: Verify protocol tests**
|
||||
|
||||
Run: `cargo test -p chanora_protocol`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Verify bridge tests and compile**
|
||||
|
||||
Run: `cargo test -p chanora_bridge`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Verify Flutter after bridge/API changes**
|
||||
|
||||
Run from `apps/chanora_flutter`: `flutter analyze && flutter test --exclude-tags e2e`
|
||||
|
||||
Expected: PASS for analyzer and non-e2e tests.
|
||||
|
||||
- [ ] **Step 5: Document seam findings**
|
||||
|
||||
Update `docs/governance/maintainability-review-2026-06-08.md` so remaining bridge DTO drift and protocol voice packet seam risks are listed under fail-safe gaps or follow-up opportunities.
|
||||
|
||||
- [ ] **Step 6: Update architecture docs if seam wording changed**
|
||||
|
||||
If the code diff clarifies protocol/bridge boundaries, update `docs/architecture/sad.md` and `docs/architecture/sdd.md` with one concise note each. The note should state whether protocol voice packet handling is an intentional exception and whether bridge DTO mirrors remain required by Flutter Rust Bridge.
|
||||
|
||||
- [ ] **Step 7: Commit protocol/bridge slice**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add crates/chanora_protocol/src/adapter.rs crates/chanora_bridge/src/api.rs docs/architecture/sad.md docs/architecture/sdd.md docs/governance/maintainability-review-2026-06-08.md
|
||||
git commit -m "refactor: clarify protocol bridge boundaries"
|
||||
```
|
||||
|
||||
Expected: one commit for protocol/bridge boundary cleanup plus matching architecture documentation.
|
||||
|
||||
## Task 5: Run a Second-Pass Simplification Search
|
||||
|
||||
**Files:**
|
||||
- Inspect: Rust and Dart source files only
|
||||
- Modify: only if the simplification is mechanical, local, and covered by tests
|
||||
- Modify: `docs/governance/maintainability-review-2026-06-08.md`
|
||||
|
||||
- [ ] **Step 1: Search for custom queue front removal**
|
||||
|
||||
Run: `rg "remove\(0\)|removeAt\(0\)" core crates apps/chanora_flutter/lib apps/chanora_flutter/test`
|
||||
|
||||
Expected: no results. If results exist, replace with `VecDeque` in Rust or a clearer Dart queue structure only when behavior is covered by a local test.
|
||||
|
||||
- [ ] **Step 2: Search for manual Rust error formatting**
|
||||
|
||||
Run: `rg "impl (std::fmt::)?Display for .*Error|impl std::error::Error for" core crates`
|
||||
|
||||
Expected: only intentional manual implementations remain. For simple enum error types, replace with `thiserror::Error` and add or preserve tests for user-facing strings.
|
||||
|
||||
- [ ] **Step 3: Search for duplicate mono downmix loops**
|
||||
|
||||
Run: `rg "chunks_exact\(2\)|downmix|mono" crates/chanora_audio/src`
|
||||
|
||||
Expected: duplicate i16/f32 mono downmix code is either absent or justified. If a duplicate remains, route it through an existing helper and run `cargo test -p chanora_audio`.
|
||||
|
||||
- [ ] **Step 4: Search for shallow modules worth documenting**
|
||||
|
||||
Run: `rg "^pub struct|^pub enum|^pub fn|^fn" crates/chanora_audio/src/processor crates/chanora_prefetch/src crates/chanora_resolver/src core/chanora_core/src`
|
||||
|
||||
Expected: identify candidates, but do not merge modules in this task. Record speculative merges in `docs/governance/maintainability-review-2026-06-08.md` unless a candidate is trivial and already covered by tests.
|
||||
|
||||
- [ ] **Step 5: Commit second-pass mechanical simplifications if any**
|
||||
|
||||
If code changed, run the relevant focused tests plus `cargo check --workspace && cargo test --workspace`, then inspect the changed files:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
```
|
||||
|
||||
Stage only the files changed by the second-pass mechanical simplification. Example for a Rust-only diagnostics simplification:
|
||||
|
||||
```bash
|
||||
git add crates/chanora_diagnostics/src/lib.rs docs/governance/maintainability-review-2026-06-08.md
|
||||
git commit -m "refactor: apply second-pass mechanical simplifications"
|
||||
```
|
||||
|
||||
Expected: commit contains only local mechanical simplifications and the review record.
|
||||
|
||||
- [ ] **Step 6: Commit review-only findings if no code changed**
|
||||
|
||||
If no code changed and only findings were added, run:
|
||||
|
||||
```bash
|
||||
git add docs/governance/maintainability-review-2026-06-08.md
|
||||
git commit -m "docs: record maintainability follow-up findings"
|
||||
```
|
||||
|
||||
Expected: documentation-only commit.
|
||||
|
||||
## Task 6: Final Documentation Alignment
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/governance/maintainability-review-2026-06-08.md`
|
||||
- Modify: `docs/governance/document-index.md`
|
||||
- Modify: `docs/implementation-status-2026-05-28.md`
|
||||
- Modify: `docs/verification/swe4-unit-verification-plan.md`
|
||||
- Modify: `docs/verification/swe5-software-integration-verification-plan.md`
|
||||
- Modify if needed: `docs/architecture/sad.md`
|
||||
- Modify if needed: `docs/architecture/sdd.md`
|
||||
|
||||
- [ ] **Step 1: Update maintainability review completion status**
|
||||
|
||||
Edit `docs/governance/maintainability-review-2026-06-08.md` so these sections are current:
|
||||
|
||||
- Changes applied
|
||||
- Remaining simplification opportunities
|
||||
- Fail-safe gaps that need evidence
|
||||
- Verification policy
|
||||
- Android ADB status
|
||||
- Git policy
|
||||
|
||||
- [ ] **Step 2: Update document index**
|
||||
|
||||
Ensure `docs/governance/document-index.md` includes `docs/governance/maintainability-review-2026-06-08.md` and this plan/spec if the repository convention indexes superpowers documents.
|
||||
|
||||
- [ ] **Step 3: Update implementation status**
|
||||
|
||||
Ensure `docs/implementation-status-2026-05-28.md` describes maintainability review results without claiming production readiness.
|
||||
|
||||
- [ ] **Step 4: Update SWE.4 verification plan**
|
||||
|
||||
Ensure `docs/verification/swe4-unit-verification-plan.md` lists Rust unit verification expectations for Core, Audio, State, Protocol, Bridge, Diagnostics, Resolver, and Prefetch when those crates are touched.
|
||||
|
||||
- [ ] **Step 5: Update SWE.5 verification plan**
|
||||
|
||||
Ensure `docs/verification/swe5-software-integration-verification-plan.md` lists integration expectations for Bridge DTO drift, protocol event folding, Flutter analyze/test, and Android runtime smoke evidence.
|
||||
|
||||
- [ ] **Step 6: Run documentation cross-link search**
|
||||
|
||||
Run: `rg "maintainability-review-2026-06-08|core-internal-split|maintainability-continuation" docs README.md`
|
||||
|
||||
Expected: references point to existing files and no stale path is introduced.
|
||||
|
||||
- [ ] **Step 7: Commit final documentation alignment**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add docs/governance/maintainability-review-2026-06-08.md docs/governance/document-index.md docs/implementation-status-2026-05-28.md docs/verification/swe4-unit-verification-plan.md docs/verification/swe5-software-integration-verification-plan.md docs/architecture/sad.md docs/architecture/sdd.md
|
||||
git commit -m "docs: align maintainability verification records"
|
||||
```
|
||||
|
||||
Expected: one documentation-focused commit.
|
||||
|
||||
## Task 7: Final Verification and Android Evidence
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/governance/maintainability-review-2026-06-08.md` only if final verification status changes
|
||||
|
||||
- [ ] **Step 1: Run full Rust verification**
|
||||
|
||||
Run: `cargo fmt --all --check && cargo check --workspace && cargo test --workspace`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run full Flutter verification**
|
||||
|
||||
Run from `apps/chanora_flutter`: `flutter analyze && flutter test --exclude-tags e2e`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Run ADB check**
|
||||
|
||||
Run: `adb devices -l`
|
||||
|
||||
Expected if connected: at least one target row with `device`. Expected if blocked: no target row and the maintainability review states Android runtime verification is blocked.
|
||||
|
||||
- [ ] **Step 4: Run Android build/install/smoke when connected**
|
||||
|
||||
Run from `apps/chanora_flutter`: `flutter build apk --debug && flutter install`
|
||||
|
||||
Expected: PASS. Manually verify app launch, permission screen access, audio settings access, and voice control responsiveness.
|
||||
|
||||
- [ ] **Step 5: Commit final verification evidence if docs changed**
|
||||
|
||||
If the maintainability review was updated with final verification evidence, run:
|
||||
|
||||
```bash
|
||||
git add docs/governance/maintainability-review-2026-06-08.md
|
||||
git commit -m "docs: record maintainability verification evidence"
|
||||
```
|
||||
|
||||
Expected: one small evidence-only documentation commit.
|
||||
|
||||
- [ ] **Step 6: Inspect final history and status**
|
||||
|
||||
Run: `git status --short && git log --oneline -10`
|
||||
|
||||
Expected: no unexpected unstaged changes related to this work; recent commits are small and logically separated.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
Spec coverage:
|
||||
|
||||
- Safe simplifications are covered by Tasks 1, 2, 3, 4, and 5.
|
||||
- Built-in replacement opportunities are covered by Tasks 2 and 5.
|
||||
- Fail-safe gaps are covered by Tasks 3, 4, 6, and 7.
|
||||
- Rust, Flutter, and Android verification are covered by Tasks 1 and 7, with focused verification in Tasks 2 through 4.
|
||||
- Documentation updates are covered by Tasks 4, 6, and 7.
|
||||
- Small commit policy is covered by every task's dedicated commit step.
|
||||
|
||||
Placeholder scan: The plan contains no open placeholders. Task 5 uses `git status --short` before staging because the exact second-pass files are only known after the search runs; the example command shows the required staging style.
|
||||
|
||||
Type consistency: The plan does not introduce new APIs or types. It preserves current crate and file boundaries from the approved design.
|
||||
@@ -1,160 +0,0 @@
|
||||
# Maintainability Continuation Design
|
||||
|
||||
**Date:** 2026-06-08
|
||||
**Status:** Approved design for implementation and full code-review remediation; Task 0 and focused audio-realtime fixes landed, documentation/governance alignment in progress
|
||||
**Scope:** Continue the current working-branch maintainability pass, add full code-review findings, and fix high-risk bugs before broad rewrites.
|
||||
|
||||
## Purpose
|
||||
|
||||
This design continues the project review already present in the working tree. The goal is to simplify the project where changes are low-risk, testable, and documented, while avoiding speculative architecture churn.
|
||||
|
||||
The work covers unnecessary functions, structs, files, modules, duplicated custom implementations, built-in replacement opportunities, outdated documents, fail-safe gaps, Android runtime verification requirements, and full code-review remediation for hidden bugs.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
Use a targeted continuation of the current maintainability pass, now ordered by safety risk.
|
||||
|
||||
The existing branch already contains a first slice of simplification: core event DTO extraction, network diagnostics locality, `VecDeque` queue improvements, derived PTT backend errors, render downmix helper reuse, state reducer reuse, workspace metadata cleanup, and documentation updates. This design treats those changes as the baseline, but the full review found privacy, realtime-audio, disconnect, and documentation-governance issues that take priority over cosmetic simplification.
|
||||
|
||||
The remediation order is:
|
||||
|
||||
- Privacy and stuck-transmit fail-safes in Flutter voice state. Fixed in commit `d835394`.
|
||||
- iOS audio-session error hardening before Rust VoiceProcessingIO startup. Missing-plugin/error handling fixed in commit `d835394`; iOS device runtime verification remains required.
|
||||
- Rust realtime audio safety, especially unsynchronized render-reference buffers and blocking/allocating callbacks. Focused callback-path hardening fixed in commit `8606eb4`; full lock-free `AudioHandler` / config / debug-recorder redesign remains a follow-up.
|
||||
- Bounded disconnect/control-plane progress in Rust protocol/core. Pending unless later code-review evidence closes it.
|
||||
- Documentation and release/governance contradictions that can cause wrong verification claims. Addressed by Task 0.2 documentation alignment.
|
||||
- Larger Module splits after behavior is protected by tests.
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
- Documentation-only audit: safer, but leaves clear simplifications unimplemented.
|
||||
- Broad architectural cleanup: may produce long-term wins, but is too risky for this pass because bridge, protocol, audio, and Android behavior have high regression cost.
|
||||
|
||||
## Architecture Boundaries
|
||||
|
||||
The existing responsibilities remain intact:
|
||||
|
||||
- Flutter owns presentation, navigation, Material 3 behavior, accessibility, localization presentation, and platform UI behavior.
|
||||
- Flutter Rust Bridge owns typed DTO/API glue and generated bindings.
|
||||
- Rust Core owns session orchestration, cross-crate coordination, bridge-facing public events, and stable public APIs.
|
||||
- Protocol owns TeamSpeak-compatible protocol isolation behind `tsclientlib`.
|
||||
- Audio owns capture, render, processing, PTT backends, platform audio behavior, and voice packet handling where explicitly documented.
|
||||
- Diagnostics owns redaction, logs, export records, and bounded diagnostic history.
|
||||
|
||||
Public interfaces should stay stable unless a change clearly removes duplicated or unnecessary code and has direct verification.
|
||||
|
||||
## Review Targets
|
||||
|
||||
The implementation review should inspect these areas first:
|
||||
|
||||
- `core/chanora_core/src/lib.rs`, `events.rs`, `network_diagnostics.rs`, and `ptt.rs`
|
||||
- `crates/chanora_audio`, especially duplicated render, capture, PTT, and platform-audio helpers
|
||||
- `crates/chanora_state` reducer paths
|
||||
- `crates/chanora_protocol` adapter ordering, event, and DTO mapping paths
|
||||
- `crates/chanora_bridge/src/api.rs`, excluding generated bridge files unless regeneration is intentionally part of a change
|
||||
- `crates/chanora_diagnostics/src/lib.rs`
|
||||
- `crates/chanora_prefetch` and `crates/chanora_resolver` as a documented follow-up seam decision unless a trivial cleanup appears
|
||||
- `apps/chanora_flutter/lib`, excluding generated localization and bridge files unless an API change requires updates
|
||||
- governance, architecture, implementation-status, and verification documents affected by the code review
|
||||
|
||||
Full code-review remediation targets:
|
||||
|
||||
- `apps/chanora_flutter/lib/main.dart`: mute ownership, iOS audio-session preflight, chat/unread follow-ups, and oversized session-controller extraction candidates.
|
||||
- `apps/chanora_flutter/lib/widgets/voice_compact.dart`: touch PTT release-on-dispose fail-safe.
|
||||
- `apps/chanora_flutter/lib/services/ios_audio_session_controller.dart`: missing-plugin fail-safe handling.
|
||||
- `apps/chanora_flutter/lib/services/audio_lifecycle_service.dart`: macOS route/default-device no-op documentation or future adapter seam.
|
||||
- `crates/chanora_audio/src/android_voice_unit.rs`, `ios_raw_unit.rs`, `ios_voice_unit.rs`, and `engine.rs`: realtime callback safety and platform lifecycle rollback.
|
||||
- `core/chanora_core/src/lib.rs` and `crates/chanora_protocol/src/adapter.rs`: bounded disconnect and control-plane progress under voice load.
|
||||
- `README.md`, `CHANGELOG.md`, `docs/release/*`, `docs/verification/*`, and `docs/governance/product-decision-register.md`: stale platform, release, VAD, Android runtime, and decision-register claims.
|
||||
|
||||
## Simplification Rules
|
||||
|
||||
Every code change must satisfy these rules:
|
||||
|
||||
- Prefer deletion, built-in APIs, derives, or reuse of existing helpers over new abstractions.
|
||||
- Merge files or modules only when the merged unit has a clearer single responsibility.
|
||||
- Split files only when it improves locality around a stable responsibility and preserves public API shape.
|
||||
- Do not manually edit generated files unless the generation process is part of the verified change.
|
||||
- Do not introduce backward-compatibility shims unless there is a persisted-data, shipped-API, external-consumer, or explicit product need.
|
||||
- Record larger architectural opportunities in the maintainability review instead of forcing them into this pass.
|
||||
|
||||
Full code-review fix rules:
|
||||
|
||||
- Fix safety bugs before Module split work.
|
||||
- Use test-driven development for production behavior changes: write the failing test, run it, implement the minimal fix, then rerun the test.
|
||||
- Keep manual/generated bridge files out of direct edits unless regeneration is intentionally verified.
|
||||
- Split huge Modules only when the split creates a deeper Module with leverage and locality; file-size-only sharding is not sufficient.
|
||||
- Compare architecture choices against established voice/chat client practice: Mumble-style bounded voice/control separation, Discord/TeamSpeak-style independent mute owners, WebRTC-style realtime callback minimalism, and Matrix/Element-style coherent state replication.
|
||||
|
||||
## Testing Design
|
||||
|
||||
Verification is tied to change type:
|
||||
|
||||
- Rust-only changes require `cargo fmt --all`, `cargo check --workspace`, and `cargo test --workspace`.
|
||||
- Flutter changes require `flutter analyze` and `flutter test --exclude-tags e2e` from `apps/chanora_flutter`.
|
||||
- Bridge DTO/API changes require Rust verification, bridge generation check, Flutter analyze, and Flutter tests.
|
||||
- Android platform, permission, lifecycle, or audio changes require Rust and Flutter verification plus Android NDK target compilation, `adb devices -l`, Android build/install, and a device or emulator smoke test.
|
||||
- Documentation-only changes require affected docs and cross-links to be read and checked; code tests are not required unless the docs describe a code change just made.
|
||||
|
||||
If no ADB target is connected, Android runtime verification must be recorded as blocked. If Android target compilation cannot find the NDK compiler, for example `aarch64-linux-android-clang`, Android build evidence must also be recorded as blocked. The implementation must not claim Android runtime success without build/install/smoke evidence from an authorized device or emulator.
|
||||
|
||||
## Fail-Safe Review
|
||||
|
||||
The review must identify fail-safe gaps and either verify them, fix them, or record the missing evidence.
|
||||
|
||||
Priority fail-safe areas:
|
||||
|
||||
- User mute ownership must not be cleared by talk-power or permission recovery.
|
||||
- Touch and keyboard PTT must release on cancellation, disposal, disconnect, lifecycle transition, or missed-up conditions.
|
||||
- iOS AVAudioSession must be configured and activated before VoiceProcessingIO startup.
|
||||
- Realtime callbacks must not block, allocate repeatedly, or use unsynchronized mutable aliasing.
|
||||
- Disconnect and control requests must be bounded and must not hold global session locks across unbounded transport waits.
|
||||
- Android and iOS device runtime behavior must be verified on hardware or an authorized emulator/simulator where applicable before platform success is claimed.
|
||||
- Android secure storage and Keystore-backed data-encryption-key handling
|
||||
- Android permission and audio lifecycle behavior
|
||||
- Stuck PTT prevention and missed-key-up recovery
|
||||
- Diagnostic redaction and privacy-sensitive event export
|
||||
- Bridge DTO drift between Core, Bridge, and Dart generated bindings
|
||||
- Protocol isolation exceptions for voice packet handling
|
||||
- Runtime behavior gaps not covered by unit tests
|
||||
|
||||
No release-readiness or production-safety claim should be made without matching evidence.
|
||||
|
||||
## Documentation Design
|
||||
|
||||
The working review record remains `docs/governance/maintainability-review-2026-06-08.md`.
|
||||
|
||||
Documents to update when affected:
|
||||
|
||||
- `README.md`
|
||||
- `CHANGELOG.md`
|
||||
- `docs/governance/document-index.md`
|
||||
- `docs/governance/product-decision-register.md`
|
||||
- `docs/architecture/sad.md`
|
||||
- `docs/architecture/sdd.md`
|
||||
- `docs/implementation-status-2026-05-28.md`
|
||||
- `docs/verification/swe4-unit-verification-plan.md`
|
||||
- `docs/verification/swe5-software-integration-verification-plan.md`
|
||||
- `docs/verification/verification-master-plan.md`
|
||||
- `docs/verification/sys4-system-integration-verification-plan.md`
|
||||
- `docs/release/release-readiness-go-nogo-record.md`
|
||||
- `docs/release/dv-waiver-register.md`
|
||||
- release or fail-safe records if verification status changes
|
||||
|
||||
Documentation should distinguish completed changes, follow-up opportunities, blocked verification, and release limitations.
|
||||
|
||||
## Commit Policy
|
||||
|
||||
No commit is created automatically. A commit happens only when explicitly requested, after inspecting `git status`, `git diff`, and recent commits.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
This work is successful when:
|
||||
|
||||
- Safe simplifications are implemented or recorded as follow-up opportunities.
|
||||
- Built-in replacement opportunities are applied only when behavior remains covered by tests.
|
||||
- Fail-safe gaps are documented with required evidence or fixed with verification.
|
||||
- Rust and Flutter verification are run as required by the touched files, including targeted regression tests for every fixed bug.
|
||||
- Android ADB runtime verification is run when a target is available or explicitly recorded as blocked.
|
||||
- Documents reflect the final code and verification state.
|
||||
- Full code-review findings are either fixed, downgraded with evidence, or recorded as follow-up risks with verification requirements.
|
||||
+1
-1
@@ -1353,7 +1353,7 @@ External Server -> Protocol Adapter -> Rust Core -> State Engine -> Bridge -> Fl
|
||||
| Protocol compatibility | SE-05, SE-11 | `tsclientlib` adapter, protocol probe, compatibility matrix |
|
||||
| Secure identity handling | SE-03, SE-16 | Platform secure storage, no plaintext private keys |
|
||||
| Diagnostic privacy | SE-17, SE-19 | Redaction before export, user-initiated export |
|
||||
| Mobile foreground voice | SE-03, SE-14 | AVAudioSession (call-scoped VoIP activation with `.mixWithOthers`, idle `.ambient` baseline), Android foreground service, lifecycle handling |
|
||||
| Mobile foreground voice | SE-03, SE-14 | AVAudioSession, Android foreground service, lifecycle handling |
|
||||
| Release trust | SE-18 | Signing, notarization, app-store release metadata review |
|
||||
|
||||
|
||||
|
||||
@@ -32,8 +32,6 @@ This plan defines unit verification coverage for the software modules that imple
|
||||
| `flutter analyze` in `apps/chanora_flutter` | Software | Dart static analysis |
|
||||
| `flutter test --exclude-tags e2e` in `apps/chanora_flutter` | Software / QA | Flutter unit and widget tests |
|
||||
|
||||
For local code-change reviews, a Rust change is not complete until `cargo fmt --all`, `cargo check --workspace`, and `cargo test --workspace` have been run fresh and read for failures. Flutter or bridge changes additionally require Flutter analysis/tests.
|
||||
|
||||
## 4. SRS Unit Coverage Focus
|
||||
|
||||
| SRS group | Unit focus | Status for DV |
|
||||
@@ -53,7 +51,6 @@ For local code-change reviews, a Rust change is not complete until `cargo fmt --
|
||||
| Event replay tool not found | P1 verification hook is incomplete | Mark deferred/P1 in SWE.6 and waiver register if discussed |
|
||||
| Platform keyring behavior not exercised in CI | Secure-storage unit evidence is incomplete for real OS services | Cover through platform audit/SYS.4, not CI-only claims |
|
||||
| Audio device hot-plug recovery follow-up exists | Limits reliability evidence for SRS-082 | Mark as P1 gap |
|
||||
| Android runtime smoke cannot run without attached target | Rust unit tests do not exercise Android permission/audio/lifecycle fail-safes | `adb devices -l` must show a device or emulator before Android runtime success can be claimed |
|
||||
|
||||
## 6. SWE.4 DV Decision Rule
|
||||
|
||||
|
||||
@@ -52,20 +52,7 @@ An integration path passes when:
|
||||
| iOS build is unsigned | Blocks App Store/TestFlight release approval | Release record must state unsigned verification only |
|
||||
| Android Keystore-backed DEK deferred | Limits secure-storage integration claim on Android | Waiver required for internal testing; public release claim blocked |
|
||||
| Event replay infrastructure not found | Limits protocol-state integration stress evidence | Mark P1 gap |
|
||||
| Android target compile/runtime blocked during local review | Missing NDK compiler `aarch64-linux-android-clang` and no attached authorized target block device/emulator verification of Android audio, permission, lifecycle, and storage fail-safe behaviour | Fix/install the NDK toolchain, connect a device/emulator, confirm with `adb devices -l`, then run Android build/install/smoke before claiming Android success |
|
||||
|
||||
## 6. Android Runtime Verification Gate
|
||||
|
||||
Android integration changes require a working Android target toolchain and a connected device or emulator. The minimum local gate is:
|
||||
|
||||
1. The Android NDK compiler needed by the target build, including `aarch64-linux-android-clang` for arm64, is available.
|
||||
2. `adb devices -l` shows one authorized target.
|
||||
3. Android app builds for that target.
|
||||
4. The app installs and launches.
|
||||
5. Permission, connect-screen, audio-start/stop, and diagnostic-export smoke paths are exercised or explicitly marked not applicable to the change.
|
||||
|
||||
If the NDK compiler is unavailable or no target is connected and authorized, Android verification is blocked rather than passed.
|
||||
|
||||
## 7. SWE.5 DV Decision Rule
|
||||
## 6. SWE.5 DV Decision Rule
|
||||
|
||||
SWE.5 can pass for DV documentation readiness if every integration path has an identified evidence method and every incomplete path has a waiver or follow-up. SWE.5 does not pass for public release until current candidate integration runs are attached to the release-readiness record.
|
||||
|
||||
@@ -36,7 +36,7 @@ This plan verifies Chanora as an integrated application system, including softwa
|
||||
|
||||
| Platform | Current candidate status | Required SYS.4 evidence before public/store release |
|
||||
|---|---|---|
|
||||
| Android | Implemented features include permissions, foreground service, Oboe audio, MODE_IN_COMMUNICATION; Android Keystore-backed DEK deferred; local target compile/runtime smoke blocked by missing `aarch64-linux-android-clang` and no authorized ADB target | NDK target compilation, `adb devices -l` authorized target evidence, device smoke, permission flow, foreground voice, secure-storage limitation waiver, Play target SDK inspection |
|
||||
| Android | Implemented features include permissions, foreground service, Oboe audio, MODE_IN_COMMUNICATION; Android Keystore-backed DEK deferred | Device smoke, permission flow, foreground voice, secure-storage limitation waiver, Play target SDK inspection |
|
||||
| iOS | Source-buildable and unsigned; AVAudioSession work exists; public artifact not ready | Unsigned build evidence, device audio session smoke, signing/TestFlight evidence before release |
|
||||
| Windows | Source-buildable; smoke procedure exists | Executed smoke result, PTT capability evidence, packaging/signing evidence before release |
|
||||
| macOS | Source-buildable; not in current release artifacts | Build evidence, PTT capability evidence, signing/notarization evidence before release |
|
||||
@@ -51,7 +51,6 @@ This plan verifies Chanora as an integrated application system, including softwa
|
||||
| Security/privacy/legal readiness | DEC-012 and privacy/security gates are signed off or explicitly block release |
|
||||
| Release artifact integrity | Build number, commit SHA, tag, artifact hashes, and signing status are recorded |
|
||||
| Environmental limitations | Source-build-only, unsigned, or platform-specific limitations are listed in release readiness |
|
||||
| Android runtime claim control | No Android runtime, permission-flow, or audio-lifecycle success is claimed until target compile, install, and device/emulator smoke evidence are attached |
|
||||
|
||||
## 6. SYS.4 DV Decision Rule
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Document status:** DV meeting baseline candidate
|
||||
**Date:** 2026-05-29
|
||||
**Applies to:** Chanora Rust workspace `0.2.0-beta.1`, Flutter app `0.3.0+100`, and current DV/release-candidate evidence
|
||||
**Applies to:** Chanora `0.2.0-beta.1` workspace / `v1.0.0-rc.1` release-candidate evidence
|
||||
**Primary upstream documents:** `docs/sysrs.md`, `docs/sysdes.md`, `docs/srs.md`, `docs/architecture/sad.md`, `docs/architecture/sdd.md`, `docs/implementation-status-2026-05-28.md`
|
||||
|
||||
## 1. Purpose
|
||||
@@ -79,7 +79,6 @@ The DV meeting can pass the documentation baseline if reviewers agree that:
|
||||
|---|---|---|---|
|
||||
| DEC-012 legal/trademark/OSS sign-off | Blocked / open | Blocks public or store release | `docs/legal/trademark-and-attribution-review.md`, `docs/release/dv-waiver-register.md` |
|
||||
| Android Keystore-backed DEK | Deferred to v1.1 | Blocks claim that Android secrets use hardware-backed DEK protection | `docs/release/dv-waiver-register.md` |
|
||||
| Android target compilation and runtime smoke | Blocked locally | Blocks Android runtime, permission-flow, and audio-lifecycle success claims until NDK compiler and authorized target evidence exist | `docs/governance/maintainability-review-2026-06-08.md`, `docs/release/dv-waiver-register.md` |
|
||||
| Full state reducer test suite | Partial | Blocks full claim for SysRS-235/SysDes-103/SRS-059 through SRS-061 | `docs/release/dv-waiver-register.md` |
|
||||
| Desktop and iOS release artifacts | Source-buildable / unsigned only | Blocks broad binary distribution claims | `docs/release/release-readiness-go-nogo-record.md` |
|
||||
| Store signing and notarization | Not complete for candidate | Blocks production release | `docs/release/release-readiness-go-nogo-record.md` |
|
||||
|
||||
Reference in New Issue
Block a user