Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions open_wearable/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
<!-- internet persmission, for fetching firmware update -->
<uses-permission android:name="android.permission.INTERNET"/>

<uses-permission android:name="android.permission.RECORD_AUDIO" />

<!-- Provide required visibility configuration for API level 30 and above -->
<queries>
<!-- If your app checks for SMS support -->
Expand Down
6 changes: 6 additions & 0 deletions open_wearable/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ PODS:
- flutter_archive (0.0.1):
- Flutter
- ZIPFoundation (= 0.9.19)
- flutter_headset_detector (3.1.0):
- Flutter
- iOSMcuManagerLibrary (1.10.1):
- SwiftCBOR (= 0.4.7)
- ZIPFoundation (= 0.9.19)
Expand Down Expand Up @@ -86,6 +88,7 @@ DEPENDENCIES:
- file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`)
- Flutter (from `Flutter`)
- flutter_archive (from `.symlinks/plugins/flutter_archive/ios`)
- flutter_headset_detector (from `.symlinks/plugins/flutter_headset_detector/ios`)
- mcumgr_flutter (from `.symlinks/plugins/mcumgr_flutter/ios`)
- open_file_ios (from `.symlinks/plugins/open_file_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
Expand Down Expand Up @@ -121,6 +124,8 @@ EXTERNAL SOURCES:
:path: Flutter
flutter_archive:
:path: ".symlinks/plugins/flutter_archive/ios"
flutter_headset_detector:
:path: ".symlinks/plugins/flutter_headset_detector/ios"
mcumgr_flutter:
:path: ".symlinks/plugins/mcumgr_flutter/ios"
open_file_ios:
Expand Down Expand Up @@ -151,6 +156,7 @@ SPEC CHECKSUMS:
file_selector_ios: ec57ec07954363dd730b642e765e58f199bb621a
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_archive: ad8edfd7f7d1bb12058d05424ba93e27d9930efe
flutter_headset_detector: 37d2407c6c59aa6e8a9daecf732854862ff6dd4a
iOSMcuManagerLibrary: e9555825af11a61744fe369c12e1e66621061b58
mcumgr_flutter: 969e99cc15e9fe658242669ce1075bf4612aef8a
open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0
Expand Down
287 changes: 284 additions & 3 deletions open_wearable/lib/view_models/sensor_recorder_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import 'dart:io';

import 'package:flutter/foundation.dart';
import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger;
import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart';

