neoagent 2.3.1-beta.2 → 2.3.1-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/.env.example +39 -0
  2. package/README.md +2 -0
  3. package/docs/capabilities.md +2 -2
  4. package/docs/configuration.md +13 -5
  5. package/docs/integrations.md +4 -1
  6. package/flutter_app/.metadata +42 -0
  7. package/flutter_app/README.md +21 -0
  8. package/flutter_app/analysis_options.yaml +32 -0
  9. package/flutter_app/android/app/build.gradle.kts +109 -0
  10. package/flutter_app/android/app/src/debug/AndroidManifest.xml +7 -0
  11. package/flutter_app/android/app/src/main/AndroidManifest.xml +147 -0
  12. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +747 -0
  13. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthConnectGateway.kt +280 -0
  14. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncNotifications.kt +113 -0
  15. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncPayload.kt +57 -0
  16. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncScheduler.kt +78 -0
  17. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncWorker.kt +253 -0
  18. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/PermissionsRationaleActivity.kt +46 -0
  19. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +21 -0
  20. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +586 -0
  21. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +78 -0
  22. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +104 -0
  23. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiHomeWidgetProvider.kt +457 -0
  24. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiWidgetStore.kt +194 -0
  25. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/VoiceLaunchWidgetProvider.kt +67 -0
  26. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetConfigActivity.kt +228 -0
  27. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncScheduler.kt +72 -0
  28. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncWorker.kt +186 -0
  29. package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetTaskRunWorker.kt +210 -0
  30. package/flutter_app/android/app/src/main/res/drawable/launch_background.xml +12 -0
  31. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_bg.xml +11 -0
  32. package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_task_bg.xml +8 -0
  33. package/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
  34. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget.xml +138 -0
  35. package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget_task_row.xml +52 -0
  36. package/flutter_app/android/app/src/main/res/layout/neoagent_voice_widget.xml +49 -0
  37. package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  38. package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  39. package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  40. package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  41. package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  42. package/flutter_app/android/app/src/main/res/values/strings.xml +12 -0
  43. package/flutter_app/android/app/src/main/res/values/styles.xml +18 -0
  44. package/flutter_app/android/app/src/main/res/values-night/styles.xml +18 -0
  45. package/flutter_app/android/app/src/main/res/xml/file_paths.xml +6 -0
  46. package/flutter_app/android/app/src/main/res/xml/neoagent_ai_widget_info.xml +12 -0
  47. package/flutter_app/android/app/src/main/res/xml/neoagent_voice_widget_info.xml +12 -0
  48. package/flutter_app/android/app/src/profile/AndroidManifest.xml +7 -0
  49. package/flutter_app/android/build.gradle.kts +24 -0
  50. package/flutter_app/android/ci-release.keystore +0 -0
  51. package/flutter_app/android/gradle/wrapper/gradle-wrapper.properties +5 -0
  52. package/flutter_app/android/gradle.properties +3 -0
  53. package/flutter_app/android/key.properties +4 -0
  54. package/flutter_app/android/settings.gradle.kts +26 -0
  55. package/flutter_app/assets/branding/app_icon_1024.png +0 -0
  56. package/flutter_app/assets/branding/app_icon_128.png +0 -0
  57. package/flutter_app/assets/branding/app_icon_192.png +0 -0
  58. package/flutter_app/assets/branding/app_icon_256.png +0 -0
  59. package/flutter_app/assets/branding/app_icon_32.png +0 -0
  60. package/flutter_app/assets/branding/app_icon_512.png +0 -0
  61. package/flutter_app/assets/branding/app_icon_64.png +0 -0
  62. package/flutter_app/assets/branding/tray_icon_template.png +0 -0
  63. package/flutter_app/lib/features/location/location_service.dart +119 -0
  64. package/flutter_app/lib/features/notifications/notification_interceptor.dart +97 -0
  65. package/flutter_app/lib/main.dart +23057 -0
  66. package/flutter_app/lib/main_app_shell.dart +1682 -0
  67. package/flutter_app/lib/main_integrations.dart +931 -0
  68. package/flutter_app/lib/main_launcher.dart +959 -0
  69. package/flutter_app/lib/main_launcher_entry.dart +5 -0
  70. package/flutter_app/lib/main_models.dart +3473 -0
  71. package/flutter_app/lib/main_shared.dart +2861 -0
  72. package/flutter_app/lib/main_theme.dart +204 -0
  73. package/flutter_app/lib/main_voice_assistant.dart +831 -0
  74. package/flutter_app/lib/src/android_apk_drop_zone.dart +32 -0
  75. package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +16 -0
  76. package/flutter_app/lib/src/android_apk_drop_zone_web.dart +348 -0
  77. package/flutter_app/lib/src/android_app_installer.dart +22 -0
  78. package/flutter_app/lib/src/android_app_installer_io.dart +122 -0
  79. package/flutter_app/lib/src/android_app_installer_stub.dart +21 -0
  80. package/flutter_app/lib/src/android_launcher_bridge.dart +239 -0
  81. package/flutter_app/lib/src/app_launch_bridge.dart +29 -0
  82. package/flutter_app/lib/src/app_release_updater.dart +511 -0
  83. package/flutter_app/lib/src/backend_client.dart +1833 -0
  84. package/flutter_app/lib/src/desktop_companion.dart +2 -0
  85. package/flutter_app/lib/src/desktop_companion_actions.dart +586 -0
  86. package/flutter_app/lib/src/desktop_companion_io.dart +538 -0
  87. package/flutter_app/lib/src/desktop_companion_stub.dart +59 -0
  88. package/flutter_app/lib/src/desktop_native_bridge.dart +91 -0
  89. package/flutter_app/lib/src/desktop_screen_capture.dart +21 -0
  90. package/flutter_app/lib/src/desktop_screen_capture_io.dart +142 -0
  91. package/flutter_app/lib/src/desktop_screen_capture_stub.dart +12 -0
  92. package/flutter_app/lib/src/diagnostics_logger.dart +119 -0
  93. package/flutter_app/lib/src/health_bridge.dart +136 -0
  94. package/flutter_app/lib/src/live_voice_capture.dart +85 -0
  95. package/flutter_app/lib/src/messaging_access_summary.dart +46 -0
  96. package/flutter_app/lib/src/network/app_http_client.dart +53 -0
  97. package/flutter_app/lib/src/network/app_http_client_factory.dart +6 -0
  98. package/flutter_app/lib/src/network/app_http_client_io.dart +138 -0
  99. package/flutter_app/lib/src/network/app_http_client_stub.dart +3 -0
  100. package/flutter_app/lib/src/network/app_http_client_web.dart +94 -0
  101. package/flutter_app/lib/src/oauth_launcher.dart +33 -0
  102. package/flutter_app/lib/src/oauth_launcher_io.dart +77 -0
  103. package/flutter_app/lib/src/oauth_launcher_stub.dart +33 -0
  104. package/flutter_app/lib/src/oauth_launcher_web.dart +107 -0
  105. package/flutter_app/lib/src/recording_bridge.dart +232 -0
  106. package/flutter_app/lib/src/recording_bridge_io.dart +1019 -0
  107. package/flutter_app/lib/src/recording_bridge_stub.dart +120 -0
  108. package/flutter_app/lib/src/recording_bridge_web.dart +689 -0
  109. package/flutter_app/lib/src/recording_payloads.dart +86 -0
  110. package/flutter_app/lib/src/theme/palette.dart +81 -0
  111. package/flutter_app/lib/src/widget_bridge.dart +49 -0
  112. package/flutter_app/linux/CMakeLists.txt +128 -0
  113. package/flutter_app/linux/flutter/CMakeLists.txt +88 -0
  114. package/flutter_app/linux/flutter/generated_plugin_registrant.cc +43 -0
  115. package/flutter_app/linux/flutter/generated_plugin_registrant.h +15 -0
  116. package/flutter_app/linux/flutter/generated_plugins.cmake +31 -0
  117. package/flutter_app/linux/runner/CMakeLists.txt +26 -0
  118. package/flutter_app/linux/runner/main.cc +6 -0
  119. package/flutter_app/linux/runner/my_application.cc +144 -0
  120. package/flutter_app/linux/runner/my_application.h +18 -0
  121. package/flutter_app/linux/runner/resources/app_icon.png +0 -0
  122. package/flutter_app/macos/Flutter/Flutter-Debug.xcconfig +2 -0
  123. package/flutter_app/macos/Flutter/Flutter-Release.xcconfig +2 -0
  124. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +40 -0
  125. package/flutter_app/macos/Podfile +42 -0
  126. package/flutter_app/macos/Podfile.lock +87 -0
  127. package/flutter_app/macos/Runner/AppDelegate.swift +576 -0
  128. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
  129. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
  130. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
  131. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
  132. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
  133. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
  134. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
  135. package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
  136. package/flutter_app/macos/Runner/Base.lproj/MainMenu.xib +342 -0
  137. package/flutter_app/macos/Runner/Configs/AppInfo.xcconfig +14 -0
  138. package/flutter_app/macos/Runner/Configs/Debug.xcconfig +2 -0
  139. package/flutter_app/macos/Runner/Configs/Release.xcconfig +2 -0
  140. package/flutter_app/macos/Runner/Configs/Warnings.xcconfig +13 -0
  141. package/flutter_app/macos/Runner/DebugProfile.entitlements +16 -0
  142. package/flutter_app/macos/Runner/Info.plist +36 -0
  143. package/flutter_app/macos/Runner/MainFlutterWindow.swift +19 -0
  144. package/flutter_app/macos/Runner/Release.entitlements +12 -0
  145. package/flutter_app/macos/Runner.xcodeproj/project.pbxproj +801 -0
  146. package/flutter_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  147. package/flutter_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
  148. package/flutter_app/macos/Runner.xcworkspace/contents.xcworkspacedata +10 -0
  149. package/flutter_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
  150. package/flutter_app/macos/RunnerTests/RunnerTests.swift +12 -0
  151. package/flutter_app/patch_strings.py +12 -0
  152. package/flutter_app/pubspec.lock +1088 -0
  153. package/flutter_app/pubspec.yaml +53 -0
  154. package/flutter_app/test/messaging_access_summary_test.dart +22 -0
  155. package/flutter_app/test/recording_payloads_test.dart +53 -0
  156. package/flutter_app/third_party/desktop_audio_capture/LICENSE +21 -0
  157. package/flutter_app/third_party/desktop_audio_capture/README.md +262 -0
  158. package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +65 -0
  159. package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +153 -0
  160. package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +110 -0
  161. package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +461 -0
  162. package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +91 -0
  163. package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +106 -0
  164. package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +219 -0
  165. package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +336 -0
  166. package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +101 -0
  167. package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +692 -0
  168. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +35 -0
  169. package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +36 -0
  170. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +32 -0
  171. package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +32 -0
  172. package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +878 -0
  173. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +27 -0
  174. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +1172 -0
  175. package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +655 -0
  176. package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +12 -0
  177. package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +30 -0
  178. package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +87 -0
  179. package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +105 -0
  180. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +80 -0
  181. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +31 -0
  182. package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +12 -0
  183. package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +23 -0
  184. package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +25 -0
  185. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +1117 -0
  186. package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +115 -0
  187. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +777 -0
  188. package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +87 -0
  189. package/flutter_app/third_party/flutter_secure_storage_linux/linux/CMakeLists.txt +30 -0
  190. package/flutter_app/third_party/flutter_secure_storage_linux/linux/flutter_secure_storage_linux_plugin.cc +215 -0
  191. package/flutter_app/third_party/flutter_secure_storage_linux/linux/include/flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h +27 -0
  192. package/flutter_app/third_party/flutter_secure_storage_linux/pubspec.yaml +20 -0
  193. package/flutter_app/tool/generate_desktop_branding.py +219 -0
  194. package/flutter_app/web/favicon.png +0 -0
  195. package/flutter_app/web/favicon.svg +12 -0
  196. package/flutter_app/web/icons/Icon-192.png +0 -0
  197. package/flutter_app/web/icons/Icon-512.png +0 -0
  198. package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
  199. package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
  200. package/flutter_app/web/index.html +39 -0
  201. package/flutter_app/web/manifest.json +35 -0
  202. package/flutter_app/windows/CMakeLists.txt +108 -0
  203. package/flutter_app/windows/flutter/CMakeLists.txt +109 -0
  204. package/flutter_app/windows/flutter/generated_plugin_registrant.cc +47 -0
  205. package/flutter_app/windows/flutter/generated_plugin_registrant.h +15 -0
  206. package/flutter_app/windows/flutter/generated_plugins.cmake +35 -0
  207. package/flutter_app/windows/runner/CMakeLists.txt +41 -0
  208. package/flutter_app/windows/runner/Runner.rc +121 -0
  209. package/flutter_app/windows/runner/flutter_window.cpp +533 -0
  210. package/flutter_app/windows/runner/flutter_window.h +37 -0
  211. package/flutter_app/windows/runner/main.cpp +53 -0
  212. package/flutter_app/windows/runner/resource.h +16 -0
  213. package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
  214. package/flutter_app/windows/runner/runner.exe.manifest +14 -0
  215. package/flutter_app/windows/runner/utils.cpp +65 -0
  216. package/flutter_app/windows/runner/utils.h +19 -0
  217. package/flutter_app/windows/runner/win32_window.cpp +299 -0
  218. package/flutter_app/windows/runner/win32_window.h +102 -0
  219. package/lib/manager.js +231 -7
  220. package/package.json +3 -1
  221. package/server/db/database.js +68 -0
  222. package/server/http/middleware.js +50 -0
  223. package/server/http/routes.js +3 -1
  224. package/server/index.js +1 -0
  225. package/server/public/.last_build_id +1 -1
  226. package/server/public/assets/NOTICES +61 -0
  227. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  228. package/server/public/flutter_bootstrap.js +1 -1
  229. package/server/public/main.dart.js +65262 -64422
  230. package/server/routes/integrations.js +86 -0
  231. package/server/routes/memory.js +11 -2
  232. package/server/routes/screenHistory.js +46 -0
  233. package/server/routes/triggers.js +81 -0
  234. package/server/services/ai/models.js +30 -0
  235. package/server/services/ai/providers/githubCopilot.js +97 -0
  236. package/server/services/ai/providers/openai.js +2 -1
  237. package/server/services/ai/providers/openaiCodex.js +31 -0
  238. package/server/services/ai/settings.js +20 -0
  239. package/server/services/ai/systemPrompt.js +1 -1
  240. package/server/services/ai/tools.js +35 -6
  241. package/server/services/browser/controller.js +47 -3
  242. package/server/services/desktop/screenRecorder.js +172 -0
  243. package/server/services/integrations/env.js +5 -0
  244. package/server/services/integrations/github/common.js +106 -0
  245. package/server/services/integrations/github/provider.js +499 -0
  246. package/server/services/integrations/github/repos.js +1124 -0
  247. package/server/services/integrations/home_assistant/provider.js +306 -26
  248. package/server/services/integrations/manager.js +63 -7
  249. package/server/services/integrations/oauth_provider.js +13 -6
  250. package/server/services/integrations/provider_config_store.js +76 -0
  251. package/server/services/integrations/registry.js +4 -0
  252. package/server/services/integrations/trello/provider.js +744 -0
  253. package/server/services/integrations/whatsapp/provider.js +6 -2
  254. package/server/services/manager.js +22 -0
  255. package/server/services/memory/manager.js +39 -2
  256. package/server/services/skills/base_catalog.js +33 -0
  257. package/server/services/tasks/adapters/index.js +1 -0
  258. package/server/services/tasks/adapters/manual.js +12 -0
  259. package/server/services/tasks/runtime.js +1 -1
  260. package/server/services/voice/openaiClient.js +4 -1
  261. package/server/services/voice/providers.js +2 -1
  262. package/server/services/widgets/service.js +49 -4
  263. package/server/utils/local_secrets.js +56 -0
  264. package/server/utils/logger.js +37 -9
