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.
- package/.env.example +39 -0
- package/README.md +2 -0
- package/docs/capabilities.md +2 -2
- package/docs/configuration.md +13 -5
- package/docs/integrations.md +4 -1
- package/flutter_app/.metadata +42 -0
- package/flutter_app/README.md +21 -0
- package/flutter_app/analysis_options.yaml +32 -0
- package/flutter_app/android/app/build.gradle.kts +109 -0
- package/flutter_app/android/app/src/debug/AndroidManifest.xml +7 -0
- package/flutter_app/android/app/src/main/AndroidManifest.xml +147 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +747 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthConnectGateway.kt +280 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncNotifications.kt +113 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncPayload.kt +57 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncScheduler.kt +78 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncWorker.kt +253 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/PermissionsRationaleActivity.kt +46 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +21 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +586 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +78 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +104 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiHomeWidgetProvider.kt +457 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiWidgetStore.kt +194 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/VoiceLaunchWidgetProvider.kt +67 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetConfigActivity.kt +228 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncScheduler.kt +72 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncWorker.kt +186 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetTaskRunWorker.kt +210 -0
- package/flutter_app/android/app/src/main/res/drawable/launch_background.xml +12 -0
- package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_bg.xml +11 -0
- package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_task_bg.xml +8 -0
- package/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
- package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget.xml +138 -0
- package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget_task_row.xml +52 -0
- package/flutter_app/android/app/src/main/res/layout/neoagent_voice_widget.xml +49 -0
- package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/values/strings.xml +12 -0
- package/flutter_app/android/app/src/main/res/values/styles.xml +18 -0
- package/flutter_app/android/app/src/main/res/values-night/styles.xml +18 -0
- package/flutter_app/android/app/src/main/res/xml/file_paths.xml +6 -0
- package/flutter_app/android/app/src/main/res/xml/neoagent_ai_widget_info.xml +12 -0
- package/flutter_app/android/app/src/main/res/xml/neoagent_voice_widget_info.xml +12 -0
- package/flutter_app/android/app/src/profile/AndroidManifest.xml +7 -0
- package/flutter_app/android/build.gradle.kts +24 -0
- package/flutter_app/android/ci-release.keystore +0 -0
- package/flutter_app/android/gradle/wrapper/gradle-wrapper.properties +5 -0
- package/flutter_app/android/gradle.properties +3 -0
- package/flutter_app/android/key.properties +4 -0
- package/flutter_app/android/settings.gradle.kts +26 -0
- package/flutter_app/assets/branding/app_icon_1024.png +0 -0
- package/flutter_app/assets/branding/app_icon_128.png +0 -0
- package/flutter_app/assets/branding/app_icon_192.png +0 -0
- package/flutter_app/assets/branding/app_icon_256.png +0 -0
- package/flutter_app/assets/branding/app_icon_32.png +0 -0
- package/flutter_app/assets/branding/app_icon_512.png +0 -0
- package/flutter_app/assets/branding/app_icon_64.png +0 -0
- package/flutter_app/assets/branding/tray_icon_template.png +0 -0
- package/flutter_app/lib/features/location/location_service.dart +119 -0
- package/flutter_app/lib/features/notifications/notification_interceptor.dart +97 -0
- package/flutter_app/lib/main.dart +23057 -0
- package/flutter_app/lib/main_app_shell.dart +1682 -0
- package/flutter_app/lib/main_integrations.dart +931 -0
- package/flutter_app/lib/main_launcher.dart +959 -0
- package/flutter_app/lib/main_launcher_entry.dart +5 -0
- package/flutter_app/lib/main_models.dart +3473 -0
- package/flutter_app/lib/main_shared.dart +2861 -0
- package/flutter_app/lib/main_theme.dart +204 -0
- package/flutter_app/lib/main_voice_assistant.dart +831 -0
- package/flutter_app/lib/src/android_apk_drop_zone.dart +32 -0
- package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +16 -0
- package/flutter_app/lib/src/android_apk_drop_zone_web.dart +348 -0
- package/flutter_app/lib/src/android_app_installer.dart +22 -0
- package/flutter_app/lib/src/android_app_installer_io.dart +122 -0
- package/flutter_app/lib/src/android_app_installer_stub.dart +21 -0
- package/flutter_app/lib/src/android_launcher_bridge.dart +239 -0
- package/flutter_app/lib/src/app_launch_bridge.dart +29 -0
- package/flutter_app/lib/src/app_release_updater.dart +511 -0
- package/flutter_app/lib/src/backend_client.dart +1833 -0
- package/flutter_app/lib/src/desktop_companion.dart +2 -0
- package/flutter_app/lib/src/desktop_companion_actions.dart +586 -0
- package/flutter_app/lib/src/desktop_companion_io.dart +538 -0
- package/flutter_app/lib/src/desktop_companion_stub.dart +59 -0
- package/flutter_app/lib/src/desktop_native_bridge.dart +91 -0
- package/flutter_app/lib/src/desktop_screen_capture.dart +21 -0
- package/flutter_app/lib/src/desktop_screen_capture_io.dart +142 -0
- package/flutter_app/lib/src/desktop_screen_capture_stub.dart +12 -0
- package/flutter_app/lib/src/diagnostics_logger.dart +119 -0
- package/flutter_app/lib/src/health_bridge.dart +136 -0
- package/flutter_app/lib/src/live_voice_capture.dart +85 -0
- package/flutter_app/lib/src/messaging_access_summary.dart +46 -0
- package/flutter_app/lib/src/network/app_http_client.dart +53 -0
- package/flutter_app/lib/src/network/app_http_client_factory.dart +6 -0
- package/flutter_app/lib/src/network/app_http_client_io.dart +138 -0
- package/flutter_app/lib/src/network/app_http_client_stub.dart +3 -0
- package/flutter_app/lib/src/network/app_http_client_web.dart +94 -0
- package/flutter_app/lib/src/oauth_launcher.dart +33 -0
- package/flutter_app/lib/src/oauth_launcher_io.dart +77 -0
- package/flutter_app/lib/src/oauth_launcher_stub.dart +33 -0
- package/flutter_app/lib/src/oauth_launcher_web.dart +107 -0
- package/flutter_app/lib/src/recording_bridge.dart +232 -0
- package/flutter_app/lib/src/recording_bridge_io.dart +1019 -0
- package/flutter_app/lib/src/recording_bridge_stub.dart +120 -0
- package/flutter_app/lib/src/recording_bridge_web.dart +689 -0
- package/flutter_app/lib/src/recording_payloads.dart +86 -0
- package/flutter_app/lib/src/theme/palette.dart +81 -0
- package/flutter_app/lib/src/widget_bridge.dart +49 -0
- package/flutter_app/linux/CMakeLists.txt +128 -0
- package/flutter_app/linux/flutter/CMakeLists.txt +88 -0
- package/flutter_app/linux/flutter/generated_plugin_registrant.cc +43 -0
- package/flutter_app/linux/flutter/generated_plugin_registrant.h +15 -0
- package/flutter_app/linux/flutter/generated_plugins.cmake +31 -0
- package/flutter_app/linux/runner/CMakeLists.txt +26 -0
- package/flutter_app/linux/runner/main.cc +6 -0
- package/flutter_app/linux/runner/my_application.cc +144 -0
- package/flutter_app/linux/runner/my_application.h +18 -0
- package/flutter_app/linux/runner/resources/app_icon.png +0 -0
- package/flutter_app/macos/Flutter/Flutter-Debug.xcconfig +2 -0
- package/flutter_app/macos/Flutter/Flutter-Release.xcconfig +2 -0
- package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +40 -0
- package/flutter_app/macos/Podfile +42 -0
- package/flutter_app/macos/Podfile.lock +87 -0
- package/flutter_app/macos/Runner/AppDelegate.swift +576 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
- package/flutter_app/macos/Runner/Base.lproj/MainMenu.xib +342 -0
- package/flutter_app/macos/Runner/Configs/AppInfo.xcconfig +14 -0
- package/flutter_app/macos/Runner/Configs/Debug.xcconfig +2 -0
- package/flutter_app/macos/Runner/Configs/Release.xcconfig +2 -0
- package/flutter_app/macos/Runner/Configs/Warnings.xcconfig +13 -0
- package/flutter_app/macos/Runner/DebugProfile.entitlements +16 -0
- package/flutter_app/macos/Runner/Info.plist +36 -0
- package/flutter_app/macos/Runner/MainFlutterWindow.swift +19 -0
- package/flutter_app/macos/Runner/Release.entitlements +12 -0
- package/flutter_app/macos/Runner.xcodeproj/project.pbxproj +801 -0
- package/flutter_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
- package/flutter_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
- package/flutter_app/macos/Runner.xcworkspace/contents.xcworkspacedata +10 -0
- package/flutter_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
- package/flutter_app/macos/RunnerTests/RunnerTests.swift +12 -0
- package/flutter_app/patch_strings.py +12 -0
- package/flutter_app/pubspec.lock +1088 -0
- package/flutter_app/pubspec.yaml +53 -0
- package/flutter_app/test/messaging_access_summary_test.dart +22 -0
- package/flutter_app/test/recording_payloads_test.dart +53 -0
- package/flutter_app/third_party/desktop_audio_capture/LICENSE +21 -0
- package/flutter_app/third_party/desktop_audio_capture/README.md +262 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +65 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +153 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +110 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +461 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +91 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +106 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +219 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +336 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +101 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +692 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +35 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +36 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +32 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +32 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +878 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +27 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +1172 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +655 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +12 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +30 -0
- package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +87 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +105 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +80 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +31 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +12 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +23 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +25 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +1117 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +115 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +777 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +87 -0
- package/flutter_app/third_party/flutter_secure_storage_linux/linux/CMakeLists.txt +30 -0
- package/flutter_app/third_party/flutter_secure_storage_linux/linux/flutter_secure_storage_linux_plugin.cc +215 -0
- package/flutter_app/third_party/flutter_secure_storage_linux/linux/include/flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h +27 -0
- package/flutter_app/third_party/flutter_secure_storage_linux/pubspec.yaml +20 -0
- package/flutter_app/tool/generate_desktop_branding.py +219 -0
- package/flutter_app/web/favicon.png +0 -0
- package/flutter_app/web/favicon.svg +12 -0
- package/flutter_app/web/icons/Icon-192.png +0 -0
- package/flutter_app/web/icons/Icon-512.png +0 -0
- package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
- package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
- package/flutter_app/web/index.html +39 -0
- package/flutter_app/web/manifest.json +35 -0
- package/flutter_app/windows/CMakeLists.txt +108 -0
- package/flutter_app/windows/flutter/CMakeLists.txt +109 -0
- package/flutter_app/windows/flutter/generated_plugin_registrant.cc +47 -0
- package/flutter_app/windows/flutter/generated_plugin_registrant.h +15 -0
- package/flutter_app/windows/flutter/generated_plugins.cmake +35 -0
- package/flutter_app/windows/runner/CMakeLists.txt +41 -0
- package/flutter_app/windows/runner/Runner.rc +121 -0
- package/flutter_app/windows/runner/flutter_window.cpp +533 -0
- package/flutter_app/windows/runner/flutter_window.h +37 -0
- package/flutter_app/windows/runner/main.cpp +53 -0
- package/flutter_app/windows/runner/resource.h +16 -0
- package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
- package/flutter_app/windows/runner/runner.exe.manifest +14 -0
- package/flutter_app/windows/runner/utils.cpp +65 -0
- package/flutter_app/windows/runner/utils.h +19 -0
- package/flutter_app/windows/runner/win32_window.cpp +299 -0
- package/flutter_app/windows/runner/win32_window.h +102 -0
- package/lib/manager.js +231 -7
- package/package.json +3 -1
- package/server/db/database.js +68 -0
- package/server/http/middleware.js +50 -0
- package/server/http/routes.js +3 -1
- package/server/index.js +1 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/NOTICES +61 -0
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +65262 -64422
- package/server/routes/integrations.js +86 -0
- package/server/routes/memory.js +11 -2
- package/server/routes/screenHistory.js +46 -0
- package/server/routes/triggers.js +81 -0
- package/server/services/ai/models.js +30 -0
- package/server/services/ai/providers/githubCopilot.js +97 -0
- package/server/services/ai/providers/openai.js +2 -1
- package/server/services/ai/providers/openaiCodex.js +31 -0
- package/server/services/ai/settings.js +20 -0
- package/server/services/ai/systemPrompt.js +1 -1
- package/server/services/ai/tools.js +35 -6
- package/server/services/browser/controller.js +47 -3
- package/server/services/desktop/screenRecorder.js +172 -0
- package/server/services/integrations/env.js +5 -0
- package/server/services/integrations/github/common.js +106 -0
- package/server/services/integrations/github/provider.js +499 -0
- package/server/services/integrations/github/repos.js +1124 -0
- package/server/services/integrations/home_assistant/provider.js +306 -26
- package/server/services/integrations/manager.js +63 -7
- package/server/services/integrations/oauth_provider.js +13 -6
- package/server/services/integrations/provider_config_store.js +76 -0
- package/server/services/integrations/registry.js +4 -0
- package/server/services/integrations/trello/provider.js +744 -0
- package/server/services/integrations/whatsapp/provider.js +6 -2
- package/server/services/manager.js +22 -0
- package/server/services/memory/manager.js +39 -2
- package/server/services/skills/base_catalog.js +33 -0
- package/server/services/tasks/adapters/index.js +1 -0
- package/server/services/tasks/adapters/manual.js +12 -0
- package/server/services/tasks/runtime.js +1 -1
- package/server/services/voice/openaiClient.js +4 -1
- package/server/services/voice/providers.js +2 -1
- package/server/services/widgets/service.js +49 -4
- package/server/utils/local_secrets.js +56 -0
- 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
|
+
}
|