import '../models/logger.dart';
import '../models/sensor_streams.dart';
Expand Down Expand Up @@ -32,18 +34,167 @@ class SensorRecorderProvider with ChangeNotifier {
bool _hasSensorsConnected = false;
String? _currentDirectory;
DateTime? _recordingStart;
final AudioRecorder _audioRecorder = AudioRecorder();
bool _isAudioRecording = false;
String? _currentAudioPath;
StreamSubscription<Amplitude>? _amplitudeSub;

bool get isRecording => _isRecording;
bool get hasSensorsConnected => _hasSensorsConnected;
String? get currentDirectory => _currentDirectory;
DateTime? get recordingStart => _recordingStart;

final List<double> _waveformData = [];
List<double> get waveformData => List.unmodifiable(_waveformData);
int _waveformRevision = 0;

/// Monotonically increases whenever a new audio amplitude sample is recorded.
///
/// The waveform keeps a capped rolling buffer, so its list length stops
/// changing once the buffer is full. Consumers can use this revision to
/// repaint when fresh samples shift through the fixed-size window.
int get waveformRevision => _waveformRevision;

InputDevice? _selectedBLEDevice;

bool _isBLEMicrophoneStreamingEnabled = false;
bool get isBLEMicrophoneStreamingEnabled => _isBLEMicrophoneStreamingEnabled;

// Path for temporary streaming file
String? _streamingPath;
bool _isStreamingActive = false;

Future<void> _selectBLEDevice() async {
try {
final devices = await _audioRecorder.listInputDevices();

try {
_selectedBLEDevice = devices.firstWhere(
(device) =>
device.label.toLowerCase().contains('bluetooth') ||
device.label.toLowerCase().contains('ble') ||
device.label.toLowerCase().contains('headset') ||
device.label.toLowerCase().contains('openearable'),
);
logger.i("Selected audio input device: ${_selectedBLEDevice!.label}");
} catch (e) {
_selectedBLEDevice = null;
logger.w("No BLE headset found");
}
} catch (e) {
logger.e("Error selecting BLE device: $e");
_selectedBLEDevice = null;
}
}

Future<bool> startBLEMicrophoneStream() async {
if (!Platform.isAndroid) {
logger.w("BLE microphone streaming only supported on Android");
return false;
}

if (_isStreamingActive) {
logger.i("BLE microphone streaming already active");
return true;
}

try {
if (!await _audioRecorder.hasPermission()) {
logger.w("No microphone permission for streaming");
return false;
}

await _selectBLEDevice();

if (_selectedBLEDevice == null) {
logger.w("No BLE headset detected, cannot start streaming");
return false;
}

const encoder = AudioEncoder.wav;
if (!await _audioRecorder.isEncoderSupported(encoder)) {
logger.w("WAV encoder not supported");
return false;
}

final tempDir = await getTemporaryDirectory();
_streamingPath =
'${tempDir.path}/ble_stream_${DateTime.now().millisecondsSinceEpoch}.wav';

final config = RecordConfig(
encoder: encoder,
sampleRate: 48000,
bitRate: 768000,
numChannels: 1,
device: _selectedBLEDevice,
);

await _audioRecorder.start(config, path: _streamingPath!);
_isStreamingActive = true;
_isBLEMicrophoneStreamingEnabled = true;

// Set up amplitude monitoring for waveform display
_amplitudeSub?.cancel();
_amplitudeSub = _audioRecorder
.onAmplitudeChanged(const Duration(milliseconds: 100))
.listen((amp) {
_appendWaveformAmplitude(amp);
notifyListeners();
});

logger.i(
"BLE microphone streaming started with device: ${_selectedBLEDevice!.label}",
);
notifyListeners();
return true;
} catch (e) {
logger.e("Failed to start BLE microphone streaming: $e");
_isStreamingActive = false;
_isBLEMicrophoneStreamingEnabled = false;
_streamingPath = null;
notifyListeners();
return false;
}
}

Future<void> stopBLEMicrophoneStream() async {
if (!_isStreamingActive) {
return;
}

try {
await _audioRecorder.stop();
_amplitudeSub?.cancel();
_amplitudeSub = null;
_isStreamingActive = false;
_isBLEMicrophoneStreamingEnabled = false;
_waveformData.clear();

// Clean up temporary streaming file
if (_streamingPath != null) {
try {
final file = File(_streamingPath!);
if (await file.exists()) {
await file.delete();
}
} catch (e) {
// Ignore cleanup errors
}
_streamingPath = null;
}

logger.i("BLE microphone streaming stopped");
notifyListeners();
} catch (e) {
logger.e("Error stopping BLE microphone streaming: $e");
}
}

Future<void> startRecording(String dirname) async {
if (_isRecording) {
return;
}

_recordingFilepathsBySensorIdentity.clear();
_isRecording = true;
_currentDirectory = dirname;
_recordingStart = DateTime.now();

Expand All @@ -63,13 +214,121 @@ class SensorRecorderProvider with ChangeNotifier {
notifyListeners();
rethrow;
}

await _startAudioRecording(
dirname,
);

notifyListeners();
}

Future<void> _startAudioRecording(String recordingFolderPath) async {
if (!Platform.isAndroid) return;

// Only start recording if BLE microphone streaming is enabled
if (!_isBLEMicrophoneStreamingEnabled) {
logger
.w("BLE microphone streaming not enabled, skipping audio recording");
return;
}

// Stop streaming session before starting actual recording
if (_isStreamingActive) {
await _audioRecorder.stop();
_amplitudeSub?.cancel();
_amplitudeSub = null;
_isStreamingActive = false;

// Clean up temporary streaming file
if (_streamingPath != null) {
try {
final file = File(_streamingPath!);
if (await file.exists()) {
await file.delete();
}
} catch (e) {
// Ignore cleanup errors
}
_streamingPath = null;
}
}

try {
if (!await _audioRecorder.hasPermission()) {
logger.w("No microphone permission for recording");
return;
}

await _selectBLEDevice();

if (_selectedBLEDevice == null) {
logger.w("No BLE headset detected, skipping audio recording");
return;
}

const encoder = AudioEncoder.wav;
if (!await _audioRecorder.isEncoderSupported(encoder)) {
logger.w("WAV encoder not supported");
return;
}

final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-');
final audioPath = '$recordingFolderPath/audio_$timestamp.wav';

final config = RecordConfig(
encoder: encoder,
sampleRate: 48000, // Set to 48kHz for BLE audio quality
bitRate: 768000, // 16-bit * 48kHz * 1 channel = 768 kbps
numChannels: 1,
device: _selectedBLEDevice,
);

await _audioRecorder.start(config, path: audioPath);
_currentAudioPath = audioPath;
_isAudioRecording = true;

logger.i(
"Audio recording started: $_currentAudioPath with device: ${_selectedBLEDevice?.label ?? 'default'}",
);

_amplitudeSub = _audioRecorder
.onAmplitudeChanged(const Duration(milliseconds: 100))
.listen((amp) {
_appendWaveformAmplitude(amp);
notifyListeners();
});
} catch (e) {
logger.e("Failed to start audio recording: $e");
_isAudioRecording = false;
}
}

void stopRecording() {
void stopRecording(bool turnOffMic) async {
_isRecording = false;
_recordingStart = null;
_recordingFilepathsBySensorIdentity.clear();
_stopAllRecorderStreams();
try {
if (_isAudioRecording) {
final path = await _audioRecorder.stop();
_amplitudeSub?.cancel();
_amplitudeSub = null;
_isAudioRecording = false;

logger.i("Audio recording saved to: $path");
_currentAudioPath = null;
}
} catch (e) {
logger.e("Error stopping audio recording: $e");
}

// Restart streaming if it was enabled before recording
if (!turnOffMic &&
_isBLEMicrophoneStreamingEnabled &&
!_isStreamingActive) {
unawaited(startBLEMicrophoneStream());
}

notifyListeners();
}

Expand Down Expand Up @@ -304,9 +563,31 @@ class SensorRecorderProvider with ChangeNotifier {
}
}

/// Appends a normalized amplitude sample to the fixed-size waveform window.
void _appendWaveformAmplitude(Amplitude amplitude) {
final normalized = (amplitude.current + 50) / 50;
_waveformData.add(normalized.clamp(0.0, 2.0));
_waveformRevision++;

if (_waveformData.length > 100) {
_waveformData.removeAt(0);
}
}

@override
void dispose() {
_disposed = true;
// Stop streaming
stopBLEMicrophoneStream();

// Stop recording
_audioRecorder.stop().then((_) {
_audioRecorder.dispose();
}).catchError((e) {
logger.e("Error stopping audio in dispose: $e");
});
_amplitudeSub?.cancel();
_waveformData.clear();
for (final wearable in _recorders.keys.toList()) {
_disposeWearable(wearable);
}
Expand Down
Loading
Loading