@@ -0,0 +1,1172 @@
1
+ import Cocoa
2
+ import FlutterMacOS
3
+ import AVFoundation
4
+ import CoreAudio
5
+
6
+ class MicCapturePlugin: NSObject, FlutterPlugin {
7
+ private var methodChannel: FlutterMethodChannel?
8
+ private var eventChannel: FlutterEventChannel?
9
+ private var eventSink: FlutterEventSink?
10
+
11
+ private var statusEventChannel: FlutterEventChannel?
12
+ var statusEventSink: FlutterEventSink?
13
+
14
+ private var decibelEventChannel: FlutterEventChannel?
15
+ var decibelEventSink: FlutterEventSink?
16
+
17
+ private var audioEngine: AVAudioEngine?
18
+ private var inputNode: AVAudioInputNode?
19
+ var isCapturing = false
20
+ var currentDeviceName: String?
21
+
22
+ // Serial queue to ensure thread safety
23
+ private let audioQueue = DispatchQueue(label: "com.mic_audio_transcriber.audio_queue", qos: .userInitiated)
24
+
25
+ // Audio format configuration (defaults, can be overridden by config)
26
+ private var sampleRate: Double = 16000.0
27
+ private var channels: UInt32 = 1
28
+ private var bitDepth: UInt32 = 16
29
+
30
+ // Gain boost to increase microphone sensitivity (default: 2.5)
31
+ private var gainBoost: Float = 2.5
32
+
33
+ // Input volume (default: 1.0)
34
+ private var inputVolume: Float = 1.0
35
+
36
+ // Debug counters for logging
37
+ private var decibelLogCount = 0
38
+ private var audioDataLogCount = 0
39
+
40
+ static func register(with registrar: FlutterPluginRegistrar) {
41
+ let instance = MicCapturePlugin()
42
+
43
+ let methodChannel = FlutterMethodChannel(
44
+ name: "com.mic_audio_transcriber/mic_capture",
45
+ binaryMessenger: registrar.messenger
46
+ )
47
+ instance.methodChannel = methodChannel
48
+ registrar.addMethodCallDelegate(instance, channel: methodChannel)
49
+
50
+ let eventChannel = FlutterEventChannel(
51
+ name: "com.mic_audio_transcriber/mic_stream",
52
+ binaryMessenger: registrar.messenger
53
+ )
54
+ instance.eventChannel = eventChannel
55
+ eventChannel.setStreamHandler(instance)
56
+
57
+ let statusEventChannel = FlutterEventChannel(
58
+ name: "com.mic_audio_transcriber/mic_status",
59
+ binaryMessenger: registrar.messenger
60
+ )
61
+ instance.statusEventChannel = statusEventChannel
62
+ statusEventChannel.setStreamHandler(StatusStreamHandler(plugin: instance))
63
+
64
+ let decibelEventChannel = FlutterEventChannel(
65
+ name: "com.mic_audio_transcriber/mic_decibel",
66
+ binaryMessenger: registrar.messenger
67
+ )
68
+ instance.decibelEventChannel = decibelEventChannel
69
+ decibelEventChannel.setStreamHandler(MicDecibelStreamHandler(plugin: instance))
70
+ }
71
+
72
+ func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
73
+ switch call.method {
74
+
75
+ case "requestPermissions":
76
+ requestPermissions(result: result)
77
+
78
+ case "hasInputDevice":
79
+ hasInputDevice(result: result)
80
+
81
+ case "getAvailableInputDevices":
82
+ getAvailableInputDevices(result: result)
83
+
84
+ case "startCapture":
85
+ if let args = call.arguments as? [String: Any] {
86
+ startCapture(config: args, result: result)
87
+ } else {
88
+ startCapture(config: nil, result: result)
89
+ }
90
+
91
+ case "stopCapture":
92
+ stopCapture(result: result)
93
+
94
+ default:
95
+ result(FlutterMethodNotImplemented)
96
+ }
97
+ }
98
+
99
+ private func requestPermissions(result: @escaping FlutterResult) {
100
+ // Check permission status on macOS
101
+ let status = AVCaptureDevice.authorizationStatus(for: .audio)
102
+
103
+ switch status {
104
+ case .authorized:
105
+ result(true)
106
+ case .notDetermined:
107
+ // Request permission
108
+ AVCaptureDevice.requestAccess(for: .audio) { granted in
109
+ DispatchQueue.main.async {
110
+ result(granted)
111
+ }
112
+ }
113
+ case .denied, .restricted:
114
+ result(false)
115
+ @unknown default:
116
+ result(false)
117
+ }
118
+ }
119
+
120
+ private func hasInputDevice(result: @escaping FlutterResult) {
121
+ // Check if there's any input device available
122
+ var deviceID: AudioDeviceID = 0
123
+ var propertySize = UInt32(MemoryLayout<AudioDeviceID>.size)
124
+
125
+ var propertyAddress = AudioObjectPropertyAddress(
126
+ mSelector: kAudioHardwarePropertyDefaultInputDevice,
127
+ mScope: kAudioObjectPropertyScopeGlobal,
128
+ mElement: kAudioObjectPropertyElementMain
129
+ )
130
+
131
+ let status = AudioObjectGetPropertyData(
132
+ AudioObjectID(kAudioObjectSystemObject),
133
+ &propertyAddress,
134
+ 0,
135
+ nil,
136
+ &propertySize,
137
+ &deviceID
138
+ )
139
+
140
+ // If status is not OK or deviceID is invalid (kAudioDeviceUnknown = 0)
141
+ if status != noErr || deviceID == kAudioObjectUnknown {
142
+ print("❌ No input device available")
143
+ result(false)
144
+ return
145
+ }
146
+
147
+ // Double check: try to get device name to ensure it's a real device
148
+ propertyAddress.mSelector = kAudioDevicePropertyDeviceNameCFString
149
+ propertySize = UInt32(MemoryLayout<CFString>.size)
150
+ var deviceNameCFString: Unmanaged<CFString>?
151
+
152
+ let nameStatus = AudioObjectGetPropertyData(
153
+ deviceID,
154
+ &propertyAddress,
155
+ 0,
156
+ nil,
157
+ &propertySize,
158
+ &deviceNameCFString
159
+ )
160
+
161
+ if nameStatus == noErr, let cfString = deviceNameCFString?.takeRetainedValue() {
162
+ let deviceName = cfString as String
163
+ print("✅ Input device available: \(deviceName)")
164
+ result(true)
165
+ } else {
166
+ print("❌ No valid input device found")
167
+ result(false)
168
+ }
169
+ }
170
+
171
+ private func getAvailableInputDevices(result: @escaping FlutterResult) {
172
+ var devices: [[String: Any]] = []
173
+
174
+ // Get all audio devices
175
+ var propertyAddress = AudioObjectPropertyAddress(
176
+ mSelector: kAudioHardwarePropertyDevices,
177
+ mScope: kAudioObjectPropertyScopeGlobal,
178
+ mElement: kAudioObjectPropertyElementMain
179
+ )
180
+
181
+ var propertySize: UInt32 = 0
182
+ var status = AudioObjectGetPropertyDataSize(
183
+ AudioObjectID(kAudioObjectSystemObject),
184
+ &propertyAddress,
185
+ 0,
186
+ nil,
187
+ &propertySize
188
+ )
189
+
190
+ guard status == noErr else {
191
+ print("❌ Failed to get audio devices size")
192
+ result(devices)
193
+ return
194
+ }
195
+
196
+ let deviceCount = Int(propertySize) / MemoryLayout<AudioDeviceID>.size
197
+ var audioDevices = [AudioDeviceID](repeating: 0, count: deviceCount)
198
+
199
+ status = AudioObjectGetPropertyData(
200
+ AudioObjectID(kAudioObjectSystemObject),
201
+ &propertyAddress,
202
+ 0,
203
+ nil,
204
+ &propertySize,
205
+ &audioDevices
206
+ )
207
+
208
+ guard status == noErr else {
209
+ print("❌ Failed to get audio devices")
210
+ result(devices)
211
+ return
212
+ }
213
+
214
+ // Get default input device for comparison
215
+ var defaultDeviceID: AudioDeviceID = 0
216
+ var defaultPropertySize = UInt32(MemoryLayout<AudioDeviceID>.size)
217
+ var defaultPropertyAddress = AudioObjectPropertyAddress(
218
+ mSelector: kAudioHardwarePropertyDefaultInputDevice,
219
+ mScope: kAudioObjectPropertyScopeGlobal,
220
+ mElement: kAudioObjectPropertyElementMain
221
+ )
222
+
223
+ AudioObjectGetPropertyData(
224
+ AudioObjectID(kAudioObjectSystemObject),
225
+ &defaultPropertyAddress,
226
+ 0,
227
+ nil,
228
+ &defaultPropertySize,
229
+ &defaultDeviceID
230
+ )
231
+
232
+ // Filter for input devices
233
+ for deviceID in audioDevices {
234
+ // Check if device has input channels
235
+ propertyAddress.mSelector = kAudioDevicePropertyStreamConfiguration
236
+ propertyAddress.mScope = kAudioDevicePropertyScopeInput
237
+ propertySize = 0
238
+
239
+ status = AudioObjectGetPropertyDataSize(
240
+ deviceID,
241
+ &propertyAddress,
242
+ 0,
243
+ nil,
244
+ &propertySize
245
+ )
246
+
247
+ guard status == noErr else { continue }
248
+
249
+ let bufferListPointer = UnsafeMutablePointer<AudioBufferList>.allocate(capacity: 1)
250
+ defer { bufferListPointer.deallocate() }
251
+
252
+ status = AudioObjectGetPropertyData(
253
+ deviceID,
254
+ &propertyAddress,
255
+ 0,
256
+ nil,
257
+ &propertySize,
258
+ bufferListPointer
259
+ )
260
+
261
+ guard status == noErr else { continue }
262
+
263
+ let bufferList = UnsafeMutableAudioBufferListPointer(bufferListPointer)
264
+ var channelCount = 0
265
+ for buffer in bufferList {
266
+ channelCount += Int(buffer.mNumberChannels)
267
+ }
268
+
269
+ // Skip if no input channels
270
+ if channelCount == 0 { continue }
271
+
272
+ // Get device name
273
+ propertyAddress.mSelector = kAudioDevicePropertyDeviceNameCFString
274
+ propertyAddress.mScope = kAudioObjectPropertyScopeGlobal
275
+ propertySize = UInt32(MemoryLayout<CFString>.size)
276
+ var deviceNameCFString: Unmanaged<CFString>?
277
+
278
+ status = AudioObjectGetPropertyData(
279
+ deviceID,
280
+ &propertyAddress,
281
+ 0,
282
+ nil,
283
+ &propertySize,
284
+ &deviceNameCFString
285
+ )
286
+
287
+ guard status == noErr, let cfString = deviceNameCFString?.takeRetainedValue() else {
288
+ continue
289
+ }
290
+
291
+ let deviceName = cfString as String
292
+
293
+ // Get transport type
294
+ propertyAddress.mSelector = kAudioDevicePropertyTransportType
295
+ propertySize = UInt32(MemoryLayout<UInt32>.size)
296
+ var transportType: UInt32 = 0
297
+
298
+ AudioObjectGetPropertyData(
299
+ deviceID,
300
+ &propertyAddress,
301
+ 0,
302
+ nil,
303
+ &propertySize,
304
+ &transportType
305
+ )
306
+
307
+ let isBluetooth = (transportType == 0x626c7565) // 'blue'
308
+ let isBuiltIn = (transportType == 0x62756c74) // 'bult'
309
+
310
+ var deviceType = "external"
311
+ if isBuiltIn {
312
+ deviceType = "built-in"
313
+ } else if isBluetooth {
314
+ deviceType = "bluetooth"
315
+ }
316
+
317
+ let deviceInfo: [String: Any] = [
318
+ "id": String(deviceID),
319
+ "name": deviceName,
320
+ "type": deviceType,
321
+ "channelCount": channelCount,
322
+ "isDefault": deviceID == defaultDeviceID
323
+ ]
324
+
325
+ devices.append(deviceInfo)
326
+ print("🎤 Found input device: \(deviceName) (\(deviceType), \(channelCount) channels)")
327
+ }
328
+
329
+ print("✅ Total input devices found: \(devices.count)")
330
+ result(devices)
331
+ }
332
+
333
+ private func startCapture(config: [String: Any]?, result: @escaping FlutterResult) {
334
+ // Ensure operations run on audio queue to avoid race conditions
335
+ audioQueue.async { [weak self] in
336
+ guard let self = self else {
337
+ DispatchQueue.main.async { result(false) }
338
+ return
339
+ }
340
+
341
+ // Always cleanup any existing engine first to ensure clean start
342
+ // This is important even if isCapturing is false (state might be out of sync)
343
+ if let existingEngine = self.audioEngine {
344
+ print("⚠️ Found existing engine, cleaning up first...")
345
+ if existingEngine.isRunning {
346
+ existingEngine.stop()
347
+ }
348
+ if let existingInput = self.inputNode {
349
+ existingInput.removeTap(onBus: 0)
350
+ }
351
+ self.audioEngine = nil
352
+ self.inputNode = nil
353
+ self.isCapturing = false
354
+ // Wait for cleanup to complete (longer for Bluetooth devices)
355
+ Thread.sleep(forTimeInterval: 0.5)
356
+ } else if self.isCapturing {
357
+ // If no engine but isCapturing is true, just reset state
358
+ print("⚠️ State mismatch: isCapturing=true but no engine, resetting...")
359
+ self.isCapturing = false
360
+ self.inputNode = nil
361
+ Thread.sleep(forTimeInterval: 0.5)
362
+ }
363
+
364
+ // Parse configuration from Flutter
365
+ if let config = config {
366
+ if let sampleRateValue = config["sampleRate"] as? NSNumber {
367
+ self.sampleRate = sampleRateValue.doubleValue
368
+ }
369
+ if let channelsValue = config["channels"] as? NSNumber {
370
+ self.channels = channelsValue.uint32Value
371
+ }
372
+ if let bitDepthValue = config["bitDepth"] as? NSNumber {
373
+ self.bitDepth = bitDepthValue.uint32Value
374
+ }
375
+ if let gainBoostValue = config["gainBoost"] as? NSNumber {
376
+ self.gainBoost = gainBoostValue.floatValue
377
+ // Clamp gain boost to reasonable range (0.1 to 10.0)
378
+ self.gainBoost = max(0.1, min(10.0, self.gainBoost))
379
+ }
380
+ if let inputVolumeValue = config["inputVolume"] as? NSNumber {
381
+ self.inputVolume = inputVolumeValue.floatValue
382
+ // Clamp input volume to valid range (0.0 to 1.0)
383
+ self.inputVolume = max(0.0, min(1.0, self.inputVolume))
384
+ }
385
+ }
386
+
387
+ // Check permission before starting
388
+ let permissionStatus = AVCaptureDevice.authorizationStatus(for: .audio)
389
+ if permissionStatus != .authorized {
390
+ print("❌ Microphone permission not granted. Status: \(permissionStatus.rawValue)")
391
+ DispatchQueue.main.async {
392
+ result(FlutterError(
393
+ code: "PERMISSION_DENIED",
394
+ message: "Microphone permission not granted. Status: \(permissionStatus.rawValue)",
395
+ details: nil
396
+ ))
397
+ }
398
+ return
399
+ }
400
+
401
+ // Create new audio engine
402
+ // All cleanup should be done above
403
+ let engine = AVAudioEngine()
404
+ let input = engine.inputNode
405
+
406
+ // Check if input is available
407
+ let inputFormat = input.outputFormat(forBus: 0)
408
+ if inputFormat.sampleRate == 0 {
409
+ print("❌ No input device available or input format invalid")
410
+ DispatchQueue.main.async {
411
+ result(FlutterError(
412
+ code: "NO_INPUT_DEVICE",
413
+ message: "No microphone input device available",
414
+ details: nil
415
+ ))
416
+ }
417
+ return
418
+ }
419
+
420
+ // Detect if device is Bluetooth and adjust wait times accordingly
421
+ let isBluetooth = self.isBluetoothDevice()
422
+ let initialWait: Double = isBluetooth ? 1.5 : 0.3
423
+ let postPrepareWait: Double = isBluetooth ? 0.8 : 0.3
424
+
425
+ if isBluetooth {
426
+ print("🔵 Bluetooth device detected - using extended wait times")
427
+ }
428
+
429
+ // Wait for device to be fully ready
430
+ print("⏳ Waiting \(initialWait)s for device to be ready...")
431
+ Thread.sleep(forTimeInterval: initialWait)
432
+
433
+ // Set input volume from config
434
+ // Note: input.volume affects the input level, but system input volume also matters
435
+ input.volume = self.inputVolume
436
+ print("🎤 Input Format:")
437
+ print(" Sample Rate: \(inputFormat.sampleRate) Hz")
438
+ print(" Channels: \(inputFormat.channelCount)")
439
+ print(" Format: \(inputFormat.commonFormat.rawValue)")
440
+ print(" Is Interleaved: \(inputFormat.isInterleaved)")
441
+ print(" Output Sample Rate: \(self.sampleRate) Hz")
442
+ print(" Output Channels: \(self.channels)")
443
+ print(" Gain Boost: \(self.gainBoost)x")
444
+ print(" Input Volume: \(self.inputVolume)")
445
+ print(" Input Node Volume: \(input.volume)")
446
+
447
+ // Check system input volume using CoreAudio
448
+ // Get default input device
449
+ var defaultDeviceID: AudioDeviceID = 0
450
+ var propertySize = UInt32(MemoryLayout<AudioDeviceID>.size)
451
+ var propertyAddress = AudioObjectPropertyAddress(
452
+ mSelector: kAudioHardwarePropertyDefaultInputDevice,
453
+ mScope: kAudioObjectPropertyScopeGlobal,
454
+ mElement: kAudioObjectPropertyElementMain
455
+ )
456
+
457
+ let status = AudioObjectGetPropertyData(
458
+ AudioObjectID(kAudioObjectSystemObject),
459
+ &propertyAddress,
460
+ 0,
461
+ nil,
462
+ &propertySize,
463
+ &defaultDeviceID
464
+ )
465
+
466
+ if status == noErr && defaultDeviceID != 0 {
467
+ // Get input volume
468
+ var inputVolume: Float32 = 0.0
469
+ propertySize = UInt32(MemoryLayout<Float32>.size)
470
+ propertyAddress = AudioObjectPropertyAddress(
471
+ mSelector: kAudioHardwareServiceDeviceProperty_VirtualMainVolume,
472
+ mScope: kAudioDevicePropertyScopeInput,
473
+ mElement: kAudioObjectPropertyElementMain
474
+ )
475
+
476
+ let getStatus = AudioObjectGetPropertyData(
477
+ defaultDeviceID,
478
+ &propertyAddress,
479
+ 0,
480
+ nil,
481
+ &propertySize,
482
+ &inputVolume
483
+ )
484
+
485
+ if getStatus == noErr {
486
+ print(" System Input Volume: \(inputVolume)")
487
+ if inputVolume == 0.0 {
488
+ print("⚠️ WARNING: System input volume is 0! Audio may be silent.")
489
+ print("⚠️ Please check System Settings > Sound > Input and increase input volume.")
490
+ }
491
+ } else {
492
+ print(" System Input Volume: Unable to read (may not be supported on this device)")
493
+ }
494
+ } else {
495
+ print(" System Input Volume: Unable to get default input device")
496
+ }
497
+
498
+ // Create output format
499
+ guard let outputFormat = AVAudioFormat(
500
+ commonFormat: .pcmFormatInt16,
501
+ sampleRate: self.sampleRate,
502
+ channels: self.channels,
503
+ interleaved: false
504
+ ) else {
505
+ print("❌ Failed to create output format")
506
+ DispatchQueue.main.async {
507
+ result(FlutterError(
508
+ code: "FORMAT_ERROR",
509
+ message: "Failed to create output format",
510
+ details: nil
511
+ ))
512
+ }
513
+ return
514
+ }
515
+
516
+ // Connect input to main mixer FIRST with nil format
517
+ // This lets AVAudioEngine use the node's native format automatically
518
+ let mainMixer = engine.mainMixerNode
519
+ let output = engine.outputNode
520
+ engine.connect(input, to: mainMixer, format: nil)
521
+
522
+ // Connect main mixer to output node to complete the audio graph
523
+ // This is required for the engine to start properly
524
+ engine.connect(mainMixer, to: output, format: nil)
525
+
526
+ // Mute the output to prevent audio playback
527
+ mainMixer.outputVolume = 0.0
528
+
529
+ // Install tap on input node AFTER connecting
530
+ // Use nil format to let AVAudioEngine automatically use the correct format
531
+ // This avoids format mismatch errors
532
+ let bufferSize: AVAudioFrameCount = 4096
533
+ input.installTap(onBus: 0, bufferSize: bufferSize, format: nil) { [weak self] (buffer, time) in
534
+ self?.processAudioBuffer(buffer, outputFormat: outputFormat)
535
+ }
536
+
537
+ // Prepare the engine before starting
538
+ engine.prepare()
539
+
540
+ // Wait for engine initialization (longer for Bluetooth)
541
+ print("⏳ Waiting \(postPrepareWait)s after engine.prepare()...")
542
+ Thread.sleep(forTimeInterval: postPrepareWait)
543
+
544
+ // Start audio engine with retry mechanism
545
+ // Bluetooth devices often need multiple attempts with longer waits
546
+ var startSuccess = false
547
+ var lastError: Error?
548
+ let maxRetries = isBluetooth ? 5 : 3 // More retries for Bluetooth
549
+ let retryDelays: [Double] = isBluetooth
550
+ ? [0.5, 1.0, 1.5, 2.0, 2.5] // Progressive delays for Bluetooth
551
+ : [0.3, 0.6, 1.0, 0, 0] // Shorter delays for wired devices
552
+
553
+ for attempt in 1...maxRetries {
554
+ do {
555
+ try engine.start()
556
+ startSuccess = true
557
+ print("✅ Engine started successfully on attempt \(attempt)")
558
+ break
559
+ } catch let startError {
560
+ lastError = startError
561
+ let errorMsg = (startError as NSError).localizedDescription
562
+ let errorCode = (startError as NSError).code
563
+
564
+ print("⚠️ Attempt \(attempt)/\(maxRetries) failed: \(errorMsg)")
565
+ print(" Error domain: \((startError as NSError).domain)")
566
+ print(" Error code: \(errorCode)")
567
+
568
+ // Check if it's the specific Bluetooth error (-10877)
569
+ if errorCode == -10877 {
570
+ print(" This is kAudioUnitErr_CannotDoInCurrentContext - device not ready")
571
+ }
572
+
573
+ if attempt < maxRetries {
574
+ // Use progressive wait times
575
+ let waitTime = retryDelays[attempt - 1]
576
+ let totalWaited = retryDelays.prefix(attempt).reduce(0, +) + initialWait + postPrepareWait
577
+ print(" ⏳ Waiting \(waitTime)s before retry (total time: \(String(format: "%.1f", totalWaited + waitTime))s)...")
578
+ Thread.sleep(forTimeInterval: waitTime)
579
+ }
580
+ }
581
+ }
582
+
583
+ // If all retries failed, clean up and return error
584
+ if !startSuccess {
585
+ let errorMsg = (lastError as NSError?)?.localizedDescription ?? "Unknown error"
586
+ let errorCode = (lastError as NSError?)?.code ?? 0
587
+ let totalTime = initialWait + postPrepareWait + retryDelays.prefix(maxRetries - 1).reduce(0, +)
588
+ print("❌ Failed to start engine after \(maxRetries) attempts")
589
+ print(" Total time waited: ~\(String(format: "%.1f", totalTime))s")
590
+
591
+ // Clean up
592
+ engine.stop()
593
+ input.removeTap(onBus: 0)
594
+
595
+ var detailedMessage = "Failed to start audio engine after \(maxRetries) attempts (\(String(format: "%.1f", totalTime))s): \(errorMsg)."
596
+ if errorCode == -10877 {
597
+ if isBluetooth {
598
+ detailedMessage += " Bluetooth device needs more time to connect. Please ensure the device is fully connected in System Settings, then try again."
599
+ } else {
600
+ detailedMessage += " Device is not ready yet. Please wait a moment and try again."
601
+ }
602
+ } else {
603
+ detailedMessage += " Device may need more time to connect."
604
+ }
605
+
606
+ DispatchQueue.main.async {
607
+ result(FlutterError(
608
+ code: "ENGINE_START_FAILED",
609
+ message: detailedMessage,
610
+ details: ["errorCode": errorCode, "totalRetries": maxRetries, "isBluetooth": isBluetooth]
611
+ ))
612
+ }
613
+ return
614
+ }
615
+
616
+ // Wait a bit to ensure engine has fully started
617
+ Thread.sleep(forTimeInterval: 0.2)
618
+
619
+ // Check if engine is running
620
+ guard engine.isRunning else {
621
+ print("❌ Audio engine failed to start - isRunning: \(engine.isRunning)")
622
+ print(" Engine state after start attempt:")
623
+ print(" - isRunning: \(engine.isRunning)")
624
+
625
+ // Try to get more error info
626
+ if let error = engine.outputNode.lastRenderTime {
627
+ print(" - Last render time: \(error)")
628
+ }
629
+
630
+ // Clean up
631
+ engine.stop()
632
+ input.removeTap(onBus: 0)
633
+
634
+ DispatchQueue.main.async {
635
+ result(FlutterError(
636
+ code: "ENGINE_START_FAILED",
637
+ message: "Audio engine failed to start - engine is not running after start() call",
638
+ details: nil
639
+ ))
640
+ }
641
+ return
642
+ }
643
+
644
+ // Get device name
645
+ let deviceName = self.getCurrentDeviceName()
646
+ self.currentDeviceName = deviceName
647
+
648
+ // Update state
649
+ self.audioEngine = engine
650
+ self.inputNode = input
651
+ self.isCapturing = true
652
+
653
+ // Send status update
654
+ self.sendStatusUpdate(isActive: true, deviceName: deviceName)
655
+
656
+ print("✅ Microphone capture started successfully!")
657
+ if let deviceName = deviceName {
658
+ print(" Device: \(deviceName)")
659
+ }
660
+ DispatchQueue.main.async { result(true) }
661
+ }
662
+ }
663
+
664
+ private func stopCapture(result: @escaping FlutterResult) {
665
+ audioQueue.async { [weak self] in
666
+ guard let self = self else {
667
+ DispatchQueue.main.async { result(false) }
668
+ return
669
+ }
670
+
671
+ self.forceStop()
672
+ DispatchQueue.main.async { result(true) }
673
+ }
674
+ }
675
+
676
+ // Force stop - complete cleanup, can be called from any thread
677
+ private func forceStop() {
678
+ guard isCapturing else {
679
+ // Even if not capturing, clean up any remaining engine
680
+ if let engine = audioEngine {
681
+ if engine.isRunning {
682
+ engine.stop()
683
+ }
684
+ audioEngine = nil
685
+ }
686
+ if let input = inputNode {
687
+ input.removeTap(onBus: 0)
688
+ inputNode = nil
689
+ }
690
+ return
691
+ }
692
+
693
+ if let engine = audioEngine, let input = inputNode {
694
+ // Remove tap first (must be done before stopping)
695
+ input.removeTap(onBus: 0)
696
+
697
+ // Stop engine
698
+ if engine.isRunning {
699
+ engine.stop()
700
+ }
701
+
702
+ // Wait a bit to ensure engine has fully stopped
703
+ Thread.sleep(forTimeInterval: 0.1)
704
+ }
705
+
706
+ // Clean up state
707
+ audioEngine = nil
708
+ inputNode = nil
709
+ isCapturing = false
710
+ currentDeviceName = nil
711
+
712
+ // Send status update
713
+ sendStatusUpdate(isActive: false, deviceName: nil)
714
+
715
+ print("✅ Microphone capture stopped")
716
+ }
717
+
718
+ // Get current microphone device name using CoreAudio
719
+ private func getCurrentDeviceName() -> String? {
720
+ var deviceName: String? = nil
721
+
722
+ // Get default input device ID
723
+ var deviceID: AudioDeviceID = 0
724
+ var propertySize = UInt32(MemoryLayout<AudioDeviceID>.size)
725
+
726
+ var propertyAddress = AudioObjectPropertyAddress(
727
+ mSelector: kAudioHardwarePropertyDefaultInputDevice,
728
+ mScope: kAudioObjectPropertyScopeGlobal,
729
+ mElement: kAudioObjectPropertyElementMain
730
+ )
731
+
732
+ let status = AudioObjectGetPropertyData(
733
+ AudioObjectID(kAudioObjectSystemObject),
734
+ &propertyAddress,
735
+ 0,
736
+ nil,
737
+ &propertySize,
738
+ &deviceID
739
+ )
740
+
741
+ guard status == noErr else {
742
+ return "Default Microphone"
743
+ }
744
+
745
+ // Get device name
746
+ propertyAddress.mSelector = kAudioDevicePropertyDeviceNameCFString
747
+ propertySize = UInt32(MemoryLayout<CFString>.size)
748
+ var deviceNameCFString: Unmanaged<CFString>?
749
+
750
+ let nameStatus = AudioObjectGetPropertyData(
751
+ deviceID,
752
+ &propertyAddress,
753
+ 0,
754
+ nil,
755
+ &propertySize,
756
+ &deviceNameCFString
757
+ )
758
+
759
+ if nameStatus == noErr, let cfString = deviceNameCFString?.takeRetainedValue() {
760
+ deviceName = cfString as String
761
+ }
762
+
763
+ return deviceName ?? "Default Microphone"
764
+ }
765
+
766
+ // Check if device is Bluetooth by name or transport type
767
+ private func isBluetoothDevice() -> Bool {
768
+ // Get default input device ID
769
+ var deviceID: AudioDeviceID = 0
770
+ var propertySize = UInt32(MemoryLayout<AudioDeviceID>.size)
771
+
772
+ var propertyAddress = AudioObjectPropertyAddress(
773
+ mSelector: kAudioHardwarePropertyDefaultInputDevice,
774
+ mScope: kAudioObjectPropertyScopeGlobal,
775
+ mElement: kAudioObjectPropertyElementMain
776
+ )
777
+
778
+ let status = AudioObjectGetPropertyData(
779
+ AudioObjectID(kAudioObjectSystemObject),
780
+ &propertyAddress,
781
+ 0,
782
+ nil,
783
+ &propertySize,
784
+ &deviceID
785
+ )
786
+
787
+ guard status == noErr else {
788
+ return false
789
+ }
790
+
791
+ // Check transport type
792
+ propertyAddress.mSelector = kAudioDevicePropertyTransportType
793
+ propertySize = UInt32(MemoryLayout<UInt32>.size)
794
+ var transportType: UInt32 = 0
795
+
796
+ let transportStatus = AudioObjectGetPropertyData(
797
+ deviceID,
798
+ &propertyAddress,
799
+ 0,
800
+ nil,
801
+ &propertySize,
802
+ &transportType
803
+ )
804
+
805
+ // kAudioDeviceTransportTypeBluetooth = 'blue' = 0x626c7565
806
+ if transportStatus == noErr && transportType == 0x626c7565 {
807
+ print("🔵 Detected Bluetooth device via transport type")
808
+ return true
809
+ }
810
+
811
+ // Fallback: check device name for Bluetooth keywords
812
+ if let deviceName = getCurrentDeviceName()?.lowercased() {
813
+ let bluetoothKeywords = ["bluetooth", "airpods", "beats", "jabra", "sony", "bose", "jbl"]
814
+ for keyword in bluetoothKeywords {
815
+ if deviceName.contains(keyword) {
816
+ print("🔵 Detected Bluetooth device via name: \(deviceName)")
817
+ return true
818
+ }
819
+ }
820
+ }
821
+
822
+ return false
823
+ }
824
+
825
+ // Send status update to Flutter
826
+ private func sendStatusUpdate(isActive: Bool, deviceName: String?) {
827
+ DispatchQueue.main.async { [weak self] in
828
+ guard let self = self, let sink = self.statusEventSink else { return }
829
+
830
+ var status: [String: Any] = [
831
+ "isActive": isActive
832
+ ]
833
+
834
+ if let deviceName = deviceName {
835
+ status["deviceName"] = deviceName
836
+ }
837
+
838
+ sink(status)
839
+ }
840
+ }
841
+
842
+ private func processAudioBuffer(_ buffer: AVAudioPCMBuffer, outputFormat: AVAudioFormat) {
843
+ // Calculate decibel directly from original buffer (Float32) for accuracy
844
+ // This avoids potential data loss during conversion
845
+ let decibel = calculateDecibelFromFloatBuffer(buffer)
846
+
847
+ // Debug: Check original buffer
848
+ audioDataLogCount += 1
849
+ if audioDataLogCount % 100 == 0 {
850
+ print("🔊 Original buffer: frameLength=\(buffer.frameLength), format=\(buffer.format), channels=\(buffer.format.channelCount)")
851
+ if let floatChannelData = buffer.floatChannelData {
852
+ let channel = floatChannelData.pointee
853
+ let firstFew = (0..<min(5, Int(buffer.frameLength))).map { channel[$0] }
854
+ print("🔊 Original buffer first few float samples: \(firstFew)")
855
+ }
856
+ if let int16ChannelData = buffer.int16ChannelData {
857
+ let channel = int16ChannelData.pointee
858
+ let firstFew = (0..<min(5, Int(buffer.frameLength))).map { channel[$0] }
859
+ print("🔊 Original buffer first few int16 samples: \(firstFew)")
860
+ }
861
+ print("🔊 Decibel from original buffer: \(String(format: "%.1f", decibel)) dB")
862
+ }
863
+
864
+ // Convert buffer to target format if needed
865
+ guard let convertedBuffer = convertBuffer(buffer, to: outputFormat) else {
866
+ print("⚠️ Decibel: Failed to convert buffer")
867
+ return
868
+ }
869
+
870
+ // Debug: Check converted buffer
871
+ if audioDataLogCount % 100 == 0 {
872
+ print("🔊 Converted buffer: frameLength=\(convertedBuffer.frameLength), format=\(convertedBuffer.format)")
873
+ if let int16ChannelData = convertedBuffer.int16ChannelData {
874
+ let channel = int16ChannelData.pointee
875
+ let firstFew = (0..<min(5, Int(convertedBuffer.frameLength))).map { channel[$0] }
876
+ print("🔊 Converted buffer first few int16 samples: \(firstFew)")
877
+ }
878
+ }
879
+
880
+ // Extract PCM data
881
+ guard let audioData = extractPCMData(from: convertedBuffer) else {
882
+ print("⚠️ Decibel: Failed to extract PCM data")
883
+ return
884
+ }
885
+
886
+ // Debug: log audio data size
887
+ if audioDataLogCount % 100 == 0 {
888
+ print("🔊 Final audio data size: \(audioData.count) bytes")
889
+ }
890
+
891
+ // Debug log occasionally
892
+ decibelLogCount += 1
893
+ if decibelLogCount % 100 == 0 {
894
+ print("🔊 Decibel calculated: \(String(format: "%.1f", decibel)) dB, audioData size: \(audioData.count) bytes")
895
+ }
896
+
897
+ // Send to Flutter via event channel on main thread
898
+ // Check eventSink in closure to ensure thread safety
899
+ DispatchQueue.main.async { [weak self] in
900
+ guard let self = self else { return }
901
+
902
+ if let sink = self.eventSink {
903
+ sink(FlutterStandardTypedData(bytes: audioData))
904
+ } else {
905
+ // Log warning if eventSink is not set (stream not subscribed)
906
+ print("⚠️ Audio data received but eventSink is nil - stream may not be subscribed")
907
+ }
908
+
909
+ // Send decibel data
910
+ if let decibelSink = self.decibelEventSink {
911
+ decibelSink([
912
+ "decibel": decibel,
913
+ "timestamp": Date().timeIntervalSince1970
914
+ ])
915
+ }
916
+ }
917
+ }
918
+
919
+ private func convertBuffer(_ buffer: AVAudioPCMBuffer, to format: AVAudioFormat) -> AVAudioPCMBuffer? {
920
+ // If formats match, return as is
921
+ if buffer.format.isEqual(format) {
922
+ return buffer
923
+ }
924
+
925
+ // Create converter
926
+ guard let converter = AVAudioConverter(from: buffer.format, to: format) else {
927
+ return nil
928
+ }
929
+
930
+ // Calculate output buffer size
931
+ let ratio = format.sampleRate / buffer.format.sampleRate
932
+ let outputFrameCapacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio)
933
+
934
+ guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: outputFrameCapacity) else {
935
+ return nil
936
+ }
937
+
938
+ // Convert
939
+ var error: NSError?
940
+ let inputBlock: AVAudioConverterInputBlock = { _, outStatus in
941
+ outStatus.pointee = .haveData
942
+ return buffer
943
+ }
944
+
945
+ converter.convert(to: outputBuffer, error: &error, withInputFrom: inputBlock)
946
+
947
+ if let error = error {
948
+ print("⚠️ Conversion error: \(error)")
949
+ return nil
950
+ }
951
+
952
+ return outputBuffer
953
+ }
954
+
955
+ private func extractPCMData(from buffer: AVAudioPCMBuffer) -> Data? {
956
+ guard let int16ChannelData = buffer.int16ChannelData else {
957
+ print("⚠️ Decibel: int16ChannelData is nil")
958
+ return nil
959
+ }
960
+
961
+ let frameLength = Int(buffer.frameLength)
962
+ let channelCount = Int(buffer.format.channelCount)
963
+
964
+ // Debug: Check if buffer has data
965
+ if audioDataLogCount % 100 == 0 {
966
+ let channel = int16ChannelData.pointee
967
+ let maxSample = (0..<frameLength).map { abs(channel[$0]) }.max() ?? 0
968
+ print("🔊 Extract PCM: frameLength=\(frameLength), channels=\(channelCount), maxSample=\(maxSample)")
969
+ }
970
+
971
+ // Apply gain boost and convert to mono if needed
972
+ var monoData = Data(capacity: frameLength * MemoryLayout<Int16>.size)
973
+ let maxValue: Float = 32767.0
974
+ let minValue: Float = -32768.0
975
+
976
+ if channelCount == 1 {
977
+ // Mono: apply gain boost
978
+ let channel = int16ChannelData.pointee
979
+ for i in 0..<frameLength {
980
+ let sample = Float(channel[i]) * gainBoost
981
+ // Clamp to prevent clipping
982
+ let clamped = max(minValue, min(maxValue, sample))
983
+ let boosted = Int16(clamped)
984
+ withUnsafeBytes(of: boosted) { monoData.append(contentsOf: $0) }
985
+ }
986
+ } else {
987
+ // Stereo: convert to mono and apply gain boost
988
+ let leftChannel = int16ChannelData.pointee
989
+ let rightChannel = int16ChannelData.advanced(by: 1).pointee
990
+
991
+ for i in 0..<frameLength {
992
+ let left = Float(leftChannel[i])
993
+ let right = Float(rightChannel[i])
994
+ // Average channels then apply gain boost
995
+ let mono = (left + right) / 2.0 * gainBoost
996
+ // Clamp to prevent clipping
997
+ let clamped = max(minValue, min(maxValue, mono))
998
+ let boosted = Int16(clamped)
999
+ withUnsafeBytes(of: boosted) { monoData.append(contentsOf: $0) }
1000
+ }
1001
+ }
1002
+
1003
+ // Debug: Check extracted data
1004
+ if audioDataLogCount % 100 == 0 {
1005
+ let firstFewBytes = monoData.prefix(10).map { Int8(bitPattern: $0) }
1006
+ print("🔊 Extracted data first few bytes: \(firstFewBytes)")
1007
+ }
1008
+
1009
+ return monoData
1010
+ }
1011
+
1012
+ /// Calculate decibel (dB) directly from Float32 buffer
1013
+ /// Returns RMS-based decibel value, typically ranges from -∞ to 0 dB
1014
+ private func calculateDecibelFromFloatBuffer(_ buffer: AVAudioPCMBuffer) -> Double {
1015
+ guard let floatChannelData = buffer.floatChannelData else {
1016
+ return -120.0
1017
+ }
1018
+
1019
+ let frameLength = Int(buffer.frameLength)
1020
+ let channelCount = Int(buffer.format.channelCount)
1021
+ guard frameLength > 0 else { return -120.0 }
1022
+
1023
+ // Calculate RMS from all channels
1024
+ var sumOfSquares: Double = 0.0
1025
+ var sampleCount = 0
1026
+
1027
+ if channelCount == 1 {
1028
+ let channel = floatChannelData.pointee
1029
+ for i in 0..<frameLength {
1030
+ let value = Double(channel[i])
1031
+ sumOfSquares += value * value
1032
+ sampleCount += 1
1033
+ }
1034
+ } else {
1035
+ // Multi-channel: use average of all channels
1036
+ for channelIndex in 0..<channelCount {
1037
+ let channel = floatChannelData.advanced(by: channelIndex).pointee
1038
+ for i in 0..<frameLength {
1039
+ let value = Double(channel[i])
1040
+ sumOfSquares += value * value
1041
+ sampleCount += 1
1042
+ }
1043
+ }
1044
+ // Average across channels
1045
+ sumOfSquares /= Double(channelCount)
1046
+ }
1047
+
1048
+ guard sampleCount > 0 else { return -120.0 }
1049
+
1050
+ let meanSquare = sumOfSquares / Double(sampleCount)
1051
+ let rms = sqrt(meanSquare)
1052
+
1053
+ // Calculate decibel: dB = 20 * log10(RMS / max_value)
1054
+ // For Float32, max_value is 1.0
1055
+ guard rms > 0 else { return -120.0 } // Avoid log(0)
1056
+
1057
+ let decibel = 20.0 * log10(rms)
1058
+
1059
+ // Clamp to reasonable range (-120 dB to 0 dB)
1060
+ return max(-120.0, min(0.0, decibel))
1061
+ }
1062
+
1063
+ /// Calculate decibel (dB) from Int16 PCM audio data
1064
+ /// Returns RMS-based decibel value, typically ranges from -∞ to 0 dB
1065
+ private func calculateDecibel(from audioData: Data) -> Double {
1066
+ guard audioData.count >= 2 else {
1067
+ print("⚠️ Decibel: audioData too small: \(audioData.count) bytes")
1068
+ return -120.0 // Silence threshold
1069
+ }
1070
+
1071
+ // Convert Data to Int16 array
1072
+ let sampleCount = audioData.count / MemoryLayout<Int16>.size
1073
+ var samples: [Int16] = []
1074
+ samples.reserveCapacity(sampleCount)
1075
+
1076
+ audioData.withUnsafeBytes { bytes in
1077
+ let int16Pointer = bytes.bindMemory(to: Int16.self)
1078
+ for i in 0..<sampleCount {
1079
+ samples.append(int16Pointer[i])
1080
+ }
1081
+ }
1082
+
1083
+ guard !samples.isEmpty else {
1084
+ print("⚠️ Decibel: no samples extracted")
1085
+ return -120.0
1086
+ }
1087
+
1088
+ // Calculate RMS (Root Mean Square)
1089
+ let sumOfSquares = samples.reduce(0.0) { sum, sample in
1090
+ let value = Double(sample)
1091
+ return sum + (value * value)
1092
+ }
1093
+ let meanSquare = sumOfSquares / Double(samples.count)
1094
+ let rms = sqrt(meanSquare)
1095
+
1096
+ // Calculate decibel: dB = 20 * log10(RMS / max_value)
1097
+ // For Int16, max_value is 32767.0
1098
+ let maxValue = 32767.0
1099
+ guard rms > 0 else {
1100
+ print("⚠️ Decibel: RMS is 0, all samples are silent. Sample count: \(samples.count), first few: \(samples.prefix(5))")
1101
+ return -120.0 // Avoid log(0)
1102
+ }
1103
+
1104
+ let decibel = 20.0 * log10(rms / maxValue)
1105
+
1106
+ // Clamp to reasonable range (-120 dB to 0 dB)
1107
+ let clampedDecibel = max(-120.0, min(0.0, decibel))
1108
+
1109
+ return clampedDecibel
1110
+ }
1111
+ }
1112
+
1113
+ // MARK: - FlutterStreamHandler
1114
+ extension MicCapturePlugin: FlutterStreamHandler {
1115
+ func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
1116
+ print("🎧 Audio stream listener attached")
1117
+ self.eventSink = events
1118
+ return nil
1119
+ }
1120
+
1121
+ func onCancel(withArguments arguments: Any?) -> FlutterError? {
1122
+ print("🎧 Audio stream listener cancelled")
1123
+ self.eventSink = nil
1124
+ return nil
1125
+ }
1126
+ }
1127
+
1128
+ // MARK: - Status Stream Handler
1129
+ class StatusStreamHandler: NSObject, FlutterStreamHandler {
1130
+ weak var plugin: MicCapturePlugin?
1131
+
1132
+ init(plugin: MicCapturePlugin) {
1133
+ self.plugin = plugin
1134
+ }
1135
+
1136
+ func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
1137
+ plugin?.statusEventSink = events
1138
+ // Send current status immediately
1139
+ let isActive = plugin?.isCapturing ?? false
1140
+ let deviceName = plugin?.currentDeviceName
1141
+ var status: [String: Any] = ["isActive": isActive]
1142
+ if let deviceName = deviceName {
1143
+ status["deviceName"] = deviceName
1144
+ }
1145
+ events(status)
1146
+ return nil
1147
+ }
1148
+
1149
+ func onCancel(withArguments arguments: Any?) -> FlutterError? {
1150
+ plugin?.statusEventSink = nil
1151
+ return nil
1152
+ }
1153
+ }
1154
+
1155
+ // MARK: - Mic Decibel Stream Handler
1156
+ class MicDecibelStreamHandler: NSObject, FlutterStreamHandler {
1157
+ weak var plugin: MicCapturePlugin?
1158
+
1159
+ init(plugin: MicCapturePlugin) {
1160
+ self.plugin = plugin
1161
+ }
1162
+
1163
+ func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
1164
+ plugin?.decibelEventSink = events
1165
+ return nil
1166
+ }
1167
+
1168
+ func onCancel(withArguments arguments: Any?) -> FlutterError? {
1169
+ plugin?.decibelEventSink = nil
1170
+ return nil
1171
+ }
1172
+ }