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 @@ -55,6 +55,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 Down Expand Up @@ -85,6 +87,7 @@ DEPENDENCIES:
- open_file_ios (from `.symlinks/plugins/open_file_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/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 @@ -121,6 +124,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/package_info_plus/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 @@ -145,6 +150,7 @@ SPEC CHECKSUMS:
open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
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
295 changes: 290 additions & 5 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,17 +30,162 @@ 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;

Future<void> startRecording(String dirname) async {
if (_isRecording) {
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;
_recordingStart = DateTime.now();

Expand All @@ -57,12 +204,136 @@ 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) {
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() {
void stopRecording(bool turnOffMic) async {
_isRecording = false;
_recordingStart = null;
_stopAllRecorderStreams();
for (Wearable wearable in _recorders.keys) {
for (Sensor sensor in _recorders[wearable]!.keys) {
Recorder? recorder = _recorders[wearable]?[sensor];
if (recorder != null) {
recorder.stop();
logger.i(
'Stopped recording for ${wearable.name} - ${sensor.sensorName}',
);
}
}
}
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 @@ -195,10 +466,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