2025. 1. 3. 19:11ㆍProject Log/학부 졸업프로젝트
이번 포스팅과 관련된 지난 포스팅
[포스팅 1] Android 에뮬레이터 마이크 설정 & 안드로이드 폰 / 아이패드 디버깅 방법 (https://yr-dev.tistory.com/59)
- Android에서는 음성 녹음 및 실행이 잘 되는데, iOS에서만 음성 녹음이 잘 안 되는 문제가 있었다.
[포스팅 2] Whisper STT 모듈 구현 (Flutter 클라이언트 & 서버 Django 앱) (https://yr-dev.tistory.com/60)
- 해당 포스팅에서는 Android 에뮬레이터에서만 테스트했었다.
- iOS 시뮬레이터 / iOS 실제 기기 / Android 실제 기기에서의 추가 테스트가 필요했다.
- 특히 iOS 시뮬레이터와 iOS 실제 기기에서는 포스팅 1의 연장선으로 녹음이 잘 안되는 문제가 있었다.
- iOS 기기에서는 기존 코드의 HTTP POST 요청이 깔끔하게 이루어지지 않는 점도 발견했다.
iOS에서만 녹음 안 되는 문제 해결(feat.AudioSession)
이전 포스팅에서 올렸던 Flutter 앱 코드로 안드로이드 에뮬레이터에서는 녹음이 정상 실행되었다. 그러나 iOS에서는 녹음이 되는 듯 보이지만, 서버에 파일을 보내면 돌아오는 건 빈 문자열뿐이었다. (iOS야 왜 그런 거니 ㅠㅠ)
제대로 된 답이 없는 Whisper에 끝없이 말을 건네보다가, 진지하게 원인을 생각해 보았다. 음성 파일이 저장된 경로도 로그에 나오고, 서버에서 빈 문자열이지만 응답은 돌아온다. 그렇다면, 녹음된 파일 자체가 잘못되었을 가능성이 가장 높았다. flutter_sound 패키지의 Recorder부터 뜯어보기로 했다.
스택오버플로우와 깃허브 이슈 페이지에서 이미 나 말고도 많은 사람들이 비슷한 문제를 겪고 있었다...
그러던 중 깃허브 이슈 페이지에서 고마운 선생님을 만나게 되었다. audio_session 플러터 패키지와 관련 코드를 추가하라는 것이었다.
(https://github.com/Canardoux/flutter_sound/issues/517)
먼저 audio_session 패키지를 설치 및 추가한다. kIsWeb이라는 상수를 사용하려면 foundation 패키지도 추가해야 한다. 이건 따로 설치가 필요하지는 않다.
flutter pub add audio_session
전체 코드는 포스팅 맨 아래에 첨부하도록 하고, AudioSession 추가 코드만 간단히 리뷰하겠다.
- 먼저 웹이 아닌 앱인 경우에 사용자에게 마이크 권한 요청을 한다.
- openRecorder() 레코더를 준비한다.
- avAudioSession 계열 설정은 iOS 오디오 설정을, androidAudio 계열 설정은 Android 오디오 설정을 처리한다.
- AVAudioSessionMode.spokenAudio나 androidAudioAttributes 설정을 통해 음성 통화 모드로 설정한다.
import 'package:audio_session/audio_session.dart';
import 'package:flutter/foundation.dart';
(생략)
Future<void> _initializeRecorder() async {
print("Initializing recorder...");
if (!kIsWeb) {
var status = await Permission.microphone.request();
if (status != PermissionStatus.granted) {
throw RecordingPermissionException('Microphone permission not granted');
}
}
await _recorder.openRecorder();
final session = await AudioSession.instance;
await session.configure(AudioSessionConfiguration(
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.allowBluetooth |
AVAudioSessionCategoryOptions.defaultToSpeaker,
avAudioSessionMode: AVAudioSessionMode.spokenAudio,
androidAudioAttributes: const AndroidAudioAttributes(
contentType: AndroidAudioContentType.speech,
flags: AndroidAudioFlags.none,
usage: AndroidAudioUsage.voiceCommunication,
),
androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
androidWillPauseWhenDucked: true,
));
print("Recorder initialized.");
}
+) 추가로 수정한 설정 파일들 기록
/ios/Podfile
microphone 퍼미션과 storage 퍼미션을 추가했다. 기존 Podfile에 post_install 코드가 이미 있기 때문에, 그사이에 필요한 코드만 끼워 넣어야 한다. 처음에 못 보고 post_install 하나를 더 만들어서 에러가 났었다.
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
# You can remove unused permissions here
# for more information: https://github.com/BaseflowIT/flutter-permission-handler/blob/master/permission_handler/ios/Classes/PermissionHandlerEnums.h
# e.g. when you don't need camera permission, just add 'PERMISSION_CAMERA=0'
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
'$(inherited)',
## dart: PermissionGroup.microphone
'PERMISSION_MICROPHONE=1',
## dart: PermissionGroup.storage
'PERMISSION_STORAGE=1',
]
end
end
end
/ios/Runner/Info.plist
마이크 권한, HTTP 연결 허용, ios Document 접근 권한을 추가했다.
<key>NSMicrophoneUsageDescription</key>
<string>앱에서 음성 녹음을 사용하려면 마이크 권한이 필요합니다.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSDocumentsFolderUsageDescription</key>
<string>앱이 파일을 저장하려면 권한이 필요합니다.</string>
Android / iOS의 시뮬레이터 / 실제 기기 - 호스트 컴퓨터 통신 IP 차이
웹소켓 통신 URL 설정이다. Android 에뮬레이터, iOS 시뮬레이터, 실제 기기(Android, iOS 모두 포함)가 호스트 컴퓨터와 통신할 때 모두 다른 IP를 사용한다. 엔드포인트는 서버의 django 프로젝트에서 /ws/whisper로 설정해 둔 것이다.
실제 기기에서 앱을 실행할 때는 호스트 컴퓨터의 CMD에서 ifconfig 커맨드를 입력한다. en0쪽의 inet 뒤에 나오는 실제 IP를 사용하면 된다.
아래는 코드가 잘 보이도록 주석을 해제해 두었다. 실제로 실행할 때는 필요한 URL 하나만 남기고, 나머지는 반드시 주석 처리를 해야 한다.
@override
void initState() {
super.initState();
channel = WebSocketChannel.connect(
// Android - Host Computer
Uri.parse('ws://10.0.2.2:8000/ws/whisper/'),
// iOS - Host Computer
Uri.parse('ws://127.0.0.1:8000/ws/whisper/'),
// iOS(real iPad) or Android(real Galaxy phone) - Host Computer (ifconfig en0)
Uri.parse('ws://{호스트 컴퓨터 IP}:8000/ws/whisper/'),
);
_initializeRecorder();
}
HTTP 통신 URL 설정이다. 웹 소켓 통신 URL 설정처럼 실행 기기마다 다르다. 엔드포인트는 서버의 django 프로젝트에서 /api/postRequest/ 로 설정해 둔 것이다.
void _sendPostRequest() async {
// Android - Host Computer
final url = Uri.parse('http://10.0.2.2:8000/api/postRequest/');
// iOS - Host Computer
final url = Uri.parse('http://127.0.0.1:8000/api/postRequest/');
// iOS(real iPad) or Android(real Galaxy phone) - Host Computer (ifconfig en0)
final url = Uri.parse('http://{호스트 컴퓨터 IP}:8000/api/postRequest/');
print("Sending POST request...");
try {
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'message': 'This is a test POST request'}),
);
if (response.statusCode == 200) {
print('POST request successful: ${response.body}');
} else {
print('POST request failed with status: ${response.statusCode}');
}
} catch (e) {
print('Error sending POST request: $e');
}
}
Host 컴퓨터에서 서버를 실행하는 커맨드도 살짝 다르다. 실제 기기와 Host 컴퓨터의 서버 통신을 테스트할 때는 Host 컴퓨터의 IP를 명시해야 한다.
// 서버 실행 커맨드 (에뮬레이터 - 호스트 컴퓨터의 서버 통신)
daphne voice_django.asgi:application
// 서버 실행 커맨드 IP 명시 (실제 기기 - 호스트 컴퓨터의 서버 통신)
daphne -b {ifconfig en0 IP} -p 8000 voice_django.asgi:application
OS별 시뮬레이터의 웹소켓 통신 및 HTTP 통신 사진이다.
OS별 실제 기기의 웹소켓 통신 및 HTTP 통신 사진이다.
HTTP POST 요청 관련 Django 프로젝트 파일 수정 내역
우선 구현하려던 기능은 WebSocket 통신이고, HTTP 통신은 호스트 컴퓨터와의 각 IP를 통한 통신을 테스트하는 용도였다. 그런데 iOS 쪽에서 약간 지저분한 로그가 찍히는 것을 발견해서, 엔드포인트를 깔끔하게 다시 설정하기로 했다.
가장 큰 변화는 http_handler 디렉토리를 새로 생성한 것이고, 이에 따라 기존 파일도 조금씩 수정한 부분이 있다.
(지난 Whisper STT 모듈 구현 포스팅의 django 프로젝트에서 수정 및 추가한 내용만 기록한다.)
voice_django/http_handler/__init__.py
아래 커맨드로 생성했다. 비어 있는 파일이지만, http_handler 디렉토리를 앱으로 인식시키므로 중요하다.
touch __init__.py
voice_django/http_handler/urls.py
- POST 요청의 엔드포인트를 정의한다.
- 현재 경로에 있는 views.py 파일을 임포트한다.
- 해당 경로로 요청이 들어오면 views.py에 있는 handle_post_request 함수를 호출한다.
from django.urls import path
from . import views
urlpatterns = [
path('api/postRequest/', views.handle_post_request, name='handle_post_request'),
]
voice_django/http_handler/views.py
- POST 요청이 들어오면, 파싱된 데이터로부터 key 값을 변수에 저장한다.
- response_data를 응답으로 반환한다.
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
import json
@csrf_exempt
def handle_post_request(request):
if request.method == 'POST':
try:
data = json.loads(request.body)
print("Received data:", data)
key = data.get('key', 'default_value')
response_data = {
'message': 'Request successful!',
'received_key': key,
}
return JsonResponse(response_data, status=200)
except json.JSONDecodeError:
return JsonResponse({'error': 'Invalid JSON'}, status=400)
return JsonResponse({'error': 'Only POST requests are allowed'}, status=405)
voice_django/voice_django/settings.py
- 위에서 구분했던 IP 주소들을 ALLOWED_HOSTS 리스트에 추가한다.
- INSTALLED_APPS에 새로 생성한 http_handler 앱을 추가했다.
ALLOWED_HOSTS = ['10.0.2.2', '127.0.0.1', '{호스트 컴퓨터 IP}']
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"channels",
"whisper",
"http_handler",
]
voice_django/voice_django/urls.py
- 루트 URL에 대한 요청을 http_handler 앱의 urls.py의 urlpatterns로 처리한다.
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path('', include('http_handler.urls')),
]
클라이언트 Flutter 앱 전체 코드
서버의 Whisper 모델과 웹소켓 통신하는 Flutter 앱 코드이다. 코드가 길어서 마지막에 첨부한다.
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_sound/flutter_sound.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:http/http.dart' as http;
import 'package:permission_handler/permission_handler.dart';
import 'package:path_provider/path_provider.dart';
import 'package:audio_session/audio_session.dart';
import 'package:flutter/foundation.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Whisper',
home: SpeechToTextPage(),
);
}
}
class SpeechToTextPage extends StatefulWidget {
@override
_SpeechToTextPageState createState() => _SpeechToTextPageState();
}
class _SpeechToTextPageState extends State<SpeechToTextPage> {
late WebSocketChannel channel;
final FlutterSoundRecorder _recorder = FlutterSoundRecorder();
bool isRecording = false;
String transcribedText = '';
@override
void initState() {
super.initState();
channel = WebSocketChannel.connect(
// Android - Host Computer
// Uri.parse('ws://10.0.2.2:8000/ws/whisper/'),
// iOS - Host Computer
// Uri.parse('ws://127.0.0.1:8000/ws/whisper/'),
// iOS(real iPad) or Android(real Galaxy phone) - Host Computer (ifconfig en0)
Uri.parse('ws://{호스트 컴퓨터 IP}:8000/ws/whisper/'),
);
_initializeRecorder();
}
Future<void> _initializeRecorder() async {
print("Initializing recorder...");
if (!kIsWeb) {
var status = await Permission.microphone.request();
if (status != PermissionStatus.granted) {
throw RecordingPermissionException('Microphone permission not granted');
}
}
await _recorder.openRecorder();
final session = await AudioSession.instance;
await session.configure(AudioSessionConfiguration(
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.allowBluetooth |
AVAudioSessionCategoryOptions.defaultToSpeaker,
avAudioSessionMode: AVAudioSessionMode.spokenAudio,
androidAudioAttributes: const AndroidAudioAttributes(
contentType: AndroidAudioContentType.speech,
flags: AndroidAudioFlags.none,
usage: AndroidAudioUsage.voiceCommunication,
),
androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
androidWillPauseWhenDucked: true,
));
print("Recorder initialized.");
}
Future<void> _startRecording() async {
print("Starting recording...");
final directory = await getApplicationDocumentsDirectory();
final path = '${directory.path}/audio.wav';
await _recorder.startRecorder(toFile: path);
setState(() {
isRecording = true;
});
print("Recording started.");
}
Future<void> _stopRecording() async {
print("Stopping recording...");
final path = await _recorder.stopRecorder();
setState(() {
isRecording = false;
});
print("Recording stopped.");
if (path != null) {
print("Recording saved at: $path");
final audioFile = File(path);
final audioBytes = await audioFile.readAsBytes();
final audioBase64 = base64Encode(audioBytes);
print("Audio encoded to base64.");
channel.sink.add(jsonEncode({
'audio': audioBase64,
}));
print("Audio data sent to server.");
}
}
void _sendPostRequest() async {
// Android - Host Computer
// final url = Uri.parse('http://10.0.2.2:8000/api/postRequest/');
// iOS - Host Computer
// final url = Uri.parse('http://127.0.0.1:8000/api/postRequest/');
// iOS(real iPad) or Android(real Galaxy phone) - Host Computer (ifconfig en0)
final url = Uri.parse('http://{호스트 컴퓨터 IP}:8000/api/postRequest/');
print("Sending POST request...");
try {
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'message': 'This is a test POST request'}),
);
if (response.statusCode == 200) {
print('POST request successful: ${response.body}');
} else {
print('POST request failed with status: ${response.statusCode}');
}
} catch (e) {
print('Error sending POST request: $e');
}
}
@override
void dispose() {
print("Disposing resources...");
_recorder.closeRecorder();
channel.sink.close();
super.dispose();
print("Resources disposed.");
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Whisper Speech-to-Text"),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (isRecording)
IconButton(
icon: Icon(Icons.stop),
onPressed: _stopRecording,
)
else
IconButton(
icon: Icon(Icons.mic),
onPressed: _startRecording,
),
SizedBox(height: 20),
ElevatedButton(
onPressed: _sendPostRequest,
child: Text("Send POST Request"),
),
SizedBox(height: 20),
StreamBuilder(
stream: channel.stream,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.active) {
var serverData = snapshot.data;
if (serverData is String) {
transcribedText = serverData;
}
return Container(
padding: EdgeInsets.all(12),
margin: EdgeInsets.symmetric(horizontal: 20),
color: Colors.blue.shade50,
child: Text(
transcribedText.isNotEmpty
? transcribedText
: "Waiting for speech...",
style: TextStyle(fontSize: 18),
),
);
} else {
return Container(
padding: EdgeInsets.all(12),
margin: EdgeInsets.symmetric(horizontal: 20),
color: Colors.grey.shade200,
child: Text(
"Waiting for speech...",
style: TextStyle(fontSize: 18),
),
);
}
},
),
],
),
),
);
}
}