From 30cfcccab06c0717e9760873559afeb9dc487f26 Mon Sep 17 00:00:00 2001 From: Yongwoo Marco Kim Date: Tue, 27 Oct 2026 20:09:46 +0900 Subject: [PATCH 1/6] =?UTF-8?q?[#26]=20feat:=20iOS=20SpeechAnalyzer=20STT?= =?UTF-8?q?=20=EC=96=B4=EB=8C=91=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/log/images/issue-26/ios-simulator.png | Bin 0 -> 81468 bytes docs/log/issue-26-ios-stt-adapter.md | 49 ++ ios/Runner.xcodeproj/project.pbxproj | 12 + ios/Runner/AppDelegate.swift | 8 + ios/Runner/Generated/SttApi.g.swift | 675 ++++++++++++++++++ ios/Runner/Info.plist | 4 + ios/Runner/IosSpeechAdapter.swift | 237 ++++++ ios/Runner/IosSpeechSession.swift | 212 ++++++ .../stt/generated/stt_api.g.dart | 636 +++++++++++++++++ .../stt/pigeon_stt_service.dart | 142 ++++ lib/src/infrastructure/stt/stt_models.dart | 83 +++ lib/src/infrastructure/stt/stt_service.dart | 15 + pigeons/stt_api.dart | 129 ++++ pubspec.lock | 56 ++ pubspec.yaml | 1 + .../stt/pigeon_stt_service_test.dart | 161 +++++ 16 files changed, 2420 insertions(+) create mode 100644 docs/log/images/issue-26/ios-simulator.png create mode 100644 docs/log/issue-26-ios-stt-adapter.md create mode 100644 ios/Runner/Generated/SttApi.g.swift create mode 100644 ios/Runner/IosSpeechAdapter.swift create mode 100644 ios/Runner/IosSpeechSession.swift create mode 100644 lib/src/infrastructure/stt/generated/stt_api.g.dart create mode 100644 lib/src/infrastructure/stt/pigeon_stt_service.dart create mode 100644 lib/src/infrastructure/stt/stt_models.dart create mode 100644 lib/src/infrastructure/stt/stt_service.dart create mode 100644 pigeons/stt_api.dart create mode 100644 test/infrastructure/stt/pigeon_stt_service_test.dart diff --git a/docs/log/images/issue-26/ios-simulator.png b/docs/log/images/issue-26/ios-simulator.png new file mode 100644 index 0000000000000000000000000000000000000000..2ad25477be2ff0aef0deba8627498e64941b5ce7 GIT binary patch literal 81468 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 literal 0 HcmV?d00001 diff --git a/docs/log/issue-26-ios-stt-adapter.md b/docs/log/issue-26-ios-stt-adapter.md new file mode 100644 index 0000000..56c1ad5 --- /dev/null +++ b/docs/log/issue-26-ios-stt-adapter.md @@ -0,0 +1,49 @@ +# 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을 정리한다. +- 종료된 generation, 중복 sequence, 빈 부분/최종 전사는 Dart 경계에서 폐기한다. +- 원시 오디오와 RMS는 Dart로 전달하지 않고, 오디오 파일을 만들거나 네트워크로 전송하지 않는다. + +Apple 문서상 `SpeechAnalyzer`의 transcriber는 음성을 Apple 서버로 전송하지 않는다. 모델 자산은 `AssetInventory`가 시스템 범위에서 관리한다. + +- +- +- +- + +## 자동 검증 + +| 항목 | 결과 | +|---|---| +| Dart 구조화 계약 | 권한 거부, 모델 미설치, 오디오 포맷 불일치 failure 매핑 테스트 통과 | +| 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 결과가 새 소비자에게 전달되지 않는지 확인한다. + +실기기 검증 중 전사문이나 음성은 로그·파일·분석 도구에 남기지 않는다. diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 89576b6..f476696 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -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 */; }; @@ -50,6 +53,9 @@ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + A26000020000000000000001 /* SttApi.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/SttApi.g.swift; sourceTree = ""; }; + A26000040000000000000001 /* IosSpeechAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosSpeechAdapter.swift; sourceTree = ""; }; + A26000060000000000000001 /* IosSpeechSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosSpeechSession.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; @@ -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; @@ -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 */, ); diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c30b367..b22739b 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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]? @@ -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 } } diff --git a/ios/Runner/Generated/SttApi.g.swift b/ios/Runner/Generated/SttApi.g.swift new file mode 100644 index 0000000..29b83cd --- /dev/null +++ b/ios/Runner/Generated/SttApi.g.swift @@ -0,0 +1,675 @@ +// Autogenerated from Pigeon (v29.0.11), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Sendable? + + init(code: String, message: String?, details: Sendable?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? ""))" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(Swift.type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func createConnectionError(withChannelName channelName: String) -> PigeonError { + return PigeonError(code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", details: "") +} + +enum SttApiPigeonInternal { + static func isNullish(_ value: Any?) -> Bool { + guard let innerValue = value else { + return true + } + + if case Optional.some(Optional.none) = value { + return true + } + + return innerValue is NSNull + } + static func doubleEquals(_ lhs: Double, _ rhs: Double) -> Bool { + return (lhs.isNaN && rhs.isNaN) || lhs == rhs + } + + static func doubleHash(_ value: Double, _ hasher: inout Hasher) { + if value.isNaN { + hasher.combine(0x7FF8000000000000) + } else { + // Normalize -0.0 to 0.0 + hasher.combine(value == 0 ? 0 : value) + } + } + + static func deepEquals(_ lhs: Any?, _ rhs: Any?) -> Bool { + let cleanLhs = nilOrValue(lhs) as Any? + let cleanRhs = nilOrValue(rhs) as Any? + switch (cleanLhs, cleanRhs) { + case (nil, nil): + return true + + case (nil, _), (_, nil): + return false + + case (let lhs as AnyObject, let rhs as AnyObject) where lhs === rhs: + return true + + case is (Void, Void): + return true + + case (let lhsArray, let rhsArray) as ([Double], [Double]): + guard lhsArray.count == rhsArray.count else { return false } + for (index, element) in lhsArray.enumerated() { + if !doubleEquals(element, rhsArray[index]) { + return false + } + } + return true + + case (let lhsArray, let rhsArray) as ([Any?], [Any?]): + guard lhsArray.count == rhsArray.count else { return false } + for (index, element) in lhsArray.enumerated() { + if !deepEquals(element, rhsArray[index]) { + return false + } + } + return true + + case (let lhsDictionary, let rhsDictionary) as ([AnyHashable: Any?], [AnyHashable: Any?]): + guard lhsDictionary.count == rhsDictionary.count else { return false } + for (lhsKey, lhsValue) in lhsDictionary { + var found = false + for (rhsKey, rhsValue) in rhsDictionary { + if deepEquals(lhsKey, rhsKey) { + if deepEquals(lhsValue, rhsValue) { + found = true + break + } else { + return false + } + } + } + if !found { return false } + } + return true + + case (let lhs as Double, let rhs as Double): + return doubleEquals(lhs, rhs) + + case (let lhsHashable, let rhsHashable) as (AnyHashable, AnyHashable): + return lhsHashable == rhsHashable + + default: + return false + } + } + + static func deepHash(value: Any?, hasher: inout Hasher) { + let cleanValue = nilOrValue(value) as Any? + if let cleanValue = cleanValue { + if let doubleValue = cleanValue as? Double { + doubleHash(doubleValue, &hasher) + } else if let valueList = cleanValue as? [Any?] { + for item in valueList { + deepHash(value: item, hasher: &hasher) + } + } else if let valueList = cleanValue as? [Double] { + for item in valueList { + doubleHash(item, &hasher) + } + } else if let valueDict = cleanValue as? [AnyHashable: Any?] { + var result = 0 + for (key, value) in valueDict { + var entryKeyHasher = Hasher() + deepHash(value: key, hasher: &entryKeyHasher) + var entryValueHasher = Hasher() + deepHash(value: value, hasher: &entryValueHasher) + result = result &+ ((entryKeyHasher.finalize() &* 31) ^ entryValueHasher.finalize()) + } + hasher.combine(result) + } else if let hashableValue = cleanValue as? AnyHashable { + hasher.combine(hashableValue) + } else { + hasher.combine(String(describing: cleanValue)) + } + } else { + hasher.combine(0) + } + } + +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + + +enum NativeSttPermission: Int, CaseIterable { + case notDetermined = 0 + case granted = 1 + case denied = 2 + case restricted = 3 +} + +enum NativeSttModelStatus: Int, CaseIterable { + case installed = 0 + case supported = 1 + case downloading = 2 + case missing = 3 + case unsupported = 4 +} + +enum NativeSttPreparationStatus: Int, CaseIterable { + case ready = 0 + case permissionRequired = 1 + case permissionDenied = 2 + case modelDownloadRequired = 3 + case modelDownloading = 4 + case unsupported = 5 +} + +enum NativeSttEventKind: Int, CaseIterable { + case ready = 0 + case partial = 1 + case finalResult = 2 + case ended = 3 + case failure = 4 + case cancelled = 5 +} + +enum NativeSttFailureCode: Int, CaseIterable { + case permissionDenied = 0 + case onDeviceUnavailable = 1 + case localeUnsupported = 2 + case modelMissing = 3 + case busy = 4 + case noMatch = 5 + case audio = 6 + case audioFormatMismatch = 7 + case interrupted = 8 + case cancelled = 9 + case unknown = 10 +} + +/// Generated class from Pigeon that represents data sent in messages. +struct NativeSttAvailability: Hashable, CustomStringConvertible { + var microphonePermission: NativeSttPermission + var speechPermission: NativeSttPermission + var onDeviceAvailable: Bool + var localeSupported: Bool + var modelStatus: NativeSttModelStatus + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> NativeSttAvailability? { + let microphonePermission = pigeonVar_list[0] as! NativeSttPermission + let speechPermission = pigeonVar_list[1] as! NativeSttPermission + let onDeviceAvailable = pigeonVar_list[2] as! Bool + let localeSupported = pigeonVar_list[3] as! Bool + let modelStatus = pigeonVar_list[4] as! NativeSttModelStatus + + return NativeSttAvailability( + microphonePermission: microphonePermission, + speechPermission: speechPermission, + onDeviceAvailable: onDeviceAvailable, + localeSupported: localeSupported, + modelStatus: modelStatus + ) + } + func toList() -> [Any?] { + return [ + microphonePermission, + speechPermission, + onDeviceAvailable, + localeSupported, + modelStatus, + ] + } + static func == (lhs: NativeSttAvailability, rhs: NativeSttAvailability) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return SttApiPigeonInternal.deepEquals(lhs.microphonePermission, rhs.microphonePermission) && SttApiPigeonInternal.deepEquals(lhs.speechPermission, rhs.speechPermission) && SttApiPigeonInternal.deepEquals(lhs.onDeviceAvailable, rhs.onDeviceAvailable) && SttApiPigeonInternal.deepEquals(lhs.localeSupported, rhs.localeSupported) && SttApiPigeonInternal.deepEquals(lhs.modelStatus, rhs.modelStatus) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("NativeSttAvailability") + SttApiPigeonInternal.deepHash(value: microphonePermission, hasher: &hasher) + SttApiPigeonInternal.deepHash(value: speechPermission, hasher: &hasher) + SttApiPigeonInternal.deepHash(value: onDeviceAvailable, hasher: &hasher) + SttApiPigeonInternal.deepHash(value: localeSupported, hasher: &hasher) + SttApiPigeonInternal.deepHash(value: modelStatus, hasher: &hasher) + } + + public var description: String { + return "NativeSttAvailability(microphonePermission: \(String(describing: microphonePermission)), speechPermission: \(String(describing: speechPermission)), onDeviceAvailable: \(String(describing: onDeviceAvailable)), localeSupported: \(String(describing: localeSupported)), modelStatus: \(String(describing: modelStatus)))" + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct NativeSttPreparation: Hashable, CustomStringConvertible { + var status: NativeSttPreparationStatus + var failureCode: NativeSttFailureCode? = nil + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> NativeSttPreparation? { + let status = pigeonVar_list[0] as! NativeSttPreparationStatus + let failureCode: NativeSttFailureCode? = nilOrValue(pigeonVar_list[1]) + + return NativeSttPreparation( + status: status, + failureCode: failureCode + ) + } + func toList() -> [Any?] { + return [ + status, + failureCode, + ] + } + static func == (lhs: NativeSttPreparation, rhs: NativeSttPreparation) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return SttApiPigeonInternal.deepEquals(lhs.status, rhs.status) && SttApiPigeonInternal.deepEquals(lhs.failureCode, rhs.failureCode) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("NativeSttPreparation") + SttApiPigeonInternal.deepHash(value: status, hasher: &hasher) + SttApiPigeonInternal.deepHash(value: failureCode, hasher: &hasher) + } + + public var description: String { + return "NativeSttPreparation(status: \(String(describing: status)), failureCode: \(String(describing: failureCode)))" + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct NativeSttStartRequest: Hashable, CustomStringConvertible { + var sessionId: String + var generation: Int64 + var locale: String + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> NativeSttStartRequest? { + let sessionId = pigeonVar_list[0] as! String + let generation = pigeonVar_list[1] as! Int64 + let locale = pigeonVar_list[2] as! String + + return NativeSttStartRequest( + sessionId: sessionId, + generation: generation, + locale: locale + ) + } + func toList() -> [Any?] { + return [ + sessionId, + generation, + locale, + ] + } + static func == (lhs: NativeSttStartRequest, rhs: NativeSttStartRequest) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return SttApiPigeonInternal.deepEquals(lhs.sessionId, rhs.sessionId) && SttApiPigeonInternal.deepEquals(lhs.generation, rhs.generation) && SttApiPigeonInternal.deepEquals(lhs.locale, rhs.locale) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("NativeSttStartRequest") + SttApiPigeonInternal.deepHash(value: sessionId, hasher: &hasher) + SttApiPigeonInternal.deepHash(value: generation, hasher: &hasher) + SttApiPigeonInternal.deepHash(value: locale, hasher: &hasher) + } + + public var description: String { + return "NativeSttStartRequest(sessionId: \(String(describing: sessionId)), generation: \(String(describing: generation)), locale: \(String(describing: locale)))" + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct NativeSttEvent: Hashable, CustomStringConvertible { + var sessionId: String + var generation: Int64 + var sequence: Int64 + var kind: NativeSttEventKind + var text: String? = nil + var failureCode: NativeSttFailureCode? = nil + var retryable: Bool? = nil + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> NativeSttEvent? { + let sessionId = pigeonVar_list[0] as! String + let generation = pigeonVar_list[1] as! Int64 + let sequence = pigeonVar_list[2] as! Int64 + let kind = pigeonVar_list[3] as! NativeSttEventKind + let text: String? = nilOrValue(pigeonVar_list[4]) + let failureCode: NativeSttFailureCode? = nilOrValue(pigeonVar_list[5]) + let retryable: Bool? = nilOrValue(pigeonVar_list[6]) + + return NativeSttEvent( + sessionId: sessionId, + generation: generation, + sequence: sequence, + kind: kind, + text: text, + failureCode: failureCode, + retryable: retryable + ) + } + func toList() -> [Any?] { + return [ + sessionId, + generation, + sequence, + kind, + text, + failureCode, + retryable, + ] + } + static func == (lhs: NativeSttEvent, rhs: NativeSttEvent) -> Bool { + if Swift.type(of: lhs) != Swift.type(of: rhs) { + return false + } + return SttApiPigeonInternal.deepEquals(lhs.sessionId, rhs.sessionId) && SttApiPigeonInternal.deepEquals(lhs.generation, rhs.generation) && SttApiPigeonInternal.deepEquals(lhs.sequence, rhs.sequence) && SttApiPigeonInternal.deepEquals(lhs.kind, rhs.kind) && SttApiPigeonInternal.deepEquals(lhs.text, rhs.text) && SttApiPigeonInternal.deepEquals(lhs.failureCode, rhs.failureCode) && SttApiPigeonInternal.deepEquals(lhs.retryable, rhs.retryable) + } + + func hash(into hasher: inout Hasher) { + hasher.combine("NativeSttEvent") + SttApiPigeonInternal.deepHash(value: sessionId, hasher: &hasher) + SttApiPigeonInternal.deepHash(value: generation, hasher: &hasher) + SttApiPigeonInternal.deepHash(value: sequence, hasher: &hasher) + SttApiPigeonInternal.deepHash(value: kind, hasher: &hasher) + SttApiPigeonInternal.deepHash(value: text, hasher: &hasher) + SttApiPigeonInternal.deepHash(value: failureCode, hasher: &hasher) + SttApiPigeonInternal.deepHash(value: retryable, hasher: &hasher) + } + + public var description: String { + return "NativeSttEvent(sessionId: \(String(describing: sessionId)), generation: \(String(describing: generation)), sequence: \(String(describing: sequence)), kind: \(String(describing: kind)), text: \(String(describing: text)), failureCode: \(String(describing: failureCode)), retryable: \(String(describing: retryable)))" + } +} + +private class SttApiPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) + if let enumResultAsInt = enumResultAsInt { + return NativeSttPermission(rawValue: enumResultAsInt) + } + return nil + case 130: + let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) + if let enumResultAsInt = enumResultAsInt { + return NativeSttModelStatus(rawValue: enumResultAsInt) + } + return nil + case 131: + let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) + if let enumResultAsInt = enumResultAsInt { + return NativeSttPreparationStatus(rawValue: enumResultAsInt) + } + return nil + case 132: + let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) + if let enumResultAsInt = enumResultAsInt { + return NativeSttEventKind(rawValue: enumResultAsInt) + } + return nil + case 133: + let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) + if let enumResultAsInt = enumResultAsInt { + return NativeSttFailureCode(rawValue: enumResultAsInt) + } + return nil + case 134: + return NativeSttAvailability.fromList(self.readValue() as! [Any?]) + case 135: + return NativeSttPreparation.fromList(self.readValue() as! [Any?]) + case 136: + return NativeSttStartRequest.fromList(self.readValue() as! [Any?]) + case 137: + return NativeSttEvent.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class SttApiPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? NativeSttPermission { + super.writeByte(129) + super.writeValue(value.rawValue) + } else if let value = value as? NativeSttModelStatus { + super.writeByte(130) + super.writeValue(value.rawValue) + } else if let value = value as? NativeSttPreparationStatus { + super.writeByte(131) + super.writeValue(value.rawValue) + } else if let value = value as? NativeSttEventKind { + super.writeByte(132) + super.writeValue(value.rawValue) + } else if let value = value as? NativeSttFailureCode { + super.writeByte(133) + super.writeValue(value.rawValue) + } else if let value = value as? NativeSttAvailability { + super.writeByte(134) + super.writeValue(value.toList()) + } else if let value = value as? NativeSttPreparation { + super.writeByte(135) + super.writeValue(value.toList()) + } else if let value = value as? NativeSttStartRequest { + super.writeByte(136) + super.writeValue(value.toList()) + } else if let value = value as? NativeSttEvent { + super.writeByte(137) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class SttApiPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return SttApiPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return SttApiPigeonCodecWriter(data: data) + } +} + +class SttApiPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = SttApiPigeonCodec(readerWriter: SttApiPigeonCodecReaderWriter()) +} + + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol NativeSttHostApi { + func checkAvailability(locale: String) async throws -> NativeSttAvailability + func prepare(locale: String) async throws -> NativeSttPreparation + func start(request: NativeSttStartRequest) async throws + func cancel(sessionId: String) async throws + func dispose() async throws +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class NativeSttHostApiSetup { + static var codec: FlutterStandardMessageCodec { SttApiPigeonCodec.shared } + /// Sets up an instance of `NativeSttHostApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: NativeSttHostApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let checkAvailabilityChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.maltto.NativeSttHostApi.checkAvailability\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + checkAvailabilityChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let localeArg = args[0] as! String + Task { @MainActor in + do { + let result = try await api.checkAvailability(locale: localeArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } + } else { + checkAvailabilityChannel.setMessageHandler(nil) + } + let prepareChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.maltto.NativeSttHostApi.prepare\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + prepareChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let localeArg = args[0] as! String + Task { @MainActor in + do { + let result = try await api.prepare(locale: localeArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } + } else { + prepareChannel.setMessageHandler(nil) + } + let startChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.maltto.NativeSttHostApi.start\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + startChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let requestArg = args[0] as! NativeSttStartRequest + Task { @MainActor in + do { + try await api.start(request: requestArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } + } else { + startChannel.setMessageHandler(nil) + } + let cancelChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.maltto.NativeSttHostApi.cancel\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + cancelChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let sessionIdArg = args[0] as! String + Task { @MainActor in + do { + try await api.cancel(sessionId: sessionIdArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } + } else { + cancelChannel.setMessageHandler(nil) + } + let disposeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.maltto.NativeSttHostApi.dispose\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + disposeChannel.setMessageHandler { _, reply in + Task { @MainActor in + do { + try await api.dispose() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } + } else { + disposeChannel.setMessageHandler(nil) + } + } +} + +/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. +protocol NativeSttFlutterApiProtocol { + @MainActor func onEvent(event eventArg: NativeSttEvent) async throws +} +class NativeSttFlutterApi: NativeSttFlutterApiProtocol { + private let binaryMessenger: FlutterBinaryMessenger + private let messageChannelSuffix: String + init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") { + self.binaryMessenger = binaryMessenger + self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + } + var codec: SttApiPigeonCodec { + return SttApiPigeonCodec.shared + } + @MainActor func onEvent(event eventArg: NativeSttEvent) async throws { + return try await withCheckedThrowingContinuation { continuation in + let channelName: String = "dev.flutter.pigeon.maltto.NativeSttFlutterApi.onEvent\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([eventArg] as [Any?]) { response in + guard let listResponse = response as? [Any?] else { + continuation.resume(throwing: createConnectionError(withChannelName: channelName)) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + continuation.resume(throwing: PigeonError(code: code, message: message, details: details)) + } else { + continuation.resume() + } + } + } + } +} diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index f2a5205..30b3ddd 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -26,6 +26,10 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSMicrophoneUsageDescription + 낭독 중 사용자의 목소리를 기기에서 인식하기 위해 마이크를 사용합니다. + NSSpeechRecognitionUsageDescription + 낭독 중 말한 내용을 기기에서 텍스트로 인식하기 위해 사용합니다. UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/ios/Runner/IosSpeechAdapter.swift b/ios/Runner/IosSpeechAdapter.swift new file mode 100644 index 0000000..6967508 --- /dev/null +++ b/ios/Runner/IosSpeechAdapter.swift @@ -0,0 +1,237 @@ +import AVFAudio +import Flutter +import Foundation +import Speech +import UIKit + +@MainActor +final class IosSpeechAdapter: NSObject, NativeSttHostApi { + private let flutterApi: NativeSttFlutterApi + private var activeSession: IosSpeechSession? + private var backgroundObserver: NSObjectProtocol? + + init(binaryMessenger: FlutterBinaryMessenger) { + flutterApi = NativeSttFlutterApi(binaryMessenger: binaryMessenger) + super.init() + backgroundObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + await self?.cancelActiveSession(emitCancelled: true) + } + } + } + + deinit { + if let backgroundObserver { + NotificationCenter.default.removeObserver(backgroundObserver) + } + } + + func checkAvailability(locale: String) async throws -> NativeSttAvailability { + let locale = Locale(identifier: locale) + let microphonePermission = Self.microphonePermission() + let speechPermission = Self.speechPermission() + let supportedLocale = await SpeechTranscriber.supportedLocale(equivalentTo: locale) + let isSupported = SpeechTranscriber.isAvailable && supportedLocale != nil + let modelStatus: NativeSttModelStatus + if let supportedLocale, SpeechTranscriber.isAvailable { + let transcriber = Self.makeTranscriber(locale: supportedLocale) + modelStatus = Self.modelStatus(await AssetInventory.status(forModules: [transcriber])) + } else { + modelStatus = .unsupported + } + return NativeSttAvailability( + microphonePermission: microphonePermission, + speechPermission: speechPermission, + onDeviceAvailable: SpeechTranscriber.isAvailable, + localeSupported: isSupported, + modelStatus: modelStatus + ) + } + + func prepare(locale: String) async throws -> NativeSttPreparation { + let microphone = await requestMicrophonePermission() + guard microphone == .granted else { + return NativeSttPreparation( + status: microphone == .notDetermined ? .permissionRequired : .permissionDenied, + failureCode: .permissionDenied + ) + } + let speech = await requestSpeechPermission() + guard speech == .granted else { + return NativeSttPreparation( + status: speech == .notDetermined ? .permissionRequired : .permissionDenied, + failureCode: .permissionDenied + ) + } + guard SpeechTranscriber.isAvailable else { + return NativeSttPreparation(status: .unsupported, failureCode: .onDeviceUnavailable) + } + let requestedLocale = Locale(identifier: locale) + guard let supportedLocale = await SpeechTranscriber.supportedLocale(equivalentTo: requestedLocale) + else { + return NativeSttPreparation(status: .unsupported, failureCode: .localeUnsupported) + } + + let transcriber = Self.makeTranscriber(locale: supportedLocale) + switch await AssetInventory.status(forModules: [transcriber]) { + case .installed: + return NativeSttPreparation(status: .ready, failureCode: nil) + case .unsupported: + return NativeSttPreparation(status: .unsupported, failureCode: .localeUnsupported) + case .downloading: + return NativeSttPreparation(status: .modelDownloading, failureCode: nil) + case .supported: + guard let request = try await AssetInventory.assetInstallationRequest( + supporting: [transcriber] + ) else { + return NativeSttPreparation(status: .modelDownloadRequired, failureCode: .modelMissing) + } + try await request.downloadAndInstall() + let installed = await AssetInventory.status(forModules: [transcriber]) == .installed + return NativeSttPreparation( + status: installed ? .ready : .modelDownloadRequired, + failureCode: installed ? nil : .modelMissing + ) + @unknown default: + return NativeSttPreparation(status: .unsupported, failureCode: .unknown) + } + } + + func start(request: NativeSttStartRequest) async throws { + guard activeSession == nil else { + throw PigeonError(code: "busy", message: "An STT session is already active.", details: nil) + } + guard Self.microphonePermission() == .granted, Self.speechPermission() == .granted else { + throw PigeonError(code: "permissionDenied", message: "STT permission is not granted.", details: nil) + } + guard SpeechTranscriber.isAvailable else { + throw PigeonError(code: "onDeviceUnavailable", message: "On-device STT is unavailable.", details: nil) + } + let requestedLocale = Locale(identifier: request.locale) + guard let locale = await SpeechTranscriber.supportedLocale(equivalentTo: requestedLocale) else { + throw PigeonError(code: "localeUnsupported", message: "The locale is unsupported.", details: nil) + } + let transcriber = Self.makeTranscriber(locale: locale) + guard await AssetInventory.status(forModules: [transcriber]) == .installed else { + throw PigeonError(code: "modelMissing", message: "The on-device model is not installed.", details: nil) + } + + let session = IosSpeechSession( + sessionId: request.sessionId, + generation: request.generation, + transcriber: transcriber + ) { [weak self] event in + guard let self, self.activeSession?.matches(event: event) == true else { return } + try? await self.flutterApi.onEvent(event: event) + } onFinished: { [weak self] session in + guard let self, self.activeSession === session else { return } + self.activeSession = nil + } + activeSession = session + do { + try await session.start() + } catch { + if activeSession === session { activeSession = nil } + await session.cancel(emitEvent: false) + throw Self.pigeonError(from: error) + } + } + + func cancel(sessionId: String) async throws { + guard activeSession?.sessionId == sessionId else { return } + await cancelActiveSession(emitCancelled: true) + } + + func dispose() async throws { + await cancelActiveSession(emitCancelled: false) + } + + private func cancelActiveSession(emitCancelled: Bool) async { + guard let session = activeSession else { return } + await session.cancel(emitEvent: emitCancelled) + if activeSession === session { activeSession = nil } + } + + private static func makeTranscriber(locale: Locale) -> SpeechTranscriber { + SpeechTranscriber( + locale: locale, + transcriptionOptions: [], + reportingOptions: [.volatileResults, .fastResults], + attributeOptions: [] + ) + } + + private static func microphonePermission() -> NativeSttPermission { + switch AVAudioApplication.shared.recordPermission { + case .undetermined: .notDetermined + case .granted: .granted + case .denied: .denied + @unknown default: .restricted + } + } + + private static func speechPermission() -> NativeSttPermission { + switch SFSpeechRecognizer.authorizationStatus() { + case .notDetermined: .notDetermined + case .authorized: .granted + case .denied: .denied + case .restricted: .restricted + @unknown default: .restricted + } + } + + private func requestMicrophonePermission() async -> NativeSttPermission { + if Self.microphonePermission() != .notDetermined { return Self.microphonePermission() } + _ = await AVAudioApplication.requestRecordPermission() + return Self.microphonePermission() + } + + private func requestSpeechPermission() async -> NativeSttPermission { + if Self.speechPermission() != .notDetermined { return Self.speechPermission() } + await withCheckedContinuation { continuation in + SFSpeechRecognizer.requestAuthorization { _ in continuation.resume() } + } + return Self.speechPermission() + } + + private static func modelStatus(_ status: AssetInventory.Status) -> NativeSttModelStatus { + switch status { + case .installed: .installed + case .supported: .supported + case .downloading: .downloading + case .unsupported: .unsupported + @unknown default: .missing + } + } + + private static func pigeonError(from error: Error) -> PigeonError { + if let error = error as? PigeonError { return error } + if let error = error as? IosSpeechError { + return PigeonError(code: error.code, message: error.message, details: nil) + } + return PigeonError(code: "unknown", message: "The STT session failed.", details: nil) + } +} + +enum IosSpeechError: Error { + case audioFormatMismatch + case audio + + var code: String { + switch self { + case .audioFormatMismatch: "audioFormatMismatch" + case .audio: "audio" + } + } + + var message: String { + switch self { + case .audioFormatMismatch: "The microphone format cannot be converted for SpeechAnalyzer." + case .audio: "The audio input could not be started." + } + } +} diff --git a/ios/Runner/IosSpeechSession.swift b/ios/Runner/IosSpeechSession.swift new file mode 100644 index 0000000..40cac8b --- /dev/null +++ b/ios/Runner/IosSpeechSession.swift @@ -0,0 +1,212 @@ +import AVFAudio +import Foundation +import Speech +import Synchronization + +@MainActor +final class IosSpeechSession { + typealias EventHandler = @MainActor (NativeSttEvent) async -> Void + typealias FinishedHandler = @MainActor (IosSpeechSession) -> Void + + let sessionId: String + let generation: Int64 + + private let transcriber: SpeechTranscriber + private let eventHandler: EventHandler + private let finishedHandler: FinishedHandler + private let engine = AVAudioEngine() + private var analyzer: SpeechAnalyzer? + private var inputContinuation: AsyncStream.Continuation? + private var resultsTask: Task? + private var sequence: Int64 = 0 + private var acceptsEvents = true + private var tapInstalled = false + + init( + sessionId: String, + generation: Int64, + transcriber: SpeechTranscriber, + eventHandler: @escaping EventHandler, + onFinished: @escaping FinishedHandler + ) { + self.sessionId = sessionId + self.generation = generation + self.transcriber = transcriber + self.eventHandler = eventHandler + finishedHandler = onFinished + } + + func matches(event: NativeSttEvent) -> Bool { + acceptsEvents && event.sessionId == sessionId && event.generation == generation + } + + func start() async throws { + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.playAndRecord, mode: .spokenAudio, options: [.defaultToSpeaker]) + try audioSession.setActive(true) + + let analyzer = SpeechAnalyzer(modules: [transcriber]) + let inputFormat = engine.inputNode.outputFormat(forBus: 0) + guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else { + throw IosSpeechError.audio + } + guard let targetFormat = await SpeechAnalyzer.bestAvailableAudioFormat( + compatibleWith: [transcriber], + considering: inputFormat + ) else { + throw IosSpeechError.audioFormatMismatch + } + try await analyzer.prepareToAnalyze(in: targetFormat) + + let (inputSequence, continuation) = AsyncStream.makeStream(of: AnalyzerInput.self) + try installTap(inputFormat: inputFormat, targetFormat: targetFormat, continuation: continuation) + self.analyzer = analyzer + inputContinuation = continuation + resultsTask = consumeResults() + + try await analyzer.start(inputSequence: inputSequence) + engine.prepare() + do { + try engine.start() + } catch { + throw IosSpeechError.audio + } + await emit(kind: .ready) + } + + func cancel(emitEvent: Bool) async { + guard acceptsEvents else { return } + if emitEvent { await emit(kind: .cancelled) } + acceptsEvents = false + await cleanUp(finalize: false) + } + + private func consumeResults() -> Task { + Task { [weak self, transcriber] in + do { + for try await result in transcriber.results { + guard let self, self.acceptsEvents else { return } + let text = String(result.text.characters).trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { continue } + await self.emit(kind: result.isFinal ? .finalResult : .partial, text: text) + } + guard let self, self.acceptsEvents else { return } + await self.emit(kind: .ended) + self.acceptsEvents = false + await self.cleanUp(finalize: false) + self.finishedHandler(self) + } catch is CancellationError { + return + } catch { + guard let self, self.acceptsEvents else { return } + await self.emit(kind: .failure, failureCode: .unknown, retryable: true) + self.acceptsEvents = false + await self.cleanUp(finalize: false) + self.finishedHandler(self) + } + } + } + + private func installTap( + inputFormat: AVAudioFormat, + targetFormat: AVAudioFormat, + continuation: AsyncStream.Continuation + ) throws { + let converter: AVAudioConverter? + if inputFormat == targetFormat { + converter = nil + } else { + guard let made = AVAudioConverter(from: inputFormat, to: targetFormat) else { + throw IosSpeechError.audioFormatMismatch + } + converter = made + } + let converterBox = AudioConverterBox(converter) + engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { buffer, _ in + if let converter = converterBox.converter { + guard let converted = Self.convert(buffer, with: converter, to: targetFormat) else { + continuation.finish() + return + } + continuation.yield(AnalyzerInput(buffer: converted)) + } else { + continuation.yield(AnalyzerInput(buffer: buffer)) + } + } + tapInstalled = true + } + + private func emit( + kind: NativeSttEventKind, + text: String? = nil, + failureCode: NativeSttFailureCode? = nil, + retryable: Bool? = nil + ) async { + guard acceptsEvents else { return } + sequence += 1 + await eventHandler( + NativeSttEvent( + sessionId: sessionId, + generation: generation, + sequence: sequence, + kind: kind, + text: text, + failureCode: failureCode, + retryable: retryable + ) + ) + } + + private func cleanUp(finalize: Bool) async { + engine.stop() + if tapInstalled { + engine.inputNode.removeTap(onBus: 0) + tapInstalled = false + } + inputContinuation?.finish() + inputContinuation = nil + let task = resultsTask + resultsTask = nil + task?.cancel() + if finalize, let analyzer { try? await analyzer.finalizeAndFinishThroughEndOfInput() } + self.analyzer = nil + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } + + private nonisolated static func convert( + _ buffer: AVAudioPCMBuffer, + with converter: AVAudioConverter, + to format: AVAudioFormat + ) -> AVAudioPCMBuffer? { + let ratio = format.sampleRate / buffer.format.sampleRate + let capacity = AVAudioFrameCount((Double(buffer.frameLength) * ratio).rounded(.up)) + 1 + guard let output = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: capacity) else { return nil } + let source = AudioBufferBox(buffer) + let consumed = Mutex(false) + var conversionError: NSError? + let status = converter.convert(to: output, error: &conversionError) { _, outputStatus in + let wasConsumed = consumed.withLock { value in + defer { value = true } + return value + } + if wasConsumed { + outputStatus.pointee = .noDataNow + return nil + } + outputStatus.pointee = .haveData + return source.buffer + } + guard status != .error, conversionError == nil else { return nil } + return output + } +} + +private struct AudioBufferBox: @unchecked Sendable { + let buffer: AVAudioPCMBuffer + init(_ buffer: AVAudioPCMBuffer) { self.buffer = buffer } +} + +private struct AudioConverterBox: @unchecked Sendable { + let converter: AVAudioConverter? + init(_ converter: AVAudioConverter?) { self.converter = converter } +} diff --git a/lib/src/infrastructure/stt/generated/stt_api.g.dart b/lib/src/infrastructure/stt/generated/stt_api.g.dart new file mode 100644 index 0000000..160ced6 --- /dev/null +++ b/lib/src/infrastructure/stt/generated/stt_api.g.dart @@ -0,0 +1,636 @@ +// Autogenerated from Pigeon (v29.0.11), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: unused_import, unused_shown_name +// ignore_for_file: type=lint + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List; + +import 'package:flutter/services.dart'; +import 'package:meta/meta.dart' show immutable, protected, visibleForTesting; + +Object? _extractReplyValueOrThrow( + List? replyList, + String channelName, { + required bool isNullValid, +}) { + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } + return replyList.firstOrNull; +} + +List wrapResponse({ + Object? result, + PlatformException? error, + bool empty = false, +}) { + if (empty) { + return []; + } + if (error == null) { + return [result]; + } + return [error.code, error.message, error.details]; +} + +bool _deepEquals(Object? a, Object? b) { + if (identical(a, b)) { + return true; + } + if (a is double && b is double) { + if (a.isNaN && b.isNaN) { + return true; + } + return a == b; + } + if (a is List && b is List) { + return a.length == b.length && + a.indexed.every( + ((int, dynamic) item) => _deepEquals(item.$2, b[item.$1]), + ); + } + if (a is Map && b is Map) { + if (a.length != b.length) { + return false; + } + for (final MapEntry entryA in a.entries) { + bool found = false; + for (final MapEntry entryB in b.entries) { + if (_deepEquals(entryA.key, entryB.key)) { + if (_deepEquals(entryA.value, entryB.value)) { + found = true; + break; + } else { + return false; + } + } + } + if (!found) { + return false; + } + } + return true; + } + return a == b; +} + +int _deepHash(Object? value) { + if (value is List) { + return Object.hashAll(value.map(_deepHash)); + } + if (value is Map) { + int result = 0; + for (final MapEntry entry in value.entries) { + result += (_deepHash(entry.key) * 31) ^ _deepHash(entry.value); + } + return result; + } + if (value is double && value.isNaN) { + // Normalize NaN to a consistent hash. + return 0x7FF8000000000000.hashCode; + } + if (value is double && value == 0.0) { + // Normalize -0.0 to 0.0 so they have the same hash code. + return 0.0.hashCode; + } + return value.hashCode; +} + +enum NativeSttPermission { notDetermined, granted, denied, restricted } + +enum NativeSttModelStatus { + installed, + supported, + downloading, + missing, + unsupported, +} + +enum NativeSttPreparationStatus { + ready, + permissionRequired, + permissionDenied, + modelDownloadRequired, + modelDownloading, + unsupported, +} + +enum NativeSttEventKind { + ready, + partial, + finalResult, + ended, + failure, + cancelled, +} + +enum NativeSttFailureCode { + permissionDenied, + onDeviceUnavailable, + localeUnsupported, + modelMissing, + busy, + noMatch, + audio, + audioFormatMismatch, + interrupted, + cancelled, + unknown, +} + +class NativeSttAvailability { + NativeSttAvailability({ + required this.microphonePermission, + required this.speechPermission, + required this.onDeviceAvailable, + required this.localeSupported, + required this.modelStatus, + }); + + NativeSttPermission microphonePermission; + + NativeSttPermission speechPermission; + + bool onDeviceAvailable; + + bool localeSupported; + + NativeSttModelStatus modelStatus; + + List _toList() { + return [ + microphonePermission, + speechPermission, + onDeviceAvailable, + localeSupported, + modelStatus, + ]; + } + + Object encode() { + return _toList(); + } + + static NativeSttAvailability decode(Object result) { + result as List; + return NativeSttAvailability( + microphonePermission: result[0]! as NativeSttPermission, + speechPermission: result[1]! as NativeSttPermission, + onDeviceAvailable: result[2]! as bool, + localeSupported: result[3]! as bool, + modelStatus: result[4]! as NativeSttModelStatus, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NativeSttAvailability || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(microphonePermission, other.microphonePermission) && + _deepEquals(speechPermission, other.speechPermission) && + _deepEquals(onDeviceAvailable, other.onDeviceAvailable) && + _deepEquals(localeSupported, other.localeSupported) && + _deepEquals(modelStatus, other.modelStatus); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'NativeSttAvailability(microphonePermission: $microphonePermission, speechPermission: $speechPermission, onDeviceAvailable: $onDeviceAvailable, localeSupported: $localeSupported, modelStatus: $modelStatus)'; + } +} + +class NativeSttPreparation { + NativeSttPreparation({required this.status, this.failureCode}); + + NativeSttPreparationStatus status; + + NativeSttFailureCode? failureCode; + + List _toList() { + return [status, failureCode]; + } + + Object encode() { + return _toList(); + } + + static NativeSttPreparation decode(Object result) { + result as List; + return NativeSttPreparation( + status: result[0]! as NativeSttPreparationStatus, + failureCode: result[1] as NativeSttFailureCode?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NativeSttPreparation || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(status, other.status) && + _deepEquals(failureCode, other.failureCode); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'NativeSttPreparation(status: $status, failureCode: $failureCode)'; + } +} + +class NativeSttStartRequest { + NativeSttStartRequest({ + required this.sessionId, + required this.generation, + required this.locale, + }); + + String sessionId; + + int generation; + + String locale; + + List _toList() { + return [sessionId, generation, locale]; + } + + Object encode() { + return _toList(); + } + + static NativeSttStartRequest decode(Object result) { + result as List; + return NativeSttStartRequest( + sessionId: result[0]! as String, + generation: result[1]! as int, + locale: result[2]! as String, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NativeSttStartRequest || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(sessionId, other.sessionId) && + _deepEquals(generation, other.generation) && + _deepEquals(locale, other.locale); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'NativeSttStartRequest(sessionId: $sessionId, generation: $generation, locale: $locale)'; + } +} + +class NativeSttEvent { + NativeSttEvent({ + required this.sessionId, + required this.generation, + required this.sequence, + required this.kind, + this.text, + this.failureCode, + this.retryable, + }); + + String sessionId; + + int generation; + + int sequence; + + NativeSttEventKind kind; + + String? text; + + NativeSttFailureCode? failureCode; + + bool? retryable; + + List _toList() { + return [ + sessionId, + generation, + sequence, + kind, + text, + failureCode, + retryable, + ]; + } + + Object encode() { + return _toList(); + } + + static NativeSttEvent decode(Object result) { + result as List; + return NativeSttEvent( + sessionId: result[0]! as String, + generation: result[1]! as int, + sequence: result[2]! as int, + kind: result[3]! as NativeSttEventKind, + text: result[4] as String?, + failureCode: result[5] as NativeSttFailureCode?, + retryable: result[6] as bool?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NativeSttEvent || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(sessionId, other.sessionId) && + _deepEquals(generation, other.generation) && + _deepEquals(sequence, other.sequence) && + _deepEquals(kind, other.kind) && + _deepEquals(text, other.text) && + _deepEquals(failureCode, other.failureCode) && + _deepEquals(retryable, other.retryable); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => _deepHash([runtimeType, ..._toList()]); + + @override + String toString() { + return 'NativeSttEvent(sessionId: $sessionId, generation: $generation, sequence: $sequence, kind: $kind, text: $text, failureCode: $failureCode, retryable: $retryable)'; + } +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is NativeSttPermission) { + buffer.putUint8(129); + writeValue(buffer, value.index); + } else if (value is NativeSttModelStatus) { + buffer.putUint8(130); + writeValue(buffer, value.index); + } else if (value is NativeSttPreparationStatus) { + buffer.putUint8(131); + writeValue(buffer, value.index); + } else if (value is NativeSttEventKind) { + buffer.putUint8(132); + writeValue(buffer, value.index); + } else if (value is NativeSttFailureCode) { + buffer.putUint8(133); + writeValue(buffer, value.index); + } else if (value is NativeSttAvailability) { + buffer.putUint8(134); + writeValue(buffer, value.encode()); + } else if (value is NativeSttPreparation) { + buffer.putUint8(135); + writeValue(buffer, value.encode()); + } else if (value is NativeSttStartRequest) { + buffer.putUint8(136); + writeValue(buffer, value.encode()); + } else if (value is NativeSttEvent) { + buffer.putUint8(137); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + final value = readValue(buffer) as int?; + return value == null ? null : NativeSttPermission.values[value]; + case 130: + final value = readValue(buffer) as int?; + return value == null ? null : NativeSttModelStatus.values[value]; + case 131: + final value = readValue(buffer) as int?; + return value == null ? null : NativeSttPreparationStatus.values[value]; + case 132: + final value = readValue(buffer) as int?; + return value == null ? null : NativeSttEventKind.values[value]; + case 133: + final value = readValue(buffer) as int?; + return value == null ? null : NativeSttFailureCode.values[value]; + case 134: + return NativeSttAvailability.decode(readValue(buffer)!); + case 135: + return NativeSttPreparation.decode(readValue(buffer)!); + case 136: + return NativeSttStartRequest.decode(readValue(buffer)!); + case 137: + return NativeSttEvent.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class NativeSttHostApi { + /// Constructor for [NativeSttHostApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + NativeSttHostApi({ + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty + ? '.$messageChannelSuffix' + : ''; + + final BinaryMessenger? pigeonVar_binaryMessenger; + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future checkAvailability(String locale) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.maltto.NativeSttHostApi.checkAvailability$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [locale], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as NativeSttAvailability; + } + + Future prepare(String locale) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.maltto.NativeSttHostApi.prepare$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [locale], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as NativeSttPreparation; + } + + Future start(NativeSttStartRequest request) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.maltto.NativeSttHostApi.start$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [request], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + } + + Future cancel(String sessionId) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.maltto.NativeSttHostApi.cancel$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [sessionId], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + } + + Future dispose() async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.maltto.NativeSttHostApi.dispose$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + } +} + +abstract class NativeSttFlutterApi { + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + void onEvent(NativeSttEvent event); + + static void setUp( + NativeSttFlutterApi? api, { + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) { + messageChannelSuffix = messageChannelSuffix.isNotEmpty + ? '.$messageChannelSuffix' + : ''; + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.maltto.NativeSttFlutterApi.onEvent$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger, + ); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + final List args = message! as List; + final NativeSttEvent arg_event = args[0]! as NativeSttEvent; + try { + api.onEvent(arg_event); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString()), + ); + } + }); + } + } + } +} diff --git a/lib/src/infrastructure/stt/pigeon_stt_service.dart b/lib/src/infrastructure/stt/pigeon_stt_service.dart new file mode 100644 index 0000000..4f9347c --- /dev/null +++ b/lib/src/infrastructure/stt/pigeon_stt_service.dart @@ -0,0 +1,142 @@ +import 'dart:async'; + +import 'generated/stt_api.g.dart' as pigeon; +import 'stt_models.dart'; +import 'stt_service.dart'; + +final class PigeonSttService implements SttService, pigeon.NativeSttFlutterApi { + PigeonSttService({pigeon.NativeSttHostApi? host}) + : _host = host ?? pigeon.NativeSttHostApi() { + pigeon.NativeSttFlutterApi.setUp(this); + } + + final pigeon.NativeSttHostApi _host; + final Map> _controllers = {}; + final Map _activeGenerations = {}; + final Map _lastSequences = {}; + bool _disposed = false; + + @override + Future checkAvailability({required String locale}) async { + _ensureActive(); + return _availabilityFromNative(await _host.checkAvailability(locale)); + } + + @override + Future prepare({required String locale}) async { + _ensureActive(); + final native = await _host.prepare(locale); + return SttPreparation( + status: SttPreparationStatus.values[native.status.index], + failureCode: native.failureCode == null + ? null + : SttFailureCode.values[native.failureCode!.index], + ); + } + + @override + Stream events(String sessionId) { + _ensureActive(); + return _controllers + .putIfAbsent(sessionId, StreamController.broadcast) + .stream; + } + + @override + Future start(SttStartRequest request) async { + _ensureActive(); + if (request.sessionId.isEmpty || request.locale.isEmpty) { + throw ArgumentError('sessionId와 locale은 비어 있을 수 없습니다.'); + } + final previous = _activeGenerations[request.sessionId]; + if (previous != null && request.generation <= previous) { + throw StateError('generation은 이전 값보다 커야 합니다.'); + } + + _activeGenerations[request.sessionId] = request.generation; + _lastSequences[request.sessionId] = -1; + try { + await _host.start( + pigeon.NativeSttStartRequest( + sessionId: request.sessionId, + generation: request.generation, + locale: request.locale, + ), + ); + } catch (_) { + if (_activeGenerations[request.sessionId] == request.generation) { + _activeGenerations.remove(request.sessionId); + _lastSequences.remove(request.sessionId); + } + rethrow; + } + } + + @override + Future cancel(String sessionId) async { + if (_disposed) return; + final generation = _activeGenerations[sessionId]; + await _host.cancel(sessionId); + if (_activeGenerations[sessionId] == generation) { + _activeGenerations.remove(sessionId); + _lastSequences.remove(sessionId); + } + } + + @override + void onEvent(pigeon.NativeSttEvent event) { + if (_disposed || _activeGenerations[event.sessionId] != event.generation) { + return; + } + final lastSequence = _lastSequences[event.sessionId] ?? -1; + if (event.sequence <= lastSequence) return; + final text = event.text?.trim(); + if ((event.kind == pigeon.NativeSttEventKind.partial || + event.kind == pigeon.NativeSttEventKind.finalResult) && + (text == null || text.isEmpty)) { + return; + } + _lastSequences[event.sessionId] = event.sequence; + _controllers[event.sessionId]?.add( + SttEvent( + sessionId: event.sessionId, + generation: event.generation, + sequence: event.sequence, + kind: SttEventKind.values[event.kind.index], + text: text, + failureCode: event.failureCode == null + ? null + : SttFailureCode.values[event.failureCode!.index], + retryable: event.retryable, + ), + ); + } + + @override + Future dispose() async { + if (_disposed) return; + _disposed = true; + _activeGenerations.clear(); + _lastSequences.clear(); + pigeon.NativeSttFlutterApi.setUp(null); + await _host.dispose(); + final controllers = _controllers.values.toList(growable: false); + _controllers.clear(); + await Future.wait(controllers.map((controller) => controller.close())); + } + + SttAvailability _availabilityFromNative( + pigeon.NativeSttAvailability native, + ) => SttAvailability( + microphonePermission: + SttPermission.values[native.microphonePermission.index], + speechPermission: SttPermission.values[native.speechPermission.index], + onDeviceAvailable: native.onDeviceAvailable, + localeSupported: native.localeSupported, + modelStatus: SttModelStatus.values[native.modelStatus.index], + ); + + void _ensureActive() { + if (_disposed) throw StateError('SttService가 이미 dispose되었습니다.'); + } +} diff --git a/lib/src/infrastructure/stt/stt_models.dart b/lib/src/infrastructure/stt/stt_models.dart new file mode 100644 index 0000000..41e657c --- /dev/null +++ b/lib/src/infrastructure/stt/stt_models.dart @@ -0,0 +1,83 @@ +enum SttPermission { notDetermined, granted, denied, restricted } + +enum SttModelStatus { installed, supported, downloading, missing, unsupported } + +enum SttPreparationStatus { + ready, + permissionRequired, + permissionDenied, + modelDownloadRequired, + modelDownloading, + unsupported, +} + +enum SttEventKind { ready, partial, finalResult, ended, failure, cancelled } + +enum SttFailureCode { + permissionDenied, + onDeviceUnavailable, + localeUnsupported, + modelMissing, + busy, + noMatch, + audio, + audioFormatMismatch, + interrupted, + cancelled, + unknown, +} + +final class SttAvailability { + const SttAvailability({ + required this.microphonePermission, + required this.speechPermission, + required this.onDeviceAvailable, + required this.localeSupported, + required this.modelStatus, + }); + + final SttPermission microphonePermission; + final SttPermission speechPermission; + final bool onDeviceAvailable; + final bool localeSupported; + final SttModelStatus modelStatus; +} + +final class SttPreparation { + const SttPreparation({required this.status, this.failureCode}); + + final SttPreparationStatus status; + final SttFailureCode? failureCode; +} + +final class SttStartRequest { + const SttStartRequest({ + required this.sessionId, + required this.generation, + required this.locale, + }); + + final String sessionId; + final int generation; + final String locale; +} + +final class SttEvent { + const SttEvent({ + required this.sessionId, + required this.generation, + required this.sequence, + required this.kind, + this.text, + this.failureCode, + this.retryable, + }); + + final String sessionId; + final int generation; + final int sequence; + final SttEventKind kind; + final String? text; + final SttFailureCode? failureCode; + final bool? retryable; +} diff --git a/lib/src/infrastructure/stt/stt_service.dart b/lib/src/infrastructure/stt/stt_service.dart new file mode 100644 index 0000000..9c3434a --- /dev/null +++ b/lib/src/infrastructure/stt/stt_service.dart @@ -0,0 +1,15 @@ +import 'stt_models.dart'; + +abstract interface class SttService { + Future checkAvailability({required String locale}); + + Future prepare({required String locale}); + + Stream events(String sessionId); + + Future start(SttStartRequest request); + + Future cancel(String sessionId); + + Future dispose(); +} diff --git a/pigeons/stt_api.dart b/pigeons/stt_api.dart new file mode 100644 index 0000000..fca38d8 --- /dev/null +++ b/pigeons/stt_api.dart @@ -0,0 +1,129 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/src/infrastructure/stt/generated/stt_api.g.dart', + dartOptions: DartOptions(), + swiftOut: 'ios/Runner/Generated/SttApi.g.swift', + swiftOptions: SwiftOptions(), + ), +) +enum NativeSttPermission { notDetermined, granted, denied, restricted } + +enum NativeSttModelStatus { + installed, + supported, + downloading, + missing, + unsupported, +} + +enum NativeSttPreparationStatus { + ready, + permissionRequired, + permissionDenied, + modelDownloadRequired, + modelDownloading, + unsupported, +} + +enum NativeSttEventKind { + ready, + partial, + finalResult, + ended, + failure, + cancelled, +} + +enum NativeSttFailureCode { + permissionDenied, + onDeviceUnavailable, + localeUnsupported, + modelMissing, + busy, + noMatch, + audio, + audioFormatMismatch, + interrupted, + cancelled, + unknown, +} + +class NativeSttAvailability { + NativeSttAvailability({ + required this.microphonePermission, + required this.speechPermission, + required this.onDeviceAvailable, + required this.localeSupported, + required this.modelStatus, + }); + + NativeSttPermission microphonePermission; + NativeSttPermission speechPermission; + bool onDeviceAvailable; + bool localeSupported; + NativeSttModelStatus modelStatus; +} + +class NativeSttPreparation { + NativeSttPreparation({required this.status, this.failureCode}); + + NativeSttPreparationStatus status; + NativeSttFailureCode? failureCode; +} + +class NativeSttStartRequest { + NativeSttStartRequest({ + required this.sessionId, + required this.generation, + required this.locale, + }); + + String sessionId; + int generation; + String locale; +} + +class NativeSttEvent { + NativeSttEvent({ + required this.sessionId, + required this.generation, + required this.sequence, + required this.kind, + this.text, + this.failureCode, + this.retryable, + }); + + String sessionId; + int generation; + int sequence; + NativeSttEventKind kind; + String? text; + NativeSttFailureCode? failureCode; + bool? retryable; +} + +@HostApi() +abstract class NativeSttHostApi { + @async + NativeSttAvailability checkAvailability(String locale); + + @async + NativeSttPreparation prepare(String locale); + + @async + void start(NativeSttStartRequest request); + + @async + void cancel(String sessionId); + + @async + void dispose(); +} + +@FlutterApi() +abstract class NativeSttFlutterApi { + void onEvent(NativeSttEvent event); +} diff --git a/pubspec.lock b/pubspec.lock index 4fbab7f..65ec6c5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "44922ecebd8e3d0cef743dadd3b54df4e87e2a4c52a29465a1872aa9e192caa8" + url: "https://pub.dev" + source: hosted + version: "5.1.2" + built_value: + dependency: transitive + description: + name: built_value + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 + url: "https://pub.dev" + source: hosted + version: "8.13.0" characters: dependency: transitive description: @@ -73,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.3" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "811709c1badd02fc054039a13f66e0db1961865f9a314175f68c3eaeebec786f" + url: "https://pub.dev" + source: hosted + version: "4.13.0" collection: dependency: transitive description: @@ -113,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: a0e4f65339b31eb6a9e6a12e14b4682a48d46fbc54a82f4a702396fbdbac63a7 + url: "https://pub.dev" + source: hosted + version: "3.1.14" fake_async: dependency: transitive description: @@ -137,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -171,6 +211,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" http_multi_server: dependency: transitive description: @@ -291,6 +339,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + pigeon: + dependency: "direct dev" + description: + name: pigeon + sha256: "9c4b979de1ed662c8f66a921b7b42ab31c366da5141f3996590c52a8a70baf74" + url: "https://pub.dev" + source: hosted + version: "29.0.11" pool: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2951149..df5f92b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,6 +15,7 @@ dev_dependencies: flutter_lints: ^6.0.0 flutter_test: sdk: flutter + pigeon: 29.0.11 test: ^1.26.3 flutter: diff --git a/test/infrastructure/stt/pigeon_stt_service_test.dart b/test/infrastructure/stt/pigeon_stt_service_test.dart new file mode 100644 index 0000000..33300fd --- /dev/null +++ b/test/infrastructure/stt/pigeon_stt_service_test.dart @@ -0,0 +1,161 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:maltto/src/infrastructure/stt/generated/stt_api.g.dart' + as pigeon; +import 'package:maltto/src/infrastructure/stt/pigeon_stt_service.dart'; +import 'package:maltto/src/infrastructure/stt/stt_models.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const codec = pigeon.NativeSttHostApi.pigeonChannelCodec; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + BasicMessageChannel hostChannel(String method) => + BasicMessageChannel( + 'dev.flutter.pigeon.maltto.NativeSttHostApi.$method', + codec, + ); + + void setHostHandler( + String method, + Future Function(Object?)? handler, + ) { + messenger.setMockDecodedMessageHandler( + hostChannel(method), + handler, + ); + } + + Future ok(Object? value) async => [value]; + + setUp(() { + setHostHandler('dispose', (_) => ok(null)); + setHostHandler('cancel', (_) => ok(null)); + }); + + tearDown(() { + for (final method in [ + 'checkAvailability', + 'prepare', + 'start', + 'cancel', + 'dispose', + ]) { + setHostHandler(method, null); + } + pigeon.NativeSttFlutterApi.setUp(null); + }); + + test('권한 거부와 모델 미설치 준비 상태를 구조화해 전달한다', () async { + setHostHandler( + 'checkAvailability', + (_) => ok( + pigeon.NativeSttAvailability( + microphonePermission: pigeon.NativeSttPermission.denied, + speechPermission: pigeon.NativeSttPermission.granted, + onDeviceAvailable: true, + localeSupported: true, + modelStatus: pigeon.NativeSttModelStatus.supported, + ), + ), + ); + setHostHandler( + 'prepare', + (_) => ok( + pigeon.NativeSttPreparation( + status: pigeon.NativeSttPreparationStatus.modelDownloadRequired, + failureCode: pigeon.NativeSttFailureCode.modelMissing, + ), + ), + ); + final service = PigeonSttService(); + + final availability = await service.checkAvailability(locale: 'ko-KR'); + final preparation = await service.prepare(locale: 'ko-KR'); + + expect(availability.microphonePermission, SttPermission.denied); + expect(availability.modelStatus, SttModelStatus.supported); + expect(preparation.status, SttPreparationStatus.modelDownloadRequired); + expect(preparation.failureCode, SttFailureCode.modelMissing); + await service.dispose(); + }); + + test('오디오 포맷 불일치를 구조화 failure 이벤트로 전달한다', () async { + setHostHandler('start', (_) => ok(null)); + final service = PigeonSttService(); + final events = []; + final subscription = service.events('session').listen(events.add); + await service.start( + const SttStartRequest( + sessionId: 'session', + generation: 1, + locale: 'ko-KR', + ), + ); + + await messenger.handlePlatformMessage( + 'dev.flutter.pigeon.maltto.NativeSttFlutterApi.onEvent', + codec.encodeMessage([ + pigeon.NativeSttEvent( + sessionId: 'session', + generation: 1, + sequence: 1, + kind: pigeon.NativeSttEventKind.failure, + failureCode: pigeon.NativeSttFailureCode.audioFormatMismatch, + retryable: false, + ), + ]), + (_) {}, + ); + await pumpEventQueue(); + + expect(events.single.failureCode, SttFailureCode.audioFormatMismatch); + await subscription.cancel(); + await service.dispose(); + }); + + test('종료된 generation의 늦은 결과와 빈 전사를 버린다', () async { + setHostHandler('start', (_) => ok(null)); + final service = PigeonSttService(); + final events = []; + final subscription = service.events('session').listen(events.add); + await service.start( + const SttStartRequest( + sessionId: 'session', + generation: 1, + locale: 'ko-KR', + ), + ); + await service.cancel('session'); + + for (final event in [ + pigeon.NativeSttEvent( + sessionId: 'session', + generation: 1, + sequence: 2, + kind: pigeon.NativeSttEventKind.finalResult, + text: '늦게 도착한 직접 쓴 문장', + ), + pigeon.NativeSttEvent( + sessionId: 'session', + generation: 2, + sequence: 1, + kind: pigeon.NativeSttEventKind.partial, + text: ' ', + ), + ]) { + await messenger.handlePlatformMessage( + 'dev.flutter.pigeon.maltto.NativeSttFlutterApi.onEvent', + codec.encodeMessage([event]), + (_) {}, + ); + } + await pumpEventQueue(); + + expect(events, isEmpty); + await subscription.cancel(); + await service.dispose(); + }); +} From cff301bd22bd6f0e615ed6fc3452f115c9f840b3 Mon Sep 17 00:00:00 2001 From: Yongwoo Marco Kim Date: Wed, 28 Oct 2026 00:19:38 +0900 Subject: [PATCH 2/6] [#26] fix: Code Review 1 --- docs/log/issue-26-ios-stt-adapter.md | 4 + ios/Runner/IosSpeechAdapter.swift | 74 +++++++++++++++++-- ios/Runner/IosSpeechSession.swift | 47 +++++++++++- .../stt/pigeon_stt_service.dart | 11 +++ lib/src/infrastructure/stt/stt_models.dart | 9 +++ .../stt/pigeon_stt_service_test.dart | 31 ++++++++ 6 files changed, 166 insertions(+), 10 deletions(-) diff --git a/docs/log/issue-26-ios-stt-adapter.md b/docs/log/issue-26-ios-stt-adapter.md index 56c1ad5..cda4026 100644 --- a/docs/log/issue-26-ios-stt-adapter.md +++ b/docs/log/issue-26-ios-stt-adapter.md @@ -11,6 +11,9 @@ - `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로 전달하지 않고, 오디오 파일을 만들거나 네트워크로 전송하지 않는다. @@ -26,6 +29,7 @@ Apple 문서상 `SpeechAnalyzer`의 transcriber는 음성을 Apple 서버로 전 | 항목 | 결과 | |---|---| | 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 빌드 통과 | diff --git a/ios/Runner/IosSpeechAdapter.swift b/ios/Runner/IosSpeechAdapter.swift index 6967508..9dccb9f 100644 --- a/ios/Runner/IosSpeechAdapter.swift +++ b/ios/Runner/IosSpeechAdapter.swift @@ -6,14 +6,25 @@ import UIKit @MainActor final class IosSpeechAdapter: NSObject, NativeSttHostApi { + private final class StartReservation { + let sessionId: String + let generation: Int64 + + init(sessionId: String, generation: Int64) { + self.sessionId = sessionId + self.generation = generation + } + } + private let flutterApi: NativeSttFlutterApi private var activeSession: IosSpeechSession? - private var backgroundObserver: NSObjectProtocol? + private var pendingStart: StartReservation? + private var observers: [NSObjectProtocol] = [] init(binaryMessenger: FlutterBinaryMessenger) { flutterApi = NativeSttFlutterApi(binaryMessenger: binaryMessenger) super.init() - backgroundObserver = NotificationCenter.default.addObserver( + observers.append(NotificationCenter.default.addObserver( forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main @@ -21,13 +32,33 @@ final class IosSpeechAdapter: NSObject, NativeSttHostApi { Task { @MainActor in await self?.cancelActiveSession(emitCancelled: true) } - } + }) + observers.append(NotificationCenter.default.addObserver( + forName: AVAudioSession.interruptionNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard + let rawValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt, + AVAudioSession.InterruptionType(rawValue: rawValue) == .began + else { return } + Task { @MainActor in + await self?.interruptActiveSession() + } + }) + observers.append(NotificationCenter.default.addObserver( + forName: AVAudioSession.mediaServicesWereResetNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + await self?.interruptActiveSession() + } + }) } deinit { - if let backgroundObserver { - NotificationCenter.default.removeObserver(backgroundObserver) - } + observers.forEach(NotificationCenter.default.removeObserver) } func checkAvailability(locale: String) async throws -> NativeSttAvailability { @@ -102,9 +133,17 @@ final class IosSpeechAdapter: NSObject, NativeSttHostApi { } func start(request: NativeSttStartRequest) async throws { - guard activeSession == nil else { + guard activeSession == nil, pendingStart == nil else { throw PigeonError(code: "busy", message: "An STT session is already active.", details: nil) } + let reservation = StartReservation( + sessionId: request.sessionId, + generation: request.generation + ) + pendingStart = reservation + defer { + if pendingStart === reservation { pendingStart = nil } + } guard Self.microphonePermission() == .granted, Self.speechPermission() == .granted else { throw PigeonError(code: "permissionDenied", message: "STT permission is not granted.", details: nil) } @@ -113,12 +152,16 @@ final class IosSpeechAdapter: NSObject, NativeSttHostApi { } let requestedLocale = Locale(identifier: request.locale) guard let locale = await SpeechTranscriber.supportedLocale(equivalentTo: requestedLocale) else { + try ensurePending(reservation) throw PigeonError(code: "localeUnsupported", message: "The locale is unsupported.", details: nil) } + try ensurePending(reservation) let transcriber = Self.makeTranscriber(locale: locale) guard await AssetInventory.status(forModules: [transcriber]) == .installed else { + try ensurePending(reservation) throw PigeonError(code: "modelMissing", message: "The on-device model is not installed.", details: nil) } + try ensurePending(reservation) let session = IosSpeechSession( sessionId: request.sessionId, @@ -132,6 +175,7 @@ final class IosSpeechAdapter: NSObject, NativeSttHostApi { self.activeSession = nil } activeSession = session + pendingStart = nil do { try await session.start() } catch { @@ -142,11 +186,13 @@ final class IosSpeechAdapter: NSObject, NativeSttHostApi { } func cancel(sessionId: String) async throws { + if pendingStart?.sessionId == sessionId { pendingStart = nil } guard activeSession?.sessionId == sessionId else { return } await cancelActiveSession(emitCancelled: true) } func dispose() async throws { + pendingStart = nil await cancelActiveSession(emitCancelled: false) } @@ -156,6 +202,17 @@ final class IosSpeechAdapter: NSObject, NativeSttHostApi { if activeSession === session { activeSession = nil } } + private func interruptActiveSession() async { + pendingStart = nil + guard let session = activeSession else { return } + await session.fail(code: .interrupted, retryable: true) + if activeSession === session { activeSession = nil } + } + + private func ensurePending(_ reservation: StartReservation) throws { + guard pendingStart === reservation else { throw CancellationError() } + } + private static func makeTranscriber(locale: Locale) -> SpeechTranscriber { SpeechTranscriber( locale: locale, @@ -213,6 +270,9 @@ final class IosSpeechAdapter: NSObject, NativeSttHostApi { if let error = error as? IosSpeechError { return PigeonError(code: error.code, message: error.message, details: nil) } + if error is CancellationError { + return PigeonError(code: "cancelled", message: "The STT start was cancelled.", details: nil) + } return PigeonError(code: "unknown", message: "The STT session failed.", details: nil) } } diff --git a/ios/Runner/IosSpeechSession.swift b/ios/Runner/IosSpeechSession.swift index 40cac8b..f67dfa2 100644 --- a/ios/Runner/IosSpeechSession.swift +++ b/ios/Runner/IosSpeechSession.swift @@ -21,6 +21,8 @@ final class IosSpeechSession { private var sequence: Int64 = 0 private var acceptsEvents = true private var tapInstalled = false + private var cleanupStarted = false + private let conversionFailed = ThreadSafeFlag() init( sessionId: String, @@ -46,6 +48,7 @@ final class IosSpeechSession { try audioSession.setActive(true) let analyzer = SpeechAnalyzer(modules: [transcriber]) + self.analyzer = analyzer let inputFormat = engine.inputNode.outputFormat(forBus: 0) guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else { throw IosSpeechError.audio @@ -56,15 +59,17 @@ final class IosSpeechSession { ) else { throw IosSpeechError.audioFormatMismatch } + try ensureActive() try await analyzer.prepareToAnalyze(in: targetFormat) + try ensureActive() let (inputSequence, continuation) = AsyncStream.makeStream(of: AnalyzerInput.self) try installTap(inputFormat: inputFormat, targetFormat: targetFormat, continuation: continuation) - self.analyzer = analyzer inputContinuation = continuation resultsTask = consumeResults() try await analyzer.start(inputSequence: inputSequence) + try ensureActive() engine.prepare() do { try engine.start() @@ -81,6 +86,14 @@ final class IosSpeechSession { await cleanUp(finalize: false) } + func fail(code: NativeSttFailureCode, retryable: Bool) async { + guard acceptsEvents else { return } + await emit(kind: .failure, failureCode: code, retryable: retryable) + acceptsEvents = false + await cleanUp(finalize: false) + finishedHandler(self) + } + private func consumeResults() -> Task { Task { [weak self, transcriber] in do { @@ -91,7 +104,15 @@ final class IosSpeechSession { await self.emit(kind: result.isFinal ? .finalResult : .partial, text: text) } guard let self, self.acceptsEvents else { return } - await self.emit(kind: .ended) + if self.conversionFailed.value { + await self.emit( + kind: .failure, + failureCode: .audioFormatMismatch, + retryable: false + ) + } else { + await self.emit(kind: .ended) + } self.acceptsEvents = false await self.cleanUp(finalize: false) self.finishedHandler(self) @@ -122,9 +143,11 @@ final class IosSpeechSession { converter = made } let converterBox = AudioConverterBox(converter) + let conversionFailed = self.conversionFailed engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { buffer, _ in if let converter = converterBox.converter { guard let converted = Self.convert(buffer, with: converter, to: targetFormat) else { + conversionFailed.set() continuation.finish() return } @@ -158,6 +181,8 @@ final class IosSpeechSession { } private func cleanUp(finalize: Bool) async { + guard !cleanupStarted else { return } + cleanupStarted = true engine.stop() if tapInstalled { engine.inputNode.removeTap(onBus: 0) @@ -168,11 +193,21 @@ final class IosSpeechSession { let task = resultsTask resultsTask = nil task?.cancel() - if finalize, let analyzer { try? await analyzer.finalizeAndFinishThroughEndOfInput() } + if let analyzer { + if finalize { + try? await analyzer.finalizeAndFinishThroughEndOfInput() + } else { + await analyzer.cancelAndFinishNow() + } + } self.analyzer = nil try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) } + private func ensureActive() throws { + guard acceptsEvents else { throw CancellationError() } + } + private nonisolated static func convert( _ buffer: AVAudioPCMBuffer, with converter: AVAudioConverter, @@ -210,3 +245,9 @@ private struct AudioConverterBox: @unchecked Sendable { let converter: AVAudioConverter? init(_ converter: AVAudioConverter?) { self.converter = converter } } + +private final class ThreadSafeFlag: Sendable { + private let storage = Mutex(false) + var value: Bool { storage.withLock { $0 } } + func set() { storage.withLock { $0 = true } } +} diff --git a/lib/src/infrastructure/stt/pigeon_stt_service.dart b/lib/src/infrastructure/stt/pigeon_stt_service.dart index 4f9347c..53feb92 100644 --- a/lib/src/infrastructure/stt/pigeon_stt_service.dart +++ b/lib/src/infrastructure/stt/pigeon_stt_service.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:flutter/services.dart'; + import 'generated/stt_api.g.dart' as pigeon; import 'stt_models.dart'; import 'stt_service.dart'; @@ -63,6 +65,15 @@ final class PigeonSttService implements SttService, pigeon.NativeSttFlutterApi { locale: request.locale, ), ); + } on PlatformException catch (error) { + if (_activeGenerations[request.sessionId] == request.generation) { + _activeGenerations.remove(request.sessionId); + _lastSequences.remove(request.sessionId); + } + final code = + SttFailureCode.values.asNameMap()[error.code] ?? + SttFailureCode.unknown; + throw SttStartException(code); } catch (_) { if (_activeGenerations[request.sessionId] == request.generation) { _activeGenerations.remove(request.sessionId); diff --git a/lib/src/infrastructure/stt/stt_models.dart b/lib/src/infrastructure/stt/stt_models.dart index 41e657c..2919811 100644 --- a/lib/src/infrastructure/stt/stt_models.dart +++ b/lib/src/infrastructure/stt/stt_models.dart @@ -27,6 +27,15 @@ enum SttFailureCode { unknown, } +final class SttStartException implements Exception { + const SttStartException(this.code); + + final SttFailureCode code; + + @override + String toString() => 'SttStartException(${code.name})'; +} + final class SttAvailability { const SttAvailability({ required this.microphonePermission, diff --git a/test/infrastructure/stt/pigeon_stt_service_test.dart b/test/infrastructure/stt/pigeon_stt_service_test.dart index 33300fd..c860186 100644 --- a/test/infrastructure/stt/pigeon_stt_service_test.dart +++ b/test/infrastructure/stt/pigeon_stt_service_test.dart @@ -116,6 +116,37 @@ void main() { await service.dispose(); }); + test('네이티브 시작 오류를 플랫폼 중립 예외로 변환한다', () async { + setHostHandler( + 'start', + (_) async => [ + 'modelMissing', + 'The on-device model is not installed.', + null, + ], + ); + final service = PigeonSttService(); + + await expectLater( + service.start( + const SttStartRequest( + sessionId: 'session', + generation: 1, + locale: 'ko-KR', + ), + ), + throwsA( + isA().having( + (exception) => exception.code, + 'code', + SttFailureCode.modelMissing, + ), + ), + ); + + await service.dispose(); + }); + test('종료된 generation의 늦은 결과와 빈 전사를 버린다', () async { setHostHandler('start', (_) => ok(null)); final service = PigeonSttService(); From 2dc54a0ca28b785243594f16a5eedb45390e9a82 Mon Sep 17 00:00:00 2001 From: Yongwoo Marco Kim Date: Wed, 28 Oct 2026 00:27:06 +0900 Subject: [PATCH 3/6] [#26] fix: Code Review 2 --- ios/Runner/IosSpeechAdapter.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ios/Runner/IosSpeechAdapter.swift b/ios/Runner/IosSpeechAdapter.swift index 9dccb9f..7c9077a 100644 --- a/ios/Runner/IosSpeechAdapter.swift +++ b/ios/Runner/IosSpeechAdapter.swift @@ -30,7 +30,7 @@ final class IosSpeechAdapter: NSObject, NativeSttHostApi { queue: .main ) { [weak self] _ in Task { @MainActor in - await self?.cancelActiveSession(emitCancelled: true) + await self?.handleBackgroundTransition() } }) observers.append(NotificationCenter.default.addObserver( @@ -209,6 +209,11 @@ final class IosSpeechAdapter: NSObject, NativeSttHostApi { if activeSession === session { activeSession = nil } } + private func handleBackgroundTransition() async { + pendingStart = nil + await cancelActiveSession(emitCancelled: true) + } + private func ensurePending(_ reservation: StartReservation) throws { guard pendingStart === reservation else { throw CancellationError() } } From 40d0428d6ed997dc55ea700c391ca9edc1ef20b4 Mon Sep 17 00:00:00 2001 From: Yongwoo Marco Kim Date: Wed, 28 Oct 2026 00:38:27 +0900 Subject: [PATCH 4/6] [#26] fix: Code Review 3 --- ios/Runner/IosSpeechSession.swift | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/ios/Runner/IosSpeechSession.swift b/ios/Runner/IosSpeechSession.swift index f67dfa2..50e4348 100644 --- a/ios/Runner/IosSpeechSession.swift +++ b/ios/Runner/IosSpeechSession.swift @@ -21,7 +21,7 @@ final class IosSpeechSession { private var sequence: Int64 = 0 private var acceptsEvents = true private var tapInstalled = false - private var cleanupStarted = false + private var cleanupTask: Task? private let conversionFailed = ThreadSafeFlag() init( @@ -80,16 +80,18 @@ final class IosSpeechSession { } func cancel(emitEvent: Bool) async { - guard acceptsEvents else { return } - if emitEvent { await emit(kind: .cancelled) } - acceptsEvents = false + if acceptsEvents { + if emitEvent { await emit(kind: .cancelled) } + acceptsEvents = false + } await cleanUp(finalize: false) } func fail(code: NativeSttFailureCode, retryable: Bool) async { - guard acceptsEvents else { return } - await emit(kind: .failure, failureCode: code, retryable: retryable) - acceptsEvents = false + if acceptsEvents { + await emit(kind: .failure, failureCode: code, retryable: retryable) + acceptsEvents = false + } await cleanUp(finalize: false) finishedHandler(self) } @@ -181,8 +183,19 @@ final class IosSpeechSession { } private func cleanUp(finalize: Bool) async { - guard !cleanupStarted else { return } - cleanupStarted = true + if let cleanupTask { + await cleanupTask.value + return + } + let task = Task { @MainActor [weak self] in + guard let self else { return } + await self.performCleanUp(finalize: finalize) + } + cleanupTask = task + await task.value + } + + private func performCleanUp(finalize: Bool) async { engine.stop() if tapInstalled { engine.inputNode.removeTap(onBus: 0) From bdec5d6a1595a6fb0344bda4c0f01803066c199c Mon Sep 17 00:00:00 2001 From: Yongwoo Marco Kim Date: Wed, 28 Oct 2026 00:43:32 +0900 Subject: [PATCH 5/6] [#26] fix: Code Review 4 --- ios/Runner/IosSpeechAdapter.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Runner/IosSpeechAdapter.swift b/ios/Runner/IosSpeechAdapter.swift index 7c9077a..e750efa 100644 --- a/ios/Runner/IosSpeechAdapter.swift +++ b/ios/Runner/IosSpeechAdapter.swift @@ -179,8 +179,8 @@ final class IosSpeechAdapter: NSObject, NativeSttHostApi { do { try await session.start() } catch { - if activeSession === session { activeSession = nil } await session.cancel(emitEvent: false) + if activeSession === session { activeSession = nil } throw Self.pigeonError(from: error) } } From efd20329ff831e5e5bd5877fd62ec4f7393025fc Mon Sep 17 00:00:00 2001 From: Yongwoo Marco Kim Date: Wed, 28 Oct 2026 00:49:12 +0900 Subject: [PATCH 6/6] [#26] fix: Code Review 5 --- ios/Runner/IosSpeechSession.swift | 26 +++++++--- .../stt/pigeon_stt_service.dart | 7 ++- .../stt/pigeon_stt_service_test.dart | 49 +++++++++++++++++++ 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/ios/Runner/IosSpeechSession.swift b/ios/Runner/IosSpeechSession.swift index 50e4348..0308c4b 100644 --- a/ios/Runner/IosSpeechSession.swift +++ b/ios/Runner/IosSpeechSession.swift @@ -81,16 +81,16 @@ final class IosSpeechSession { func cancel(emitEvent: Bool) async { if acceptsEvents { - if emitEvent { await emit(kind: .cancelled) } acceptsEvents = false + if emitEvent { await emitUnchecked(kind: .cancelled) } } await cleanUp(finalize: false) } func fail(code: NativeSttFailureCode, retryable: Bool) async { if acceptsEvents { - await emit(kind: .failure, failureCode: code, retryable: retryable) acceptsEvents = false + await emitUnchecked(kind: .failure, failureCode: code, retryable: retryable) } await cleanUp(finalize: false) finishedHandler(self) @@ -106,24 +106,24 @@ final class IosSpeechSession { await self.emit(kind: result.isFinal ? .finalResult : .partial, text: text) } guard let self, self.acceptsEvents else { return } + self.acceptsEvents = false if self.conversionFailed.value { - await self.emit( + await self.emitUnchecked( kind: .failure, failureCode: .audioFormatMismatch, retryable: false ) } else { - await self.emit(kind: .ended) + await self.emitUnchecked(kind: .ended) } - self.acceptsEvents = false await self.cleanUp(finalize: false) self.finishedHandler(self) } catch is CancellationError { return } catch { guard let self, self.acceptsEvents else { return } - await self.emit(kind: .failure, failureCode: .unknown, retryable: true) self.acceptsEvents = false + await self.emitUnchecked(kind: .failure, failureCode: .unknown, retryable: true) await self.cleanUp(finalize: false) self.finishedHandler(self) } @@ -168,6 +168,20 @@ final class IosSpeechSession { retryable: Bool? = nil ) async { guard acceptsEvents else { return } + await emitUnchecked( + kind: kind, + text: text, + failureCode: failureCode, + retryable: retryable + ) + } + + private func emitUnchecked( + kind: NativeSttEventKind, + text: String? = nil, + failureCode: NativeSttFailureCode? = nil, + retryable: Bool? = nil + ) async { sequence += 1 await eventHandler( NativeSttEvent( diff --git a/lib/src/infrastructure/stt/pigeon_stt_service.dart b/lib/src/infrastructure/stt/pigeon_stt_service.dart index 53feb92..e7e9b01 100644 --- a/lib/src/infrastructure/stt/pigeon_stt_service.dart +++ b/lib/src/infrastructure/stt/pigeon_stt_service.dart @@ -51,8 +51,11 @@ final class PigeonSttService implements SttService, pigeon.NativeSttFlutterApi { throw ArgumentError('sessionId와 locale은 비어 있을 수 없습니다.'); } final previous = _activeGenerations[request.sessionId]; - if (previous != null && request.generation <= previous) { - throw StateError('generation은 이전 값보다 커야 합니다.'); + if (previous != null) { + if (request.generation <= previous) { + throw StateError('generation은 이전 값보다 커야 합니다.'); + } + throw const SttStartException(SttFailureCode.busy); } _activeGenerations[request.sessionId] = request.generation; diff --git a/test/infrastructure/stt/pigeon_stt_service_test.dart b/test/infrastructure/stt/pigeon_stt_service_test.dart index c860186..7ce97ce 100644 --- a/test/infrastructure/stt/pigeon_stt_service_test.dart +++ b/test/infrastructure/stt/pigeon_stt_service_test.dart @@ -147,6 +147,55 @@ void main() { await service.dispose(); }); + test('실행 중인 session의 새 start 거부가 기존 generation을 보존한다', () async { + setHostHandler('start', (_) => ok(null)); + final service = PigeonSttService(); + final events = []; + final subscription = service.events('session').listen(events.add); + await service.start( + const SttStartRequest( + sessionId: 'session', + generation: 1, + locale: 'ko-KR', + ), + ); + + await expectLater( + service.start( + const SttStartRequest( + sessionId: 'session', + generation: 2, + locale: 'ko-KR', + ), + ), + throwsA( + isA().having( + (exception) => exception.code, + 'code', + SttFailureCode.busy, + ), + ), + ); + await messenger.handlePlatformMessage( + 'dev.flutter.pigeon.maltto.NativeSttFlutterApi.onEvent', + codec.encodeMessage([ + pigeon.NativeSttEvent( + sessionId: 'session', + generation: 1, + sequence: 1, + kind: pigeon.NativeSttEventKind.partial, + text: '직접 쓴 테스트 문장', + ), + ]), + (_) {}, + ); + await pumpEventQueue(); + + expect(events.single.generation, 1); + await subscription.cancel(); + await service.dispose(); + }); + test('종료된 generation의 늦은 결과와 빈 전사를 버린다', () async { setHostHandler('start', (_) => ok(null)); final service = PigeonSttService();