Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/log/images/issue-26/ios-simulator.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
53 changes: 53 additions & 0 deletions docs/log/issue-26-ios-stt-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Issue #26 iOS SpeechAnalyzer STT 어댑터

검증일: 2026-10-27

## 구현 범위

- `pigeons/stt_api.dart`를 단일 전송 계약으로 두고 Pigeon 29.0.11로 Dart/Swift 코드를 생성했다.
- 공개 Dart 계약(`SttService`, `SttAvailability`, `SttPreparation`, `SttEvent`)과 생성 타입을 `PigeonSttService`에서 분리했다.
- iOS 26+ `SpeechAnalyzer` + `SpeechTranscriber`의 온디바이스 `ko-KR` 지원과 `AssetInventory` 설치 상태를 확인하고, `prepare`에서 필요한 모델 설치를 수행한다.
- 마이크와 음성 인식 권한을 각각 확인한다. 권한이 없으면 오디오 세션을 시작하지 않는다.
- `AVAudioEngine` 입력 포맷과 `bestAvailableAudioFormat`이 다르면 `AVAudioConverter`로 변환한다. 변환기를 만들 수 없으면 `audioFormatMismatch`로 시작을 거부한다.
- volatile/final 결과와 ready/ended/failure/cancelled를 session ID, generation, 단조 증가 sequence와 함께 전달한다.
- 취소·dispose·백그라운드 전환 시 이벤트 발행을 차단하고 audio tap, 입력 stream, 결과 Task, analyzer, audio session을 정리한다.
- 시작 예약을 첫 비동기 작업 전에 잡고 각 비동기 경계 뒤에 취소 여부를 다시 확인해, 준비 중 cancel과 동시 start가 마이크를 고아 상태로 남기지 않게 한다.
- 전화·Siri 등 오디오 interruption과 media-services reset은 재시도 가능한 `interrupted` failure로 세션을 종료한다.
- 실행 중 오디오 변환 실패는 정상 `ended`가 아니라 재시도 불가능한 `audioFormatMismatch` failure로 전달한다.
- 종료된 generation, 중복 sequence, 빈 부분/최종 전사는 Dart 경계에서 폐기한다.
- 원시 오디오와 RMS는 Dart로 전달하지 않고, 오디오 파일을 만들거나 네트워크로 전송하지 않는다.

Apple 문서상 `SpeechAnalyzer`의 transcriber는 음성을 Apple 서버로 전송하지 않는다. 모델 자산은 `AssetInventory`가 시스템 범위에서 관리한다.

- <https://developer.apple.com/documentation/speech/speechanalyzer>
- <https://developer.apple.com/documentation/speech/assetinventory>
- <https://developer.apple.com/documentation/speech/asking-permission-to-use-speech-recognition>
- <https://developer.apple.com/documentation/avfaudio/avaudioapplication/requestrecordpermission(completionhandler:)>

## 자동 검증

| 항목 | 결과 |
|---|---|
| Dart 구조화 계약 | 권한 거부, 모델 미설치, 오디오 포맷 불일치 failure 매핑 테스트 통과 |
| Dart 시작 오류 | Pigeon `PlatformException`을 플랫폼 중립 `SttStartException`으로 변환하는 테스트 통과 |
| generation 방어 | cancel 뒤 도착한 이전 generation 결과와 빈 전사 폐기 테스트 통과 |
| iOS XCTest | iPhone 17 Pro / iOS 26.3.1 대상 `xcodebuild test` 통과 |
| iOS SDK 컴파일 | Xcode 26.2 / iOS Simulator SDK 26.2에서 Debug 빌드 통과 |
| 시뮬레이터 실행 | iPhone 17 Pro / iOS 26.3.1에서 설치·실행 및 기존 화면 렌더링 확인 |

시뮬레이터 실행 화면: [ios-simulator.png](images/issue-26/ios-simulator.png)

## 실기기 검증 상태와 절차

`xcdevice`에서 물리 iPhone 12 Pro(iOS 18.7.8)가 보였지만 연결 가능 상태가 아니며, 최소 지원 버전 iOS 26도 충족하지 않는다. 따라서 **실기기 오프라인 `ko-KR` 부분/최종 결과는 검증하지 않았고 성공으로 판정하지 않는다.**

iOS 26+ 실기기가 준비되면 다음을 확인해야 한다.

1. 앱을 새로 설치하고 마이크·음성 인식 권한의 허용/거부 상태가 각각 구조화 상태로 전달되는지 확인한다.
2. `ko-KR` 모델이 없는 상태에서 `modelDownloadRequired` 또는 설치 진행 상태가 표시되고, 설치 뒤 `ready`가 되는지 확인한다.
3. 네트워크를 끈 상태에서 직접 작성한 더미 문장을 읽고 non-final 결과가 `partial`, 확정 결과가 `finalResult`로 같은 generation에 도착하는지 확인한다.
4. 입력 포맷과 analyzer 포맷이 다른 기기에서 변환 후 결과가 나오며, 변환 불가 시 `audioFormatMismatch`로 종료되는지 확인한다.
5. 낭독 중 홈 화면으로 이동했을 때 마이크 표시가 사라지고 `cancelled` 뒤 늦은 결과가 전달되지 않는지 확인한다.
6. 취소 직후 generation을 증가시켜 재시작하고 이전 generation 결과가 새 소비자에게 전달되지 않는지 확인한다.

실기기 검증 중 전사문이나 음성은 로그·파일·분석 도구에 남기지 않는다.
12 changes: 12 additions & 0 deletions ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
A26000010000000000000001 /* SttApi.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = A26000020000000000000001 /* SttApi.g.swift */; };
A26000030000000000000001 /* IosSpeechAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A26000040000000000000001 /* IosSpeechAdapter.swift */; };
A26000050000000000000001 /* IosSpeechSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = A26000060000000000000001 /* IosSpeechSession.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
Expand Down Expand Up @@ -50,6 +53,9 @@
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
A26000020000000000000001 /* SttApi.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/SttApi.g.swift; sourceTree = "<group>"; };
A26000040000000000000001 /* IosSpeechAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosSpeechAdapter.swift; sourceTree = "<group>"; };
A26000060000000000000001 /* IosSpeechSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosSpeechSession.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
Expand Down Expand Up @@ -123,6 +129,9 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
A26000020000000000000001 /* SttApi.g.swift */,
A26000040000000000000001 /* IosSpeechAdapter.swift */,
A26000060000000000000001 /* IosSpeechSession.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
Expand Down Expand Up @@ -282,6 +291,9 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
A26000010000000000000001 /* SttApi.g.swift in Sources */,
A26000030000000000000001 /* IosSpeechAdapter.swift in Sources */,
A26000050000000000000001 /* IosSpeechSession.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
Expand Down
8 changes: 8 additions & 0 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import UIKit

@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var speechAdapter: IosSpeechAdapter?

override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
Expand All @@ -12,5 +14,11 @@ import UIKit

func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
guard let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "IosSpeechAdapter") else {
return
}
let adapter = IosSpeechAdapter(binaryMessenger: registrar.messenger())
NativeSttHostApiSetup.setUp(binaryMessenger: registrar.messenger(), api: adapter)
speechAdapter = adapter
}
}
Loading
Loading