From d41636f7f542347e6bbd823ed50f9e388fb52b45 Mon Sep 17 00:00:00 2001 From: Aaron Gabriel Neyer Date: Fri, 3 Oct 2025 20:20:46 -0700 Subject: [PATCH 1/2] Add OpenAI Whisper transcription with in-app settings Features: - Integrated OpenAI Whisper API for audio transcription - Added Settings screen for API key configuration - Secure API key storage using SharedPreferences - One-click transcription from post-recording screen - Helpful user guidance and error handling Technical changes: - Created WhisperService for OpenAI API integration - Changed recording format from AAC/ADTS to M4A for Whisper compatibility - Added settings UI with API key management and validation - Implemented secure storage for API keys (no hardcoded keys) - Added url_launcher and http dependencies - Updated PostRecordingScreen with transcribe button - Disabled real-time transcription (microphone conflict on Android) UI improvements: - Settings icon in app bar for easy access - API key status indicators (configured/not configured) - Password-style input with show/hide toggle - Direct link to OpenAI dashboard - Pricing information display - Smart navigation to Settings when API key missing Bug fixes: - Fixed microphone access conflict between flutter_sound and speech_to_text - Removed unused transcription service from recording screen - Updated audio codec for better compatibility --- lib/screens/home_screen.dart | 15 + lib/screens/post_recording_screen.dart | 118 +++++- lib/screens/recording_screen.dart | 137 +------ lib/screens/settings_screen.dart | 371 ++++++++++++++++++ lib/services/audio_service.dart | 10 +- lib/services/storage_service.dart | 32 ++ lib/services/transcription_service.dart | 67 +++- lib/services/whisper_service.dart | 121 ++++++ .../.plugin_symlinks/url_launcher_linux | 1 + linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 70 +++- pubspec.yaml | 2 + 14 files changed, 807 insertions(+), 144 deletions(-) create mode 100644 lib/screens/settings_screen.dart create mode 100644 lib/services/whisper_service.dart create mode 120000 linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 657ba56..7d6648c 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -3,6 +3,7 @@ import 'package:parachute/models/recording.dart'; import 'package:parachute/services/storage_service.dart'; import 'package:parachute/screens/recording_screen.dart'; import 'package:parachute/screens/recording_detail_screen.dart'; +import 'package:parachute/screens/settings_screen.dart'; import 'package:parachute/widgets/recording_tile.dart'; import 'package:parachute/utils/sample_data.dart'; @@ -106,6 +107,20 @@ class _HomeScreenState extends State with WidgetsBindingObserver { title: const Text('Parachute'), centerTitle: true, elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.settings), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SettingsScreen(), + ), + ); + }, + tooltip: 'Settings', + ), + ], ), body: _isLoading ? const Center(child: CircularProgressIndicator()) diff --git a/lib/screens/post_recording_screen.dart b/lib/screens/post_recording_screen.dart index cf9b5fe..62a9b59 100644 --- a/lib/screens/post_recording_screen.dart +++ b/lib/screens/post_recording_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:parachute/models/recording.dart'; import 'package:parachute/services/audio_service.dart'; import 'package:parachute/services/storage_service.dart'; +import 'package:parachute/services/whisper_service.dart'; +import 'package:parachute/screens/settings_screen.dart'; class PostRecordingScreen extends StatefulWidget { final String recordingPath; @@ -24,6 +26,7 @@ class _PostRecordingScreenState extends State { final TextEditingController _transcriptController = TextEditingController(); final StorageService _storageService = StorageService(); final AudioService _audioService = AudioService(); + final WhisperService _whisperService = WhisperService(); final List _predefinedTags = [ 'Project A', @@ -37,6 +40,7 @@ class _PostRecordingScreenState extends State { final Set _selectedTags = {}; bool _isPlaying = false; bool _isSaving = false; + bool _isTranscribing = false; @override void initState() { @@ -69,6 +73,90 @@ class _PostRecordingScreenState extends State { } } + Future _transcribeRecording() async { + if (_isTranscribing) return; + + // Check if API key is configured + final isConfigured = await _whisperService.isConfigured(); + if (!isConfigured) { + if (!mounted) return; + + // Show dialog to navigate to settings + final goToSettings = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('API Key Required'), + content: const Text( + 'To use transcription, you need to configure your OpenAI API key in Settings.\n\n' + 'Would you like to go to Settings now?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Go to Settings'), + ), + ], + ), + ); + + if (goToSettings == true && mounted) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SettingsScreen(), + ), + ); + } + return; + } + + setState(() => _isTranscribing = true); + + try { + final transcript = await _whisperService.transcribeAudio( + widget.recordingPath, + ); + + if (mounted) { + _transcriptController.text = transcript; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Transcription completed!'), + duration: Duration(seconds: 2), + ), + ); + } + } on WhisperException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Transcription failed: ${e.message}'), + duration: const Duration(seconds: 4), + backgroundColor: Colors.red, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Unexpected error: $e'), + duration: const Duration(seconds: 4), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() => _isTranscribing = false); + } + } + } + Future _saveRecording() async { if (_isSaving) return; @@ -232,11 +320,33 @@ class _PostRecordingScreenState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Transcript', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Transcript', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ElevatedButton.icon( + onPressed: _isTranscribing ? null : _transcribeRecording, + icon: _isTranscribing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.auto_awesome, size: 18), + label: Text(_isTranscribing ? 'Transcribing...' : 'Transcribe'), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, ), + ), + ], ), const SizedBox(height: 8), SizedBox( diff --git a/lib/screens/recording_screen.dart b/lib/screens/recording_screen.dart index 4313608..9916479 100644 --- a/lib/screens/recording_screen.dart +++ b/lib/screens/recording_screen.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:parachute/services/audio_service.dart'; -import 'package:parachute/services/transcription_service.dart'; import 'package:parachute/screens/post_recording_screen.dart'; import 'package:parachute/widgets/recording_visualizer.dart'; @@ -14,7 +13,6 @@ class RecordingScreen extends StatefulWidget { class _RecordingScreenState extends State { final AudioService _audioService = AudioService(); - final TranscriptionService _transcriptionService = TranscriptionService(); RecordingState _recordingState = RecordingState.stopped; Duration _recordingDuration = Duration.zero; Duration _pausedDuration = Duration.zero; @@ -22,8 +20,6 @@ class _RecordingScreenState extends State { DateTime? _pauseStartTime; Timer? _timer; String? _recordingPath; - String _currentTranscription = ''; - bool _transcriptionAvailable = false; @override void initState() { @@ -35,7 +31,6 @@ class _RecordingScreenState extends State { void dispose() { _timer?.cancel(); _audioService.dispose(); - _transcriptionService.dispose(); super.dispose(); } @@ -99,34 +94,19 @@ class _RecordingScreenState extends State { } Future _initializeTranscription() async { - try { - print('Attempting to initialize transcription service...'); - final initialized = await _transcriptionService.initialize(); - - if (initialized) { - print('Transcription service initialized successfully'); - _transcriptionAvailable = true; - - // Start listening for transcription - await _transcriptionService.startListening( - onResult: (text) { - if (mounted) { - setState(() { - _currentTranscription = text; - }); - } - }, - ); - print('Transcription listening started'); - } else { - print('Transcription service not available on this device'); - _transcriptionAvailable = false; - } - } catch (e) { - print('Transcription error (non-fatal): $e'); - _transcriptionAvailable = false; - // Don't show error to user - transcription is optional - } + // NOTE: Real-time transcription is disabled due to microphone conflict. + // On Android, speech_to_text and flutter_sound cannot access the microphone + // simultaneously. iOS supports concurrent access but Android does not. + // + // Alternative solutions: + // 1. Use post-recording transcription with cloud APIs (Whisper, Google STT) + // 2. Use vosk_flutter for offline transcription with audio stream + // 3. Implement transcription after recording completes + // + // For now, transcription is left as a placeholder for future implementation. + + print( + 'Real-time transcription skipped - microphone conflict with audio recording'); } void _startTimer() { @@ -157,15 +137,6 @@ class _RecordingScreenState extends State { _pauseStartTime = DateTime.now(); _timer?.cancel(); - // Pause transcription if available - if (_transcriptionAvailable) { - try { - await _transcriptionService.pauseListening(); - } catch (e) { - print('Error pausing transcription: $e'); - } - } - setState(() { _recordingState = RecordingState.paused; }); @@ -179,23 +150,6 @@ class _RecordingScreenState extends State { _pauseStartTime = null; } - // Resume transcription if available - if (_transcriptionAvailable) { - try { - await _transcriptionService.resumeListening( - onResult: (text) { - if (mounted) { - setState(() { - _currentTranscription = text; - }); - } - }, - ); - } catch (e) { - print('Error resuming transcription: $e'); - } - } - setState(() { _recordingState = RecordingState.recording; }); @@ -210,17 +164,8 @@ class _RecordingScreenState extends State { _recordingState = RecordingState.stopped; }); - // Stop transcription if available and get final text + // Transcription is currently disabled due to microphone conflicts String transcription = ''; - if (_transcriptionAvailable) { - try { - await _transcriptionService.stopListening(); - transcription = _transcriptionService.getFinalTranscription(); - print('Final transcription: $transcription'); - } catch (e) { - print('Error stopping transcription: $e'); - } - } final path = await _audioService.stopRecording(); if (path != null && mounted) { @@ -338,60 +283,6 @@ class _RecordingScreenState extends State { const SizedBox(height: 20), - // Live transcription preview (only show if transcription is working) - if (_currentTranscription.isNotEmpty) - Container( - margin: const EdgeInsets.symmetric(horizontal: 32), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceVariant - .withOpacity(0.5), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: - Theme.of(context).colorScheme.primary.withOpacity(0.3), - ), - ), - constraints: const BoxConstraints(maxHeight: 100), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Icon( - Icons.mic, - size: 16, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 4), - Text( - 'Live Transcription', - style: Theme.of(context) - .textTheme - .labelSmall - ?.copyWith( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const SizedBox(height: 4), - Flexible( - child: SingleChildScrollView( - child: Text( - _currentTranscription, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - ), - ], - ), - ), - const Spacer(), // Control buttons diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart new file mode 100644 index 0000000..059b0f9 --- /dev/null +++ b/lib/screens/settings_screen.dart @@ -0,0 +1,371 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:parachute/services/storage_service.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({super.key}); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + final StorageService _storageService = StorageService(); + final TextEditingController _apiKeyController = TextEditingController(); + bool _isLoading = true; + bool _isSaving = false; + bool _obscureApiKey = true; + bool _hasApiKey = false; + + @override + void initState() { + super.initState(); + _loadApiKey(); + } + + @override + void dispose() { + _apiKeyController.dispose(); + super.dispose(); + } + + Future _loadApiKey() async { + setState(() => _isLoading = true); + + final apiKey = await _storageService.getOpenAIApiKey(); + if (apiKey != null && apiKey.isNotEmpty) { + _apiKeyController.text = apiKey; + _hasApiKey = true; + } + + setState(() => _isLoading = false); + } + + Future _saveApiKey() async { + final apiKey = _apiKeyController.text.trim(); + + if (apiKey.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please enter an API key'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + // Basic validation: OpenAI keys start with 'sk-' + if (!apiKey.startsWith('sk-')) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Invalid API key format. OpenAI keys start with "sk-"'), + backgroundColor: Colors.red, + ), + ); + return; + } + + setState(() => _isSaving = true); + + final success = await _storageService.saveOpenAIApiKey(apiKey); + + setState(() => _isSaving = false); + + if (success) { + setState(() => _hasApiKey = true); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('API key saved successfully!'), + backgroundColor: Colors.green, + ), + ); + } + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to save API key'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _deleteApiKey() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete API Key?'), + content: const Text( + 'Are you sure you want to remove your OpenAI API key? ' + 'You won\'t be able to use transcription until you add a new key.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true) { + final success = await _storageService.deleteOpenAIApiKey(); + if (success) { + _apiKeyController.clear(); + setState(() => _hasApiKey = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('API key deleted'), + ), + ); + } + } + } + } + + Future _openApiKeyHelp() async { + final url = Uri.parse('https://platform.openai.com/api-keys'); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Settings'), + centerTitle: true, + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + // Header + const Text( + 'OpenAI API Configuration', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Configure your OpenAI API key to enable AI-powered transcription', + style: TextStyle( + color: Colors.grey[600], + fontSize: 14, + ), + ), + const SizedBox(height: 24), + + // Status Card + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _hasApiKey + ? Colors.green.withOpacity(0.1) + : Colors.orange.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _hasApiKey ? Colors.green : Colors.orange, + width: 2, + ), + ), + child: Row( + children: [ + Icon( + _hasApiKey ? Icons.check_circle : Icons.warning, + color: _hasApiKey ? Colors.green : Colors.orange, + size: 32, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _hasApiKey ? 'API Key Configured' : 'No API Key', + style: TextStyle( + fontWeight: FontWeight.bold, + color: + _hasApiKey ? Colors.green : Colors.orange, + ), + ), + const SizedBox(height: 4), + Text( + _hasApiKey + ? 'Transcription is enabled' + : 'Add an API key to enable transcription', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // API Key Input + TextField( + controller: _apiKeyController, + obscureText: _obscureApiKey, + decoration: InputDecoration( + labelText: 'OpenAI API Key', + hintText: 'sk-...', + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon( + _obscureApiKey + ? Icons.visibility + : Icons.visibility_off, + ), + onPressed: () { + setState(() => _obscureApiKey = !_obscureApiKey); + }, + ), + ), + ), + const SizedBox(height: 16), + + // Action Buttons + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: _isSaving ? null : _saveApiKey, + icon: _isSaving + ? const SizedBox( + width: 16, + height: 16, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save), + label: Text(_isSaving ? 'Saving...' : 'Save'), + style: ElevatedButton.styleFrom( + backgroundColor: + Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + if (_hasApiKey) ...[ + const SizedBox(width: 8), + ElevatedButton.icon( + onPressed: _deleteApiKey, + icon: const Icon(Icons.delete), + label: const Text('Delete'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ], + ], + ), + const SizedBox(height: 24), + + // Help Section + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info_outline, color: Colors.blue[700]), + const SizedBox(width: 8), + const Text( + 'How to get an API key', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ], + ), + const SizedBox(height: 12), + const Text( + '1. Visit platform.openai.com/api-keys\n' + '2. Sign in or create an account\n' + '3. Click "Create new secret key"\n' + '4. Copy the key (starts with "sk-")\n' + '5. Paste it above and tap Save', + style: TextStyle(fontSize: 14, height: 1.5), + ), + const SizedBox(height: 12), + ElevatedButton.icon( + onPressed: _openApiKeyHelp, + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('Open OpenAI Dashboard'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Pricing Info + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.attach_money, color: Colors.grey[700]), + const SizedBox(width: 8), + const Text( + 'Pricing', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Transcription costs \$0.006 per minute\n' + '• 1 min recording = \$0.006\n' + '• 10 min recording = \$0.06\n' + '• 1 hour recording = \$0.36', + style: TextStyle(fontSize: 14, color: Colors.grey[700]), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 46ff2e3..027c04c 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -124,8 +124,8 @@ class AudioService { print('Created recordings directory: ${recordingsDir.path}'); } final timestamp = DateTime.now().millisecondsSinceEpoch; - // Use AAC format for better compatibility - final path = '${recordingsDir.path}/recording_$timestamp.aac'; + // Use M4A format for better compatibility (works with Whisper API) + final path = '${recordingsDir.path}/recording_$timestamp.m4a'; print('Generated recording path: $path'); return path; } catch (e) { @@ -167,10 +167,10 @@ class AudioService { _currentRecordingPath = await _getRecordingPath(); print('Will record to: $_currentRecordingPath'); - // Start recording with minimal configuration to avoid errors + // Start recording with M4A AAC format (compatible with Whisper API) await _recorder!.startRecorder( toFile: _currentRecordingPath, - codec: Codec.aacADTS, + codec: Codec.aacMP4, ); _recordingStartTime = DateTime.now(); @@ -292,7 +292,7 @@ class AudioService { await _player!.startPlayer( fromURI: filePath, - codec: Codec.aacADTS, + codec: Codec.aacMP4, whenFinished: () { print('Playback finished'); }, diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 7732ccb..a7ffa8c 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -10,6 +10,7 @@ class StorageService { static const String _recordingsKey = 'recordings'; static const String _hasInitializedKey = 'has_initialized'; + static const String _openaiApiKeyKey = 'openai_api_key'; final AudioService _audioService = AudioService(); Future> getRecordings() async { @@ -169,4 +170,35 @@ class StorageService { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_recordingsKey); } + + // OpenAI API Key Management + Future getOpenAIApiKey() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_openaiApiKeyKey); + } + + Future saveOpenAIApiKey(String apiKey) async { + try { + final prefs = await SharedPreferences.getInstance(); + return await prefs.setString(_openaiApiKeyKey, apiKey.trim()); + } catch (e) { + print('Error saving OpenAI API key: $e'); + return false; + } + } + + Future deleteOpenAIApiKey() async { + try { + final prefs = await SharedPreferences.getInstance(); + return await prefs.remove(_openaiApiKeyKey); + } catch (e) { + print('Error deleting OpenAI API key: $e'); + return false; + } + } + + Future hasOpenAIApiKey() async { + final apiKey = await getOpenAIApiKey(); + return apiKey != null && apiKey.isNotEmpty; + } } diff --git a/lib/services/transcription_service.dart b/lib/services/transcription_service.dart index 93c200e..73fb1fd 100644 --- a/lib/services/transcription_service.dart +++ b/lib/services/transcription_service.dart @@ -23,13 +23,20 @@ class TranscriptionService { if (_isInitialized) return true; try { + print('Requesting speech recognition permissions...'); _isInitialized = await _speechToText.initialize( onStatus: (status) { print('Speech recognition status: $status'); + if (status == 'notListening' && _isListening) { + print( + 'Speech recognition stopped unexpectedly, may have timed out'); + } }, onError: (error) { print('Speech recognition error: ${error.errorMsg}'); + print('Error type: ${error.permanent}'); }, + debugLogging: true, ); if (_isInitialized) { @@ -43,6 +50,9 @@ class TranscriptionService { // Check if the system locale is available final systemLocale = await _speechToText.systemLocale(); print('System locale: ${systemLocale?.localeId}'); + } else { + print( + 'TranscriptionService initialization failed - permission denied or not available'); } return _isInitialized; @@ -60,9 +70,13 @@ class TranscriptionService { await initialize(); } - if (!_isInitialized || _isListening) { - print( - 'Cannot start listening: initialized=$_isInitialized, listening=$_isListening'); + if (!_isInitialized) { + print('Cannot start listening: not initialized'); + return; + } + + if (_isListening) { + print('Already listening, skipping startListening call'); return; } @@ -90,8 +104,8 @@ class TranscriptionService { _restartListening(onResult: onResult, localeId: localeId); } }, - listenFor: const Duration(seconds: 30), - pauseFor: const Duration(seconds: 3), + listenFor: const Duration(minutes: 10), + pauseFor: const Duration(seconds: 10), partialResults: true, localeId: localeId, cancelOnError: false, @@ -129,8 +143,8 @@ class TranscriptionService { _restartListening(onResult: onResult, localeId: localeId); } }, - listenFor: const Duration(seconds: 30), - pauseFor: const Duration(seconds: 3), + listenFor: const Duration(minutes: 10), + pauseFor: const Duration(seconds: 10), partialResults: true, localeId: localeId, cancelOnError: false, @@ -161,6 +175,7 @@ class TranscriptionService { try { await _speechToText.stop(); + _isListening = false; print('Paused listening for speech'); } catch (e) { print('Error pausing speech recognition: $e'); @@ -171,9 +186,43 @@ class TranscriptionService { Function(String)? onResult, String? localeId, }) async { - if (!_isListening) return; + if (_isListening) return; // Already listening + + // Don't clear transcription on resume, keep accumulating + if (_transcriptionController == null || + _transcriptionController!.isClosed) { + _transcriptionController = StreamController.broadcast(); + } - await startListening(onResult: onResult, localeId: localeId); + try { + await _speechToText.listen( + onResult: (SpeechRecognitionResult result) { + // Append to existing transcription + final newWords = result.recognizedWords; + if (newWords.isNotEmpty) { + _transcription = + _transcription.isEmpty ? newWords : '$_transcription $newWords'; + _transcriptionController?.add(_transcription); + onResult?.call(_transcription); + } + + if (result.finalResult && _isListening) { + _restartListening(onResult: onResult, localeId: localeId); + } + }, + listenFor: const Duration(minutes: 10), + pauseFor: const Duration(seconds: 10), + partialResults: true, + localeId: localeId, + cancelOnError: false, + listenMode: ListenMode.dictation, + ); + _isListening = true; + print('Resumed listening for speech'); + } catch (e) { + print('Error resuming speech recognition: $e'); + _isListening = false; + } } String getFinalTranscription() { diff --git a/lib/services/whisper_service.dart b/lib/services/whisper_service.dart new file mode 100644 index 0000000..82e5803 --- /dev/null +++ b/lib/services/whisper_service.dart @@ -0,0 +1,121 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:http/http.dart' as http; +import 'package:parachute/services/storage_service.dart'; + +/// Service for transcribing audio using OpenAI's Whisper API +/// +/// Usage: +/// 1. Configure your API key in Settings (tap the gear icon in the app) +/// 2. Call transcribeAudio() with the path to your audio file +/// +/// Cost: ~$0.006 per minute of audio +/// Supported formats: mp3, mp4, mpeg, mpga, m4a, wav, webm +class WhisperService { + final StorageService _storageService = StorageService(); + + /// Transcribes an audio file using OpenAI's Whisper API + /// + /// [audioPath] - Absolute path to the audio file + /// [language] - Optional ISO-639-1 language code (e.g., 'en', 'es', 'fr') + /// If not specified, Whisper will auto-detect the language + /// [prompt] - Optional prompt to guide the transcription style + /// + /// Returns the transcribed text + /// Throws [WhisperException] if transcription fails + Future transcribeAudio( + String audioPath, { + String? language, + String? prompt, + }) async { + // Get API key from storage + final apiKey = await _storageService.getOpenAIApiKey(); + + // Validate API key + if (apiKey == null || apiKey.isEmpty) { + throw WhisperException( + 'OpenAI API key not configured. ' + 'Please add your API key in Settings.', + ); + } + + // Validate file exists + final file = File(audioPath); + if (!await file.exists()) { + throw WhisperException('Audio file not found: $audioPath'); + } + + try { + // Create multipart request + var request = http.MultipartRequest( + 'POST', + Uri.parse('https://api.openai.com/v1/audio/transcriptions'), + ); + + // Add headers + request.headers['Authorization'] = 'Bearer $apiKey'; + + // Add form fields + request.fields['model'] = 'whisper-1'; + if (language != null) { + request.fields['language'] = language; + } + if (prompt != null) { + request.fields['prompt'] = prompt; + } + + // Add audio file + request.files.add(await http.MultipartFile.fromPath( + 'file', + audioPath, + )); + + print('Sending audio to Whisper API for transcription...'); + + // Send request + final streamedResponse = await request.send(); + final response = await http.Response.fromStream(streamedResponse); + + // Check response status + if (response.statusCode == 200) { + final jsonResponse = jsonDecode(response.body); + final text = jsonResponse['text'] as String? ?? ''; + print('Transcription successful (${text.length} characters)'); + return text; + } else { + // Parse error response + final errorBody = jsonDecode(response.body); + final errorMessage = errorBody['error']?['message'] ?? 'Unknown error'; + throw WhisperException( + 'Whisper API error (${response.statusCode}): $errorMessage', + ); + } + } on SocketException catch (e) { + throw WhisperException( + 'Network error: Please check your internet connection', + ); + } on FormatException catch (e) { + throw WhisperException( + 'Invalid response from Whisper API: ${e.message}', + ); + } catch (e) { + if (e is WhisperException) rethrow; + throw WhisperException('Unexpected error: ${e.toString()}'); + } + } + + /// Check if the API key is configured + Future isConfigured() async { + return await _storageService.hasOpenAIApiKey(); + } +} + +/// Custom exception for Whisper API errors +class WhisperException implements Exception { + final String message; + + WhisperException(this.message); + + @override + String toString() => message; +} diff --git a/linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux b/linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux new file mode 120000 index 0000000..46abe8b --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/url_launcher_linux @@ -0,0 +1 @@ +/Users/unforced/.pub-cache/hosted/pub.dev/url_launcher_linux-3.2.1/ \ No newline at end of file diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..f6f23bf 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + 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/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..f16b4c3 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 10aa28b..d2a677d 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,9 +8,11 @@ import Foundation import path_provider_foundation import shared_preferences_foundation import speech_to_text +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SpeechToTextPlugin.register(with: registry.registrar(forPlugin: "SpeechToTextPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index a91e767..65ed162 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -177,7 +177,7 @@ packages: source: hosted version: "6.3.1" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 @@ -565,6 +565,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: c0fb544b9ac7efa10254efaf00a951615c362d1ea1877472f8f6c0fa00fcf15b + url: "https://pub.dev" + source: hosted + version: "6.3.23" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 + url: "https://pub.dev" + source: hosted + version: "6.3.4" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" vector_math: dependency: transitive description: @@ -614,5 +678,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.8.0 <4.0.0" - flutter: ">=3.29.0" + dart: ">=3.9.0 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index 44f758a..65ec2ae 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,6 +16,8 @@ dependencies: path_provider: ^2.0.0 shared_preferences: ^2.0.0 speech_to_text: ^7.0.0 + http: ^1.2.0 + url_launcher: ^6.3.0 dev_dependencies: flutter_test: From 859c9ac26f2a83416d69569751aa8df6bfee945f Mon Sep 17 00:00:00 2001 From: Aaron Gabriel Neyer Date: Fri, 3 Oct 2025 20:27:36 -0700 Subject: [PATCH 2/2] Fix code review issues: remove debug prints, fix deprecated API, add error handling --- lib/screens/post_recording_screen.dart | 2 +- lib/services/storage_service.dart | 40 ++++++++++++++++---------- lib/services/whisper_service.dart | 20 +++++++------ 3 files changed, 38 insertions(+), 24 deletions(-) diff --git a/lib/screens/post_recording_screen.dart b/lib/screens/post_recording_screen.dart index 62a9b59..28286f1 100644 --- a/lib/screens/post_recording_screen.dart +++ b/lib/screens/post_recording_screen.dart @@ -276,7 +276,7 @@ class _PostRecordingScreenState extends State { Text( '${widget.duration.inMinutes}:${(widget.duration.inSeconds % 60).toString().padLeft(2, '0')}', style: TextStyle( - color: Colors.grey.withValues(alpha: 0.7), + color: Colors.grey.withOpacity(0.7), ), ), ], diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index a7ffa8c..7a7af83 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -14,22 +14,27 @@ class StorageService { final AudioService _audioService = AudioService(); Future> getRecordings() async { - final prefs = await SharedPreferences.getInstance(); + try { + final prefs = await SharedPreferences.getInstance(); - // Check if this is first launch - final hasInitialized = prefs.getBool(_hasInitializedKey) ?? false; - if (!hasInitialized) { - // Create sample recordings for demo on first launch - await _createSampleRecordings(); - await prefs.setBool(_hasInitializedKey, true); - } + // Check if this is first launch + final hasInitialized = prefs.getBool(_hasInitializedKey) ?? false; + if (!hasInitialized) { + // Create sample recordings for demo on first launch + await _createSampleRecordings(); + await prefs.setBool(_hasInitializedKey, true); + } - final recordingsJson = prefs.getStringList(_recordingsKey) ?? []; + final recordingsJson = prefs.getStringList(_recordingsKey) ?? []; - return recordingsJson - .map((json) => Recording.fromJson(jsonDecode(json))) - .toList() - ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + return recordingsJson + .map((json) => Recording.fromJson(jsonDecode(json))) + .toList() + ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); + } catch (e) { + print('Error getting recordings: $e'); + return []; + } } Future saveRecording(Recording recording) async { @@ -173,8 +178,13 @@ class StorageService { // OpenAI API Key Management Future getOpenAIApiKey() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getString(_openaiApiKeyKey); + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_openaiApiKeyKey); + } catch (e) { + print('Error getting OpenAI API key: $e'); + return null; + } } Future saveOpenAIApiKey(String apiKey) async { diff --git a/lib/services/whisper_service.dart b/lib/services/whisper_service.dart index 82e5803..916fdef 100644 --- a/lib/services/whisper_service.dart +++ b/lib/services/whisper_service.dart @@ -70,8 +70,6 @@ class WhisperService { audioPath, )); - print('Sending audio to Whisper API for transcription...'); - // Send request final streamedResponse = await request.send(); final response = await http.Response.fromStream(streamedResponse); @@ -80,15 +78,21 @@ class WhisperService { if (response.statusCode == 200) { final jsonResponse = jsonDecode(response.body); final text = jsonResponse['text'] as String? ?? ''; - print('Transcription successful (${text.length} characters)'); return text; } else { // Parse error response - final errorBody = jsonDecode(response.body); - final errorMessage = errorBody['error']?['message'] ?? 'Unknown error'; - throw WhisperException( - 'Whisper API error (${response.statusCode}): $errorMessage', - ); + try { + final errorBody = jsonDecode(response.body); + final errorMessage = + errorBody['error']?['message'] ?? 'Unknown error'; + throw WhisperException( + 'Whisper API error (${response.statusCode}): $errorMessage', + ); + } catch (e) { + throw WhisperException( + 'Whisper API error (${response.statusCode}): Failed to parse error response', + ); + } } } on SocketException catch (e) { throw WhisperException(