From 93ac9ca18d53b1a942f27ebd3ff7d81fb9cff564 Mon Sep 17 00:00:00 2001 From: HuiChan Seo <78739194+seochan99@users.noreply.github.com> Date: Tue, 20 Feb 2024 17:27:52 +0900 Subject: [PATCH] =?UTF-8?q?feat=20:=20record=20word=20=EC=9D=8C=EC=86=8C?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ios/Podfile.lock | 6 - lib/models/record_word_model.dart | 13 ++ lib/viewModels/record/record_viewmodel.dart | 114 ++++++++++++++++++ .../script/create_script_viewmodel.dart | 35 +++--- lib/views/word/widget/word_list_widget.dart | 1 - .../word/widget/word_sentence_widget.dart | 109 ++++++++++++----- 6 files changed, 217 insertions(+), 61 deletions(-) create mode 100644 lib/models/record_word_model.dart create mode 100644 lib/viewModels/record/record_viewmodel.dart diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 592c3a6..fa33bb9 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -634,8 +634,6 @@ PODS: - AppAuth/Core (1.6.2) - AppAuth/ExternalUserAgent (1.6.2): - AppAuth/Core - - audioplayers_darwin (0.0.1): - - Flutter - BoringSSL-GRPC (0.0.24): - BoringSSL-GRPC/Implementation (= 0.0.24) - BoringSSL-GRPC/Interface (= 0.0.24) @@ -842,7 +840,6 @@ PODS: - FlutterMacOS DEPENDENCIES: - - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/ios`) - cloud_firestore (from `.symlinks/plugins/cloud_firestore/ios`) - firebase_auth (from `.symlinks/plugins/firebase_auth/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`) @@ -890,8 +887,6 @@ SPEC REPOS: - Try EXTERNAL SOURCES: - audioplayers_darwin: - :path: ".symlinks/plugins/audioplayers_darwin/ios" cloud_firestore: :path: ".symlinks/plugins/cloud_firestore/ios" firebase_auth: @@ -928,7 +923,6 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: abseil: 926fb7a82dc6d2b8e1f2ed7f3a718bce691d1e46 AppAuth: 3bb1d1cd9340bd09f5ed189fb00b1cc28e1e8570 - audioplayers_darwin: 877d9a4d06331c5c374595e46e16453ac7eafa40 BoringSSL-GRPC: 3175b25143e648463a56daeaaa499c6cb86dad33 cloud_firestore: ba576bee785a05ff952e4da7fa4e23c196917436 Firebase: 10c8cb12fb7ad2ae0c09ffc86cd9c1ab392a0031 diff --git a/lib/models/record_word_model.dart b/lib/models/record_word_model.dart new file mode 100644 index 0000000..942603b --- /dev/null +++ b/lib/models/record_word_model.dart @@ -0,0 +1,13 @@ +class RecordWordModel { + final String pronunciation; + final int similarity; + + RecordWordModel({required this.pronunciation, required this.similarity}); + + factory RecordWordModel.fromJson(Map json) { + return RecordWordModel( + pronunciation: json['pronunciation'], + similarity: json['similarity'], + ); + } +} diff --git a/lib/viewModels/record/record_viewmodel.dart b/lib/viewModels/record/record_viewmodel.dart new file mode 100644 index 0000000..28485b9 --- /dev/null +++ b/lib/viewModels/record/record_viewmodel.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; + +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:get/get.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:flutter_sound/flutter_sound.dart'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; + +class RecordViewModel extends GetxController { + final FlutterSoundRecorder _audioRecorder = FlutterSoundRecorder(); + + final RxBool isRecording = false.obs; + RxString audioFilePath = ''.obs; + final RxMap response = + {}.obs; // Specify the types + + @override + void onInit() { + _requestPermission(); + super.onInit(); + } + + Future _requestPermission() async { + var microphoneStatus = await Permission.microphone.status; + if (!microphoneStatus.isGranted) { + await Permission.microphone.request(); + } + } + + Future _startRecording() async { + if (isRecording.value) return; + + // Ensure the recorder is open + if (!_audioRecorder.isStopped) { + await _audioRecorder.closeRecorder(); + } + + // Open the recorder + await _audioRecorder.openRecorder(); + final directory = await getApplicationDocumentsDirectory(); + final filePath = + '${directory.path}/${DateTime.now().millisecondsSinceEpoch}.aac'; + + print('Recording to $filePath'); + try { + await _audioRecorder.startRecorder( + toFile: filePath, + codec: Codec.aacADTS, + ); + + isRecording.value = true; + } catch (e) { + print('Error starting recorder: $e'); + // Handle the error as needed + } + } + + Future _stopRecording() async { + try { + final path = await _audioRecorder.stopRecorder(); + audioFilePath.value = path!; + isRecording.value = false; + return path; // 녹음이 중지된 파일의 경로를 반환합니다. + } catch (e) {} + return null; + } + + Future sendTextAndAudio(String content) async { + String url = '${dotenv.env['API_URL']!}/study/syllable'; + print('Sending data and audio to $url'); + print('audioFilePath.value: ${audioFilePath.value}'); + print('content: $content'); + if (audioFilePath.value.isEmpty) { + print('Audio file is not available.'); + return; + } + + try { + var request = http.MultipartRequest('POST', Uri.parse(url)) + ..fields['content'] = content + ..files.add( + await http.MultipartFile.fromPath('audio', audioFilePath.value)); + + print('Sending data and audio...----------------------------'); + var response = await request.send(); + if (response.statusCode == 200) { + // Read the response stream and convert it to a JSON object + final respStr = await response.stream.bytesToString(); + final jsonResponse = json.decode(respStr); + + // Store the response in the RxMap + this.response.value = jsonResponse; + } else { + print( + 'Failed to send data and audio. Status code: ${response.statusCode}', + ); + } + } catch (e) { + print(e.toString()); + } + } + + void toggleRecording() async { + isRecording.value ? await _stopRecording() : await _startRecording(); + update(); + } + + @override + void onClose() { + _audioRecorder.closeRecorder(); + super.onClose(); + } +} diff --git a/lib/viewModels/script/create_script_viewmodel.dart b/lib/viewModels/script/create_script_viewmodel.dart index a273980..273897c 100644 --- a/lib/viewModels/script/create_script_viewmodel.dart +++ b/lib/viewModels/script/create_script_viewmodel.dart @@ -8,13 +8,10 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:flutter_sound/flutter_sound.dart'; import 'package:path_provider/path_provider.dart'; import 'dart:async'; -import 'dart:io'; import 'package:http/http.dart' as http; import 'package:earlips/views/script/analyze_screen.dart'; import 'package:earlips/viewModels/script/analyze_viewmodel.dart'; - - class CreateScriptViewModel extends ChangeNotifier { bool isRecording = false; bool _isRecorderInitialized = false; // 녹음기 초기화 여부 : 파일 @@ -22,10 +19,11 @@ class CreateScriptViewModel extends ChangeNotifier { FlutterSoundRecorder? _audioRecorder; String audioFilePath = ''; - bool handDone = false; - TextEditingController writedTextController = TextEditingController(); // 사용자 입력을 위한 컨트롤러 - TextEditingController voicedTextController = TextEditingController(); // 음성 인식 결과를 위한 컨트롤러 + TextEditingController writedTextController = + TextEditingController(); // 사용자 입력을 위한 컨트롤러 + TextEditingController voicedTextController = + TextEditingController(); // 음성 인식 결과를 위한 컨트롤러 stt.SpeechToText speechToText = stt.SpeechToText(); CreateScriptViewModel() { @@ -53,9 +51,8 @@ class CreateScriptViewModel extends ChangeNotifier { if (!_isRecorderInitialized || _isRecording) return; final directory = await getApplicationDocumentsDirectory(); - final filePath = '${directory.path}/${DateTime - .now() - .millisecondsSinceEpoch}.aac'; + final filePath = + '${directory.path}/${DateTime.now().millisecondsSinceEpoch}.aac'; await _audioRecorder!.startRecorder( toFile: filePath, @@ -78,9 +75,7 @@ class CreateScriptViewModel extends ChangeNotifier { } } - Future sendTextAndAudio() async { - String url = dotenv.env['SCRIPT_API'].toString(); String textToSend = writedTextController.text; if (audioFilePath.isEmpty) { @@ -91,7 +86,8 @@ class CreateScriptViewModel extends ChangeNotifier { try { var request = http.MultipartRequest('POST', Uri.parse(url)) ..fields['content'] = textToSend // 'content' 필드 이름으로 수정 - ..files.add(await http.MultipartFile.fromPath('audio', audioFilePath)); // 'audio' 필드 이름은 그대로 유지 + ..files.add(await http.MultipartFile.fromPath( + 'audio', audioFilePath)); // 'audio' 필드 이름은 그대로 유지 var response = await request.send(); if (response.statusCode == 200) { @@ -115,21 +111,19 @@ class CreateScriptViewModel extends ChangeNotifier { print('Received null or invalid data from the server.'); } } else { - print('Failed to send data and audio. Status code: ${response.statusCode}'); + print( + 'Failed to send data and audio. Status code: ${response.statusCode}'); } } catch (e) { print(e.toString()); } } - - - void _handleStatus(String status) { if (handDone) return; if (status == 'done') { stopListening(); - Future.delayed(Duration(milliseconds: 100), () { + Future.delayed(const Duration(milliseconds: 100), () { startListening(); }); } @@ -160,12 +154,12 @@ class CreateScriptViewModel extends ChangeNotifier { speechToText.listen( onResult: (result) { if (result.finalResult) { - voicedTextController.text += result.recognizedWords + " "; + voicedTextController.text += "${result.recognizedWords} "; notifyListeners(); } }, - listenFor: Duration(minutes: 5), - pauseFor: Duration(seconds: 3), + listenFor: const Duration(minutes: 5), + pauseFor: const Duration(seconds: 3), ); } @@ -179,7 +173,6 @@ class CreateScriptViewModel extends ChangeNotifier { await sendTextAndAudio(); // 비동기 호출로 수정 } - @override void dispose() { // 컨트롤러들을 정리합니다. diff --git a/lib/views/word/widget/word_list_widget.dart b/lib/views/word/widget/word_list_widget.dart index 2b7e989..96ea2be 100644 --- a/lib/views/word/widget/word_list_widget.dart +++ b/lib/views/word/widget/word_list_widget.dart @@ -39,7 +39,6 @@ class _WordListState extends State { itemCount: widget.wordDataList.length, onPageChanged: (index) { wordViewModel.currentIndex.value = index; - print('currentIndex: ${wordViewModel.currentIndex.value}'); }, itemBuilder: (context, index) { final wordData = widget.wordDataList[index]; diff --git a/lib/views/word/widget/word_sentence_widget.dart b/lib/views/word/widget/word_sentence_widget.dart index 91c7709..dd04734 100644 --- a/lib/views/word/widget/word_sentence_widget.dart +++ b/lib/views/word/widget/word_sentence_widget.dart @@ -1,4 +1,3 @@ -// word_sentence_widget.dart import 'dart:io'; import 'package:earlips/models/word_data_model.dart'; @@ -6,50 +5,94 @@ import 'package:earlips/viewModels/record/record_viewmodel.dart'; import 'package:earlips/viewModels/word/word_viewmodel.dart'; import 'package:flutter/material.dart'; import 'package:flutter_sound/flutter_sound.dart'; +import 'package:get/get.dart'; // Import GetX library import 'package:permission_handler/permission_handler.dart'; -import 'package:get/get.dart'; class WordSentenceWidget extends StatelessWidget { final List wordDataList; + const WordSentenceWidget({super.key, required this.wordDataList}); @override Widget build(BuildContext context) { + Get.put(RecordViewModel()); // Ensure RecordViewmodel is initialized final wordViewModel = Get.find(); // Access your ViewModel! + return GetBuilder( - init: RecordViewModel(), - builder: (viewModel) => Center( - child: Column( - children: [ - const Text('WordSentenceWidget'), - StreamBuilder( - stream: viewModel.recorder.onProgress, - builder: (context, snapshot) { - final disposition = snapshot.hasData - ? snapshot.data!.duration - : Duration.zero; + builder: (RecordViewModel model) { + return Center( + child: Column( + children: [ + const Text('WordSentenceWidget'), + Positioned( + bottom: 20, + left: 0, + right: 0, + child: Align( + alignment: Alignment.bottomCenter, + child: Ink( + decoration: BoxDecoration( + color: model.isRecording.value ? Colors.red : Colors.blue, + borderRadius: BorderRadius.circular(40), + ), + child: InkWell( + borderRadius: BorderRadius.circular(40), + onTap: () async { + if (model.isRecording.value) { + // Stop recording and send text and audio + model.toggleRecording(); + // wordDataList[wordViewModel.currentIndex.value] + // .wordCard + // .word + await model.sendTextAndAudio("희찬"); - return Text('Recorder: ${disposition.inSeconds}s'); - }), - ElevatedButton( - onPressed: () async { - if (viewModel.recorder.isRecording) { - await viewModel.stopRecording( - wordDataList[wordViewModel.currentIndex.value] - .wordCard - .word); - } else { - await viewModel.startRecording(); - } - }, - child: Icon( - viewModel.recorder.isRecording ? Icons.stop : Icons.mic, - size: 40, + // Handle the response here, e.g., show it in a dialog + Get.dialog( + AlertDialog( + title: const Text('Response from Server'), + content: GetBuilder( + builder: (model) => Column( + children: [ + Text( + 'Pronunciation: ${model.response['pronunciation']}', + ), + Text( + 'Similarity: ${model.response['similarity']}', + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () { + Get.back(); + }, + child: const Text('OK'), + ), + ], + ), + ); + } else { + // Start recording + model.toggleRecording(); + } + }, + child: Padding( + padding: const EdgeInsets.all(20), + child: Icon( + model.isRecording.value ? Icons.stop : Icons.mic, + size: 30, + color: Colors.white, + ), + ), + ), + ), + ), ), - ), - ], - ), - ), + ], + ), + ); + }, ); } }