feat(voice): add iOS VAD runtime support
This commit is contained in:
@@ -0,0 +1,767 @@
|
||||
{
|
||||
"document_id": "CHANORA_P1_VOICE_AGENT_SPEC",
|
||||
"version": "1.1.0",
|
||||
"language": "en",
|
||||
"audience": "ai_agents",
|
||||
"scope": "P1_voice_only",
|
||||
"mode": "modify_existing_scaffold_do_not_rewrite",
|
||||
"based_on": {
|
||||
"archive": "chanora-product-scaffold-v0.zip",
|
||||
"previous_spec": "chanora_p1_voice_design_from_current_scaffold.json"
|
||||
},
|
||||
"project_context": {
|
||||
"ui": "Flutter",
|
||||
"core": "Rust",
|
||||
"network": "tsclientlib",
|
||||
"audio_crate": "crates/chanora_audio",
|
||||
"bridge_crate": "crates/chanora_bridge",
|
||||
"flutter_app": "apps/chanora_flutter",
|
||||
"phase_1_platform": "iOS",
|
||||
"future_platforms": [
|
||||
"Android",
|
||||
"Windows",
|
||||
"macOS",
|
||||
"Linux"
|
||||
]
|
||||
},
|
||||
"current_scaffold_facts": {
|
||||
"ios_audio_backend_exists": true,
|
||||
"ios_current_backend": "VoiceProcessingIO AudioUnit",
|
||||
"android_audio_backend_exists": true,
|
||||
"android_current_backend": "Oboe",
|
||||
"desktop_audio_backend_exists": true,
|
||||
"desktop_current_backend": "cpal / SDL depending on platform",
|
||||
"current_network_frame": {
|
||||
"sample_rate_hz": 48000,
|
||||
"channels": 1,
|
||||
"frame_ms": 20,
|
||||
"samples": 960,
|
||||
"codec": "Opus VoIP"
|
||||
},
|
||||
"current_effects_api_exists": true,
|
||||
"current_effects_are_real_dsp": false,
|
||||
"current_voice_activity_is_real_vad": false,
|
||||
"current_voice_activity_behavior": "VoiceActivity behaves like Continuous",
|
||||
"current_ios_has_platform_voice_processing": true,
|
||||
"current_ios_platform_processing_likely_includes": [
|
||||
"AEC",
|
||||
"NS",
|
||||
"AGC"
|
||||
],
|
||||
"current_rust_software_ns_exists": false,
|
||||
"current_rust_software_aec_exists": false,
|
||||
"current_rust_software_agc_exists": false
|
||||
},
|
||||
"p1_primary_goals": [
|
||||
"Add real VoiceActivity mode using ML VAD.",
|
||||
"Keep iOS VoiceProcessingIO as default shipping voice-processing path.",
|
||||
"Add optional iOS Sonora mode for experimental raw Rust AEC3/NS/AGC2/HPF.",
|
||||
"Make AEC/NS/AGC route-aware and backend-aware.",
|
||||
"Expose voice processing configuration and stats to Flutter.",
|
||||
"Preserve current 20ms Opus/tsclientlib transport behavior.",
|
||||
"Add internal 10ms processing frames without changing network framing."
|
||||
],
|
||||
"non_goals": [
|
||||
"Do not rewrite P0.",
|
||||
"Do not move PCM processing to Flutter.",
|
||||
"Do not replace tsclientlib.",
|
||||
"Do not require Android/Desktop implementation in Phase 1.",
|
||||
"Do not require CoreML conversion for P1.",
|
||||
"Do not require GPU/NPU execution providers for correctness.",
|
||||
"Do not ship AI noise suppression as P1 default.",
|
||||
"Do not use energy VAD as production transmit gate."
|
||||
],
|
||||
"hard_invariants": {
|
||||
"INV_001": "Flutter MUST NOT process realtime PCM.",
|
||||
"INV_002": "Rust AudioEngine MUST own voice state, VAD state, processing config, route policy, stats, and diagnostics.",
|
||||
"INV_003": "Platform audio layer MUST only provide audio I/O, device/session config, and route detection.",
|
||||
"INV_004": "tsclientlib MUST remain the voice network/session layer.",
|
||||
"INV_005": "Keep existing 20ms Opus frame/network contract in P1.",
|
||||
"INV_006": "Add internal 10ms processing frames by splitting 20ms frames into two 10ms frames.",
|
||||
"INV_007": "Realtime callbacks MUST NOT call Flutter, block on network, perform file I/O, allocate unbounded memory, or run ML inference.",
|
||||
"INV_008": "Rust panics MUST NOT cross FFI/bridge boundaries.",
|
||||
"INV_009": "Never enable platform AEC and Rust AEC simultaneously on the same route.",
|
||||
"INV_010": "Never enable VoiceProcessingIO and Sonora AEC3 simultaneously.",
|
||||
"INV_011": "Software AEC-capable backends MUST receive both capture and render-reference streams.",
|
||||
"INV_012": "Render reference MUST be copied from decoded/mixed remote PCM before playout.",
|
||||
"INV_013": "VAD MUST NOT stop capture or playback; VAD may only gate transmit and update speaking/debug state."
|
||||
},
|
||||
"canonical_audio_model": {
|
||||
"network_frame": {
|
||||
"sample_rate_hz": 48000,
|
||||
"channels": 1,
|
||||
"frame_ms": 20,
|
||||
"samples": 960,
|
||||
"sample_format": "i16_or_f32_internal_conversion",
|
||||
"status": "keep_existing"
|
||||
},
|
||||
"processing_frame": {
|
||||
"sample_rate_hz": 48000,
|
||||
"channels": 1,
|
||||
"frame_ms": 10,
|
||||
"samples": 480,
|
||||
"sample_format": "f32",
|
||||
"status": "new_internal_contract"
|
||||
},
|
||||
"vad_model_input": {
|
||||
"sample_rate_hz": 16000,
|
||||
"channels": 1,
|
||||
"sample_format": "f32",
|
||||
"source": "processed_capture",
|
||||
"resample_from": "48kHz"
|
||||
}
|
||||
},
|
||||
"target_architecture": {
|
||||
"layers": [
|
||||
"Flutter UI",
|
||||
"Typed Bridge / FFI",
|
||||
"Rust Application Core",
|
||||
"tsclientlib Adapter",
|
||||
"Rust AudioEngine",
|
||||
"AudioDevice backend",
|
||||
"AudioProcessor backend",
|
||||
"VoiceActivityDetector backend",
|
||||
"VoiceTransmitStateMachine",
|
||||
"AudioStats",
|
||||
"WavDebugRecorder"
|
||||
],
|
||||
"capture_path": [
|
||||
"Platform mic input",
|
||||
"Platform audio callback",
|
||||
"Minimal realtime-safe push into Rust",
|
||||
"Canonical conversion to 48kHz mono f32",
|
||||
"20ms network frame split into 2x10ms processing frames",
|
||||
"Raw mic debug tap",
|
||||
"AudioProcessor.process_capture",
|
||||
"Processed mic debug tap",
|
||||
"ML VAD worker consumes processed capture",
|
||||
"VoiceActivityStateMachine updates transmit state",
|
||||
"If transmitting, collect 10ms frames into 20ms Opus frame",
|
||||
"tsclientlib / existing send path"
|
||||
],
|
||||
"render_path": [
|
||||
"tsclientlib receive/decode/jitter/mix",
|
||||
"Remote mixer output",
|
||||
"Copy render_reference before playout",
|
||||
"If software AEC backend active, call AudioProcessor.process_render on 10ms slices",
|
||||
"Platform output callback",
|
||||
"Speaker / receiver / headset"
|
||||
]
|
||||
},
|
||||
"modules_to_add": {
|
||||
"crates/chanora_audio/src/frame.rs": "AudioFrame10ms, AudioFrame20ms, split/merge helpers, i16/f32 conversion, dBFS helpers.",
|
||||
"crates/chanora_audio/src/processor.rs": "AudioProcessor trait, AudioBackend enum, backend selector.",
|
||||
"crates/chanora_audio/src/processor/platform.rs": "PlatformVoiceProcessing backend marker/policy.",
|
||||
"crates/chanora_audio/src/processor/sonora.rs": "Optional Sonora backend: HPF, AEC3, NS, AGC2, limiter hook.",
|
||||
"crates/chanora_audio/src/processor/noop.rs": "No-op backend for tests/headset/debug.",
|
||||
"crates/chanora_audio/src/vad/mod.rs": "VoiceActivityDetector trait, VadOutput, VadBackend enum.",
|
||||
"crates/chanora_audio/src/vad/silero_onnx.rs": "Silero ONNX VAD runtime wrapper.",
|
||||
"crates/chanora_audio/src/vad/webrtc.rs": "WebRTC VAD fallback.",
|
||||
"crates/chanora_audio/src/vad/energy_debug.rs": "Debug-only energy VAD.",
|
||||
"crates/chanora_audio/src/voice_activity.rs": "VoiceActivity state machine.",
|
||||
"crates/chanora_audio/src/stats.rs": "Expanded AudioStats schema.",
|
||||
"crates/chanora_audio/src/debug_wav.rs": "Async WAV dump writer.",
|
||||
"crates/chanora_audio/src/route_policy.rs": "AudioRoute enum and route-to-processing policy.",
|
||||
"crates/chanora_audio/src/ios_raw_unit.rs": "Optional raw iOS RemoteIO path for Sonora mode."
|
||||
},
|
||||
"modules_to_modify": {
|
||||
"crates/chanora_audio/src/lib.rs": [
|
||||
"Export AudioProcessingConfig, AudioStats, AudioRoute, AudioBackend, VadBackend.",
|
||||
"Replace no-op effects semantics with backend-aware processing config."
|
||||
],
|
||||
"crates/chanora_audio/src/engine.rs": [
|
||||
"Own AudioProcessingConfig.",
|
||||
"Own AudioProcessor backend.",
|
||||
"Own VoiceActivityDetector backend.",
|
||||
"Own VoiceActivityStateMachine.",
|
||||
"Expose expanded stats.",
|
||||
"Preserve existing 20ms Opus send/receive behavior."
|
||||
],
|
||||
"crates/chanora_audio/src/transmit_selector.rs": [
|
||||
"Remove VoiceActivity == Continuous behavior.",
|
||||
"Accept VoiceActivityStateMachine result for VoiceActivity transmit mode."
|
||||
],
|
||||
"crates/chanora_audio/src/ios_voice_unit.rs": [
|
||||
"Keep as platform VoiceProcessingIO backend.",
|
||||
"Ensure Rust AEC/NS/AGC disabled when this backend is active.",
|
||||
"Feed captured audio into Rust VAD/state pipeline.",
|
||||
"Do not run ML inference in callback.",
|
||||
"Expose render_reference for debug/future software AEC validation."
|
||||
],
|
||||
"crates/chanora_audio/src/android_voice_unit.rs": [
|
||||
"Keep current Oboe backend.",
|
||||
"Future: adapt to same AudioProcessor/VAD config and stats."
|
||||
],
|
||||
"crates/chanora_bridge/src/api.rs": [
|
||||
"Add set_audio_processing_config.",
|
||||
"Add audio_processing_stats.",
|
||||
"Add set_vad_model_path or bundled model discovery.",
|
||||
"Add enable_audio_debug_wav_dump."
|
||||
],
|
||||
"apps/chanora_flutter/ios/Runner/AppDelegate.swift": [
|
||||
"Keep AVAudioSession ownership.",
|
||||
"Add/verify mediaServicesWereReset handling.",
|
||||
"Send detailed route class to Rust.",
|
||||
"Do not send PCM to Flutter."
|
||||
]
|
||||
},
|
||||
"voice_processing_backends": {
|
||||
"platform_voice_processing": {
|
||||
"description": "OS/platform voice processing.",
|
||||
"ios_implementation": "VoiceProcessingIO AudioUnit",
|
||||
"ios_default": true,
|
||||
"owns": [
|
||||
"AEC",
|
||||
"NS",
|
||||
"AGC"
|
||||
],
|
||||
"rust_aec": "disabled",
|
||||
"rust_ns": "disabled_by_default",
|
||||
"rust_agc": "disabled_by_default"
|
||||
},
|
||||
"sonora": {
|
||||
"description": "Optional Rust-native WebRTC-style voice processor.",
|
||||
"ios_status": "experimental_optional",
|
||||
"future_cross_platform_status": "candidate",
|
||||
"owns": [
|
||||
"HPF",
|
||||
"AEC3",
|
||||
"NS",
|
||||
"AGC2",
|
||||
"limiter_hook"
|
||||
],
|
||||
"requires": [
|
||||
"raw_or_near_raw_capture",
|
||||
"render_reference",
|
||||
"48kHz mono 10ms frames"
|
||||
],
|
||||
"must_not_run_with": [
|
||||
"VoiceProcessingIO",
|
||||
"platform AEC"
|
||||
],
|
||||
"shipping_default": false
|
||||
},
|
||||
"webrtc_apm": {
|
||||
"description": "Fallback/candidate WebRTC APM backend.",
|
||||
"status": "candidate_or_fallback",
|
||||
"requires": [
|
||||
"render_reference_for_AEC"
|
||||
]
|
||||
},
|
||||
"noop": {
|
||||
"description": "No processing.",
|
||||
"status": "debug_or_headset_policy"
|
||||
}
|
||||
},
|
||||
"ios_processing_modes": {
|
||||
"platform_default": {
|
||||
"mode_id": "ios_platform_voice_processing",
|
||||
"shipping_default": true,
|
||||
"audio_io": "VoiceProcessingIO AudioUnit",
|
||||
"av_audio_session": {
|
||||
"category": "playAndRecord",
|
||||
"mode": "voiceChat",
|
||||
"preferred_sample_rate_hz": 48000,
|
||||
"preferred_io_buffer_duration_ms": [
|
||||
5,
|
||||
10
|
||||
],
|
||||
"options": [
|
||||
"defaultToSpeaker_when_speaker_route",
|
||||
"allowBluetoothHFP",
|
||||
"allowBluetoothA2DP_only_for_output_policy"
|
||||
]
|
||||
},
|
||||
"processing": {
|
||||
"aec": "platform",
|
||||
"ns": "platform",
|
||||
"agc": "platform",
|
||||
"hpf": "platform_or_safe_rust_optional",
|
||||
"limiter": "safe_rust_optional",
|
||||
"sonora": "disabled"
|
||||
},
|
||||
"vad": {
|
||||
"backend": "silero_vad_onnx",
|
||||
"input_source": "platform_processed_capture"
|
||||
}
|
||||
},
|
||||
"sonora_experimental": {
|
||||
"mode_id": "ios_raw_sonora",
|
||||
"shipping_default": false,
|
||||
"audio_io": "RemoteIO_or_raw_input_output_path",
|
||||
"av_audio_session": {
|
||||
"category": "playAndRecord",
|
||||
"mode": "default_or_measurement_candidate",
|
||||
"avoid": [
|
||||
"voiceChat",
|
||||
"videoChat",
|
||||
"VoiceProcessingIO",
|
||||
"setVoiceProcessingEnabled(true)",
|
||||
"setPrefersEchoCancelledInput(true)"
|
||||
]
|
||||
},
|
||||
"processing": {
|
||||
"backend": "sonora",
|
||||
"order": [
|
||||
"hpf",
|
||||
"aec3",
|
||||
"ns",
|
||||
"agc2",
|
||||
"limiter",
|
||||
"vad_input_tap"
|
||||
],
|
||||
"requires_render_reference": true,
|
||||
"requires_raw_or_near_raw_capture": true
|
||||
},
|
||||
"vad": {
|
||||
"backend": "silero_vad_onnx",
|
||||
"input_source": "sonora_processed_capture"
|
||||
},
|
||||
"fallback": {
|
||||
"on_backend_failure": "switch_to_ios_platform_voice_processing",
|
||||
"on_render_reference_missing": "reject_sonora_aec_activation"
|
||||
}
|
||||
}
|
||||
},
|
||||
"route_policy": {
|
||||
"ios": {
|
||||
"speaker": {
|
||||
"default": "platform_voice_processing",
|
||||
"optional": "sonora_experimental",
|
||||
"aec": "platform_by_default",
|
||||
"ns": "platform_by_default",
|
||||
"agc": "platform_by_default",
|
||||
"vad": "silero_vad_onnx",
|
||||
"sonora_allowed_if": [
|
||||
"raw_io_available",
|
||||
"render_reference_available"
|
||||
]
|
||||
},
|
||||
"earpiece": {
|
||||
"default": "platform_voice_processing",
|
||||
"optional": "sonora_experimental",
|
||||
"vad": "silero_vad_onnx"
|
||||
},
|
||||
"wired_headset": {
|
||||
"default": "noop_or_conservative",
|
||||
"aec": "off",
|
||||
"ns": "conservative_optional",
|
||||
"agc": "conservative_optional",
|
||||
"optional": "sonora_ns_agc_without_aec",
|
||||
"vad": "silero_vad_onnx"
|
||||
},
|
||||
"bluetooth_hfp": {
|
||||
"default": "route_managed",
|
||||
"aec": "app_side_off",
|
||||
"ns": "route_managed_or_conservative",
|
||||
"agc": "route_managed_or_conservative",
|
||||
"sonora": "disabled_by_default",
|
||||
"vad": "silero_vad_onnx"
|
||||
},
|
||||
"bluetooth_a2dp": {
|
||||
"default": "invalid_for_duplex_voice",
|
||||
"transmit_allowed": false,
|
||||
"vad": "disabled"
|
||||
},
|
||||
"unknown": {
|
||||
"default": "safe_fallback",
|
||||
"aec": "off_until_classified",
|
||||
"vad": "silero_vad_onnx"
|
||||
}
|
||||
},
|
||||
"future_platforms": {
|
||||
"android": {
|
||||
"default": "platform_effects_if_available_else_rust_backend",
|
||||
"audio_io": "Oboe",
|
||||
"vad": "silero_vad_onnx"
|
||||
},
|
||||
"windows": {
|
||||
"default": "rust_backend_candidate",
|
||||
"audio_io": "WASAPI_or_CPAL",
|
||||
"vad": "silero_vad_onnx"
|
||||
},
|
||||
"macos": {
|
||||
"default": "platform_or_rust_policy",
|
||||
"audio_io": "CoreAudio_or_CPAL",
|
||||
"vad": "silero_vad_onnx"
|
||||
},
|
||||
"linux": {
|
||||
"default": "rust_backend_candidate",
|
||||
"audio_io": "CPAL_with_ALSA_PulseAudio_PipeWire",
|
||||
"vad": "silero_vad_onnx"
|
||||
}
|
||||
}
|
||||
},
|
||||
"vad_runtime_strategy": {
|
||||
"owner": "Rust AudioEngine",
|
||||
"default_backend": "silero_vad_onnx",
|
||||
"model_artifact": "silero_vad.onnx",
|
||||
"primary_runtime": "onnxruntime_c_api",
|
||||
"rust_binding": "ort_or_onnxruntime_wrapper",
|
||||
"fallback_backend": "webrtc_vad",
|
||||
"debug_backend": "energy_vad",
|
||||
"flutter_onnx_plugins": {
|
||||
"allowed_in_realtime_voice_path": false,
|
||||
"blocked_packages_for_core_path": [
|
||||
"onnxruntime_v2",
|
||||
"fonnx"
|
||||
],
|
||||
"reason": "Flutter must not process realtime PCM or own VAD inference."
|
||||
},
|
||||
"coreml": {
|
||||
"p1_primary": false,
|
||||
"p1_conversion_required": false,
|
||||
"p2_candidate": true,
|
||||
"allowed_p1_use": "CoreML Execution Provider benchmark only, not correctness dependency"
|
||||
},
|
||||
"execution_provider_policy": {
|
||||
"correctness_provider": "CPU",
|
||||
"acceleration_providers_optional": true,
|
||||
"ios_optional": [
|
||||
"CoreML_EP_after_benchmark"
|
||||
],
|
||||
"android_optional": [
|
||||
"NNAPI_after_benchmark"
|
||||
],
|
||||
"windows_optional": [
|
||||
"DirectML_after_benchmark",
|
||||
"CUDA_after_benchmark"
|
||||
],
|
||||
"macos_optional": [
|
||||
"CoreML_EP_after_benchmark"
|
||||
],
|
||||
"linux_optional": [
|
||||
"OpenVINO_after_benchmark",
|
||||
"CUDA_after_benchmark",
|
||||
"ROCm_after_benchmark"
|
||||
]
|
||||
},
|
||||
"runtime_rules": {
|
||||
"create_session_once": true,
|
||||
"reuse_session": true,
|
||||
"run_outside_realtime_callback": true,
|
||||
"do_not_create_session_per_frame": true,
|
||||
"fallback_if_model_missing": true,
|
||||
"fallback_if_inference_lag_exceeds_budget": true,
|
||||
"fallback_if_memory_budget_exceeded": true
|
||||
},
|
||||
"model_input": {
|
||||
"sample_rate_hz": 16000,
|
||||
"channels": 1,
|
||||
"source": "processed_capture",
|
||||
"resample_from_hz": 48000
|
||||
},
|
||||
"defaults": {
|
||||
"open_threshold": 0.55,
|
||||
"close_threshold": 0.35,
|
||||
"open_after_ms": 40,
|
||||
"hangover_ms": 500,
|
||||
"pre_roll_ms": 160,
|
||||
"min_tx_ms": 200,
|
||||
"max_inference_lag_ms": 60
|
||||
},
|
||||
"ios_memory_budget": {
|
||||
"target_extra_rss_mb": 30,
|
||||
"max_acceptable_extra_rss_mb": 50,
|
||||
"fail_threshold_extra_rss_mb": 80,
|
||||
"measurement_required": true,
|
||||
"measurement_tool": "Xcode Instruments"
|
||||
}
|
||||
},
|
||||
"noise_suppression_strategy": {
|
||||
"p1_default": "platform_or_webRTC_style_NS_not_AI_NS",
|
||||
"ios": {
|
||||
"default": "VoiceProcessingIO_NS",
|
||||
"rust_ns": "disabled_by_default",
|
||||
"reason": "Avoid double noise suppression and voice degradation."
|
||||
},
|
||||
"ios_sonora_optional": {
|
||||
"default": "Sonora_NS",
|
||||
"requires": [
|
||||
"raw_io",
|
||||
"sonora_backend_enabled"
|
||||
],
|
||||
"status": "experimental"
|
||||
},
|
||||
"android": {
|
||||
"default": "Android NoiseSuppressor if available",
|
||||
"fallback_candidate": "Sonora_NS_or_WebRTC_APM_NS"
|
||||
},
|
||||
"desktop": {
|
||||
"default_candidate": "Sonora_NS_or_WebRTC_APM_NS"
|
||||
},
|
||||
"not_p1_default": [
|
||||
"RNNoise",
|
||||
"DeepFilterNet",
|
||||
"AI_noise_suppression_default"
|
||||
]
|
||||
},
|
||||
"audio_processing_config_schema": {
|
||||
"schema_version": "p1.audio_processing.v1",
|
||||
"sample_rate_hz": 48000,
|
||||
"network_frame_ms": 20,
|
||||
"processing_frame_ms": 10,
|
||||
"route": "speaker|earpiece|wired_headset|bluetooth_hfp|bluetooth_a2dp|unknown",
|
||||
"ios_mode": "platform_voice_processing|sonora_experimental",
|
||||
"processing_backend": "platform_voice_processing|sonora|webrtc_apm|noop",
|
||||
"vad_backend": "silero_vad_onnx|webrtc_vad|energy_debug|disabled",
|
||||
"aec": "platform|sonora|webrtc_apm|off",
|
||||
"ns": "platform|sonora|webrtc_apm|off|conservative",
|
||||
"agc": "platform|sonora|webrtc_apm|off|conservative",
|
||||
"hpf_enabled": true,
|
||||
"limiter_enabled": true,
|
||||
"vad": {
|
||||
"model": "silero_vad.onnx",
|
||||
"runtime": "onnxruntime_c_api",
|
||||
"input_source": "processed_capture",
|
||||
"model_sample_rate_hz": 16000,
|
||||
"open_threshold": 0.55,
|
||||
"close_threshold": 0.35,
|
||||
"open_after_ms": 40,
|
||||
"hangover_ms": 500,
|
||||
"pre_roll_ms": 160,
|
||||
"min_tx_ms": 200,
|
||||
"max_inference_lag_ms": 60,
|
||||
"fallback_backend": "webrtc_vad"
|
||||
},
|
||||
"debug": {
|
||||
"stats_enabled": true,
|
||||
"wav_dump_enabled": false
|
||||
},
|
||||
"invalid_combinations": [
|
||||
"ios_mode=platform_voice_processing AND processing_backend=sonora",
|
||||
"aec=platform AND aec=sonora",
|
||||
"VoiceProcessingIO enabled AND Sonora AEC3 enabled",
|
||||
"vad_backend=energy_debug in production",
|
||||
"route=bluetooth_a2dp AND transmit_allowed=true"
|
||||
]
|
||||
},
|
||||
"ffi_bridge_api_additions": {
|
||||
"set_audio_processing_config": {
|
||||
"crate": "crates/chanora_bridge/src/api.rs",
|
||||
"input": "BridgeAudioProcessingConfig",
|
||||
"output": "Result<(), BridgeError>"
|
||||
},
|
||||
"audio_processing_stats": {
|
||||
"crate": "crates/chanora_bridge/src/api.rs",
|
||||
"input": "none",
|
||||
"output": "BridgeAudioProcessingStats"
|
||||
},
|
||||
"set_vad_model_path": {
|
||||
"crate": "crates/chanora_bridge/src/api.rs",
|
||||
"input": "String",
|
||||
"output": "Result<(), BridgeError>",
|
||||
"note": "May be replaced by bundled model discovery."
|
||||
},
|
||||
"enable_audio_debug_wav_dump": {
|
||||
"crate": "crates/chanora_bridge/src/api.rs",
|
||||
"input": "bool",
|
||||
"output": "Result<(), BridgeError>"
|
||||
},
|
||||
"set_ios_voice_processing_mode": {
|
||||
"crate": "crates/chanora_bridge/src/api.rs",
|
||||
"input": "platform_voice_processing|sonora_experimental",
|
||||
"output": "Result<(), BridgeError>"
|
||||
}
|
||||
},
|
||||
"runtime_stats_schema": {
|
||||
"existing_keep": [
|
||||
"frames_sent",
|
||||
"frames_received"
|
||||
],
|
||||
"add": {
|
||||
"input_dbfs": "float",
|
||||
"render_dbfs": "float",
|
||||
"processed_dbfs": "float",
|
||||
"vad_probability": "float",
|
||||
"vad_active": "bool",
|
||||
"speaking": "bool",
|
||||
"transmitting": "bool",
|
||||
"vad_backend": "enum",
|
||||
"vad_fallback_active": "bool",
|
||||
"vad_inference_lag_ms": "float",
|
||||
"vad_runtime_memory_estimate_mb": "float_optional",
|
||||
"processing_backend": "enum",
|
||||
"ios_voice_processing_mode": "enum_optional",
|
||||
"audio_route": "enum",
|
||||
"actual_sample_rate_hz": "uint32",
|
||||
"actual_io_buffer_frames": "uint32",
|
||||
"capture_queue_ms": "float",
|
||||
"render_queue_ms": "float",
|
||||
"estimated_echo_delay_ms": "float_optional",
|
||||
"input_overruns": "uint64",
|
||||
"output_underruns": "uint64",
|
||||
"callback_xruns": "uint64",
|
||||
"clipped_samples": "uint64",
|
||||
"sonora_enabled": "bool",
|
||||
"platform_voice_processing_enabled": "bool"
|
||||
}
|
||||
},
|
||||
"debug_wav": {
|
||||
"enabled_by_default": false,
|
||||
"streams": {
|
||||
"raw_mic": "before_audio_processor",
|
||||
"render_reference": "remote_mixer_output_before_playout",
|
||||
"processed_mic": "after_audio_processor"
|
||||
},
|
||||
"rules": [
|
||||
"Never write WAV from realtime callback.",
|
||||
"Use bounded queues to async writer.",
|
||||
"Include route/backend/timestamp in filenames or sidecar metadata."
|
||||
]
|
||||
},
|
||||
"failure_modes": {
|
||||
"silero_model_missing": [
|
||||
"fallback_to_webrtc_vad",
|
||||
"emit_structured_error",
|
||||
"stats.vad_fallback_active=true"
|
||||
],
|
||||
"onnxruntime_unavailable": [
|
||||
"fallback_to_webrtc_vad",
|
||||
"do_not_crash_audio_engine"
|
||||
],
|
||||
"vad_inference_late": [
|
||||
"do_not_block_audio",
|
||||
"use_latest_valid_probability_if_not_stale",
|
||||
"fallback_to_webrtc_vad_if_stale"
|
||||
],
|
||||
"onnx_memory_budget_exceeded": [
|
||||
"fallback_to_webrtc_vad",
|
||||
"mark_ml_vad_unavailable",
|
||||
"emit_structured_error"
|
||||
],
|
||||
"sonora_requested_with_vpio": [
|
||||
"reject_config",
|
||||
"return_invalid_config",
|
||||
"do_not_start_double_processing"
|
||||
],
|
||||
"sonora_aec_without_render_reference": [
|
||||
"reject_sonora_aec_activation",
|
||||
"allow_sonora_ns_agc_only_if_configured",
|
||||
"emit_structured_error"
|
||||
],
|
||||
"route_change": [
|
||||
"update AudioRoute",
|
||||
"recompute AudioProcessingConfig",
|
||||
"reset processor if needed",
|
||||
"reset AEC delay state if software AEC active"
|
||||
],
|
||||
"ios_interruption": [
|
||||
"stop_or_pause_audio_io",
|
||||
"resume_after_interruption_end",
|
||||
"update_stats"
|
||||
],
|
||||
"ios_media_services_reset": [
|
||||
"rebuild_AVAudioSession",
|
||||
"rebuild_AudioUnit",
|
||||
"reapply_config",
|
||||
"update_stats"
|
||||
]
|
||||
},
|
||||
"implementation_phases": {
|
||||
"P1A_foundation": [
|
||||
"Add frame.rs 10ms/20ms types.",
|
||||
"Add AudioProcessingConfig.",
|
||||
"Add expanded AudioStats.",
|
||||
"Add AudioRoute/AudioBackend/VadBackend enums.",
|
||||
"Keep existing 20ms Opus path passing."
|
||||
],
|
||||
"P1B_voice_activity": [
|
||||
"Implement VoiceActivityDetector trait.",
|
||||
"Implement VoiceActivityStateMachine.",
|
||||
"Make TransmitMode::VoiceActivity real VAD-gated mode.",
|
||||
"Preserve PTT and Continuous behavior."
|
||||
],
|
||||
"P1C_silero_onnx_vad": [
|
||||
"Choose Rust ONNX Runtime binding.",
|
||||
"Bundle or locate silero_vad.onnx.",
|
||||
"Create ONNX session once and reuse it.",
|
||||
"Implement 48kHz-to-16kHz VAD adapter.",
|
||||
"Run inference off callback thread.",
|
||||
"Implement WebRTC VAD fallback."
|
||||
],
|
||||
"P1D_ios_platform_default": [
|
||||
"Keep VoiceProcessingIO default.",
|
||||
"Route VPIO processed capture to Rust VAD pipeline.",
|
||||
"Expose route/backend/VAD stats.",
|
||||
"Add mediaServicesWereReset handling if missing."
|
||||
],
|
||||
"P1E_ios_sonora_optional": [
|
||||
"Add ios_raw_unit.rs RemoteIO/raw path.",
|
||||
"Add processor/sonora.rs backend.",
|
||||
"Wire render_reference to Sonora process_render.",
|
||||
"Wire raw capture to Sonora process_capture.",
|
||||
"Reject Sonora when VPIO active.",
|
||||
"Mark Sonora experimental and disabled by default."
|
||||
],
|
||||
"P1F_diagnostics": [
|
||||
"Add raw_mic/render_reference/processed_mic WAV dump.",
|
||||
"Expose audio_processing_stats to Flutter debug UI.",
|
||||
"No Flutter PCM."
|
||||
],
|
||||
"P1G_acceptance": [
|
||||
"Speaker echo test VPIO default.",
|
||||
"Speaker echo test Sonora optional.",
|
||||
"Double-talk test VPIO vs Sonora.",
|
||||
"VoiceActivity first-syllable test.",
|
||||
"Quiet-room false-open test.",
|
||||
"Keyboard-noise false-open test.",
|
||||
"Route switching test.",
|
||||
"Interruption recovery test.",
|
||||
"Media services reset recovery test.",
|
||||
"30-minute memory/thermal test with ONNX Runtime."
|
||||
]
|
||||
},
|
||||
"acceptance_criteria": {
|
||||
"vad": {
|
||||
"VAD_001": "VoiceActivity no longer behaves as Continuous.",
|
||||
"VAD_002": "Silero ONNX VAD active by default in P1 builds where runtime/model are available.",
|
||||
"VAD_003": "WebRTC VAD fallback works if Silero/ONNX Runtime is unavailable, late, or over memory budget.",
|
||||
"VAD_004": "First syllable preserved using pre-roll.",
|
||||
"VAD_005": "Hangover prevents choppy transmit close.",
|
||||
"VAD_006": "ML inference never runs inside realtime callback."
|
||||
},
|
||||
"ios_platform": {
|
||||
"IOSP_001": "VoiceProcessingIO remains default iOS shipping backend.",
|
||||
"IOSP_002": "Platform AEC/NS/AGC active on speaker/earpiece policy.",
|
||||
"IOSP_003": "Rust/Sonora AEC/NS/AGC disabled by default when VPIO active.",
|
||||
"IOSP_004": "Route/interruption/media-services-reset handled without app restart."
|
||||
},
|
||||
"ios_sonora": {
|
||||
"IOSS_001": "Sonora backend exists as optional experimental mode.",
|
||||
"IOSS_002": "Sonora mode uses raw or near-raw iOS I/O, not VoiceProcessingIO.",
|
||||
"IOSS_003": "Sonora AEC3 receives render_reference before playout.",
|
||||
"IOSS_004": "Config rejects VoiceProcessingIO + Sonora AEC3 double-processing.",
|
||||
"IOSS_005": "Sonora can be compared against VPIO using same stats and WAV dumps."
|
||||
},
|
||||
"noise_suppression": {
|
||||
"NS_001": "iOS default uses platform NS through VoiceProcessingIO.",
|
||||
"NS_002": "Rust NS is not double-enabled on iOS platform path.",
|
||||
"NS_003": "Sonora NS is available only in Sonora experimental mode.",
|
||||
"NS_004": "AI noise suppression is not P1 default."
|
||||
},
|
||||
"diagnostics": {
|
||||
"DIAG_001": "Stats expose route, backend, iOS mode, VAD probability, VAD backend, fallback state, sample rate, buffer size, xruns.",
|
||||
"DIAG_002": "Debug WAV dump supports raw_mic, render_reference, processed_mic.",
|
||||
"DIAG_003": "WAV writer never runs on realtime callback."
|
||||
},
|
||||
"regression": {
|
||||
"REG_001": "PTT mode remains functionally unchanged except diagnostics.",
|
||||
"REG_002": "Continuous mode remains functionally unchanged except diagnostics.",
|
||||
"REG_003": "Existing voice_join/voice_leave lifecycle remains public lifecycle.",
|
||||
"REG_004": "Existing tsclientlib receive/decode/mix path remains active."
|
||||
}
|
||||
},
|
||||
"agent_execution_rules": {
|
||||
"AGENT_001": "Modify existing scaffold; do not introduce a parallel audio engine.",
|
||||
"AGENT_002": "Start in crates/chanora_audio; bridge and Flutter changes expose config/stats only.",
|
||||
"AGENT_003": "Preserve 20ms Opus network framing.",
|
||||
"AGENT_004": "Introduce 10ms processing frames internally only.",
|
||||
"AGENT_005": "Do not enable Sonora on iOS default path.",
|
||||
"AGENT_006": "Do not enable Rust AEC when platform AEC is active.",
|
||||
"AGENT_007": "Do not run ML inference in realtime callback.",
|
||||
"AGENT_008": "Do not use Flutter ONNX plugins in the core realtime path.",
|
||||
"AGENT_009": "Every implementation PR should reference requirement or acceptance IDs.",
|
||||
"AGENT_010": "If uncertain, preserve P0 lifecycle, tsclientlib transport, and cross-platform AudioEngine boundary."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# iOS release build
|
||||
|
||||
This document records the credential-free iOS P1 release path and the signing handoff for TestFlight/App Store builds.
|
||||
|
||||
## Unsigned verification build
|
||||
|
||||
Run from the repository root on macOS:
|
||||
|
||||
```bash
|
||||
flutter --version
|
||||
./tools/build-ios.sh --no-codesign
|
||||
```
|
||||
|
||||
Expected unsigned output:
|
||||
|
||||
```text
|
||||
apps/chanora_flutter/build/ios/iphoneos/Runner.app/
|
||||
```
|
||||
|
||||
## Store export configuration
|
||||
|
||||
The App Store export template lives at:
|
||||
|
||||
```text
|
||||
apps/chanora_flutter/ios/ExportOptions/AppStore.plist
|
||||
```
|
||||
|
||||
Use it after Apple signing assets are available:
|
||||
|
||||
```bash
|
||||
./tools/build-ios.sh --export-method app-store --export-options-plist apps/chanora_flutter/ios/ExportOptions/AppStore.plist
|
||||
```
|
||||
|
||||
Required signing assets:
|
||||
|
||||
- Apple Developer team access for `app.chanora.chanoraFlutter`
|
||||
- App Store distribution certificate or automatic signing permission
|
||||
- App Store provisioning profile if automatic signing is not used
|
||||
- Xcode 26 or later for uploads on or after 2026-04-28
|
||||
|
||||
## Verification checklist
|
||||
|
||||
- `flutter test` passes in `apps/chanora_flutter`
|
||||
- `flutter analyze` passes in `apps/chanora_flutter`
|
||||
- `flutter build ios --release --no-codesign` succeeds
|
||||
- Signed App Store export succeeds once credentials are installed
|
||||
- App Store metadata does not imply TeamSpeak affiliation
|
||||
Reference in New Issue
Block a user