diff --git a/lib/open_earable_flutter.dart b/lib/open_earable_flutter.dart index c326246..0fb30af 100644 --- a/lib/open_earable_flutter.dart +++ b/lib/open_earable_flutter.dart @@ -70,6 +70,7 @@ export 'src/models/wearable_factory.dart'; export 'src/models/capabilities/system_device.dart'; export 'src/managers/ble_gatt_manager.dart'; export 'src/models/capabilities/time_synchronizable.dart'; +export 'src/models/capabilities/audio_response_manager.dart'; export 'src/fota/fota.dart'; diff --git a/lib/src/models/capabilities/audio_response_manager.dart b/lib/src/models/capabilities/audio_response_manager.dart new file mode 100644 index 0000000..7cdfa39 --- /dev/null +++ b/lib/src/models/capabilities/audio_response_manager.dart @@ -0,0 +1,4 @@ +/// An interface for managing audio response measurements. +abstract class AudioResponseManager { + Future> measureAudioResponse(Map parameters); +} diff --git a/lib/src/models/devices/open_earable_factory.dart b/lib/src/models/devices/open_earable_factory.dart index 11dadeb..fa8ec6c 100644 --- a/lib/src/models/devices/open_earable_factory.dart +++ b/lib/src/models/devices/open_earable_factory.dart @@ -10,6 +10,7 @@ import '../../../open_earable_flutter.dart' show logger; import '../../managers/v2_sensor_handler.dart'; import '../../utils/sensor_value_parser/v2_sensor_value_parser.dart'; import '../capabilities/audio_mode_manager.dart'; +import '../capabilities/audio_response_manager.dart'; import '../capabilities/fota_capability.dart'; import '../capabilities/fota_slot_info_capability.dart'; import '../capabilities/sensor.dart'; @@ -99,6 +100,14 @@ class OpenEarableFactory extends WearableFactory { ), ); } + if (await bleManager!.hasService(deviceId: device.id, serviceId: audioResponseServiceUuid)) { + wearable.registerCapability( + OpenEarableV2AudioResponseManager( + bleManager: bleManager!, + deviceId: device.id, + ), + ); + } if (await bleManager!.hasService( deviceId: device.id, serviceId: mcuMgrSmpServiceUuid, diff --git a/lib/src/models/devices/open_earable_v2.dart b/lib/src/models/devices/open_earable_v2.dart index e2b617d..0d79ff8 100644 --- a/lib/src/models/devices/open_earable_v2.dart +++ b/lib/src/models/devices/open_earable_v2.dart @@ -41,6 +41,10 @@ const String _timeSyncTimeMappingCharacteristicUuid = const String _timeSyncRttCharacteristicUuid = "2e04cbf9-939d-4be5-823e-271838b75259"; +const String audioResponseServiceUuid = "12345678-1234-5678-9abc-def123456789"; +const String _audioResponseControlCharacteristicUuid = "12345679-1234-5678-9abc-def123456789"; +const String _audioResponseDataCharacteristicUuid = "1234567a-1234-5678-9abc-def123456789"; + final VersionConstraint _versionConstraint = VersionConstraint.parse(">=2.1.0 <2.3.0"); @@ -552,6 +556,147 @@ class OpenEarableV2 extends BluetoothWearable _pairedDevice?.unpair(); _pairedDevice = null; } + +} + +// MARK: AudioResponseManager + +class OpenEarableV2AudioResponseManager implements AudioResponseManager { + final BleGattManager bleManager; + final String deviceId; + + OpenEarableV2AudioResponseManager({ + required this.bleManager, + required this.deviceId, + }); + + void _triggerAudioResponseMeasurement() { + bleManager.write( + deviceId: deviceId, + serviceId: audioResponseServiceUuid, + characteristicId: _audioResponseControlCharacteristicUuid, + byteData: [0xFF], // Command to start audio response measurement + ); + } + + Future> _parseAudioResponseData(Uint8List data) async { + if (data.isEmpty) { + throw StateError('Audio response data is empty'); + } + + // New v1 payload size: + // 1 (version) + 1 (quality) + 1 (mean_magnitude) + 1 (num_peaks) + // + 9*2 (frequencies) + 9*2 (magnitudes) = 40 bytes + const int expectedLenV1 = 40; + + if (data.length < expectedLenV1) { + throw StateError( + 'Audio response data too short: ${data.length} bytes (expected $expectedLenV1)', + ); + } + + final int version = data[0]; + if (version != 1) { + throw StateError('Unsupported audio response data version: $version'); + } + + if (data.length != expectedLenV1) { + throw StateError( + 'Unexpected audio response data length for version 1: ${data.length} bytes (expected $expectedLenV1)', + ); + } + + final int quality = data[1]; + final int meanMagnitudeRaw = data[2]; + final double meanMagnitude = meanMagnitudeRaw / 8.0; + final int numPeaks = data[3]; + + // Frequencies: 9 * uint16_t (12.4 fixed point) starting at offset 4 + // NOTE: Endianness: this uses big-endian to match your previous implementation. + // If firmware sends little-endian, swap the byte order. + const int freqBase = 4; + final List frequenciesRaw = List.filled(9, 0); + final List frequenciesHz = List.filled(9, 0); + for (int i = 0; i < 9; i++) { + final int off = freqBase + i * 2; + final int raw = (data[off + 1] << 8) | data[off]; + frequenciesRaw[i] = raw; + frequenciesHz[i] = raw / 16.0; // 12.4 fixed point -> Hz + } + + // Magnitudes: 9 * uint16_t starting at offset 4 + 18 = 22 + const int magBase = freqBase + 9 * 2; // 22 + final List magnitudes = List.filled(9, 0); + for (int i = 0; i < 9; i++) { + final int off = magBase + i * 2; + final int mag = (data[off + 1] << 8) | data[off]; + magnitudes[i] = mag; + } + + final List> points = List.generate(9, (i) { + return { + 'frequency_hz': frequenciesHz[i], + 'frequency_raw_q12_4': frequenciesRaw[i], + 'magnitude': magnitudes[i], + }; + }); + + return { + 'version': version, + 'quality': quality, + 'mean_magnitude': meanMagnitude, + 'mean_magnitude_raw': meanMagnitudeRaw, + 'num_peaks': numPeaks, + 'frequencies_hz': frequenciesHz, + 'frequencies_raw_q12_4': frequenciesRaw, + 'magnitudes': magnitudes, + 'points': points, + }; + } + + @override + Future> measureAudioResponse( + Map parameters, + ) async { + _triggerAudioResponseMeasurement(); + + // Wait for the result via notification + final completer = Completer>(); + + late final StreamSubscription> audioRespSub; + audioRespSub = bleManager + .subscribe( + deviceId: deviceId, + serviceId: audioResponseServiceUuid, + characteristicId: _audioResponseDataCharacteristicUuid, + ) + .listen( + (data) async { + logger.d("Received audio response data: $data"); + try { + final parsed = await _parseAudioResponseData(Uint8List.fromList(data)); + if (!completer.isCompleted) { + completer.complete(parsed); + } + } catch (e, stack) { + logger.e("Error parsing audio response data: $e, $stack"); + if (!completer.isCompleted) { + completer.completeError(e, stack); + } + } finally { + await audioRespSub.cancel(); + } + }, + onError: (error, stack) async { + logger.e("Error during audio response subscription: $error, $stack"); + if (!completer.isCompleted) { + completer.completeError(error, stack); + } + }, + ); + + return completer.future; + } } // MARK: OpenEarableV2Mic