Skip to content

Commit 3f66d62

Browse files
authored
Merge pull request #12 from hasanravda/hasan/refactor-structure
Refactor: Separate transcription concerns into service, domain, and presentation layers
2 parents 0573274 + 9d3d272 commit 3f66d62

File tree

8 files changed

+568
-542
lines changed

8 files changed

+568
-542
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import 'dart:convert';
2+
import 'dart:io';
3+
import 'dart:async';
4+
import 'package:flutter_dotenv/flutter_dotenv.dart';
5+
import 'package:http/http.dart' as http;
6+
7+
class DeepgramService {
8+
final String? _apiKey;
9+
10+
DeepgramService({String? apiKey}) : _apiKey = apiKey?.trim();
11+
12+
String _resolveApiKey() {
13+
final configuredKey = _apiKey;
14+
if (configuredKey != null && configuredKey.isNotEmpty) {
15+
return configuredKey;
16+
}
17+
18+
try {
19+
return (dotenv.env['DEEPGRAM_API_KEY'] ?? '').trim();
20+
} catch (_) {
21+
return '';
22+
}
23+
}
24+
25+
Future<String> transcribe(String recordingPath) async {
26+
final apiKey = _resolveApiKey();
27+
if (apiKey.isEmpty) {
28+
throw Exception('Missing DEEPGRAM_API_KEY in environment');
29+
}
30+
31+
final uri = Uri.parse('https://api.deepgram.com/v1/listen?model=nova-2');
32+
33+
final file = File(recordingPath);
34+
if (!await file.exists()) {
35+
throw Exception('Recording file not found');
36+
}
37+
38+
final bytes = await file.readAsBytes();
39+
40+
http.Response response;
41+
try {
42+
response = await http.post(
43+
uri,
44+
headers: {
45+
'Authorization': 'Token $apiKey',
46+
'Content-Type': 'audio/m4a',
47+
},
48+
body: bytes,
49+
).timeout(const Duration(seconds: 30));
50+
} on TimeoutException {
51+
throw Exception('Deepgram request timed out after 30 seconds');
52+
}
53+
54+
if (response.statusCode == 200) {
55+
final decodedResponse = json.decode(response.body);
56+
57+
if (decodedResponse is! Map<String, dynamic>) {
58+
throw Exception('Deepgram returned unexpected response format');
59+
}
60+
61+
final results = decodedResponse['results'];
62+
if (results is! Map<String, dynamic>) {
63+
return 'No speech detected';
64+
}
65+
66+
final channels = results['channels'];
67+
if (channels is! List || channels.isEmpty || channels.first is! Map<String, dynamic>) {
68+
return 'No speech detected';
69+
}
70+
71+
final alternatives = (channels.first as Map<String, dynamic>)['alternatives'];
72+
if (alternatives is! List || alternatives.isEmpty || alternatives.first is! Map<String, dynamic>) {
73+
return 'No speech detected';
74+
}
75+
76+
final transcript = (alternatives.first as Map<String, dynamic>)['transcript'];
77+
final result = transcript is String ? transcript.trim() : '';
78+
return result.isNotEmpty ? result : 'No speech detected';
79+
} else {
80+
throw Exception('Deepgram failed: ${response.statusCode}');
81+
}
82+
}
83+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import 'package:doc_pilot_new_app_gradel_fix/services/chatbot_service.dart';
2+
3+
class GeminiService {
4+
final ChatbotService _chatbotService = ChatbotService();
5+
6+
Future<String> generateSummary(String transcription) async {
7+
return await _chatbotService.getGeminiResponse(
8+
"Generate a summary of the conversation based on this transcription: $transcription",
9+
);
10+
}
11+
12+
Future<String> generatePrescription(String transcription) async {
13+
await Future.delayed(const Duration(seconds: 3));
14+
return await _chatbotService.getGeminiResponse(
15+
"Generate a prescription based on the conversation in this transcription: $transcription",
16+
);
17+
}
18+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
class TranscriptionModel {
2+
final String rawTranscript;
3+
final String summary;
4+
final String prescription;
5+
6+
const TranscriptionModel({
7+
this.rawTranscript = '',
8+
this.summary = '',
9+
this.prescription = '',
10+
});
11+
12+
TranscriptionModel copyWith({
13+
String? rawTranscript,
14+
String? summary,
15+
String? prescription,
16+
}) {
17+
return TranscriptionModel(
18+
rawTranscript: rawTranscript ?? this.rawTranscript,
19+
summary: summary ?? this.summary,
20+
prescription: prescription ?? this.prescription,
21+
);
22+
}
23+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import 'dart:developer' as developer;
2+
import 'dart:math';
3+
import 'dart:async';
4+
import 'package:flutter/foundation.dart';
5+
import 'package:path_provider/path_provider.dart';
6+
import 'package:record/record.dart';
7+
import 'package:permission_handler/permission_handler.dart';
8+
import '../data/deepgram_service.dart';
9+
import '../data/gemini_service.dart';
10+
import '../domain/transcription_model.dart';
11+
12+
enum TranscriptionState { idle, recording, transcribing, processing, done, error }
13+
14+
class TranscriptionController extends ChangeNotifier {
15+
final _audioRecorder = AudioRecorder();
16+
final _deepgramService = DeepgramService();
17+
final _geminiService = GeminiService();
18+
19+
TranscriptionState state = TranscriptionState.idle;
20+
TranscriptionModel data = const TranscriptionModel();
21+
String? errorMessage;
22+
String _recordingPath = '';
23+
24+
// Waveform — kept here since it's driven by recording state
25+
final List<double> waveformValues = List.filled(40, 0.0);
26+
Timer? _waveformTimer;
27+
28+
bool get isRecording => state == TranscriptionState.recording;
29+
bool get isProcessing =>
30+
state == TranscriptionState.transcribing ||
31+
state == TranscriptionState.processing;
32+
33+
String get transcription => data.rawTranscript;
34+
String get summary => data.summary;
35+
String get prescription => data.prescription;
36+
37+
Future<bool> requestPermissions() async {
38+
final status = await Permission.microphone.request();
39+
40+
if (status.isGranted) {
41+
return true;
42+
}
43+
44+
if (status.isPermanentlyDenied) {
45+
_setError('Microphone permission permanently denied. Please enable it in settings.');
46+
return false;
47+
}
48+
49+
_setError('Microphone permission denied');
50+
return false;
51+
}
52+
53+
Future<void> toggleRecording() async {
54+
if (isRecording) {
55+
await _stopRecording();
56+
} else {
57+
await _startRecording();
58+
}
59+
}
60+
61+
Future<void> _startRecording() async {
62+
try {
63+
if (!await _audioRecorder.hasPermission()) {
64+
final granted = await requestPermissions();
65+
if (!granted) {
66+
return;
67+
}
68+
}
69+
70+
final directory = await getTemporaryDirectory();
71+
_recordingPath =
72+
'${directory.path}/recording_${DateTime.now().millisecondsSinceEpoch}.m4a';
73+
74+
await _audioRecorder.start(
75+
RecordConfig(
76+
encoder: AudioEncoder.aacLc,
77+
bitRate: 128000,
78+
sampleRate: 44100,
79+
),
80+
path: _recordingPath,
81+
);
82+
83+
// Reset previous data
84+
data = const TranscriptionModel();
85+
state = TranscriptionState.recording;
86+
_startWaveformAnimation();
87+
notifyListeners();
88+
89+
developer.log('Started recording to: $_recordingPath');
90+
} catch (e) {
91+
_setError('Error starting recording: $e');
92+
}
93+
}
94+
95+
Future<void> _stopRecording() async {
96+
try {
97+
_waveformTimer?.cancel();
98+
_resetWaveform();
99+
100+
await _audioRecorder.stop();
101+
state = TranscriptionState.transcribing;
102+
notifyListeners();
103+
104+
developer.log('Recording stopped, transcribing...');
105+
await _transcribe();
106+
} catch (e) {
107+
_setError('Error stopping recording: $e');
108+
}
109+
}
110+
111+
Future<void> _transcribe() async {
112+
try {
113+
final transcript = await _deepgramService.transcribe(_recordingPath);
114+
115+
data = data.copyWith(rawTranscript: transcript);
116+
state = TranscriptionState.processing;
117+
notifyListeners();
118+
119+
if (transcript.isNotEmpty && transcript != 'No speech detected') {
120+
await _processWithGemini(transcript);
121+
} else {
122+
state = TranscriptionState.done;
123+
notifyListeners();
124+
}
125+
} catch (e) {
126+
_setError('Transcription error: $e');
127+
}
128+
}
129+
130+
Future<void> _processWithGemini(String transcript) async {
131+
try {
132+
final summary = await _geminiService.generateSummary(transcript);
133+
final prescription = await _geminiService.generatePrescription(transcript);
134+
135+
data = data.copyWith(summary: summary, prescription: prescription);
136+
state = TranscriptionState.done;
137+
notifyListeners();
138+
139+
developer.log('Gemini processing complete');
140+
} catch (e) {
141+
_setError('Gemini error: $e');
142+
}
143+
}
144+
145+
void _startWaveformAnimation() {
146+
_waveformTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
147+
for (int i = 0; i < waveformValues.length; i++) {
148+
waveformValues[i] = Random().nextDouble();
149+
}
150+
notifyListeners();
151+
});
152+
}
153+
154+
void _resetWaveform() {
155+
for (int i = 0; i < waveformValues.length; i++) {
156+
waveformValues[i] = 0.0;
157+
}
158+
}
159+
160+
void _setError(String message) {
161+
errorMessage = message;
162+
state = TranscriptionState.error;
163+
notifyListeners();
164+
developer.log(message);
165+
}
166+
167+
@override
168+
void dispose() {
169+
_waveformTimer?.cancel();
170+
_audioRecorder.dispose();
171+
super.dispose();
172+
}
173+
}

0 commit comments

Comments
 (0)