text๋ฅผ speech๋ก ์ฎ๊ธฐ๋ ์ผ์ ์๊ฐ๋ณด๋ค ๊ฐ๋จํ๋ค. '๊ทธ๋ฐ๋ฐ speech๋ฅผ text๋ก ๋ฐ๊พธ๋ ๊ฒ์ ์ด๋ป๊ฒ ํ์ง? MLKit ์ด๋ผ๋ ์ฌ์ฉํด์ผ ํ๋?' ๋ผ๋ ๊ฑฑ์ . ๊ฒ์ํด๋ณด๋ MLKit๊น์ง ์ฌ์ฉํ ํ์๋ ์์ ๊ฒ ๊ฐ๊ณ SFSpeechRecognizer
, AVAudioEngine
์ ์ฌ์ฉํด์ ๊ธฐ๋ฅ์ ๊ตฌํํ SwiftUI ์ํ์ด ์์ด ์ดํด๋ณด๊ณ ์ ํ๋ค. ํ ์ดํ๋ก์ ํธ์ ํด๋น ๊ธฐ๋ฅ์ ๋ฃ์ด ๋ฒ์ญํ๊ธฐ ์ํ๋ ๋ฌธ์ฅ์ text๋ก ์
๋ ฅํ ๋ฟ๋ง ์๋๋ผ ๋ง์ดํฌ๋ฅผ ์ฌ์ฉํด ์
๋ ฅํ ์ ์๊ฒ ๋ง๋ค ์์ ์ด๋ค.
https://developer.apple.com/tutorials/app-dev-training/transcribing-speech-to-text
base๊ฐ ๋ ์ํ ์์ . ํํ ๋ฆฌ์ผ์ด ์์ฒญ๋๊ฒ ์น์ ํ๋ค.
ํด๋น ํ๋ ์์ํฌ๋ฅผ ์ฌ์ฉํ๋ฉด ๋ น์๋ ์ค๋์ค๋ ์์ฑ์์ ๋จ์ด๋ฅผ ํฌ์ฐฉํ ์ ์๋ค. ์ฐ๋ฆฌ๊ฐ ์์ดํฐ ํค๋ณด๋์์ ์ฌ์ฉํ ์ ์๋ ๋ฐ์์ฐ๊ธฐ ๊ธฐ๋ฅ์ด ์ค๋์ค๋ฅผ ํ ์คํธ๋ก ๋ฐ๊พธ๊ธฐ ์ํด ์คํผ์น ํ๋ ์์ํฌ๋ฅผ ์ฌ์ฉํ๋ค. ํค๋ณด๋์ ์์กดํ์ง ์๊ณ ์์ฑ์ผ๋ก ๋ช ๋ นํ๋ ๊ฒ๋ ๊ฐ๋ฅํด์ง๋ค. ํด๋น ํ๋ ์์ํฌ๋ ๋ค๊ตญ์ด๋ฅผ ์ง์ํ๋ ๊ฐ๊ฐ์ SFSpeechRecognizer ๊ฐ์ฒด๋ ํ๊ฐ์ง ์ธ์ด๋ง ์ปค๋ฒํ๋ค. (๋์ ๊ฒฝ์ฐ 4๊ฐ ๊ตญ์ด๋ฅผ ๋ฒ์ญํ๊ธฐ ์ํด 4๊ฐ์ ๊ฐ์ฒด๋ฅผ ์ฌ์ฉํด์ผํ ๊ฒ) ๋ค๊ตญ์ด ์ง์์ ์ํด์๋ ๊ธฐ๋ณธ์ ์ผ๋ก ๋คํธ์ํฌ ์ฐ๊ฒฐ์ด ๋๋ ํ๊ฒฝ์ ์ ์ ๋ก ํ๋ค. (on-device๋ก ์ง์ํ๋ ์ธ์ด๊ฐ ์์ง๋ง ํ๋ ์์ํฌ ์์ฒด๊ฐ ์ ํ ์๋ฒ์ ์์กดํ๊ธฐ ๋๋ฌธ์ด๋ค.)
class SFSpeechRecognizer : NSObject
1) authorization setup
project > target > Info
Privacy - Speech Recognition Usage Description : You can view a text transcription of your meeting in the app.
Privacy - Microphone Usage Description : Audio is recorded to transcribe the meeting. Audio recordings are discarded after transcription.
2) create object
import AVFoundation
import Foundation
import Speech
import SwiftUI
// interface๊ฐ ๋๋ wrapper class๋์ง..
class SpeechRecognizer: ObservableObject {
private let recognizer: SFSpeechRecognizer?
init() {
recognizer = SFSpeechRecognizer()
Task(priority: .background) {
do {
guard recognizer != nil else {
throw RecognizerError.nilRecognizer
}
guard await SFSpeechRecognizer.hasAuthorizationToRecognize() else {
throw RecognizerError.notAuthorizedToRecognize
}
guard await AVAudioSession.sharedInstance().hasPermissionToRecord() else {
throw RecognizerError.notPermittedToRecord
}
} catch {
speakError(error)
}
}
}
3) check authorization
extension SFSpeechRecognizer {
static func hasAuthorizationToRecognize() async -> Bool {
await withCheckedContinuation { continuation in
requestAuthorization { status in
continuation.resume(returning: status == .authorized)
}
}
}
}
func withCheckedContinuation<T>(function: String = #function, _ body: (CheckedContinuation<T, Never>) -> Void) async -> T
withCheckedContinuation
: concurrent generic function, ํ์ฌ ์งํ๋๋ ํ์คํฌ๋ฅผ ์ค๋จํ๊ณ closure๋ฅผ ์ํํ๋ค. closure์ argument๋ก CheckedContinuation
์ด ์ ๋ฌ๋๋๋ฐ ๋ฐ๋์ resumeํด์ผ ์ค๋จ๋์๋ ํ์คํฌ๋ฅผ ์ด์ด๋๊ฐ ์ ์๋ค.class func requestAuthorization(_ handler: @escaping (SFSpeechRecognizerAuthorizationStatus) -> Void)
requestAuthorization(_:)
: SFSpeechRecognizer Type Method, handler๋ AuthorizationStatus ๊ฐ์ ๋ฐ๋๋ฐ status ์ํ๊ฐ "known" ์ผ ๋์ ์ด block์ด execute ๋๋ค. status parameter๋ ํ์ฌ ๋ด ์ฑ์ auth ์ํ ๊ฐ์ ๊ฐ์ง๊ณ ์๋ค.func resume(returning value: T)
์์์ SFSpeechRecognizer๋ ํ ๊ฐ์ฒด๊ฐ ํ๋์ ์ธ์ด๋ง ์ฒ๋ฆฌํ ์ ์๋ค๊ณ ์ธ๊ธํ๋ค. ๋ฐ๋ผ์ ๊ธฐ๋ณธ ์์ฑ์๋ ์ฌ์ฉ์์ default ์ธ์ด ์ ํ ์ ๋ฐ๋ผ๊ฐ๋ ๊ฐ์ฒด๊ฐ ์์ฑ๋๋ค. ํน์ ์ธ์ด๋ฅผ ์ธ์ํ๋๋ก ์ค์ ํ๊ธฐ ์ํด์๋ ๋ค๋ฅธ ์์ฑ์๋ฅผ ์ฌ์ฉํด์ผ ํ๋ค.
init?(locale: Locale)
// Creates a speech recognizer associated with the specified locale.
ํน์ view์์ ์์ฑ ์ธ์์ ์์ํ๊ฑฐ๋ ๋ฐ๋๋ก ์์ฑ ์ธ์ ํ๋ก์ธ์ค๋ฅผ ์ ์ง์ํค๋ ํฌ์ธํธ๊ฐ ์๋ค๊ณ ๊ฐ์ ํ์. ๋ฒํผ tap ์ด๋ฒคํธ์ผ ์๋ ์๊ฒ ๊ณ UIKit์ด๋ผ๋ฉด viewWillAppear/viewWillDisapear, SwiftUI๋ผ๋ฉด onAppear/OnDisappear ์ผ ์ ์๊ฒ ๋ค. ์ธ์ ์ธ์ง๋ ์ ํํ๊ธฐ ๋๋ฆ์ด๊ณ ์ค์ํ ๊ฒ์ ํน์ ์์ ์ ์ฐ๋ฆฌ๊ฐ ์์ฑํ speechRecognizer๋ฅผ ์์ํด์ผํ๋ค๋ ๊ฒ์ด๋ค.
.onAppear {
speechRecognizer.reset()
speechRecognizer.transcribe()
}
class SpeechRecognizer: ObservableObject {
private var audioEngine: AVAudioEngine?
private var request: SFSpeechAudioBufferRecognitionRequest?
private var task: SFSpeechRecognitionTask?
private let recognizer: SFSpeechRecognizer?
func reset() {
task?.cancel()
audioEngine?.stop()
audioEngine = nil
request = nil
task = nil
}
audioEngine: AVAudioEngine
request: SFSpeechAudioBufferRecognitionRequest
let (audioEngine, request) = try Self.prepareEngine()
private static func prepareEngine() throws -> (AVAudioEngine, SFSpeechAudioBufferRecognitionRequest) {
let audioEngine = AVAudioEngine()
let request = SFSpeechAudioBufferRecognitionRequest()
request.shouldReportPartialResults = true
let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers)
try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
let inputNode = audioEngine.inputNode
let recordingFormat = inputNode.outputFormat(forBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { (buffer: AVAudioPCMBuffer, when: AVAudioTime) in
request.append(buffer)
}
audioEngine.prepare()
try audioEngine.start()
return (audioEngine, request)
}
๊ทธ๋์ ๋ tuple๋ก returnํด์ tuple๋ก ๋ฐ์ผ๋ฉด ์ฌ๋ฌ๊ฐ์ ๋ณ์๋ฅผ ํ ๋ฒ์ initializeํ ์ ์๋๊ฑฐ ๋๋ง ๋ชฐ๋๋ ํน์ฌ? ๐คช
๊ทธ๋ฅ ์๊ฐ์์ด ๋ณต์ฌ+๋ถ์ฌ๋ฃ๊ธฐ ํ๋ฉด ํธํ ๊ฑฐ ๊ฐ์๋ฐ ๊ทธ๋ฅ ๋์ด๊ฐ๊ธฐ์๋ ๋ง์์ด ๋ถํธํ๋ค. audioEngine์ ๋ฑ ๋ด๋ ํ์ํ ๊ฑฐ ๊ฐ์๋ฐ (๋ฉ๋ 100) ์ AudioSession
์ด๋ inputNode
๊ฐ ๋์ฒด ๋ฌด์์ด์ง.๐ง ๊ทธ๋ฌ๋๊น ์์๋ณด์.
1) ๐ AudioSession
class AVAudioSession: NSObject
AVAudioSession.sharedInstance()
category
๋ฅผ ์์ ํ๋ฉด ๋๋ค.var category: AVAudioSession.Category { get }
func setCategory(AVAudioSession.Category, options: AVAudioSession.CategoryOptions)
duckOthers
: ํด๋น ์ธ์
์ ์ค๋์ค๊ฐ ์ฌ์๋ ๋ ๋ค๋ฅธ ์ค๋์ค ์ธ์
์ ๋ณผ๋ฅจ์ ์ค์ด๋ ์ต์
func setActive(
_ active: Bool,
options: AVAudioSession.SetActiveOptions = []
) throws
notifyOthersOnDeactivation
: ๋ด ์ฑ์ ์ค๋์ค ์ธ์
์ ๋นํ์ฑํํ๋ฉด ๋ค๋ฅธ ์ฑ๋คํํ
์๋ฆฌ๋ ์ต์
(๊ทธ๋์ผ ๋ค๋ฅธ ์ฑ์ ์๋ฆฌ๋ฅผ ๋ค์ ์ผค ์ ์๊ฒ ์ง..)2) ๐ฌ inputNode
class AVAudioEngine : NSObject
AVAudioNode
๊ทธ๋ฃน์ ์์ง์ด ๊ฐ์ง๊ณ ์๋๋ฐ ์ค๋์ค๋ฅผ ํ๋ก์ธ์ฑํ๋๋ฐ์ ์ด ๋
ธ๋ ๊ทธ๋ฃน์ด ํ์ํ๋ค.let audioFile = /* An AVAudioFile instance that points to file that's open for reading. */
let audioEngine = AVAudioEngine()
let playerNode = AVAudioPlayerNode()
// Attach the player node to the audio engine.
audioEngine.attach(playerNode)
// Connect the player node to the output node.
audioEngine.connect(playerNode,
to: audioEngine.outputNode,
format: audioFile.processingFormat)
playerNode.scheduleFile(audioFile,
at: nil,
completionCallbackType: .dataPlayedBack) { _ in
/* Handle any work that's necessary after playback. */
}
var inputNode: AVAudioInputNode { get }
recording tap
์์ฑํ๊ธฐfunc outputFormat(forBus bus: AVAudioNodeBus) -> AVAudioFormat
AVAudioNodeBus
: ์ค๋์ค ๋
ธ๋์ ๋ถ์ด์๋ ๋ฒ์ค์ ์ธ๋ฑ์ค๋ก Int ๊ฐ์ ๋ฐ๋๋คAVAudioFormat
: ์ค๋์ค ํฌ๋งท ๊ฐ์ฒด, AudioStreamBasicDescription์ wrapperfunc installTap(
onBus bus: AVAudioNodeBus,
bufferSize: AVAudioFrameCount,
format: AVAudioFormat?,
block tapBlock: @escaping AVAudioNodeTapBlock
)
typealias AVAudioNodeTapBlock = (AVAudioPCMBuffer, AVAudioTime) -> Void
์ด์ ์ ๋ชจ๋ ๊ณผ์ ์ ์ด transcribe์ ์ํ ๊ฒ์ด์๋ค. ์ฐ๋ฆฌ๋ ์ด ๊ณผ์ ์ ํตํด ์ค๋นํ ์ค๋์ค ์์ง์ผ๋ก ์์ฑ์ ๋ฐ๊ณ ๊ทธ ์์ฑ์ ํ ๋๋ก ํ ์คํธ๋ฅผ ์ถ์ถํ๋ค.
func transcribe() {
DispatchQueue(label: "Speech Recognizer Queue", qos: .background).async { [weak self] in
guard let self = self, let recognizer = self.recognizer, recognizer.isAvailable else {
self?.speakError(RecognizerError.recognizerIsUnavailable)
return
}
do {
let (audioEngine, request) = try Self.prepareEngine()
self.audioEngine = audioEngine
self.request = request
self.task = recognizer.recognitionTask(with: request, resultHandler: self.recognitionHandler(result:error:))
} catch {
self.reset()
self.speakError(error)
}
}
}
recognitionTask(with:resultHandler:)
๋ฉ์๋๋ก request๋ฅผ ์คํํ๊ณ ๊ทธ ๊ฒฐ๊ณผ๋ฅผ ํธ๋ค๋ฌ ์์์ ์ฒ๋ฆฌํ๋ค. SFSpeechRecognitionResult
, Error ๋ฅผ ๋ฐ๋๋ค.์ญ์ ์ฝ๋๊ฐ ๊น๋ํ ๋ฐ์๋ ์ด์ ๊ฐ ์๋ค. ๊ธฐ๋ฅ์ extractํ์ฌ ํธ๋ค๋ฌ๋ฅผ ๋ณ๊ฐ์ private ๋ฉ์๋๋ก ์ ์ธํ๋ค. ๋๋ ์์ผ๋ก ์ด๋ ๊ฒ ์ฝ๋๋ฅผ ์ง์ผ์ง.
private func recognitionHandler(result: SFSpeechRecognitionResult?, error: Error?) {
let receivedFinalResult = result?.isFinal ?? false
let receivedError = error != nil
if receivedFinalResult || receivedError {
audioEngine?.stop()
audioEngine?.inputNode.removeTap(onBus: 0)
}
if let result = result {
speak(result.bestTranscription.formattedString)
}
}
private func speak(_ message: String) {
transcript = message
}
bestTranscription
๋ง ๊ทธ๋๋ก ๊ฐ์ฅ ์ ๋ขฐ์ฑ์ด ๋์ ์์ฑ -> ๋ฌธ์์ ๊ฒฐ๊ณผ๋ฅผ ์ถ์ถํด ํด๋์ค์ ํ๋กํผํฐ์ ๋ด๋๋ค.์ฝ๋ ์์ฒด๋ ๋ช์ค ๋์ง ์์ง๋ง ์ค๋์ค๋ฅผ ๊ฐ์ ธ๋ค ์ฐ๋ ๊ฐ๋ ์ด ์์ํด์ ๋ ธ๋๋ ์ธํ ์์ํ์ด๋ ๋ฏ์ด๋ณด๋ ๋ฐ์ ์๊ฐ์ด ๊ฑธ๋ ธ๋ค. ๋ฌผ๋ก ๋ค ์ดํดํ ๊ฒ์ ์๋๊ณ ์ด ์ค๋์ค ํ๋ก์ธ์ค๋ฅผ ํ๋ํ๋ ์๋ฒฝํ๊ฒ ์ดํดํ ์๊ฐ์ ์๋ค. ์น๊ฐ๋ฐ์ ํ ๋์๋ ์ด๋ฏธ ์์ฑ๋ ์ค๋์ค ํ์ผ์ ๋ค๋ฃจ๋ ์ผ ์ ๋๋ ์์์ง๋ง ๊ทธ ์ด์์ ์์๋๋ฐ ์ด๋ฒ ์๋์๋ ๋ด๊ฐ ๋ค์ดํฐ๋ธ ์์์ ํ์ฉํ๋ค๋ ๊ฒ์ด ๋ถ๋ช ํ๊ฒ ๋๊ปด์ ธ์ ์ฌ๋ฐ์๋ค.