feat(voice): add iOS VAD runtime support

This commit is contained in:
Edison Jwa
2026-05-21 20:51:45 +09:00
parent 171baf6e41
commit 6af4ecab0f
73 changed files with 11529 additions and 1249 deletions
+767
View File
@@ -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."
}
}
+47
View File
@@ -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