iOS만 녹음 안 되는 문제 해결 & 시뮬레이터/실제 기기-호스트 컴퓨터 IP 구분

2025. 1. 3. 19:11Project 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 하나를 더 만들어서 에러가 났었다.

(https://stackoverflow.com/questions/69608443/flutter-ios-can-not-use-microphone-problem-with-permission-handler)

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 통신 사진이다.

안드로이드 에뮬레이터(좌) / iOS 시뮬레이터(우) 실행 화면 및 URL 설정 방법

 

OS별 실제 기기의 웹소켓 통신 및 HTTP 통신 사진이다.

안드로이드 실제 기기(좌) / iOS 실제 기기(우) 실행 화면 및 URL 설정

 

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),
                    ),
                  );
                }
              },
            ),
          ],
        ),
      ),
    );
  }
}