From 1ca4d1a13112457dd8cef536e414d7d420135852 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Tue, 6 Jan 2026 14:43:09 +0100 Subject: [PATCH 01/15] refactor recorder: extract controls into separate widget --- .../local_recorder_dialogs.dart | 50 ++++ .../local_recorder/local_recorder_files.dart | 53 ++++ .../local_recorder/local_recorder_view.dart | 15 +- .../local_recorder/recording_controls.dart | 262 ++++++++++++++++++ open_wearable/pubspec.lock | 16 +- open_wearable/pubspec.yaml | 1 + 6 files changed, 390 insertions(+), 7 deletions(-) create mode 100644 open_wearable/lib/widgets/sensors/local_recorder/local_recorder_dialogs.dart create mode 100644 open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart create mode 100644 open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_dialogs.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_dialogs.dart new file mode 100644 index 00000000..ffa6c76e --- /dev/null +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_dialogs.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; + +class LocalRecorderDialogs { + static Future askOverwriteConfirmation( + BuildContext context, + String dirPath, + ) async { + return await showPlatformDialog( + context: context, + builder: (ctx) => AlertDialog( + title: PlatformText('Directory not empty'), + content: PlatformText( + '"$dirPath" already contains files or folders.\n\n' + 'New sensor files will be added; existing files with the same ' + 'names will be overwritten. Continue anyway?'), + actions: [ + PlatformTextButton( + onPressed: () => Navigator.pop(ctx, false), + child: PlatformText('Cancel'), + ), + PlatformTextButton( + onPressed: () => Navigator.pop(ctx, true), + child: PlatformText('Continue'), + ), + ], + ), + ) ?? + false; + } + + static Future showErrorDialog( + BuildContext context, + String message, + ) async { + await showPlatformDialog( + context: context, + builder: (_) => PlatformAlertDialog( + title: PlatformText('Error'), + content: PlatformText(message), + actions: [ + PlatformDialogAction( + child: PlatformText('OK'), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ); + } +} diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart new file mode 100644 index 00000000..db4b5bf4 --- /dev/null +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart @@ -0,0 +1,53 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; + +class Files { + static Future pickDirectory() async { + if (!Platform.isIOS && !kIsWeb) { + final recordingName = + 'OpenWearable_Recording_${DateTime.now().toIso8601String()}'; + Directory? appDir = await getExternalStorageDirectory(); + if (appDir == null) return null; + + String dirPath = '${appDir.path}/$recordingName'; + Directory dir = Directory(dirPath); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dirPath; + } + + if (Platform.isIOS) { + final recordingName = + 'OpenWearable_Recording_${DateTime.now().toIso8601String()}'; + String dirPath = '${(await getIOSDirectory()).path}/$recordingName'; + Directory dir = Directory(dirPath); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dirPath; + } + + return null; + } + + static Future getIOSDirectory() async { + Directory appDocDir = await getApplicationDocumentsDirectory(); + final dirPath = '${appDocDir.path}/Recordings'; + final dir = Directory(dirPath); + + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + return dir; + } + + static Future isDirectoryEmpty(String path) async { + final dir = Directory(path); + if (!await dir.exists()) return true; + return await dir.list(followLinks: false).isEmpty; + } +} diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart index c6b22012..430ff4b7 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart @@ -5,8 +5,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:logger/logger.dart'; -import 'package:open_file/open_file.dart'; +import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_dialogs.dart'; import 'package:provider/provider.dart'; +import 'package:open_file/open_file.dart'; import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_empty_state_card.dart'; @@ -234,7 +235,11 @@ class _LocalRecorderViewState extends State { await localRecorderShareFile(file); } catch (e) { _logger.e('Error sharing file: $e'); - await _showErrorDialog('Failed to share file: $e'); + if (!mounted) return; + await LocalRecorderDialogs.showErrorDialog( + context, + 'Failed to share file: $e', + ); } } @@ -243,7 +248,11 @@ class _LocalRecorderViewState extends State { await localRecorderShareFolder(folder); } catch (e) { _logger.e('Error sharing folder: $e'); - await _showErrorDialog('Failed to share folder: $e'); + if (!mounted) return; + await LocalRecorderDialogs.showErrorDialog( + context, + 'Failed to share folder: $e', + ); } } diff --git a/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart new file mode 100644 index 00000000..0fc3b322 --- /dev/null +++ b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart @@ -0,0 +1,262 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:logger/logger.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; +import 'package:open_wearable/view_models/wearables_provider.dart'; +import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_dialogs.dart'; +import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_files.dart'; +import 'package:provider/provider.dart'; + +Logger _logger = Logger(); + +class RecordingControls extends StatefulWidget { + const RecordingControls({ + super.key, + required this.canStartRecording, + required this.isRecording, + required this.recorder, + required this.updateRecordingsList, + }); + + final bool canStartRecording; + final bool isRecording; + final SensorRecorderProvider recorder; + + final Future Function() updateRecordingsList; + + @override + State createState() => _RecordingControls(); +} + +class _RecordingControls extends State { + Duration _elapsedRecording = Duration.zero; + Timer? _recordingTimer; + bool _isHandlingStopAction = false; + bool _lastRecordingState = false; + SensorRecorderProvider? _recorder; + DateTime? _activeRecordingStart; + + String _formatDuration(Duration d) { + String twoDigits(int n) => n.toString().padLeft(2, '0'); + final hours = twoDigits(d.inHours); + final minutes = twoDigits(d.inMinutes.remainder(60)); + final seconds = twoDigits(d.inSeconds.remainder(60)); + return '$hours:$minutes:$seconds'; + } + + Future _handleStopRecording( + SensorRecorderProvider recorder, { + required bool turnOffSensors, + }) async { + if (_isHandlingStopAction) return; + setState(() { + _isHandlingStopAction = true; + }); + + try { + recorder.stopRecording(); + if (turnOffSensors) { + final wearablesProvider = context.read(); + final futures = wearablesProvider.sensorConfigurationProviders.values + .map((provider) => provider.turnOffAllSensors()); + await Future.wait(futures); + } + await widget.updateRecordingsList(); + } catch (e) { + _logger.e('Error stopping recording: $e'); + if (!mounted) return; + await LocalRecorderDialogs.showErrorDialog( + context, + 'Failed to stop recording: $e', + ); + } finally { + if (mounted) { + setState(() { + _isHandlingStopAction = false; + }); + } + } + } + + @override + void didUpdateWidget(covariant RecordingControls oldWidget) { + super.didUpdateWidget(oldWidget); + + // Start timer if parent says recording started + if (widget.isRecording && !oldWidget.isRecording) { + _startRecordingTimer(widget.recorder.recordingStart); + } + + // Stop timer if parent says recording stopped + if (!widget.isRecording && oldWidget.isRecording) { + _stopRecordingTimer(); + } + } + + @override + void dispose() { + _recordingTimer?.cancel(); + _recorder?.removeListener(_handleRecorderUpdate); + super.dispose(); + } + + void _handleRecorderUpdate() { + final recorder = _recorder; + if (recorder == null) return; + final isRecording = recorder.isRecording; + final start = recorder.recordingStart; + if (isRecording && !_lastRecordingState) { + _startRecordingTimer(start); + } else if (!isRecording && _lastRecordingState) { + _stopRecordingTimer(); + } else if (isRecording && + _lastRecordingState && + start != null && + _activeRecordingStart != null && + start != _activeRecordingStart) { + _startRecordingTimer(start); + } + _lastRecordingState = isRecording; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextRecorder = context.watch(); + if (!identical(_recorder, nextRecorder)) { + _recorder?.removeListener(_handleRecorderUpdate); + _recorder = nextRecorder; + _recorder?.addListener(_handleRecorderUpdate); + _handleRecorderUpdate(); + } + } + + void _startRecordingTimer(DateTime? start) { + final reference = start ?? DateTime.now(); + _activeRecordingStart = reference; + _recordingTimer?.cancel(); + setState(() { + _elapsedRecording = DateTime.now().difference(reference); + }); + _recordingTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() { + final base = _activeRecordingStart ?? reference; + _elapsedRecording = DateTime.now().difference(base); + }); + }); + } + + void _stopRecordingTimer() { + _recordingTimer?.cancel(); + _recordingTimer = null; + _activeRecordingStart = null; + if (!mounted) return; + setState(() { + _elapsedRecording = Duration.zero; + }); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: !widget.isRecording + ? ElevatedButton.icon( + icon: const Icon(Icons.play_arrow), + style: ElevatedButton.styleFrom( + backgroundColor: widget.canStartRecording + ? Colors.green.shade600 + : Colors.grey.shade400, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(48), + ), + label: const Text( + 'Start Recording', + style: TextStyle(fontSize: 18), + ), + onPressed: !widget.canStartRecording + ? null + : () async { + final dir = await Files.pickDirectory(); + if (dir == null) return; + + // Check if directory is empty + if (!await Files.isDirectoryEmpty(dir)) { + if (!context.mounted) return; + final proceed = + await LocalRecorderDialogs.askOverwriteConfirmation( + context, + dir, + ); + if (!proceed) return; + } + + widget.recorder.startRecording(dir); + await widget.updateRecordingsList(); // Refresh list + }, + ) + : Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.stop), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(48), + ), + label: const Text( + 'Stop Recording', + style: TextStyle(fontSize: 18), + ), + onPressed: _isHandlingStopAction + ? null + : () => _handleStopRecording( + widget.recorder, + turnOffSensors: false, + ), + ), + ), + const SizedBox(width: 8), + ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 90, + ), + child: Text( + _formatDuration(_elapsedRecording), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + ElevatedButton.icon( + icon: const Icon(Icons.power_settings_new), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red[800], + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(48), + ), + label: const Text( + 'Stop & Turn Off Sensors', + style: TextStyle(fontSize: 18), + ), + onPressed: _isHandlingStopAction + ? null + : () => _handleStopRecording( + widget.recorder, + turnOffSensors: true, + ), + ), + ], + ), + ); + } +} diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 39926ab2..a1e57e74 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -532,10 +532,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -816,6 +816,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.6" + playback_capture: + dependency: "direct main" + description: + name: playback_capture + sha256: b2766b8741c00b51e0140660e9503d493a38cf5c9b0b9c5127c1d61f07a8e5e3 + url: "https://pub.dev" + source: hosted + version: "0.0.4" plugin_platform_interface: dependency: transitive description: @@ -1009,10 +1017,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.8" tuple: dependency: transitive description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 510d1c8b..9b54812b 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -62,6 +62,7 @@ dependencies: sensors_plus: ^7.0.0 device_info_plus: ^12.3.0 pub_semver: ^2.2.0 + playback_capture: ^0.0.4 dev_dependencies: flutter_test: From fae489e9c690864544b1699e7e2e91eb67d7ab2b Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Fri, 16 Jan 2026 17:10:03 +0100 Subject: [PATCH 02/15] add custom audio waveform to the Sensors tab with device selector --- .../android/app/src/main/AndroidManifest.xml | 2 + open_wearable/ios/Podfile.lock | 6 + .../sensors/values/sensor_values_page.dart | 376 ++++++++++++++++-- .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + open_wearable/pubspec.lock | 90 ++++- open_wearable/pubspec.yaml | 3 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 10 files changed, 444 insertions(+), 44 deletions(-) diff --git a/open_wearable/android/app/src/main/AndroidManifest.xml b/open_wearable/android/app/src/main/AndroidManifest.xml index 8cb9f463..eb49e63d 100644 --- a/open_wearable/android/app/src/main/AndroidManifest.xml +++ b/open_wearable/android/app/src/main/AndroidManifest.xml @@ -40,6 +40,8 @@ + + diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock index 1e2cdfa2..e5d0411d 100644 --- a/open_wearable/ios/Podfile.lock +++ b/open_wearable/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - audio_waveforms (0.0.1): + - Flutter - audioplayers_darwin (0.0.1): - Flutter - FlutterMacOS @@ -80,6 +82,7 @@ PODS: - ZIPFoundation (0.9.19) DEPENDENCIES: + - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) @@ -109,6 +112,8 @@ SPEC REPOS: - ZIPFoundation EXTERNAL SOURCES: + audio_waveforms: + :path: ".symlinks/plugins/audio_waveforms/ios" audioplayers_darwin: :path: ".symlinks/plugins/audioplayers_darwin/darwin" device_info_plus: @@ -143,6 +148,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: + audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index f847afa7..b91e31b5 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -7,7 +7,11 @@ import 'package:open_wearable/view_models/sensor_data_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:open_wearable/widgets/sensors/values/sensor_value_card.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; +import 'package:record/record.dart'; +import 'dart:async'; +import 'package:path_provider/path_provider.dart'; class SensorValuesPage extends StatefulWidget { final Map<(Wearable, Sensor), SensorDataProvider>? sharedProviders; @@ -30,11 +34,183 @@ class _SensorValuesPageState extends State bool get _ownsProviders => widget.sharedProviders == null; + // Audio State + late final AudioRecorder _audioRecorder; + bool _isRecording = false; + String? _errorMessage; + List _devices = []; + InputDevice? _selectedDevice; + StreamSubscription? _recordSub; + StreamSubscription? _amplitudeSub; + RecordState _recordState = RecordState.stop; + List _waveformData = []; + Amplitude? _amplitude; + @override bool get wantKeepAlive => true; + @override + void initState() { + super.initState(); + _audioRecorder = AudioRecorder(); + + // Only subscribe to state changes initially + _recordSub = _audioRecorder.onStateChanged().listen((recordState) { + if (mounted) { + setState(() => _recordState = recordState); + + // Clean up amplitude subscription when recording stops + if (recordState == RecordState.stop) { + _amplitudeSub?.cancel(); + _amplitudeSub = null; + } + } + }); + + _initRecording(); + } + + Future _initRecording() async { + print("Initializing audio recorder"); + + try { + if (await _audioRecorder.hasPermission()) { + print("Permission granted"); + await _loadDevices(); + await _startRecording(); + } else { + print("No permission, requesting..."); + final status = await Permission.microphone.request(); + if (status.isGranted) { + await _loadDevices(); + await _startRecording(); + } else { + if (mounted) { + setState(() => _errorMessage = 'Microphone permission denied'); + } + } + } + } catch (e) { + print("Init error: $e"); + if (mounted) { + setState(() => _errorMessage = 'Failed to initialize: $e'); + } + } + } + + Future _loadDevices() async { + try { + final devs = await _audioRecorder.listInputDevices(); + if (mounted) { + setState(() { + _devices = devs; + if (_selectedDevice == null && _devices.isNotEmpty) { + _selectedDevice = _devices.first; + print("Selected device: ${_selectedDevice?.label}"); + } + }); + } + } catch (e) { + print("Error loading devices: $e"); + } + } + + Future _getRecordingPath() async { + final directory = await getTemporaryDirectory(); + return '${directory.path}/temp_recording_${DateTime.now().millisecondsSinceEpoch}.m4a'; + } + + Future _startRecording() async { + try { + const encoder = AudioEncoder.aacLc; + + if (!await _audioRecorder.isEncoderSupported(encoder)) { + if (mounted) { + setState(() => _errorMessage = 'Encoder not supported'); + } + return; + } + + final path = await _getRecordingPath(); + + final config = RecordConfig( + encoder: encoder, + numChannels: 1, + device: _selectedDevice, + ); + + await _audioRecorder.start(config, path: path); + + // Wait a bit to ensure recording is active + await Future.delayed(Duration(milliseconds: 100)); + + _amplitudeSub?.cancel(); + // Subscribe to amplitude changes after recording started + _amplitudeSub = _audioRecorder + .onAmplitudeChanged(const Duration(milliseconds: 100)) + .listen( + (amp) { + if (mounted) { + setState(() { + _amplitude = amp; + // Add normalized amplitude to waveform data + final normalized = (amp.current + 50) / 50; + _waveformData.add(normalized.clamp(0.0, 2.0)); + + // Keep only last 100 samples + if (_waveformData.length > 100) { + _waveformData.removeAt(0); + } + }); + } + }, + onError: (error) { + print("Amplitude stream error: $error"); + }, + ); + + if (mounted) { + setState(() { + _isRecording = true; + _errorMessage = null; + }); + } + } catch (e) { + print("Recording start error: $e"); + if (mounted) { + setState(() => _errorMessage = 'Failed to start recording: $e'); + } + } + } + + Future _changeDevice(InputDevice? device) async { + if (device == null) return; + + // Stop current recording + if (_recordState != RecordState.stop) { + await _audioRecorder.stop(); + _amplitudeSub?.cancel(); + _amplitudeSub = null; + } + + // Update selected device and restart + if (mounted) { + setState(() { + _selectedDevice = device; + _waveformData.clear(); + _isRecording = false; + }); + } + + await _startRecording(); + } + @override void dispose() { + _audioRecorder.stop(); + _recordSub?.cancel(); + _amplitudeSub?.cancel(); + _audioRecorder.dispose(); if (_ownsProviders) { for (final provider in _ownedProviders.values) { provider.dispose(); @@ -262,31 +438,77 @@ class _SensorValuesPageState extends State return ordered; } + Widget _buildAudioUI() { + return Column( + children: [ + if (_devices.isNotEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const Text('Input: ', + style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(width: 8), + Expanded( + child: DropdownButton( + value: _selectedDevice, + isExpanded: true, + items: _devices + .map((d) => + DropdownMenuItem(value: d, child: Text(d.label))) + .toList(), + onChanged: _changeDevice, + ), + ), + IconButton( + icon: const Icon(Icons.refresh), onPressed: _loadDevices), + ], + ), + ), + ), + if (_isRecording) + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: CustomPaint( + size: const Size(double.infinity, 100), + painter: WaveformPainter(_waveformData), + ), + ), + ) + else if (_errorMessage != null) + Padding( + padding: const EdgeInsets.all(8.0), + child: PlatformText(_errorMessage!, + style: const TextStyle(color: Colors.red)), + ), + const SizedBox(height: 10), + ], + ); + } + Widget _buildSmallScreenLayout( BuildContext context, List charts, { required bool hasAnySensors, required bool hideCardsWithoutLiveData, }) { - if (charts.isEmpty) { - final emptyState = _resolveEmptyState( - hasAnySensors: hasAnySensors, - hideCardsWithoutLiveData: hideCardsWithoutLiveData, - ); - return Padding( - padding: SensorPageSpacing.pagePaddingWithBottomInset(context), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 500), - child: _buildEmptyStateCard(context, emptyState), - ), - ), - ); - } - return ListView( padding: SensorPageSpacing.pagePaddingWithBottomInset(context), - children: charts, + children: [ + _buildAudioUI(), + ...charts, + if (charts.isEmpty) + Center( + child: _buildEmptyStateCard( + context, + _resolveEmptyState( + hasAnySensors: hasAnySensors, + hideCardsWithoutLiveData: hideCardsWithoutLiveData), + ), + ), + ], ); } @@ -296,26 +518,35 @@ class _SensorValuesPageState extends State required bool hasAnySensors, required bool hideCardsWithoutLiveData, }) { - final emptyState = _resolveEmptyState( - hasAnySensors: hasAnySensors, - hideCardsWithoutLiveData: hideCardsWithoutLiveData, - ); - - return GridView.builder( + return SingleChildScrollView( padding: SensorPageSpacing.pagePaddingWithBottomInset(context), - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 500, - childAspectRatio: 1.5, - crossAxisSpacing: SensorPageSpacing.gridGap, - mainAxisSpacing: SensorPageSpacing.gridGap, + child: Column( + children: [ + _buildAudioUI(), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 500, + childAspectRatio: 1.5, + crossAxisSpacing: SensorPageSpacing.gridGap, + mainAxisSpacing: SensorPageSpacing.gridGap, + ), + itemCount: charts.isEmpty ? 1 : charts.length, + itemBuilder: (context, index) { + if (charts.isEmpty) { + return _buildEmptyStateCard( + context, + _resolveEmptyState( + hasAnySensors: hasAnySensors, + hideCardsWithoutLiveData: hideCardsWithoutLiveData), + ); + } + return charts[index]; + }, + ), + ], ), - itemCount: charts.isEmpty ? 1 : charts.length, - itemBuilder: (context, index) { - if (charts.isEmpty) { - return _buildEmptyStateCard(context, emptyState); - } - return charts[index]; - }, ); } @@ -435,3 +666,78 @@ class _SensorValuesEmptyState { this.removeCardBackground = false, }); } + +// Custom waveform painter with vertical bars +class WaveformPainter extends CustomPainter { + final List waveformData; + final Color waveColor; + final double spacing; + final double waveThickness; + final bool showMiddleLine; + + WaveformPainter( + this.waveformData, { + this.waveColor = Colors.blue, + this.spacing = 4.0, + this.waveThickness = 3.0, + this.showMiddleLine = true, + }); + + @override + void paint(Canvas canvas, Size size) { + if (waveformData.isEmpty) return; + + final double height = size.height; + final double centerY = height / 2; + + // Draw middle line first (behind the bars) + if (showMiddleLine) { + final centerLinePaint = Paint() + ..color = Colors.grey.withAlpha(75) + ..strokeWidth = 1.0; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + centerLinePaint, + ); + } + + // Paint for the vertical bars + final paint = Paint() + ..color = waveColor + ..strokeWidth = waveThickness + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + + // Calculate starting position to align bars from right + final totalWaveformWidth = waveformData.length * spacing; + final startX = size.width - totalWaveformWidth; + + // Draw each amplitude value as a vertical bar + for (int i = 0; i < waveformData.length; i++) { + final x = startX + (i * spacing); + final amplitude = waveformData[i]; + + // Scale amplitude to fit within the canvas height + // Amplitude is normalized to 0-2 range, scale it to use 80% of half height + final barHeight = amplitude * centerY * 0.8; + + // Draw top half of the bar (above center line) + final topY = centerY - barHeight; + final bottomY = centerY + barHeight; + + // Draw the vertical line from top to bottom + canvas.drawLine( + Offset(x, topY), + Offset(x, bottomY), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant WaveformPainter oldDelegate) { + return oldDelegate.waveformData.length != waveformData.length || + oldDelegate.waveColor != waveColor; + } +} diff --git a/open_wearable/linux/flutter/generated_plugin_registrant.cc b/open_wearable/linux/flutter/generated_plugin_registrant.cc index f547c379..b180d86f 100644 --- a/open_wearable/linux/flutter/generated_plugin_registrant.cc +++ b/open_wearable/linux/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -21,6 +22,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) open_file_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin"); open_file_linux_plugin_register_with_registrar(open_file_linux_registrar); + g_autoptr(FlPluginRegistrar) record_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); + record_linux_plugin_register_with_registrar(record_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/open_wearable/linux/flutter/generated_plugins.cmake b/open_wearable/linux/flutter/generated_plugins.cmake index 6462693b..dcc28ba2 100644 --- a/open_wearable/linux/flutter/generated_plugins.cmake +++ b/open_wearable/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux file_selector_linux open_file_linux + record_linux url_launcher_linux ) diff --git a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift index 39bfb0ef..8d21d935 100644 --- a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift @@ -12,6 +12,7 @@ import file_selector_macos import flutter_archive import open_file_mac import package_info_plus +import record_macos import share_plus import shared_preferences_foundation import universal_ble @@ -26,6 +27,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterArchivePlugin.register(with: registry.registrar(forPlugin: "FlutterArchivePlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin")) diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index a1e57e74..bf98b748 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + audio_waveforms: + dependency: "direct main" + description: + name: audio_waveforms + sha256: "03b3430ecf430a2e90185518a228c02be3d26653c62dd931e50d671213a6dbc8" + url: "https://pub.dev" + source: hosted + version: "2.0.2" audioplayers: dependency: "direct main" description: @@ -532,10 +540,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -753,7 +761,7 @@ packages: source: hosted version: "2.3.0" permission_handler: - dependency: transitive + dependency: "direct main" description: name: permission_handler sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 @@ -856,6 +864,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + record: + dependency: "direct main" + description: + name: record + sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277 + url: "https://pub.dev" + source: hosted + version: "6.2.0" + record_android: + dependency: transitive + description: + name: record_android + sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6" + url: "https://pub.dev" + source: hosted + version: "1.5.0" record_use: dependency: transitive description: @@ -864,6 +920,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" rxdart: dependency: transitive description: @@ -1017,10 +1089,10 @@ packages: dependency: transitive description: name: test_api - sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.8" + version: "0.7.10" tuple: dependency: transitive description: @@ -1121,10 +1193,10 @@ packages: dependency: transitive description: name: vector_graphics - sha256: "81da85e9ca8885ade47f9685b953cb098970d11be4821ac765580a6607ea4373" + sha256: "6409a25046024f0f8c5d8a59fec314081e81f9d436b66ca4015a8b49772bf445" url: "https://pub.dev" source: hosted - version: "1.1.21" + version: "1.2.0" vector_graphics_codec: dependency: transitive description: @@ -1153,10 +1225,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "046d3928e16fa4dc46e8350415661755ab759d9fc97fc21b5ab295f71e4f0499" + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" url: "https://pub.dev" source: hosted - version: "15.1.0" + version: "15.2.0" wakelock_plus: dependency: "direct main" description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 9b54812b..5b96a0ed 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -63,6 +63,9 @@ dependencies: device_info_plus: ^12.3.0 pub_semver: ^2.2.0 playback_capture: ^0.0.4 + audio_waveforms: ^2.0.2 + record: ^6.1.2 + permission_handler: ^12.0.1 dev_dependencies: flutter_test: diff --git a/open_wearable/windows/flutter/generated_plugin_registrant.cc b/open_wearable/windows/flutter/generated_plugin_registrant.cc index 37245d29..0eaaf699 100644 --- a/open_wearable/windows/flutter/generated_plugin_registrant.cc +++ b/open_wearable/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -20,6 +21,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FileSelectorWindows")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + RecordWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UniversalBlePluginCApiRegisterWithRegistrar( diff --git a/open_wearable/windows/flutter/generated_plugins.cmake b/open_wearable/windows/flutter/generated_plugins.cmake index ed5b9c00..3689918f 100644 --- a/open_wearable/windows/flutter/generated_plugins.cmake +++ b/open_wearable/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_windows file_selector_windows permission_handler_windows + record_windows share_plus universal_ble url_launcher_windows From 73cb599799ef2d090a442dbb96d7a8fad5b8376e Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Mon, 19 Jan 2026 14:53:19 +0100 Subject: [PATCH 03/15] fix scrolling problems with charts screen --- .../sensors/values/sensor_values_page.dart | 15 ++++++++++----- open_wearable/pubspec.lock | 8 ++++++++ open_wearable/pubspec.yaml | 1 + 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index b91e31b5..801b1b9d 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -709,17 +709,22 @@ class WaveformPainter extends CustomPainter { ..strokeCap = StrokeCap.round ..style = PaintingStyle.stroke; - // Calculate starting position to align bars from right - final totalWaveformWidth = waveformData.length * spacing; + // Calculate how many bars can fit in the available width + final maxBars = (size.width / spacing).floor(); + final startIndex = + waveformData.length > maxBars ? waveformData.length - maxBars : 0; + + // Calculate starting position (always start at 0 or align right) + final visibleData = waveformData.sublist(startIndex); + final totalWaveformWidth = visibleData.length * spacing; final startX = size.width - totalWaveformWidth; // Draw each amplitude value as a vertical bar - for (int i = 0; i < waveformData.length; i++) { + for (int i = 0; i < visibleData.length; i++) { final x = startX + (i * spacing); - final amplitude = waveformData[i]; + final amplitude = visibleData[i]; // Scale amplitude to fit within the canvas height - // Amplitude is normalized to 0-2 range, scale it to use 80% of half height final barHeight = amplitude * centerY * 0.8; // Draw top half of the bar (above center line) diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index bf98b748..0f9aa10a 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -358,6 +358,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + flutter_headset_detector: + dependency: "direct main" + description: + name: flutter_headset_detector + sha256: fe061eceb106a61b837ae58eda1575604db24299a4ebe13e34839dd3d30085df + url: "https://pub.dev" + source: hosted + version: "3.1.0" flutter_lints: dependency: "direct dev" description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 5b96a0ed..14acb0ed 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -64,6 +64,7 @@ dependencies: pub_semver: ^2.2.0 playback_capture: ^0.0.4 audio_waveforms: ^2.0.2 + flutter_headset_detector: ^3.1.0 record: ^6.1.2 permission_handler: ^12.0.1 From 032cc017ad458e58768790d4bce121b62364ac5d Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Mon, 19 Jan 2026 15:42:05 +0100 Subject: [PATCH 04/15] auto select OpenEarable as audio source, improve UI of Chart tab, fix spacing --- open_wearable/ios/Podfile.lock | 6 ++++++ .../widgets/sensors/values/sensor_values_page.dart | 14 +++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock index e5d0411d..2a1f5fbc 100644 --- a/open_wearable/ios/Podfile.lock +++ b/open_wearable/ios/Podfile.lock @@ -46,6 +46,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) @@ -89,6 +91,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`) @@ -126,6 +129,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: @@ -157,6 +162,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 diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index 801b1b9d..8747566a 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -104,9 +104,17 @@ class _SensorValuesPageState extends State if (mounted) { setState(() { _devices = devs; - if (_selectedDevice == null && _devices.isNotEmpty) { - _selectedDevice = _devices.first; - print("Selected device: ${_selectedDevice?.label}"); + // Automatically select BLE headset + _selectedDevice = _devices.firstWhere( + (device) => + device.label.toLowerCase().contains('bluetooth') || + device.label.toLowerCase().contains('ble') || + device.label.toLowerCase().contains('headset'), + orElse: () => + _devices.isNotEmpty ? _devices.first : null as InputDevice, + ); + if (_selectedDevice != null) { + print("Auto-selected BLE device: ${_selectedDevice?.label}"); } }); } From 36e7bcd14f3c32c56755d98e4071bf5dc8e2374c Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Fri, 23 Jan 2026 17:17:19 +0100 Subject: [PATCH 05/15] add audio file to recording folder. Fixed some bugs: switching tabs does not cause audio recording to stop. Audio recording is only available on android. Audio recording only records from ble headset. --- open_wearable/ios/Podfile.lock | 6 - .../view_models/sensor_recorder_provider.dart | 121 +++++++- .../sensors/values/sensor_values_page.dart | 273 +++++++++++------- open_wearable/pubspec.lock | 8 - open_wearable/pubspec.yaml | 1 - 5 files changed, 281 insertions(+), 128 deletions(-) diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock index 2a1f5fbc..82b7fdd3 100644 --- a/open_wearable/ios/Podfile.lock +++ b/open_wearable/ios/Podfile.lock @@ -1,6 +1,4 @@ PODS: - - audio_waveforms (0.0.1): - - Flutter - audioplayers_darwin (0.0.1): - Flutter - FlutterMacOS @@ -84,7 +82,6 @@ PODS: - ZIPFoundation (0.9.19) DEPENDENCIES: - - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) @@ -115,8 +112,6 @@ SPEC REPOS: - ZIPFoundation EXTERNAL SOURCES: - audio_waveforms: - :path: ".symlinks/plugins/audio_waveforms/ios" audioplayers_darwin: :path: ".symlinks/plugins/audioplayers_darwin/darwin" device_info_plus: @@ -153,7 +148,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: - audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index d79e2627..00884db5 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; +import 'package:record/record.dart'; import '../models/logger.dart'; import '../models/sensor_streams.dart'; @@ -32,18 +33,50 @@ class SensorRecorderProvider with ChangeNotifier { bool _hasSensorsConnected = false; String? _currentDirectory; DateTime? _recordingStart; + final AudioRecorder _audioRecorder = AudioRecorder(); + bool _isAudioRecording = false; + String? _currentAudioPath; + StreamSubscription? _amplitudeSub; bool get isRecording => _isRecording; bool get hasSensorsConnected => _hasSensorsConnected; String? get currentDirectory => _currentDirectory; DateTime? get recordingStart => _recordingStart; + final List _waveformData = []; + List get waveformData => List.unmodifiable(_waveformData); + + InputDevice? _selectedBLEDevice; + + Future _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 startRecording(String dirname) async { if (_isRecording) { return; } - _recordingFilepathsBySensorIdentity.clear(); + _isRecording = true; _currentDirectory = dirname; _recordingStart = DateTime.now(); @@ -63,13 +96,90 @@ class SensorRecorderProvider with ChangeNotifier { notifyListeners(); rethrow; } + + await _startAudioRecording( + dirname, + ); + + notifyListeners(); + } + + Future _startAudioRecording(String recordingFolderPath) async { + if (_selectedBLEDevice == null) { + logger.w("No BLE headset detected, skipping audio recording"); + return; + } + if (!Platform.isAndroid) return; + try { + if (!await _audioRecorder.hasPermission()) { + logger.w("No microphone permission for recording"); + return; + } + + await _selectBLEDevice(); + + 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() async { _isRecording = false; _recordingStart = null; _recordingFilepathsBySensorIdentity.clear(); _stopAllRecorderStreams(); + try { + if (_isAudioRecording) { + final path = await _audioRecorder.stop(); + _amplitudeSub?.cancel(); + _amplitudeSub = null; + _isAudioRecording = false; + _waveformData.clear(); + + logger.i("Audio recording saved to: $path"); + _currentAudioPath = null; + } + } catch (e) { + logger.e("Error stopping audio recording: $e"); + } notifyListeners(); } @@ -307,6 +417,13 @@ class SensorRecorderProvider with ChangeNotifier { @override void dispose() { _disposed = true; + _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); } diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index 8747566a..e4b4434d 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -1,9 +1,13 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:logger/logger.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/models/app_shutdown_settings.dart'; import 'package:open_wearable/models/wearable_display_group.dart'; import 'package:open_wearable/view_models/sensor_data_provider.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:open_wearable/widgets/sensors/values/sensor_value_card.dart'; @@ -13,6 +17,8 @@ import 'package:record/record.dart'; import 'dart:async'; import 'package:path_provider/path_provider.dart'; +Logger _logger = Logger(); + class SensorValuesPage extends StatefulWidget { final Map<(Wearable, Sensor), SensorDataProvider>? sharedProviders; @@ -36,15 +42,16 @@ class _SensorValuesPageState extends State // Audio State late final AudioRecorder _audioRecorder; + bool _isPreviewRecording = false; bool _isRecording = false; String? _errorMessage; - List _devices = []; InputDevice? _selectedDevice; StreamSubscription? _recordSub; StreamSubscription? _amplitudeSub; RecordState _recordState = RecordState.stop; - List _waveformData = []; - Amplitude? _amplitude; + final List _waveformData = []; + + bool _isInitializing = true; @override bool get wantKeepAlive => true; @@ -52,38 +59,52 @@ class _SensorValuesPageState extends State @override void initState() { super.initState(); - _audioRecorder = AudioRecorder(); + if (Platform.isAndroid) { + _audioRecorder = AudioRecorder(); - // Only subscribe to state changes initially - _recordSub = _audioRecorder.onStateChanged().listen((recordState) { - if (mounted) { - setState(() => _recordState = recordState); + _recordSub = _audioRecorder!.onStateChanged().listen((recordState) { + if (mounted) { + setState(() => _recordState = recordState); - // Clean up amplitude subscription when recording stops - if (recordState == RecordState.stop) { - _amplitudeSub?.cancel(); - _amplitudeSub = null; + if (recordState == RecordState.stop) { + _amplitudeSub?.cancel(); + _amplitudeSub = null; + } } - } - }); + }); + + _initRecording(); + } + } - _initRecording(); + // Add method to check if provider is recording + bool _isProviderRecording(BuildContext context) { + try { + final recorder = + Provider.of(context, listen: false); + return recorder.isRecording; + } catch (e) { + return false; + } } Future _initRecording() async { - print("Initializing audio recorder"); + if (!Platform.isAndroid || _audioRecorder == null) return; + + if (_isProviderRecording(context)) { + if (mounted) setState(() => _isInitializing = false); + return; + } try { - if (await _audioRecorder.hasPermission()) { - print("Permission granted"); - await _loadDevices(); - await _startRecording(); + if (await _audioRecorder!.hasPermission()) { + await _selectBLEDevice(); + await _startPreview(); } else { - print("No permission, requesting..."); final status = await Permission.microphone.request(); if (status.isGranted) { - await _loadDevices(); - await _startRecording(); + await _selectBLEDevice(); + await _startPreview(); } else { if (mounted) { setState(() => _errorMessage = 'Microphone permission denied'); @@ -91,81 +112,97 @@ class _SensorValuesPageState extends State } } } catch (e) { - print("Init error: $e"); if (mounted) { setState(() => _errorMessage = 'Failed to initialize: $e'); } + } finally { + if (mounted) { + setState(() => _isInitializing = false); + } } } - Future _loadDevices() async { + Future _selectBLEDevice() async { + if (!Platform.isAndroid || _audioRecorder == null) return; try { - final devs = await _audioRecorder.listInputDevices(); - if (mounted) { - setState(() { - _devices = devs; - // Automatically select BLE headset - _selectedDevice = _devices.firstWhere( - (device) => - device.label.toLowerCase().contains('bluetooth') || - device.label.toLowerCase().contains('ble') || - device.label.toLowerCase().contains('headset'), - orElse: () => - _devices.isNotEmpty ? _devices.first : null as InputDevice, - ); - if (_selectedDevice != null) { - print("Auto-selected BLE device: ${_selectedDevice?.label}"); - } - }); + final devices = await _audioRecorder!.listInputDevices(); + + // Try to find BLE device + try { + _selectedDevice = 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( + "Auto-selected BLE device for preview: ${_selectedDevice!.label}"); + } catch (e) { + // No BLE device found + _selectedDevice = null; + _logger.e("No BLE headset found"); } } catch (e) { - print("Error loading devices: $e"); + _logger.e("Error selecting BLE device: $e"); + _selectedDevice = null; } } - Future _getRecordingPath() async { + Future _getTemporaryPath() async { final directory = await getTemporaryDirectory(); - return '${directory.path}/temp_recording_${DateTime.now().millisecondsSinceEpoch}.m4a'; + return '${directory.path}/preview_${DateTime.now().millisecondsSinceEpoch}.m4a'; } - Future _startRecording() async { + Future _startPreview() async { + if (!Platform.isAndroid || _audioRecorder == null) return; + + // Don't start if provider is recording + if (_isProviderRecording(context)) { + return; + } + + // Don't start if no BLE device selected + if (_selectedDevice == null) { + if (mounted) { + setState(() => _errorMessage = 'No BLE headset detected'); + } + return; + } + try { - const encoder = AudioEncoder.aacLc; + const encoder = AudioEncoder.wav; - if (!await _audioRecorder.isEncoderSupported(encoder)) { + if (!await _audioRecorder!.isEncoderSupported(encoder)) { if (mounted) { - setState(() => _errorMessage = 'Encoder not supported'); + setState(() => _errorMessage = 'WAV encoder not supported'); } return; } - final path = await _getRecordingPath(); + final path = await _getTemporaryPath(); final config = RecordConfig( encoder: encoder, + sampleRate: 48000, + bitRate: 768000, numChannels: 1, device: _selectedDevice, ); - await _audioRecorder.start(config, path: path); - - // Wait a bit to ensure recording is active + await _audioRecorder!.start(config, path: path); await Future.delayed(Duration(milliseconds: 100)); _amplitudeSub?.cancel(); - // Subscribe to amplitude changes after recording started - _amplitudeSub = _audioRecorder + _amplitudeSub = _audioRecorder! .onAmplitudeChanged(const Duration(milliseconds: 100)) .listen( (amp) { if (mounted) { setState(() { - _amplitude = amp; - // Add normalized amplitude to waveform data final normalized = (amp.current + 50) / 50; _waveformData.add(normalized.clamp(0.0, 2.0)); - // Keep only last 100 samples if (_waveformData.length > 100) { _waveformData.removeAt(0); } @@ -173,57 +210,85 @@ class _SensorValuesPageState extends State } }, onError: (error) { - print("Amplitude stream error: $error"); + _logger.e("Amplitude stream error: $error"); }, ); if (mounted) { setState(() { - _isRecording = true; + _isPreviewRecording = true; _errorMessage = null; }); } } catch (e) { - print("Recording start error: $e"); + _logger.e("Preview start error: $e"); if (mounted) { - setState(() => _errorMessage = 'Failed to start recording: $e'); + setState(() => _errorMessage = 'Failed to start preview: $e'); } } } - Future _changeDevice(InputDevice? device) async { - if (device == null) return; + Future _stopPreview() async { + if (!Platform.isAndroid || _audioRecorder == null) return; + if (!_isPreviewRecording) return; - // Stop current recording - if (_recordState != RecordState.stop) { - await _audioRecorder.stop(); + try { + final tempPath = await _audioRecorder!.stop(); _amplitudeSub?.cancel(); _amplitudeSub = null; - } - // Update selected device and restart - if (mounted) { - setState(() { - _selectedDevice = device; - _waveformData.clear(); - _isRecording = false; - }); - } + if (tempPath != null) { + try { + final file = File(tempPath); + if (await file.exists()) { + await file.delete(); + } + } catch (e) { + _logger.e("Error deleting temp preview file: $e"); + } + } - await _startRecording(); + if (mounted) { + setState(() { + _isPreviewRecording = false; + _waveformData.clear(); + }); + } + } catch (e) { + _logger.e("Error stopping preview: $e"); + } } @override void dispose() { - _audioRecorder.stop(); - _recordSub?.cancel(); - _amplitudeSub?.cancel(); - _audioRecorder.dispose(); if (_ownsProviders) { for (final provider in _ownedProviders.values) { provider.dispose(); } _ownedProviders.clear(); + // Stop and clean up preview recording + if (Platform.isAndroid) { + if (_recordState != RecordState.stop) { + _audioRecorder.stop().then((tempPath) { + if (tempPath != null) { + try { + final file = File(tempPath); + file.exists().then((exists) { + if (exists) { + file.delete(); + } + }); + } catch (e) { + _logger.e("Error deleting temp preview file: $e"); + } + } + }); + } + + _recordSub?.cancel(); + _amplitudeSub?.cancel(); + _audioRecorder.dispose(); + } } super.dispose(); } @@ -240,8 +305,18 @@ class _SensorValuesPageState extends State builder: (context, hideCardsWithoutLiveData, __) { final shouldHideCardsWithoutLiveData = hideCardsWithoutLiveData && !disableLiveDataGraphs; - return Consumer( - builder: (context, wearablesProvider, child) { + return Consumer2( + builder: (context, wearablesProvider, recorderProvider, child) { + // Stop preview if provider starts recording + if (Platform.isAndroid && + recorderProvider.isRecording && + _isPreviewRecording) { + _stopPreview(); + } else if (Platform.isAndroid && + !recorderProvider.isRecording && + !_isPreviewRecording) { + _initRecording(); + } return FutureBuilder>( future: buildWearableDisplayGroups( wearablesProvider.wearables, @@ -449,32 +524,6 @@ class _SensorValuesPageState extends State Widget _buildAudioUI() { return Column( children: [ - if (_devices.isNotEmpty) - Card( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - const Text('Input: ', - style: TextStyle(fontWeight: FontWeight.bold)), - const SizedBox(width: 8), - Expanded( - child: DropdownButton( - value: _selectedDevice, - isExpanded: true, - items: _devices - .map((d) => - DropdownMenuItem(value: d, child: Text(d.label))) - .toList(), - onChanged: _changeDevice, - ), - ), - IconButton( - icon: const Icon(Icons.refresh), onPressed: _loadDevices), - ], - ), - ), - ), if (_isRecording) Card( child: Padding( @@ -488,8 +537,10 @@ class _SensorValuesPageState extends State else if (_errorMessage != null) Padding( padding: const EdgeInsets.all(8.0), - child: PlatformText(_errorMessage!, - style: const TextStyle(color: Colors.red)), + child: PlatformText( + _errorMessage!, + style: const TextStyle(color: Colors.red), + ), ), const SizedBox(height: 10), ], diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 0f9aa10a..b99379de 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -17,14 +17,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" - audio_waveforms: - dependency: "direct main" - description: - name: audio_waveforms - sha256: "03b3430ecf430a2e90185518a228c02be3d26653c62dd931e50d671213a6dbc8" - url: "https://pub.dev" - source: hosted - version: "2.0.2" audioplayers: dependency: "direct main" description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 14acb0ed..844afb60 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -63,7 +63,6 @@ dependencies: device_info_plus: ^12.3.0 pub_semver: ^2.2.0 playback_capture: ^0.0.4 - audio_waveforms: ^2.0.2 flutter_headset_detector: ^3.1.0 record: ^6.1.2 permission_handler: ^12.0.1 From 0eef312d12d64ac1ecf98280fbd5187284c8f238 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Mon, 26 Jan 2026 15:05:05 +0100 Subject: [PATCH 06/15] fix bug --- .../lib/view_models/sensor_recorder_provider.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index 00884db5..c3207daa 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -105,10 +105,6 @@ class SensorRecorderProvider with ChangeNotifier { } Future _startAudioRecording(String recordingFolderPath) async { - if (_selectedBLEDevice == null) { - logger.w("No BLE headset detected, skipping audio recording"); - return; - } if (!Platform.isAndroid) return; try { if (!await _audioRecorder.hasPermission()) { @@ -118,6 +114,11 @@ class SensorRecorderProvider with ChangeNotifier { 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"); From dcdc5b09472c76a79914bd39bf29f3bcd198fc10 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Mon, 26 Jan 2026 17:46:02 +0100 Subject: [PATCH 07/15] starting of audio streaming is now manuallycontrolled --- .../view_models/sensor_recorder_provider.dart | 151 +++++++++++- .../ble_microphone_streaming_row.dart | 53 +++++ .../sensor_configuration_device_row.dart | 7 + .../sensors/values/sensor_values_page.dart | 223 ++---------------- 4 files changed, 225 insertions(+), 209 deletions(-) create mode 100644 open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index c3207daa..7ce7bf1d 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -3,6 +3,7 @@ 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'; @@ -48,6 +49,14 @@ class SensorRecorderProvider with ChangeNotifier { InputDevice? _selectedBLEDevice; + bool _isBLEMicrophoneStreamingEnabled = false; + bool get isBLEMicrophoneStreamingEnabled => _isBLEMicrophoneStreamingEnabled; + + // Separate AudioRecorder for streaming + AudioRecorder? _streamingAudioRecorder; + bool _isStreamingActive = false; + StreamSubscription? _streamingAmplitudeSub; + Future _selectBLEDevice() async { try { final devices = await _audioRecorder.listInputDevices(); @@ -71,11 +80,120 @@ class SensorRecorderProvider with ChangeNotifier { } } - Future startRecording(String dirname) async { - if (_isRecording) { + Future 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; + } + + _streamingAudioRecorder = AudioRecorder(); + + const encoder = AudioEncoder.wav; + if (!await _streamingAudioRecorder!.isEncoderSupported(encoder)) { + logger.w("WAV encoder not supported"); + _streamingAudioRecorder = null; + return false; + } + + final tempDir = await getTemporaryDirectory(); + final tempPath = + '${tempDir.path}/ble_stream_${DateTime.now().millisecondsSinceEpoch}.wav'; + + final config = RecordConfig( + encoder: encoder, + sampleRate: 48000, + bitRate: 768000, + numChannels: 1, + device: _selectedBLEDevice, + ); + + await _streamingAudioRecorder!.start(config, path: tempPath); + _isStreamingActive = true; + _isBLEMicrophoneStreamingEnabled = true; + + // Set up amplitude monitoring for waveform display + _streamingAmplitudeSub?.cancel(); + _streamingAmplitudeSub = _streamingAudioRecorder! + .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(); + }); + + Future.delayed(const Duration(seconds: 1), () async { + try { + final file = File(tempPath); + if (await file.exists()) { + await file.delete(); + } + } catch (e) { + // Ignore cleanup errors + } + }); + + 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; + _streamingAudioRecorder?.dispose(); + _streamingAudioRecorder = null; + notifyListeners(); + return false; + } + } + + Future stopBLEMicrophoneStream() async { + if (!_isStreamingActive) { return; } - _recordingFilepathsBySensorIdentity.clear(); + + try { + await _streamingAudioRecorder?.stop(); + _streamingAmplitudeSub?.cancel(); + _streamingAmplitudeSub = null; + _streamingAudioRecorder?.dispose(); + _streamingAudioRecorder = null; + _isStreamingActive = false; + _isBLEMicrophoneStreamingEnabled = false; + _waveformData.clear(); + + 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(); @@ -106,6 +224,22 @@ class SensorRecorderProvider with ChangeNotifier { Future _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 _streamingAudioRecorder?.stop(); + _streamingAmplitudeSub?.cancel(); + _streamingAmplitudeSub = null; + _isStreamingActive = false; + } + try { if (!await _audioRecorder.hasPermission()) { logger.w("No microphone permission for recording"); @@ -181,6 +315,12 @@ class SensorRecorderProvider with ChangeNotifier { } catch (e) { logger.e("Error stopping audio recording: $e"); } + + // Restart streaming if it was enabled before recording + if (_isBLEMicrophoneStreamingEnabled && !_isStreamingActive) { + unawaited(startBLEMicrophoneStream()); + } + notifyListeners(); } @@ -417,7 +557,10 @@ class SensorRecorderProvider with ChangeNotifier { @override void dispose() { - _disposed = true; + // Stop streaming + stopBLEMicrophoneStream(); + + // Stop recording _audioRecorder.stop().then((_) { _audioRecorder.dispose(); }).catchError((e) { diff --git a/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart new file mode 100644 index 00000000..e6efa864 --- /dev/null +++ b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart @@ -0,0 +1,53 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:provider/provider.dart'; +import '../../../view_models/sensor_recorder_provider.dart'; + +/// Widget to control BLE microphone streaming +class BLEMicrophoneStreamingRow extends StatelessWidget { + const BLEMicrophoneStreamingRow({super.key}); + + @override + Widget build(BuildContext context) { + if (!Platform.isAndroid) { + return const SizedBox.shrink(); + } + + return Consumer( + builder: (context, recorderProvider, child) { + final isStreamingEnabled = recorderProvider.isBLEMicrophoneStreamingEnabled; + + return PlatformListTile( + title: PlatformText('BLE Microphone Streaming'), + subtitle: PlatformText( + isStreamingEnabled + ? 'Microphone stream is active' + : 'Enable to start microphone streaming', + ), + trailing: PlatformSwitch( + value: isStreamingEnabled, + onChanged: (value) async { + if (value) { + final success = await recorderProvider.startBLEMicrophoneStream(); + if (!success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: PlatformText( + 'Failed to start BLE microphone streaming. ' + 'Make sure a BLE headset is connected and microphone permission is granted.', + ), + backgroundColor: Colors.red, + ), + ); + } + } else { + await recorderProvider.stopBLEMicrophoneStream(); + } + }, + ), + ); + }, + ); + } +} diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart index 4845f87a..e7db901c 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart @@ -7,6 +7,7 @@ import 'package:open_wearable/view_models/sensor_configuration_storage.dart'; import 'package:open_wearable/view_models/sensor_profile_service.dart'; import 'package:open_wearable/widgets/app_toast.dart'; import 'package:open_wearable/widgets/devices/stereo_position_badge.dart'; +import 'package:open_wearable/widgets/sensors/configuration/ble_microphone_streaming_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/edge_recorder_prefix_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/save_config_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/sensor_configuration_profile_widgets.dart'; @@ -383,6 +384,12 @@ class _SensorConfigurationDeviceRowState ), ]; + // Add BLE microphone streaming control (Android only) + content.addAll([ + const Divider(), + const BLEMicrophoneStreamingRow(), + ]); + if (device.hasCapability()) { content.addAll([ const InsetSectionDivider(), diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index e4b4434d..55c3cc0e 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -2,7 +2,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:logger/logger.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/models/app_shutdown_settings.dart'; import 'package:open_wearable/models/wearable_display_group.dart'; @@ -11,13 +10,9 @@ import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:open_wearable/widgets/sensors/values/sensor_value_card.dart'; -import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; import 'package:record/record.dart'; import 'dart:async'; -import 'package:path_provider/path_provider.dart'; - -Logger _logger = Logger(); class SensorValuesPage extends StatefulWidget { final Map<(Wearable, Sensor), SensorDataProvider>? sharedProviders; @@ -60,202 +55,28 @@ class _SensorValuesPageState extends State void initState() { super.initState(); if (Platform.isAndroid) { - _audioRecorder = AudioRecorder(); - - _recordSub = _audioRecorder!.onStateChanged().listen((recordState) { - if (mounted) { - setState(() => _recordState = recordState); - - if (recordState == RecordState.stop) { - _amplitudeSub?.cancel(); - _amplitudeSub = null; - } - } - }); - - _initRecording(); - } - } - - // Add method to check if provider is recording - bool _isProviderRecording(BuildContext context) { - try { - final recorder = - Provider.of(context, listen: false); - return recorder.isRecording; - } catch (e) { - return false; + _checkStreamingStatus(); } } - Future _initRecording() async { - if (!Platform.isAndroid || _audioRecorder == null) return; - - if (_isProviderRecording(context)) { - if (mounted) setState(() => _isInitializing = false); - return; - } - - try { - if (await _audioRecorder!.hasPermission()) { - await _selectBLEDevice(); - await _startPreview(); - } else { - final status = await Permission.microphone.request(); - if (status.isGranted) { - await _selectBLEDevice(); - await _startPreview(); - } else { - if (mounted) { - setState(() => _errorMessage = 'Microphone permission denied'); - } - } - } - } catch (e) { - if (mounted) { - setState(() => _errorMessage = 'Failed to initialize: $e'); - } - } finally { - if (mounted) { - setState(() => _isInitializing = false); - } - } - } - - Future _selectBLEDevice() async { - if (!Platform.isAndroid || _audioRecorder == null) return; - try { - final devices = await _audioRecorder!.listInputDevices(); - - // Try to find BLE device - try { - _selectedDevice = 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( - "Auto-selected BLE device for preview: ${_selectedDevice!.label}"); - } catch (e) { - // No BLE device found - _selectedDevice = null; - _logger.e("No BLE headset found"); - } - } catch (e) { - _logger.e("Error selecting BLE device: $e"); - _selectedDevice = null; - } - } - - Future _getTemporaryPath() async { - final directory = await getTemporaryDirectory(); - return '${directory.path}/preview_${DateTime.now().millisecondsSinceEpoch}.m4a'; - } - - Future _startPreview() async { - if (!Platform.isAndroid || _audioRecorder == null) return; - - // Don't start if provider is recording - if (_isProviderRecording(context)) { - return; - } - - // Don't start if no BLE device selected - if (_selectedDevice == null) { - if (mounted) { - setState(() => _errorMessage = 'No BLE headset detected'); - } - return; - } - - try { - const encoder = AudioEncoder.wav; - - if (!await _audioRecorder!.isEncoderSupported(encoder)) { - if (mounted) { - setState(() => _errorMessage = 'WAV encoder not supported'); - } - return; - } - - final path = await _getTemporaryPath(); - - final config = RecordConfig( - encoder: encoder, - sampleRate: 48000, - bitRate: 768000, - numChannels: 1, - device: _selectedDevice, - ); - - await _audioRecorder!.start(config, path: path); - await Future.delayed(Duration(milliseconds: 100)); - - _amplitudeSub?.cancel(); - _amplitudeSub = _audioRecorder! - .onAmplitudeChanged(const Duration(milliseconds: 100)) - .listen( - (amp) { - if (mounted) { - setState(() { - final normalized = (amp.current + 50) / 50; - _waveformData.add(normalized.clamp(0.0, 2.0)); - - if (_waveformData.length > 100) { - _waveformData.removeAt(0); - } - }); - } - }, - onError: (error) { - _logger.e("Amplitude stream error: $error"); - }, - ); - + void _checkStreamingStatus() { + final recorderProvider = + Provider.of(context, listen: false); + if (!recorderProvider.isBLEMicrophoneStreamingEnabled) { if (mounted) { setState(() { - _isPreviewRecording = true; - _errorMessage = null; + _isInitializing = false; + _errorMessage = + 'BLE microphone streaming not enabled. Enable it in sensor configuration.'; }); } - } catch (e) { - _logger.e("Preview start error: $e"); - if (mounted) { - setState(() => _errorMessage = 'Failed to start preview: $e'); - } - } - } - - Future _stopPreview() async { - if (!Platform.isAndroid || _audioRecorder == null) return; - if (!_isPreviewRecording) return; - - try { - final tempPath = await _audioRecorder!.stop(); - _amplitudeSub?.cancel(); - _amplitudeSub = null; - - if (tempPath != null) { - try { - final file = File(tempPath); - if (await file.exists()) { - await file.delete(); - } - } catch (e) { - _logger.e("Error deleting temp preview file: $e"); - } - } - + } else { if (mounted) { setState(() { - _isPreviewRecording = false; - _waveformData.clear(); + _isInitializing = false; + _errorMessage = null; }); } - } catch (e) { - _logger.e("Error stopping preview: $e"); } } @@ -279,7 +100,7 @@ class _SensorValuesPageState extends State } }); } catch (e) { - _logger.e("Error deleting temp preview file: $e"); + //_logger.e("Error deleting temp preview file: $e"); } } }); @@ -307,16 +128,6 @@ class _SensorValuesPageState extends State hideCardsWithoutLiveData && !disableLiveDataGraphs; return Consumer2( builder: (context, wearablesProvider, recorderProvider, child) { - // Stop preview if provider starts recording - if (Platform.isAndroid && - recorderProvider.isRecording && - _isPreviewRecording) { - _stopPreview(); - } else if (Platform.isAndroid && - !recorderProvider.isRecording && - !_isPreviewRecording) { - _initRecording(); - } return FutureBuilder>( future: buildWearableDisplayGroups( wearablesProvider.wearables, @@ -563,8 +374,9 @@ class _SensorValuesPageState extends State child: _buildEmptyStateCard( context, _resolveEmptyState( - hasAnySensors: hasAnySensors, - hideCardsWithoutLiveData: hideCardsWithoutLiveData), + hasAnySensors: hasAnySensors, + hideCardsWithoutLiveData: hideCardsWithoutLiveData, + ), ), ), ], @@ -597,8 +409,9 @@ class _SensorValuesPageState extends State return _buildEmptyStateCard( context, _resolveEmptyState( - hasAnySensors: hasAnySensors, - hideCardsWithoutLiveData: hideCardsWithoutLiveData), + hasAnySensors: hasAnySensors, + hideCardsWithoutLiveData: hideCardsWithoutLiveData, + ), ); } return charts[index]; From b170a53a3f7d9a23bfdfb09ede1f4f3f07a59c00 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Tue, 27 Jan 2026 15:49:43 +0100 Subject: [PATCH 08/15] microphone stream can now be turned on and off together with the sensor configuration. OpenEarable firmware needs adjustment for this to work. Haven't disabled it for iOS yet --- .../ble_microphone_streaming_row.dart | 7 ++++--- .../sensor_configuration_device_row.dart | 11 +++++------ .../configuration/sensor_configuration_view.dart | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart index e6efa864..16c0bb5e 100644 --- a/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart @@ -4,7 +4,6 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:provider/provider.dart'; import '../../../view_models/sensor_recorder_provider.dart'; -/// Widget to control BLE microphone streaming class BLEMicrophoneStreamingRow extends StatelessWidget { const BLEMicrophoneStreamingRow({super.key}); @@ -16,7 +15,8 @@ class BLEMicrophoneStreamingRow extends StatelessWidget { return Consumer( builder: (context, recorderProvider, child) { - final isStreamingEnabled = recorderProvider.isBLEMicrophoneStreamingEnabled; + final isStreamingEnabled = + recorderProvider.isBLEMicrophoneStreamingEnabled; return PlatformListTile( title: PlatformText('BLE Microphone Streaming'), @@ -29,7 +29,8 @@ class BLEMicrophoneStreamingRow extends StatelessWidget { value: isStreamingEnabled, onChanged: (value) async { if (value) { - final success = await recorderProvider.startBLEMicrophoneStream(); + final success = + await recorderProvider.startBLEMicrophoneStream(); if (!success && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart index e7db901c..cdc66924 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart @@ -6,6 +6,7 @@ import 'package:open_wearable/models/device_name_formatter.dart'; import 'package:open_wearable/view_models/sensor_configuration_storage.dart'; import 'package:open_wearable/view_models/sensor_profile_service.dart'; import 'package:open_wearable/widgets/app_toast.dart'; +import 'package:open_wearable/widgets/devices/device_detail/stereo_pos_label.dart'; import 'package:open_wearable/widgets/devices/stereo_position_badge.dart'; import 'package:open_wearable/widgets/sensors/configuration/ble_microphone_streaming_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/edge_recorder_prefix_row.dart'; @@ -140,6 +141,10 @@ class _SensorConfigurationDeviceRowState child: tabBar, ), ], + if (device.hasCapability()) + StereoPosLabel( + device: device.requireCapability(), + ), ], ), ), @@ -384,12 +389,6 @@ class _SensorConfigurationDeviceRowState ), ]; - // Add BLE microphone streaming control (Android only) - content.addAll([ - const Divider(), - const BLEMicrophoneStreamingRow(), - ]); - if (device.hasCapability()) { content.addAll([ const InsetSectionDivider(), diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart index cc9f6411..e1634df6 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart @@ -4,6 +4,7 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import 'package:open_wearable/models/wearable_display_group.dart'; import 'package:open_wearable/view_models/sensor_configuration_provider.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/app_toast.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; @@ -274,6 +275,10 @@ class SensorConfigurationView extends StatelessWidget { int actionableCount = 0; + final recorderProvider = + Provider.of(context, listen: false); + bool shouldEnableMicrophoneStreaming = false; + for (final target in targets) { final primaryEntriesToApply = _entriesToApplyForProvider(target.provider); final mirroredEntriesToApply = _entriesToApplyForMirroredTarget(target); @@ -286,6 +291,9 @@ class SensorConfigurationView extends StatelessWidget { for (final entry in primaryEntriesToApply) { final SensorConfiguration config = entry.$1; final SensorConfigurationValue value = entry.$2; + if (config.name.toLowerCase().contains('microphone')) { + shouldEnableMicrophoneStreaming = true; + } // Always push the selected canonical value to the primary device on // apply. This also heals primary-side drift/unknown states. config.setConfiguration(value); @@ -297,6 +305,14 @@ class SensorConfigurationView extends StatelessWidget { config.setConfiguration(value); } + if (shouldEnableMicrophoneStreaming && + !recorderProvider.isBLEMicrophoneStreamingEnabled) { + await recorderProvider.startBLEMicrophoneStream(); + } else if (!shouldEnableMicrophoneStreaming && + recorderProvider.isBLEMicrophoneStreamingEnabled) { + await recorderProvider.stopBLEMicrophoneStream(); + } + logger.d( "Applied ${primaryEntriesToApply.length} primary and ${mirroredEntriesToApply.length} mirrored sensor settings for ${target.primaryDevice.name}", ); From 80ca7a1a44c23efdb899ca30cee4c8fb1a5b5080 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Wed, 28 Jan 2026 15:21:14 +0100 Subject: [PATCH 09/15] microphone now also stops when the recording is stopeed with the 'turn off all sensors' button --- .../view_models/sensor_recorder_provider.dart | 6 ++++-- .../local_recorder/recording_controls.dart | 3 ++- .../sensors/values/sensor_values_page.dart | 18 +++++++++++------- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index 7ce7bf1d..c33eb380 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -296,7 +296,7 @@ class SensorRecorderProvider with ChangeNotifier { } } - void stopRecording() async { + void stopRecording(bool turnOffMic) async { _isRecording = false; _recordingStart = null; _recordingFilepathsBySensorIdentity.clear(); @@ -317,7 +317,9 @@ class SensorRecorderProvider with ChangeNotifier { } // Restart streaming if it was enabled before recording - if (_isBLEMicrophoneStreamingEnabled && !_isStreamingActive) { + if (!turnOffMic && + _isBLEMicrophoneStreamingEnabled && + !_isStreamingActive) { unawaited(startBLEMicrophoneStream()); } diff --git a/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart index 0fc3b322..59b12d27 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart @@ -55,12 +55,13 @@ class _RecordingControls extends State { }); try { - recorder.stopRecording(); + recorder.stopRecording(turnOffSensors); if (turnOffSensors) { final wearablesProvider = context.read(); final futures = wearablesProvider.sensorConfigurationProviders.values .map((provider) => provider.turnOffAllSensors()); await Future.wait(futures); + await recorder.stopBLEMicrophoneStream(); } await widget.updateRecordingsList(); } catch (e) { diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index 55c3cc0e..b7291547 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -167,6 +167,7 @@ class _SensorValuesPageState extends State return _buildSmallScreenLayout( context, charts, + recorderProvider, hasAnySensors: hasAnySensors, hideCardsWithoutLiveData: shouldHideCardsWithoutLiveData, @@ -175,6 +176,7 @@ class _SensorValuesPageState extends State return _buildLargeScreenLayout( context, charts, + recorderProvider, hasAnySensors: hasAnySensors, hideCardsWithoutLiveData: shouldHideCardsWithoutLiveData, @@ -332,16 +334,16 @@ class _SensorValuesPageState extends State return ordered; } - Widget _buildAudioUI() { + Widget _buildAudioUI(SensorRecorderProvider recorderProvider) { return Column( children: [ - if (_isRecording) + if (recorderProvider.isBLEMicrophoneStreamingEnabled) Card( child: Padding( padding: const EdgeInsets.all(16), child: CustomPaint( size: const Size(double.infinity, 100), - painter: WaveformPainter(_waveformData), + painter: WaveformPainter(recorderProvider.waveformData), ), ), ) @@ -360,14 +362,15 @@ class _SensorValuesPageState extends State Widget _buildSmallScreenLayout( BuildContext context, - List charts, { + List charts, + SensorRecorderProvider recorderProvider, { required bool hasAnySensors, required bool hideCardsWithoutLiveData, }) { return ListView( padding: SensorPageSpacing.pagePaddingWithBottomInset(context), children: [ - _buildAudioUI(), + _buildAudioUI(recorderProvider), ...charts, if (charts.isEmpty) Center( @@ -385,7 +388,8 @@ class _SensorValuesPageState extends State Widget _buildLargeScreenLayout( BuildContext context, - List charts, { + List charts, + SensorRecorderProvider recorderProvider, { required bool hasAnySensors, required bool hideCardsWithoutLiveData, }) { @@ -393,7 +397,7 @@ class _SensorValuesPageState extends State padding: SensorPageSpacing.pagePaddingWithBottomInset(context), child: Column( children: [ - _buildAudioUI(), + _buildAudioUI(recorderProvider), GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), From 88811ff81fc3c30c210ddc72646a9ae61a488fea Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Mon, 2 Feb 2026 15:19:35 +0100 Subject: [PATCH 10/15] disable streaming option for iOS, remove audio recorder app because it is not needed --- .../sensor_configuration_detail_view.dart | 17 +++++++++++++---- .../sensor_configuration_view.dart | 7 ++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart index 0f7d46d1..549a13c1 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart' show setEquals; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/view_models/sensor_configuration_provider.dart'; @@ -38,7 +39,16 @@ class SensorConfigurationDetailView extends StatelessWidget { final targetOptions = sensorConfiguration is ConfigurableSensorConfiguration ? (sensorConfiguration as ConfigurableSensorConfiguration) .availableOptions - .toList(growable: false) + .where((option) { + // If on Android, show everything. + if (Platform.isAndroid) return true; + + // If on iOS, hide 'microphone' + 'stream' combination + final isMic = + sensorConfiguration.name.toLowerCase().contains('microphone'); + final isStream = option is StreamSensorConfigOption; + return !(isMic && isStream); + }).toList(growable: false) : const []; return ListView( @@ -350,9 +360,8 @@ class _OptionToggleTile extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: selected - ? accentColor.withValues(alpha: 0.06) - : Colors.transparent, + color: + selected ? accentColor.withValues(alpha: 0.06) : Colors.transparent, borderRadius: BorderRadius.circular(10), border: Border.all( color: (selected ? accentColor : colorScheme.outlineVariant) diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart index e1634df6..778ba641 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; @@ -292,7 +293,11 @@ class SensorConfigurationView extends StatelessWidget { final SensorConfiguration config = entry.$1; final SensorConfigurationValue value = entry.$2; if (config.name.toLowerCase().contains('microphone')) { - shouldEnableMicrophoneStreaming = true; + final options = + target.provider.getSelectedConfigurationOptions(config); + if (options.any((opt) => opt is StreamSensorConfigOption)) { + shouldEnableMicrophoneStreaming = true; + } } // Always push the selected canonical value to the primary device on // apply. This also heals primary-side drift/unknown states. From cdf6030f95fbf828613451e448821113868cbabe Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Mon, 2 Feb 2026 15:23:44 +0100 Subject: [PATCH 11/15] add missing trailing commas --- .../sensor_configuration_device_row.dart | 8 +++ .../sensors/values/sensor_values_page.dart | 57 ++++++++++++++++--- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart index cdc66924..7320b4b0 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart @@ -349,9 +349,17 @@ class _SensorConfigurationDeviceRowState if (!mounted) return; setState(() { _content = [ +<<<<<<< HEAD const Padding( padding: EdgeInsets.all(12), child: Text('This device does not support sensor configuration.'), +======= + Padding( + padding: const EdgeInsets.all(8.0), + child: PlatformText( + "This device does not support configuring sensors.", + ), +>>>>>>> 445a3da (add missing trailing commas) ), ]; }); diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index b7291547..8fa4c83c 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -335,24 +335,65 @@ class _SensorValuesPageState extends State } Widget _buildAudioUI(SensorRecorderProvider recorderProvider) { + // If initializing, show a loading card + if (_isInitializing && Platform.isAndroid) { + return Card( + child: Container( + height: 100, + alignment: Alignment.center, + child: const CircularProgressIndicator(), + ), + ); + } + return Column( children: [ if (recorderProvider.isBLEMicrophoneStreamingEnabled) Card( child: Padding( padding: const EdgeInsets.all(16), - child: CustomPaint( - size: const Size(double.infinity, 100), - painter: WaveformPainter(recorderProvider.waveformData), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.fiber_manual_record, + color: Colors.red, + size: 16, + ), + const SizedBox(width: 8), + Text( + 'AUDIO WAVEFORM ${recorderProvider.isRecording ? "(RECORDING)" : ""}', + style: Theme.of(context).textTheme.labelLarge, + ), + ], + ), + const SizedBox(height: 8), + CustomPaint( + size: const Size(double.infinity, 100), + painter: WaveformPainter(recorderProvider.waveformData), + ), + ], ), ), ) else if (_errorMessage != null) - Padding( - padding: const EdgeInsets.all(8.0), - child: PlatformText( - _errorMessage!, - style: const TextStyle(color: Colors.red), + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.red), + const SizedBox(width: 12), + Expanded( + child: PlatformText( + _errorMessage!, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ), ), ), const SizedBox(height: 10), From a0851f0f14d7c601c7ee180ec429d0e2d7a2c6bc Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Wed, 4 Feb 2026 12:50:07 +0100 Subject: [PATCH 12/15] only use one instance of AudioRecorder for both streaming and recording --- .../view_models/sensor_recorder_provider.dart | 73 ++++++++++--------- 1 file changed, 40 insertions(+), 33 deletions(-) diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index c33eb380..ff4bdaff 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -52,10 +52,9 @@ class SensorRecorderProvider with ChangeNotifier { bool _isBLEMicrophoneStreamingEnabled = false; bool get isBLEMicrophoneStreamingEnabled => _isBLEMicrophoneStreamingEnabled; - // Separate AudioRecorder for streaming - AudioRecorder? _streamingAudioRecorder; + // Path for temporary streaming file + String? _streamingPath; bool _isStreamingActive = false; - StreamSubscription? _streamingAmplitudeSub; Future _selectBLEDevice() async { try { @@ -104,17 +103,14 @@ class SensorRecorderProvider with ChangeNotifier { return false; } - _streamingAudioRecorder = AudioRecorder(); - const encoder = AudioEncoder.wav; - if (!await _streamingAudioRecorder!.isEncoderSupported(encoder)) { + if (!await _audioRecorder.isEncoderSupported(encoder)) { logger.w("WAV encoder not supported"); - _streamingAudioRecorder = null; return false; } final tempDir = await getTemporaryDirectory(); - final tempPath = + _streamingPath = '${tempDir.path}/ble_stream_${DateTime.now().millisecondsSinceEpoch}.wav'; final config = RecordConfig( @@ -125,13 +121,13 @@ class SensorRecorderProvider with ChangeNotifier { device: _selectedBLEDevice, ); - await _streamingAudioRecorder!.start(config, path: tempPath); + await _audioRecorder.start(config, path: _streamingPath!); _isStreamingActive = true; _isBLEMicrophoneStreamingEnabled = true; // Set up amplitude monitoring for waveform display - _streamingAmplitudeSub?.cancel(); - _streamingAmplitudeSub = _streamingAudioRecorder! + _amplitudeSub?.cancel(); + _amplitudeSub = _audioRecorder .onAmplitudeChanged(const Duration(milliseconds: 100)) .listen((amp) { final normalized = (amp.current + 50) / 50; @@ -144,17 +140,6 @@ class SensorRecorderProvider with ChangeNotifier { notifyListeners(); }); - Future.delayed(const Duration(seconds: 1), () async { - try { - final file = File(tempPath); - if (await file.exists()) { - await file.delete(); - } - } catch (e) { - // Ignore cleanup errors - } - }); - logger.i( "BLE microphone streaming started with device: ${_selectedBLEDevice!.label}", ); @@ -164,8 +149,7 @@ class SensorRecorderProvider with ChangeNotifier { logger.e("Failed to start BLE microphone streaming: $e"); _isStreamingActive = false; _isBLEMicrophoneStreamingEnabled = false; - _streamingAudioRecorder?.dispose(); - _streamingAudioRecorder = null; + _streamingPath = null; notifyListeners(); return false; } @@ -177,15 +161,26 @@ class SensorRecorderProvider with ChangeNotifier { } try { - await _streamingAudioRecorder?.stop(); - _streamingAmplitudeSub?.cancel(); - _streamingAmplitudeSub = null; - _streamingAudioRecorder?.dispose(); - _streamingAudioRecorder = null; + 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) { @@ -234,10 +229,23 @@ class SensorRecorderProvider with ChangeNotifier { // Stop streaming session before starting actual recording if (_isStreamingActive) { - await _streamingAudioRecorder?.stop(); - _streamingAmplitudeSub?.cancel(); - _streamingAmplitudeSub = null; + 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 { @@ -307,7 +315,6 @@ class SensorRecorderProvider with ChangeNotifier { _amplitudeSub?.cancel(); _amplitudeSub = null; _isAudioRecording = false; - _waveformData.clear(); logger.i("Audio recording saved to: $path"); _currentAudioPath = null; From 98ebd65d9831413c1c9d3a9323b51179378fb44b Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Tue, 31 Mar 2026 15:26:32 +0300 Subject: [PATCH 13/15] resolve merge conflict --- .../lib/view_models/sensor_recorder_provider.dart | 6 +++++- .../configuration/sensor_configuration_device_row.dart | 8 -------- .../sensors/local_recorder/local_recorder_view.dart | 2 +- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index ff4bdaff..c0b1bc43 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -188,7 +188,10 @@ class SensorRecorderProvider with ChangeNotifier { } } - void startRecording(String dirname) async { + Future startRecording(String dirname) async { + if (_isRecording) { + return; + } _isRecording = true; _currentDirectory = dirname; _recordingStart = DateTime.now(); @@ -566,6 +569,7 @@ class SensorRecorderProvider with ChangeNotifier { @override void dispose() { + _disposed = true; // Stop streaming stopBLEMicrophoneStream(); diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart index 7320b4b0..cdc66924 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart @@ -349,17 +349,9 @@ class _SensorConfigurationDeviceRowState if (!mounted) return; setState(() { _content = [ -<<<<<<< HEAD const Padding( padding: EdgeInsets.all(12), child: Text('This device does not support sensor configuration.'), -======= - Padding( - padding: const EdgeInsets.all(8.0), - child: PlatformText( - "This device does not support configuring sensors.", - ), ->>>>>>> 445a3da (add missing trailing commas) ), ]; }); diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart index 430ff4b7..440649f0 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart @@ -138,7 +138,7 @@ class _LocalRecorderViewState extends State { }); try { - recorder.stopRecording(); + recorder.stopRecording(mode == _StopRecordingMode.stopAndTurnOffSensors); if (mode == _StopRecordingMode.stopAndTurnOffSensors) { final wearablesProvider = context.read(); final futures = wearablesProvider.sensorConfigurationProviders.values From a6a1bac08c8df33be35904bda8684c38b8dc97ad Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 5 May 2026 13:40:46 +0200 Subject: [PATCH 14/15] fix(audio): keep waveform repainting --- .../view_models/sensor_recorder_provider.dart | 35 ++++++++------ .../sensors/values/sensor_values_page.dart | 46 ++++--------------- 2 files changed, 30 insertions(+), 51 deletions(-) diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index c0b1bc43..a161391e 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -46,6 +46,14 @@ class SensorRecorderProvider with ChangeNotifier { final List _waveformData = []; List 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; @@ -130,13 +138,7 @@ class SensorRecorderProvider with ChangeNotifier { _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); - } - + _appendWaveformAmplitude(amp); notifyListeners(); }); @@ -292,13 +294,7 @@ class SensorRecorderProvider with ChangeNotifier { _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); - } - + _appendWaveformAmplitude(amp); notifyListeners(); }); } catch (e) { @@ -567,6 +563,17 @@ 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; diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index 8fa4c83c..54cdc7fe 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -11,8 +11,6 @@ import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:open_wearable/widgets/sensors/values/sensor_value_card.dart'; import 'package:provider/provider.dart'; -import 'package:record/record.dart'; -import 'dart:async'; class SensorValuesPage extends StatefulWidget { final Map<(Wearable, Sensor), SensorDataProvider>? sharedProviders; @@ -35,16 +33,7 @@ class _SensorValuesPageState extends State bool get _ownsProviders => widget.sharedProviders == null; - // Audio State - late final AudioRecorder _audioRecorder; - bool _isPreviewRecording = false; - bool _isRecording = false; String? _errorMessage; - InputDevice? _selectedDevice; - StreamSubscription? _recordSub; - StreamSubscription? _amplitudeSub; - RecordState _recordState = RecordState.stop; - final List _waveformData = []; bool _isInitializing = true; @@ -87,29 +76,6 @@ class _SensorValuesPageState extends State provider.dispose(); } _ownedProviders.clear(); - // Stop and clean up preview recording - if (Platform.isAndroid) { - if (_recordState != RecordState.stop) { - _audioRecorder.stop().then((tempPath) { - if (tempPath != null) { - try { - final file = File(tempPath); - file.exists().then((exists) { - if (exists) { - file.delete(); - } - }); - } catch (e) { - //_logger.e("Error deleting temp preview file: $e"); - } - } - }); - } - - _recordSub?.cancel(); - _amplitudeSub?.cancel(); - _audioRecorder.dispose(); - } } super.dispose(); } @@ -372,7 +338,10 @@ class _SensorValuesPageState extends State const SizedBox(height: 8), CustomPaint( size: const Size(double.infinity, 100), - painter: WaveformPainter(recorderProvider.waveformData), + painter: WaveformPainter( + recorderProvider.waveformData, + sampleRevision: recorderProvider.waveformRevision, + ), ), ], ), @@ -584,9 +553,10 @@ class _SensorValuesEmptyState { }); } -// Custom waveform painter with vertical bars +/// Paints the live audio amplitude window as a horizontally scrolling waveform. class WaveformPainter extends CustomPainter { final List waveformData; + final int sampleRevision; final Color waveColor; final double spacing; final double waveThickness; @@ -594,6 +564,7 @@ class WaveformPainter extends CustomPainter { WaveformPainter( this.waveformData, { + required this.sampleRevision, this.waveColor = Colors.blue, this.spacing = 4.0, this.waveThickness = 3.0, @@ -659,7 +630,8 @@ class WaveformPainter extends CustomPainter { @override bool shouldRepaint(covariant WaveformPainter oldDelegate) { - return oldDelegate.waveformData.length != waveformData.length || + return oldDelegate.sampleRevision != sampleRevision || + oldDelegate.waveformData.length != waveformData.length || oldDelegate.waveColor != waveColor; } } From 48ddde92790fa32034dd322004efe48088191817 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 5 May 2026 13:40:56 +0200 Subject: [PATCH 15/15] chore(analyzer): clean sensor configuration warnings --- .../configuration/sensor_configuration_device_row.dart | 1 - .../sensors/configuration/sensor_configuration_view.dart | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart index cdc66924..904933f9 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart @@ -8,7 +8,6 @@ import 'package:open_wearable/view_models/sensor_profile_service.dart'; import 'package:open_wearable/widgets/app_toast.dart'; import 'package:open_wearable/widgets/devices/device_detail/stereo_pos_label.dart'; import 'package:open_wearable/widgets/devices/stereo_position_badge.dart'; -import 'package:open_wearable/widgets/sensors/configuration/ble_microphone_streaming_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/edge_recorder_prefix_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/save_config_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/sensor_configuration_profile_widgets.dart'; diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart index 778ba641..afe8a3d1 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; @@ -323,6 +322,10 @@ class SensorConfigurationView extends StatelessWidget { ); } + if (!context.mounted) { + return; + } + if (actionableCount == 0) { AppToast.show( context,