Commit Graph
22 Commits
Author SHA1 Message Date
Edison Jwa 2f6d45fb04 feat(audio): desktop Silero ONNX VAD + Windows PTT modernization + MSVC CRT build fix (#37)
* feat(audio): add Silero ONNX VAD with WebRTC fallback

Introduce SileroOnnxVad and SileroOnnxVadWorker for desktop targets. The worker runs Silero v6 ONNX inference on a dedicated thread, accumulating 10 ms frames into the 512-sample 16 kHz input the model expects. Add VadOutput, VoiceActivityDetector trait, and WebRtcFallbackVad to provide a uniform VAD interface with graceful fallback when the ONNX model is unavailable. Wire the new VadBackend variants through AudioProcessingConfig and the snapshot stats so the bridge can report which detector is active.

* feat(audio): integrate desktop VAD worker into capture engine

Wire SileroOnnxVadWorker into the desktop capture path so voice activity can open the transmit gate before encoding. The capture callback now processes all audio through resample, downmix, and VAD unconditionally; transmit_active still gates Opus encoding.

Add new_desktop_audio_processing_state() to construct the config/stats/worker triple, and apply_desktop_vad_backend() to synchronously load or clear the worker on config changes. Override processing_backend to Noop for desktop so bridge diagnostics report the correct backend rather than the iOS-oriented PlatformVoiceProcessing default.

Includes review-driven cleanups: StreamConfig clone to deref per clippy, and a comment explaining why two try_lock calls on silero_vad_worker are structurally necessary (borrow checker requires the policy probe and the fallback path to not share a lock guard because mark_vad_fallback_active takes &mut self).

* fix(audio): modernize Windows PTT to current windows-rs API

Port the Raw Input plus low-level keyboard hook PTT backend to the newer windows-rs patterns: OptionalHandle, Result-returning CreateWindowExW, and None for CallNextHookEx. Replaces the old HHOOK(0) pointer casts. Add deterministic tests for mouse button 4 and 5 press and release driving the gate.

* build(windows): force MSVC release CRT for audiopus cmake builds

audiopus_sys calls cmake::build(opus_path), so downstream Cargo env cannot use cmake-rs Config::define() to override CMake's MSVC Debug CRT defaults. Point cmake-rs at a small wrapper that injects the policy and cache variables during configure while passing cmake --build, --version, and -E through unchanged. Keeps Opus Debug builds on Rust's release dynamic CRT (/MD) instead of CMake's default debug CRT (/MDd), which otherwise pulls in unresolved __imp__CrtDbgReportW symbols at test link.

Document that the iOS deployment target is intentionally absent from this file. It is enforced by tools/build-ios.sh and the Xcode project; setting it globally here would make native macOS cargo check runs try to link iPhone objects against the macOS SDK.

* build(flutter): update pubspec.lock after plugin additions

Regenerated lockfile reflecting the local_notifications and connectivity_plus plugin additions from the poke-notifications feature.

* fix(audio): address PR #37 review findings

Six fixes from independent PR review:

1. BLOCKER: Replace Windows-only cmake .cmd wrapper with cross-platform
   CMake env vars. Setting CMAKE=tools/cmake-msvc-release-crt.cmd
   globally broke non-Windows hosts because cmake-rs would try to
   execute a .cmd file on macOS/Linux. Instead, set
   CMAKE_POLICY_DEFAULT_CMP0091=NEW and CMAKE_MSVC_RUNTIME_LIBRARY=
   MultiThreadedDLL as env vars that CMake reads natively. MSVC-
   specific vars are safely ignored by GCC/Clang toolchains. Delete
   the now-unnecessary wrapper script.

2. IMPORTANT: Join the Silero worker thread in Drop instead of
   detaching it. The old code dropped the JoinHandle which detaches
   the thread; the new code calls handle.join() after closing the
   channel, ensuring the ONNX session is cleaned up before the
   worker is replaced during config changes.

3. IMPORTANT: Single-try_lock refactor of the capture VAD callback.
   The double try_lock (policy probe + send) is replaced by a single
   scoped try_lock that both probes availability and sends the frame.
   The guard is dropped before the fallback path, which needs &mut
   self for mark_vad_fallback_active. This also eliminates the
   VadWorkerPolicy enum and callback_vad_worker_policy function,
   whose behavior is now inlined into the callback.

4. IMPORTANT: Remove tracing from the realtime capture callback.
   mark_vad_fallback_active and sync_vad_backend emitted info!/warn!
   from the audio thread. Replace with silent atomic state
   publishing via SharedAudioProcessingStats; the bridge stats
   stream already exposes vad_fallback_active for diagnostics.

5. IMPORTANT: Defer ONNX model load outside the worker mutex.
   apply_desktop_vad_backend_to_worker now constructs the new worker
   before taking the lock, then swaps it in under a short hold.
   This prevents the realtime callback from being blocked during
   model I/O + thread spawn.

6. MINOR: Remove unused VadBackend import from vad/mod.rs after
   deleting the policy code.

* fix(audio): address PR #37 second-pass review findings

5-agent review found 5 blocking issues. All addressed:

1. BLOCKER: CMake env vars don't reach CMake cache. Restored .cmd wrapper
   but scoped to Windows MSVC targets only via [target.x86_64-pc-windows-msvc]
   and [target.aarch64-pc-windows-msvc] in .cargo/config.toml. Non-Windows
   hosts are unaffected.

2. BLOCKER: processing_backend normalized in set_audio_processing_config
   on desktop (cfg-gated override to Noop), mirroring startup default.

3. BLOCKER: Model-path reload was already wired via reload_audio_processing_config.
   Fixed misleading doc comment in core/lib.rs.

4. BLOCKER: DEC-030 updated to reflect desktop VoiceActivity enablement.
   Traceability docs (SRS, SysDes, SAD, SDD, implementation-status) updated.

5. Silero ONNX cfg narrowed to desktop-only (excludes macOS/Android).
   Cargo.toml ort dependency target cfg narrowed similarly.

6. Realtime callback debt documented as TODO at CaptureState::ingest.

* fix(audio): exclude ort dep on Android target

ort does not provide first-class Android prebuilts in our pin, mirror the
iOS/macOS exclusion so cargo metadata succeeds for android targets.

* test(audio): fix stale select_ptt_backend import in ptt_privacy

The helper moved out of the ptt_backends submodule onto the crate root;
update the integration test imports so the test compiles again.

* build(windows): scope MSVC release CRT cmake wrapper via Cargo [env]

Cargo's [target.<triple>] table only forwards a fixed allowlist
(linker, runner, rustflags, rustdocflags, ar), so setting CMAKE there
was silently dropped and audiopus_sys kept linking the debug CRT,
producing LNK4098 'MSVCRTD conflicts' and __imp__CrtDbgReportW errors
on x86_64-pc-windows-msvc test builds.

Move the override to Cargo's [env] table using cc/cmake-rs's
target-suffixed CMAKE_<triple> lookup (force=true, relative=true) so it
applies to MSVC targets only and not to host tooling. Add stdout
markers to the wrapper so its invocation is provable in cargo -vv logs.

Verified: cargo test -p chanora_audio --target x86_64-pc-windows-msvc
--lib --no-run now links cleanly; CMakeCache.txt records
CMAKE_MSVC_RUNTIME_LIBRARY=MultiThreadedDLL and CMP0091=NEW.

* fix(flutter): gate VoiceActivity transmit mode by platform support

VoiceActivity relies on the native VAD worker, which is only wired up
on Windows, Linux, and Android. Showing the option on iOS, macOS, or
web let users select a mode that silently never transmitted.

Add voiceActivityTransmitAvailable + transmitModeSegmentsFor() helpers
in voice_settings_controls.dart, hide the VAD row in voice_compact.dart
and drop the VAD segment from the settings dialog when unsupported.
Keep the legacy const transmitModeSegments for the existing widget test
and add two new tests covering the gated helper.
2026-06-09 20:47:16 +09:00
Edison Jwa d83539436e fix(voice): preserve mute owners and release touch ptt 2026-06-08 18:00:32 +09:00
Edison Jwa 5f1423c349 feat(voice): unified mobile voice bar with gesture-isolated PTT row (#22)
* feat(voice): unified mobile voice bar with gesture-isolated PTT row

Replace separate VoiceStatusChip + VoicePttButton with a single
CompactVoiceBar widget that combines both into a two-row layout:

- Control row (tap): status text, mute, deafen, settings chevron
- PTT row (hold): full-width hold-to-talk, shown only in PTT mode

Gesture isolation prevents mis-touch between rows: the control row
uses tap-only InkWell/IconButton while the PTT row uses a raw
Listener for pointer-down/up events.

Key changes:
- Add CompactVoiceBar widget with state-colored container (normal,
  muted, talk-power-blocked)
- Remove mute/deafen IconButtons from AppBar headerActions
- Restructure voice details sheet into primary section + collapsible
  ExpansionTiles (audio processing, PTT capability, debug)
- Optimistic state updates for mute/deafen to eliminate tap delay
- Instant PTT visual feedback (no AnimatedContainer fade)
- Constant geometry across all states (no layout shift on toggle)

* fix(voice): preserve current PTT button format

* feat(voice): move mute/deafen controls into VoiceStatusChip

* fix(voice): ensure consistent chip height across mute states

Remove isSelected/selectedIcon from IconButtons inside VoiceStatusChip.
Material 3 toggle IconButtons (_SelectableIconButton) can vary in height
when the selected state changes due to tap target sizing. Use simple
conditional icons instead and set shrinkWrap tap target size with tight
constraints for stable 40x40 buttons regardless of state.

* fix(voice): remove leftover duplicate mute/deafen buttons in VoiceStatusChip

* fix(voice): replace unsafe stereo cast with bytemuck and localise talk-power tooltip

Replace the raw-pointer `&mut [(f32, f32)]` to `&mut [f32]` cast in
the oboe output callback with `bytemuck::cast_slice_mut`, eliminating
the unsafe block and relying on bytemuck compile-time NoUninit
verification instead.

Add voiceTalkPowerBlocked l10n key (en + zh) and replace the only
remaining hard-coded English tooltip in VoiceStatusChip with it.
2026-06-05 20:58:16 +09:00
Edison Jwa 82441f3d97 feat(voice): real-time mic input level metering at 30 Hz (#25)
* feat(voice): add real-time mic input level metering at 30 Hz

Expose input RMS from the audio engine through the bridge as a
dedicated Rust→Dart Stream<double>, replacing the binary on/off
indicator with a proportional dBFS level meter.

Rust side:
- chanora_audio: add set_input_dbfs/input_dbfs accessors to
  SharedAudioProcessingStats; restructure CaptureState::ingest()
  to compute dBFS from mono buffer before the PTT guard so the
  meter shows mic activity even when not transmitting.
- chanora_core: widen audio_stats() return to include f32 input
  level.
- chanora_bridge: add input_level: f32 to BridgeAudioStats and
  new input_level_stream(sink: StreamSink<f32>) that pushes at
  ~30 Hz via tokio interval task.
- Update frb_generated.rs serialization for the new field.

Flutter side:
- VoiceLevelMeter: accept optional double level (dBFS), map
  -60..0 dBFS to 0..1 fill fraction, animate with
  TweenAnimationBuilder for smooth transitions.
- voice_compact.dart: subscribe to inputLevelStream in the voice
  details sheet for 30 Hz meter updates, keeping 250 ms poll for
  TX/RX counters.
- voice_bar.dart: accept optional inputLevel from the stream.
- main.dart: subscribe to inputLevelStream, pass to VoiceBar.

* chore: sync Flutter build config and dependency updates

- Add Flutter migrator flags to gradle.properties (builtInKotlin, newDsl)
- Add FlutterGeneratedPluginSwiftPackage to iOS/macOS Xcode projects
- Update meta 1.17→1.18, test_api 0.7.10→0.7.11
- Rebuild chanora_bridge framework for macOS
- Update Podfile.lock for iOS and macOS

* fix(voice): correct meter animation, pre-gain dBFS, stream lifecycle, and protocol warnings

B1: Convert VoiceLevelMeter to StatefulWidget tracking previous fill
     as Tween begin so the meter animates smoothly instead of resetting
     to zero on every frame.

B2: Compute dBFS from pre-gain mono samples in CaptureState::ingest()
     so the level meter reflects raw mic input, matching mobile paths.

B4: End input_level_stream after 10 consecutive session errors instead
     of emitting -120 dBFS forever when the session is gone.

Also fixes all 13 clippy warnings in chanora_protocol: collapsed
nested if-let patterns, replaced .ok() + Some matching with Ok, used
? operator, and introduced EventChannels struct to reduce the four
helper functions below the 7-argument threshold.

* fix(voice): use MissedTickBehavior::Skip for level meter stream and align dBFS doc

Set MissedTickBehavior::Skip on the input_level_stream tokio interval
so slow audio_stats() calls skip missed ticks instead of bursting,
preventing CPU spikes on the UI meter thread.

Align VoiceLevelMeter class doc: the mapping floors at -60 dBFS
(via dbfsToFraction), not the full -120 range.
2026-06-05 20:57:16 +09:00
Edison Jwa fe6e07353e chore: restore product scaffold to rollback baseline 2026-05-29 14:02:04 +09:00
Edison Jwa 487aff4e7c fix: improve voice and chat controls 2026-05-25 18:26:56 +09:00
Edison Jwa b8df25a195 Reduce Linux setup ambiguity and surface desktop input/message failures honestly
Clarify ONNX Runtime guidance with direct-open install hints, restore desktop WebRTC VAD visibility, map mouse side buttons through focused PTT capture/runtime paths, and wait for server acks before showing chat sends as successful.

Constraint: Linux release UX must stay functional when ONNX Runtime is optional and GNOME portal availability varies
Rejected: Keep desktop VAD locked to Silero only | misleads users when ONNX Runtime is skipped
Confidence: medium
Scope-risk: moderate
Directive: Preserve the protocol send-ack wait path for chat so UI success always tracks real server acceptance
Tested: flutter analyze lib/main.dart lib/widgets/chat_views.dart lib/widgets/input_dialogs.dart lib/widgets/startup_dependency_screen.dart; flutter test test/widgets/input_dialogs_test.dart test/widgets/chat_views_test.dart test/services/startup_dependency_check_test.dart test/widgets/startup_dependency_screen_test.dart test/widgets/voice_settings_controls_test.dart test/widgets/audio_processing_config_state_test.dart; cargo test -p chanora_protocol --lib; cargo test -p chanora_audio ptt_backends --lib
Not-tested: Live manual GNOME portal rebind/global PTT on a real desktop session; observer-bot chat against a live server after the sender-name fallback change
2026-05-25 11:55:10 +09:00
Edison Jwa 5515ff6643 feat: stabilize voice activity and audio routing 2026-05-25 01:19:09 +09:00
Edison Jwa 7d5d8c2c90 feat: integrate chat voice and diagnostics client 2026-05-23 06:51:55 +09:00
Edison Jwa bf284018e6 feat: Android Oboe voice backend — WebRTC APM, VAD, HW/SW toggle, BBCode welcome, link trust, foreground task
Audio engine (Rust):
- Android Oboe: WebRTC APM (AEC/NS/AGC/HPF) + TEN/Silero ONNX VAD
- Hardware effects (JNI) with software fallback per-effect
- Render reference buffer for AEC between output/capture callbacks
- Voice activity gate: suppress transmission when speaker muted (all platforms)
- Audio focus (SDD-109) + Bluetooth SCO (SDD-110) via JNI
- ONNX Runtime 1.26 via ort 2.0.0-rc.12 (down from rc.10, ndarray 0.17)
- VAD worker channel capacity 8→32, initial seq u64::MAX (warm-up fix)
- TEN VAD default backend (was Silero)
- Platform→WebrtcApm resolution after hardware binding
- oboe-rs edisonjwa fork with get_raw_session_id()

Android Kotlin:
- AndroidAudioFocusController + AndroidBluetoothScoController
- AndroidAudioLifecycleController (route changes to Flutter)
- ProGuard rules for new controllers

Flutter UI:
- VoiceSettings: Android HW/SW toggle (Platform auto / WebRTC APM)
- VoiceStatusChip: mute warning border + Speaker muted label
- BBCode welcome message parser (BbCodeText, case-insensitive)
- Welcome message foldable (expanded by default)
- Link trust dialog (domain wildcards, SharedPreferences)
- HapticFeedback on voice sheet opener
- Server name in AppBar, version v0.1.0
- Default channel (id=1) visible, serverquery clients hidden
- flutter_foreground_task integration

Config:
- ort load-dynamic on all non-iOS (Android/Linux/Windows)
- ONNX Runtime AAR 1.26.0
- ndarray moved to common deps (was Apple-only)
2026-05-22 09:29:57 +09:00
Edison Jwa 6af4ecab0f feat(voice): add iOS VAD runtime support 2026-05-21 20:51:45 +09:00
Edison Jwa f8a9c7a422 fix(android): add output device picker 2026-05-20 16:26:13 +09:00
Edison Jwa 8c253f1d4d feat(voice): harden Android audio and channel joins 2026-05-19 01:58:07 +09:00
Edison Jwa 7a59f5b9a1 feat(ios,p0): iOS P0 platform, audio fixes, channel UX 2026-05-17 22:00:00 +09:00
EdisonJwa 2f6cdd3ad4 Revert "diag(ios,audio): log AVAudioSession state + route changes around picker overrides"
This reverts commit da631a2bef.
2026-05-17 00:17:10 +08:00
EdisonJwa da631a2bef diag(ios,audio): log AVAudioSession state + route changes around picker overrides
Add structured NSLog instrumentation to AppDelegate.swift and debugPrint
chains in voice_compact.dart::_AudioOutputPickerSheetState so we can
correlate user picker taps with what iOS actually does to the route.

Three diagnostic streams:

* 'chanora.session[<tag>]' from Swift — full session snapshot (category,
  mode, sampleRate, ioBufferDuration, current route inputs+outputs,
  preferredInput) emitted on every setActive and every
  AVAudioSession.routeChangeNotification with the reason decoded
  (override / routeConfigurationChange / newDeviceAvailable / etc).
* 'chanora.route[<tag>]' from Dart — current route's inputs+outputs
  emitted before/after every overrideOutputAudioPort or
  setPreferredInput call, plus a delayed re-check at +250 ms to detect
  silent reverts.
* Existing 'chanora: ...' debugPrint lines from the picker now include
  the OK case (override returned, setPreferredInput returned) so we see
  a positive signal in the log when the API didn't throw.

Used to root-cause the 'speaker selector not working' issue: the
hypothesis is that cpal's RemoteIO AudioUnit reacts to its own format
configuration notifications by triggering routeConfigurationChange
that reverts our Dart-side override. The logs will confirm or deny
this — if we see 'chanora.session[routeChange.override] out=Speaker'
followed by 'chanora.session[routeChange.routeConfigurationChange]
out=Receiver' within a few hundred ms, that's the smoking gun.

Pure diagnostic commit. No behavioural change. Logs are NSLog +
debugPrint so they appear in Xcode console / 'flutter logs' / the
device log via Console.app or 'devicectl device log'.
2026-05-16 23:57:50 +08:00
EdisonJwa b1d117033e fix(ios,audio): don't call setPreferredInput after speaker/receiver override (silent route reversion)
User report: 'speakerphone (built-in mic input)' logs printed
successfully but audio output didn't actually switch to speaker.
No exception thrown by either AVAudioSession call.

Root cause:

Previous _selectSpeaker called:
  1. await overrideOutputAudioPort(.speaker)
  2. await setPreferredInput(builtInMic)

Apple-documented behavior in .voiceChat mode: when
setPreferredInput is called, iOS recalculates the entire route
based on the natural input/output pairing for the selected port.

Built-in mic's natural output pairing is the receiver/earpiece
(matches the 'I'm talking on a phone' UX of .voiceChat). So step 2
caused iOS to SILENTLY REVERT the speaker override from step 1
and route output back through the earpiece.

Net: await chain returned without exception (both calls 'succeeded'
in API terms), debugPrint logged the success message, but the user
heard the call audio coming out of the earpiece, not the speaker.

Same issue affected _selectReceiver (after the speaker bug fix
was reverted): redundant setPreferredInput(builtInMic) call risked
the same recalc race.

Fix:

  * _selectSpeaker: only call overrideOutputAudioPort(.speaker).
    No setPreferredInput. The override alone is sufficient \u2014 the
    input stays on whatever the system was already using (built-in
    mic by default, or BT/wired if connected).

  * _selectReceiver: only call overrideOutputAudioPort(.none).
    No setPreferredInput. Removing the speaker override naturally
    returns to .voiceChat's default route (receiver).

  * _selectInput (BT / wired / USB): unchanged. These inputs PAIR
    their own output device, so .none + setPreferredInput is the
    correct combo (user hears audio through the same device they
    speak into).

flutter build ios --release --no-codesign: 21.2 s, Runner.app
30.4 MB.
2026-05-16 21:36:59 +08:00
EdisonJwa 9413703372 fix(ios,audio): drop .defaultToSpeaker; speakerphone toggle now actually switches
User report: 'speaker change not work' \u2014 selecting Speaker or
iPhone receiver in the audio output picker had no audible effect.

Root cause:

AVAudioSession was configured with category options
[.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP] +
mode .voiceChat. The .defaultToSpeaker flag tells iOS 'this app's
baseline output route is the speakerphone, even though .voiceChat
mode would normally route to the receiver.'

When the user picked Speaker:
  overrideOutputAudioPort(.speaker)   <- already at speaker baseline; no-op
When the user picked iPhone receiver:
  overrideOutputAudioPort(.none)       <- removes speaker OVERRIDE,
                                          restores baseline = .defaultToSpeaker
                                          = speakerphone. Receiver
                                          row silently mapped to speaker.

So both rows produced the same audible state. The picker UI changed
the selected radio but the route didn't actually move.

Fix:

  1. AppDelegate.swift: drop .defaultToSpeaker from options. With
     pure .voiceChat mode (no .defaultToSpeaker), the baseline is
     the receiver/earpiece. overrideOutputAudioPort then works as
     documented:
       Default                      = receiver
       overrideOutputAudioPort(.speaker)  -> speakerphone
       overrideOutputAudioPort(.none)     -> back to receiver
       BT/AirPods connected         -> automatic
       Wired headphones plugged in  -> automatic

  2. voice_compact.dart: replace 'catch (_) {/* ignore */}' silent
     swallow with debugPrint logging of (a) the actual exception
     and (b) which route was selected. So if iOS rejects an
     override (e.g. wired headphones plugged in), we can see WHY
     in the device log instead of a silent picker no-op.

  3. _selectSpeaker also now sets preferredInput to the built-in
     mic so input + output stay consistent. Previously the
     speakerphone override could leave the mic still routed to a
     previously-selected BT input \u2014 user hears self through
     speaker but server hears nothing.

flutter build ios --release --no-codesign: 21.9 s, Runner.app
30.4 MB.
2026-05-16 21:32:41 +08:00
EdisonJwa f0016155aa feat(ui,ios): replace audio_router with audio_session + custom VoIP-style picker
User reported: the 'output device' picker only listed AirPlay
destinations (other iPhones / AirPlay speakers / AppleTV) and not
the speaker / iPhone receiver / AirPods / wired headset choices.

Root cause: audio_router 1.1.1's iOS path uses AVRoutePickerView,
which is Apple's **AirPlay** picker UI \u2014 by design it only lists
AirPlay-eligible output destinations, NOT the input/output route
choices we need (speaker vs receiver vs Bluetooth HFP vs wired).
AVRoutePickerView is the right UI for 'cast audio elsewhere'; for
'pick how I hear / talk' (VoIP) the right primitive is direct
AVAudioSession calls.

Fix: replace audio_router with audio_session 0.2.3 (Ryan Heise,
verified publisher, 865k downloads, MIT). audio_session exposes:

  * AVAudioSession.availableInputs \u2014 enumerate every real input
    port: builtInMic, bluetoothHfp, bluetoothA2dp, headsetMic
    (wired), usbAudio, carAudio, airPlay.
  * AVAudioSession.currentRoute \u2014 .inputs + .outputs of the
    active route.
  * AVAudioSession.setPreferredInput(port) \u2014 switch the input
    (HFP / wired / USB / car audio also move output to themselves).
  * AVAudioSession.overrideOutputAudioPort(.speaker | .none) \u2014
    toggle built-in speakerphone vs receiver/earpiece.
  * AVAudioSession.routeChangeStream \u2014 live notifications when
    the user plugs / unplugs / connects a device while the picker
    is open.

This is exactly the same primitive Discord, WhatsApp, FaceTime
use for their VoIP audio chooser. No native UI plugin needed.

New widgets in voice_compact.dart:

  * _AudioOutputTile: shows the active output port name (Speaker /
    iPhone / AirPods / 'Phil's Wired Headset' / etc.) with the
    matching icon. Subscribes to routeChangeStream for live
    updates. Tap opens _AudioOutputPickerSheet.

  * _AudioOutputPickerSheet: bottom sheet with 'Choose audio' title
    and a Discord-style list:
      - Speaker          (volume_up)
      - iPhone           (phone_in_talk; the receiver/earpiece)
      - <BT name>        (bluetooth_audio)
      - <Wired headset>  (headset)
      - <USB / Car>      (usb / directions_car)
    Selected row is highlighted + has a check mark. Tap routes:
      - Speaker  -> overrideOutputAudioPort(.speaker)
      - iPhone   -> overrideOutputAudioPort(.none) + setPreferredInput(builtInMic)
      - External -> overrideOutputAudioPort(.none) + setPreferredInput(port)

  * _PickerRow: shared row widget with selected/check styling.

AppDelegate.swift is unchanged: the manual AVAudioSession
.setCategory(playAndRecord / .voiceChat) we already do at launch
(0466000 / 4ee2b38) is fully compatible with audio_session \u2014 the
plugin only adds Dart-side accessors over the same underlying
AVAudioSession singleton.

Removed l10n keys not used anymore (audioRouteUsb was already gone).
Kept audioRouteSpeaker / Receiver / Bluetooth / WiredHeadset /
CarAudio / Airplay / Unknown \u2014 all still used by the new picker.

flutter analyze: 6 pre-existing Radio deprecation infos (unchanged).
flutter build ios --release --no-codesign: 54.9 s, Runner.app
30.4 MB (+200 KB vs audio_router build).
2026-05-16 20:53:00 +08:00
EdisonJwa 7c62d14dd8 feat(ui,ios): inline mode+tail into voice sheet, fix Unknown audio route, tap-outside unfocus
Three user-reported issues addressed at once.

1. Audio output displaying as Unknown on iOS

The route tile only set _device from currentDeviceStream events,
which fire on route *changes*. On first sheet open with no route
change yet, _device was null \u2192 _deviceLabel fell through to
audioRouteUnknown.

Fix: query AudioRouterPlatform.instance.getCurrentDevice() in
initState before attaching the stream listener. Plugin returns the
current AVAudioSession route synchronously (well, via Future) so
the tile renders Speaker / iPhone receiver / AirPods / etc.
immediately on first open. Errors swallowed \u2014 the stream remains
authoritative for subsequent updates.

2. 'Adjust mode & release tail' too deep (chip \u2192 modal \u2192 button \u2192 dialog)

Inlined the mode radio buttons and release-tail slider directly
into the voice modal sheet. Dropped the OutlinedButton 'Adjust'
trigger and the nested VoiceSettingsDialog dispatch entirely on
mobile.

Modal sheet is now a single-screen control panel:

  Title 'Voice'
  --------
  Audio output: <current route>           >    (iOS/Android only)
  --------
  Transmit mode
    \u25c9 PTT
    \u25cb Continuous
    \u25cb Voice activity (Coming soon)             (disabled)
  --------
  Release tail                  200 ms
  [\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u25cf\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501]                       (0\u20131000 ms, step 50)
  Bound key: F                                  (desktop only)
  --------
  Level meter
  TX/RX frame counts
  PTT capability badge                          (desktop only)

VoiceSettingsDialog is retained for the wide-mode VoiceBar
'configure' button (desktop entrypoint) and the PTT-bind flow, so
desktop UX is unaffected.

New widgets: _VoiceSheetBody (StatefulWidget with local _mode +
_tail), _ModeRow (RadioListTile-shaped row with optional disabled
state for VoiceActivity). New API on showVoiceDetailsSheet:
onModeChanged + onReleaseTailChanged callbacks (replace
onAdjustVoiceSettings). Wiring in main.dart writes through to
rust.setTransmitMode / rust.setReleaseTailMs and mirrors _state.

l10n: dropped voiceAdjustSettings (en + zh). Added voiceBoundKeyLabel
(en + zh) for the desktop-only bound-key row.

3. iOS first-tap-keyboard regression (flutter/flutter#181474)

The 79f8360 _kickFocus workaround (unfocus + microtask refocus on
every TextField.onTap) was kept, but extended with a tap-outside-
to-unfocus GestureDetector wrapping the connect form Column. This
guarantees the FocusNode is in the unfocused state when the next
field tap arrives, so the focus transition is always false\u2192true
on first tap.

GestureDetector(HitTestBehavior.translucent, onTap: unfocus) is the
canonical pattern recommended in the flutter/flutter#181474 thread
+ several older iOS keyboard issues. Translucent behaviour means it
catches taps on the column padding / empty regions without
swallowing taps on the TextFields themselves (those have
onTap: _kickFocus already).

flutter analyze: 6 pre-existing Radio.groupValue deprecation infos
in voice_settings.dart (unchanged). flutter build ios --release
--no-codesign: 27.9 s, Runner.app 30.2 MB (unchanged).

Awaiting iPhone retest to confirm all three fixes.
2026-05-16 18:44:48 +08:00
EdisonJwa b8e9c8549e feat(ui,ios,android): audio output route picker + collapse voice controls into single modal
Two user-reported issues addressed:

  1. 'on supported devices such as iphone or android user should be
     able to select audio device such as speaker or airpods or phone'
  2. 'the bottom folder is duplicated with the app bar settings'

Issue 1 \u2014 audio output route picker:

Added the audio_router 1.1.1 plugin (MIT, supports iOS + Android)
which renders the platform-native picker:

  * iOS: Apple AVRoutePickerView system sheet \u2014 the same UI as
    Control Center's audio chooser. Lists Speaker / iPhone receiver /
    AirPods / connected Bluetooth devices / AirPlay / CarPlay.
    System manages the device list; we don't have to track route
    changes manually.

  * Android (post-rc.8 when we wire the platform): Material Design 3
    dialog backed by AudioManager.setCommunicationDevice() with
    SCO Bluetooth + USB headsets filtered for VoIP.

The picker prerequisite documented by the plugin (audio session
must be playAndRecord/voiceChat before the picker fires) is already
satisfied by our AppDelegate.swift configuration from commit 0466000.

The new _AudioOutputTile widget in voice_compact.dart subscribes to
AudioRouter.currentDeviceStream so the row label + icon auto-update
when the user plugs in headphones, connects AirPods, etc. \u2014 no
manual KVO observation needed.

Tile is mobile-only (Platform.isIOS || Platform.isAndroid). Desktop
hosts continue to use the system mixer; the tile is hidden.

Plugin caveat: the published enum AudioSourceType has no .usb
variant (despite README mentioning USB support). We map only the
seven real enum cases: builtinSpeaker / builtinReceiver / bluetooth /
wiredHeadset / carAudio / airplay / unknown.

Issue 2 \u2014 collapse voice controls into the single modal sheet:

The AppBar gear icon (Icons.tune) that opened VoiceSettingsDialog
was removed. It duplicated the configuration entry point that the
status chip \u2192 modal-sheet path already provides, and the user found
that duplication confusing on a phone-narrow screen where AppBar
real estate is precious.

The voice modal sheet (showVoiceDetailsSheet) is now the **single**
voice-controls surface on mobile, with layout (top to bottom):

  1. Audio output route picker tile (iOS / Android only) \u2014 new.
  2. Mode + bind / release-tail recap (display only).
  3. 'Adjust mode & release tail' OutlinedButton that closes the
     sheet and opens the same VoiceSettingsDialog the gear icon
     used to open. One config form, not two.
  4. Mic level meter.
  5. TX / RX frame counts + mic state.
  6. PTT capability badge (desktop only).

Sheet title renamed from 'Voice settings' (which collided with the
gear-icon tooltip) to 'Voice'. New l10n keys: voiceSheetTitle,
voiceAdjustSettings, audioOutputLabel, audioRoute{Speaker,Receiver,
Bluetooth,WiredHeadset,CarAudio,Airplay,Unknown}. en + zh translated.

Build: flutter build ios --release --no-codesign clean, 50.5 s,
Runner.app 30.2 MB (+200 KB from audio_router). flutter analyze
clean (6 pre-existing Radio.groupValue deprecation infos in
voice_settings.dart, unchanged).
2026-05-16 18:18:43 +08:00
EdisonJwa 3b08ea9d32 feat(ui,mobile): Plan E voice UI -- status chip + bottom-anchored PTT + AppBar mutes
Restructure the narrow / mobile body layout around three principles
(Hoober thumb-zone research validated, Material 3 components):

  1. Channel tree gets ~85% of the screen height.
  2. Live voice state is always visible in a 2-line status chip
     above the PTT button.
  3. PTT button is wide, bottom-anchored (thumb-natural lower zone).
  4. Frequent toggles (mic mute, headset mute, voice settings) live
     in the AppBar so they don't compete with the channel tree.
  5. Non-essential live readouts (level meter, TX/RX, capability
     badge) live in a modal sheet opened by tapping the status chip
     -- progressive disclosure.

apps/chanora_flutter/lib/widgets/voice_compact.dart  (new file)
  * VoiceStatusChip: 2-line live readout. Line 1 = mode + bind hint;
    Line 2 = release-tail + 'Mic on/off'. Tap opens
    showVoiceDetailsSheet.
  * VoicePttButton: 56 dp wide bottom-anchored Push to Talk button.
    Same touch-and-hold gestures as the previous _PttHoldButton.
  * showVoiceDetailsSheet: modal bottom sheet with mode recap +
    bind/tail hint + level meter + TX/RX counts + PTT capability
    badge (desktop-only).

apps/chanora_flutter/lib/main.dart
  * AppBar gains mic-mute, headset-mute, voice-settings icons when
    in voice channel AND MediaQuery width < 840 dp (mobile only).
    Wide mode keeps these controls inside the existing VoiceBar
    widget unchanged.
  * AppBar title becomes a Row of 'app name + channel chip' when
    in voice channel.
  * Narrow-mode body restructured: Expanded(channelTree) +
    VoiceStatusChip + VoicePttButton (latter only when in voice
    channel AND PTT mode). The old narrow-mode VoiceBar is gone;
    wide-mode VoiceBar is unchanged.
  * New _onOpenVoiceDetailsSheet handler bridges the chip-tap to
    showVoiceDetailsSheet.
  * New top-level helper _isTouchOnlyPttHost mirrors the helpers
    in widgets/voice_bar.dart and widgets/voice_settings.dart so
    the AppBar + narrow-mode chip can branch consistently.

apps/chanora_flutter/lib/l10n/app_en.arb
apps/chanora_flutter/lib/l10n/app_zh.arb
apps/chanora_flutter/lib/l10n/generated/*  (regenerated)
  * New string voicePttHoldHint = 'Hold the button' / '按住按钮'.
    Surfaced in line 1 of VoiceStatusChip on touch-only hosts and
    in the modal sheet's PTT line where the desktop equivalent
    would name a bound key.

Wide-mode (>= 840 dp) layout intentionally unchanged so the
signed-off rc.8 desktop verification still applies.

flutter analyze: clean (6 pre-existing Radio.groupValue infos).
Local cargo + flutter analyze pass; Mac was offline at commit
time so iOS device build verification is pending the next sync.
2026-05-16 16:57:08 +08:00