Skip to content
Closed
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 @@ -50,6 +50,8 @@ PODS:
- Flutter
- permission_handler_apple (9.3.0):
- Flutter
- record_ios (1.2.0):
- Flutter
- SDWebImage (5.21.5):
- SDWebImage/Core (= 5.21.5)
- SDWebImage/Core (5.21.5)
Expand All @@ -76,6 +78,7 @@ DEPENDENCIES:
- mcumgr_flutter (from `.symlinks/plugins/mcumgr_flutter/ios`)
- open_file_ios (from `.symlinks/plugins/open_file_ios/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- record_ios (from `.symlinks/plugins/record_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- universal_ble (from `.symlinks/plugins/universal_ble/darwin`)
Expand Down Expand Up @@ -107,6 +110,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/open_file_ios/ios"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
record_ios:
:path: ".symlinks/plugins/record_ios/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
Expand All @@ -127,6 +132,7 @@ SPEC CHECKSUMS:
mcumgr_flutter: 969e99cc15e9fe658242669ce1075bf4612aef8a
open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
Expand Down
3 changes: 2 additions & 1 deletion open_wearable/lib/apps/widgets/apps_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ final List<AppInfo> _apps = [
wearable.requireCapability<SensorManager>(),
sensorConfigProvider,
wearable.hasCapability<StereoDevice>() &&
await wearable.requireCapability<StereoDevice>().position == DevicePosition.left,
await wearable.requireCapability<StereoDevice>().position ==
DevicePosition.left,
),
);
},
Expand Down
280 changes: 278 additions & 2 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 All @@ -28,12 +30,160 @@ 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);

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) {
final normalized = (amp.current + 50) / 50;
_waveformData.add(normalized.clamp(0.0, 2.0));

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

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");
}
}

void startRecording(String dirname) async {
_isRecording = true;
_currentDirectory = dirname;
Expand All @@ -43,10 +193,101 @@ class SensorRecorderProvider with ChangeNotifier {
await _startRecorderForWearable(wearable, dirname);
}

await _startAudioRecording(
dirname,
);

notifyListeners();
}

void stopRecording() {
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) {
final normalized = (amp.current + 50) / 50;
_waveformData.add(normalized.clamp(0.0, 2.0));

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

notifyListeners();
});
} catch (e) {
logger.e("Failed to start audio recording: $e");
_isAudioRecording = false;
}
}

void stopRecording(bool turnOffMic) async {
_isRecording = false;
_recordingStart = null;
for (Wearable wearable in _recorders.keys) {
Expand All @@ -60,6 +301,27 @@ class SensorRecorderProvider with ChangeNotifier {
}
}
}
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 @@ -174,10 +436,24 @@ class SensorRecorderProvider with ChangeNotifier {

@override
void dispose() {
for (final wearable in _recorders.keys.toList()) {
// 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) {
_disposeWearable(wearable);
}
_recorders.clear();

super.dispose();
}
}
Loading
Loading