by Keylime
리턴제로는 빠르고 정확한 STT(Speech To Text) 음성인식 API를 제공합니다. 음성인식을 활용할 수 있는 방법은 무궁무진하게 많고, 개발자들은 리턴제로의 STT API를 이용하여, 자신만의 프로그램에 음성인식 기능을 손쉽게 넣을 수 있습니다.
이번 튜토리얼에서는 MacOS에서 시스템 오디오를 실시간 음성인식 API로 자막 요청하기를 주제로 선정하여, Swift 언어로 STT API 사용방법 소개 및 샘플 코드를 소개하는 튜토리얼을 진행하겠습니다.
리턴제로의 STT API은 2가지 방식이 있습니다.
- 음성파일을 한 번에 텍스트로 변환하는 일반 STT
- 음성을 분할하여 전송하여 실시간으로 변환하는 스트리밍 STT
튜토리얼에서는 실시간 음성인식 자막을 구현하기 위해 스트리밍 STT를 사용하였고, 리턴제로의 스트리밍 STT의 방식에는 gRPC와 WebSocket 두 가지 방식을 제공하고 있는데, 이 중에서 gRPC 방식을 이용하겠습니다.
1. 요구 환경
- MacOS 13.0 이상 (최소 12.3 이상)
- 시스템 오디오 캡처를 위해 Apple에서 제공하는 ScreenCaptureKit 프레임워크를 사용했습니다.
- ScreenCaptureKit은 Mac 환경에서만 사용가능하고, MacOS 12.3 버전 이상에서 사용가능합니다.
- 튜토리얼에서 사용하는 코드 중 일부가 MacOS 13.0 이상을 요구하여 13.0 이상에서 사용하는 것을 권장드립니다.
- 다른 OS 환경의 경우
- 시스템 오디오를 캡처하기 위해서는 루프백(출력되는 오디오를 입력으로 전달하는 기능)을 소프트웨어, 하드웨어적으로 구현하여야 합니다.
- 각 환경에 맞는 다른 방식으로 시스템 오디오 캡처 구현을 대체하면 그 이외의 부분은 튜토리얼과 비슷하게 구현 가능합니다.
2. 리턴제로 STT API 개발자 애플리케이션 등록
- 리턴제로 Developers 사이트 에서 회원가입
MY 콘솔
이동 후 내 애플리케이션에서새 등록
을 클릭하여 애플리케이션 생성- 창을 닫기 전에 반드시 CLIENT ID와 CLIENT SECRET 메모
- CLIENT ID는 다시 확인 가능하나, CLIENT SECRET은 잃어버릴 경우 확인이 불가능하여 재발급 요청으로 다시 만들어야 합니다.
3. 개발 환경 설정
Homebrew 패키지 관리 애플리케이션 설치
- 튜토리얼에 필요한 Mac 프로그램을 터미널에서 명령어로 설치하기 위한 프로그램입니다.
- 다음과 같은 명령어를 Mac의 터미널에서 실행하여 Homebrew를 설치합니다.
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
- 만약 설치 도중에 경로 지정 명령어 입력을 요구한다면 입력하여 설치를 완료합니다.
- Homebrew 버전 확인 명령어로 설치 성공 여부를 확인할 수 있으며, 만약 실패할 경우 다시 설치를 시도합니다.
brew --version
Xcode Command Line Tool 설치
- 튜토리얼 코드를 Xcode 없이 터미널에서 실행하기 위한 프로그램입니다.
xcode-select --install
Swift 설치
- Swift 언어 및 Swift Package Manager 프로그램입니다.
brew install swift
Proto 및 GRPC 관련 프로그램 설치
- 실시간 STT API의 GRPC 프로토콜에 사용할 proto 파일을 컴파일하기 위한 프로그램입니다.
brew install protobuf swift-protobuf grpc-swift
4. 프로젝트 생성 및 설정
프로젝트 구조
프로젝트 생성
- 프로젝트를 만들 빈 폴더를 생성하고, 해당 폴더에 들어가서 Swift 프로젝트를 생성합니다.
- SwiftPM(Swift Package Manager)로 Xcode 없이 Swift 의존성 관리를 가능하며, Command Line에서 실행가능한 프로젝트가 생성됩니다.
- 튜토리얼에서는
mac-system-audio-stt
를 루트 디렉토리명인 동시에 프로젝트명으로 사용하였습니다.
mkdir mac-system-audio-stt
cd mac-system-audio-stt
swift package init --type executable
Package.swift 설정
- SwiftPM에서 관리할 프로젝트 설정을 관리하는 파일로, 다음과 같이 설정해줍니다.
// swift-tools-version: 5.10
: 해당 주석은 패키지를 빌드하는 데 필요한 Swift 도구의 최소 버전을 지정하기에 절대로 지우면 안되고, 버전 정보를 사용할 버전에 맞게 수정할 수 있습니다.- 튜토리얼과 다른 프로젝트 이름을 사용한다면 그에 맞게 설정합니다.
// swift-tools-version: 5.10
import PackageDescription
let package = Package(
name: "mac-system-audio-stt",
platforms: [.macOS(.v13)],
dependencies: [
.package(url: "https://github.com/grpc/grpc-swift", from: "1.23.0"),
],
targets: [
.executableTarget(
name: "mac-system-audio-stt",
dependencies: [
.product(name: "GRPC", package: "grpc-swift")
],
resources: [
.process("Resources/")
],
linkerSettings: [
.linkedFramework("ScreenCaptureKit")
]
)
]
)
gRPC와 Proto란?
- gRPC는 원격 프로시저 호출(RPC) 프레임워크로, 클라이언트가 마치 로컬 함수를 호출하는 것처럼 다른 컴퓨터(서버)에 있는 함수를 호출할 수 있습니다.
- Proto는 gRPC에서 사용되는 인터페이스 정의 언어입니다. Proto 파일에서 서비스와 메시지 구조를 정의하여 클라이언트-서버 간 통신 API를 명세합니다.
- Proto 파일을 특정 프로그래밍 언어로 컴파일하면, 해당 언어에 맞는 gRPC 코드가 생성됩니다.
Proto 파일 컴파일 방법
- 리턴제로 STT API 서버의 proto 파일을 github 링크에서 다운 받아
{프로젝트 폴더}/Sources/
에 저장합니다.
- wget을 이용하여 터미널을 통해 다운 받을 수도 있습니다.
brew install wget
wget https://raw.github.com/vito-ai/openapi-grpc/main/protos/vito-stt-client.proto
{프로젝트 폴더}/Sources/
에서 다음 명령어를 실행하여 proto 파일을 컴파일하면vito-stt-client.pb.swift
와vito-stt-client.grpc.swift
2개의 파일이 생성됩니다.
protoc --swift_out=. --grpc-swift_out=. vito-stt-client.proto
.env 파일 설정
{프로젝트 폴더}/Sources/Resources/
디렉토리를 만들고secret.env
파일을 만들고, 다음과 같은 형식으로 리턴제로 개발자 애플리케이션의 CLIENT ID와 CLIENT SECRET을 입력하고 저장합니다.
CLIENT_ID={리턴제로 개발자 애플리케이션 CLIENT ID}
CLIENT_SECRET={리턴제로 개발자 애플리케이션 CLIENT SECRET}
- git으로 레포지터리 관리를 한다면,
.gitignore
파일에 다음과 같이 추가하여, 유출되면 안되는 정보가 github 등에 업로드 되는 것을 막습니다.
/Sources/Resources/*.env
- 샘플 코드에서 사용하는 디폴트 네임은
secret.env
이지만, 추후 설명할 코드에서 읽을 파일 정보를 수정하는 것으로 변경 가능합니다.
5. 튜토리얼 샘플 코드 실행
리턴 제로 API 서버 Config (SystemAudioSTT.swift)
// 캡처된 시스템 오디오를 GRPC로 실시간 STT를 요청하는 클래스
class SystemAudioSTT {
// 리턴 제로 API 서버 Config
private let API_BASE = "https://openapi.vito.ai"
private let GRPC_SERVER_HOST = "grpc-openapi.vito.ai"
private let GRPC_SERVER_PORT = 443
// ...
}
- API_BASE: client_id와 client_secret으로 OAuth 요청을해서 API access_token을 발급 받는 http 서버
- GRPC_SERVER_HOST, GRPC_SERVER_PORT: 리턴제로 STT API gRPC 서버
스트리밍 STT - DecoderConfig (SystemAudioSTT.swift)
// 캡처된 시스템 오디오를 GRPC로 실시간 STT를 요청하는 클래스
class SystemAudioSTT {
// ...
// 스트리밍 STT - DecoderConfig (https://developers.rtzr.ai/docs/stt-streaming/grpc)
private let SAMPLE_RATE: Int32 = 16000 // (8000 ~ 48000 Hz, Required)
private let ENCODING = OnlineDecoder_DecoderConfig.AudioEncoding.linear16 // (Required)
private let MODEL_NAME: String = "sommers_ko" // STT 모델 (default: sommers_ko)
private let USE_ITN: Bool = true // 영어, 숫자, 단위 변환 사용 여부 (default: true)
private let USE_DISFLUENCY_FILTER: Bool = false // 간투어 필터기능 (default: false)
private let USE_PROFANITY_FILTER: Bool = false // 비속어 필터 기능 (default: false)
private let KEYWORDS: [String] = [] // 키워드 부스팅 (default: [])
// ...
}
- Required의 경우 반드시 설정이 필요하고, 그 외에는 설정하지 않을 경우 default 값으로 처리됩니다.
- Required가 아닌 옵션들의 경우, API 요청할때 설정하지 않고 전송하여도 처리 가능합니다.
- 해당 상수들의 값을 수정하지 않고 그대로 사용해도 되고, 필요에 맞게 수정해서 사용가능합니다.
- SAMPLE_RATE: 오디오 캡처 sample rate (단위 Hz), 8000~48000 내에서 가능하고 16000 권장
- ENCODING: 오디오 인코딩 방식
- linear16 이외에도 가능하나, 튜토리얼 코드에서는 linear pcm 32bit float로 캡처되는 오디오를 linear pcm 16bit int로 컨버팅 후 사용하였고, 다른 인코딩 방식을 사용하기 위해서는 해당 파트의 컨버팅 코드 수정이 필요합니다.
- proto 파일의 주석 또는 하단 링크에서 사용가능한 인코딩 포맷 목록을 확인할 수 있습니다.
- 기타 DecoderConfig들을 수정해 STT API의 추가적인 기능들을 이용하실 수 있습니다. 하단 링크에서 자세한 설명을 확인할 수 있습니다.
프로젝트 빌드 및 실행
- 프로젝트 폴더에서 다음과 같은 명령어로 프로젝트 빌드
swift build
- 빌드된 이후 다음과 같은 명령어로 프로젝트 실행
swift run
- MacOS 시스템 오디오 녹음 권한 설정
- ScreenCaptureKit으로 시스템 오디오를 캡처하기 위해서는 MacOS 권한 설정이 필요합니다.
- 프로그램 최초 실행시 권한 설정을 요구하는 메시지가 뜨며 차단되는데, 권한 설정 후 프로그램을 재실행하면 정상 작동합니다.
- 위 스크린샷은 MacOS 14.6.1 기준이며, 시스템 설정 - 개인정보 보호 및 보안 - 화면 및 시스템 오디오 녹음에서 사용할 애플리케이션을 추가합니다.
- 튜토리얼에서는 터미널에서 실행하니, 사용할 터미널 프로그램(튜토리얼 작성자는 WezTerm 터미널 사용)을 등록하고, 터미널이 아닌 별도 애플리케이션으로 구현하였다면, 해당 애플리케이션을 등록합니다.
- 실행 결과
- 실행 후 음악 플레이어, 유튜브 등 사람의 음성이 담긴 오디오를 실행하면 실시간으로 STT API를 이용해서 자막을 보여줍니다.
- 좀 더 빠른 자막 출력을 위해, chunk 단위로 STT 결과를 즉시 띄우고, 문장 단위로 정확한 자막으로 덮어쓰기 후 줄 바꿈을 합니다.
기타사항
ctrl + c
를 입력하여 오디오 캡처 및 프로그램을 종료할 수 있습니다.- 음성이 담긴 오디오가 일정 시간(약 1분) 이상 서버에 전송되지 않을 경우 자동으로 서버에서 gRPC 연결을 종료합니다. 만약 그 이상 연결을 원할 경우 주기적으로 재연결을 하는 방식으로 연장 가능합니다.
6. 소스 코드 설명 - 개요
- 튜토리얼의 코드는 크게 2가지 파트로 나누어집니다.
SystemAudioSTT.swift
- gRPC를 통해 STT API 요청 및 자막 출력 기능을 다루는 코드
AudioRecorder.swift
- Apple의 ScreenCaptureKit 프레임워크를 통해 시스템 오디오를 캡처하는 기능을 다루는 코드
- 하단 github 링크로부터 튜토리얼 소스코드를 다운 받을 수 있습니다.
7. 소스 코드 설명 - SystemAudioSTT.swift
소스 코드
프로그램 시작
// 프로그램 동작 확인 전역 변수
var isRunning = true
@main
struct Main {
static var systemAudioSTT: SystemAudioSTT?
// 프로그램 시작 메소드
static func main() {
print("Mac System Audio STT Start")
setupSignalHandler()
checkIsRunning()
loadEnvironment()
// ...
}
// ...
}
- Swift에서는 @main을 엔트리 포인트로 지정하여, 터미널에서 코드실행시 @main이 달린 Main 구조체의 main 메소드를 실행합니다.
프로그램 종료 인터럽트
// Ctrl + C로 프로그램 종료
static func setupSignalHandler() {
signal(SIGINT) { _ in
isRunning = false
}
}
// isRunning이 true인 동안 blocking하다가 false가 되는 순간 프로그램 종료
static func checkIsRunning() {
Task {
while isRunning {
try? await Task.sleep(nanoseconds: 100_000_000) // 100ms
}
await systemAudioSTT?.stop()
try? await Task.sleep(nanoseconds: 100_000_000)
print("\\nMac System Audio STT Stop")
exit(0)
}
}
- 사용자가 정지 명령을 내리기 전까지는 멈추지 않고 시스템 오디오 캡처가 계속 진행되니, ctrl + c를 누를 경우 프로그램이 종료되게 인터럽트 핸들러를 프로그램 실행시 실행합니다.
- ctrl + c 명령시 안전한 종료를 위해 gRPC 클라이언트 연결을 해제하고, 시스템 오디오 캡처를 중지한 뒤 종료한 뒤 exit하도록 합니다.
- isRunning 플래그가 true인 동안 무한 루프를 돌려 해당 스레드를 blocking하는데, 그냥 무한 루프를 돌리면 다른 스레드를 실행할 수 없으므로, 해당 스레드를 주기적으로 sleep 시킵니다.
- await systemAudioSTT?.stop()으로 ScreenCaptureKit의 오디오 캡처를 중단합니다.
- 오디오 캡처가 중단되면, 후술할 gRPC 코드에서 generator가 무한으로 제공하여 blocking되던 for문을 탈출하여 gRPC를 종료합니다.
.env 환경설정 파일 로드
// env 파일 loader
static func loadEnvironment() {
guard let path = Bundle.module.path(forResource: "secret", ofType: "env") else {
print("env를 찾지 못했습니다.")
return
}
do {
let contents = try String(contentsOfFile: path, encoding: .utf8)
let envVars = contents.components(separatedBy: .newlines)
for envVar in envVars {
let components = envVar.components(separatedBy: "=")
if components.count == 2 {
setenv(components[0], components[1], 1)
}
}
} catch {
print("env를 읽는데 실패하였습니다.")
}
}
- 리턴제로 API 애플리케이션의 CLIENT ID와 CLIENT SECRET은 보안을 위해 코드에 넣지 않고, 환경변수로 관리하는게 안전하기에 env파일로 부터 읽습니다.
- Swift에는 major한 .env 라이브러리가 없기에 위와 같은 코드로 읽고 환경 변수 설정을 합니다.
secret.env
가 아닌 다른 이름의.env
파일을 사용하고 싶다면,Bundle.module.path(forResource: "secret", ofType: "env")
부분의forResource
를 수정하는 식으로 변경 가능합니다.- 만약 터미널 기반 프로그램이 아닌 Xcode로 애플리케이션을 만들 경우
env
대신plist
,xcconfig
등의 방식을 대신 선택 가능합니다.
오디오 캡처 및 실시간 STT로 자막 출력 애플리케이션 실행
@main
struct Main {
static var systemAudioSTT: SystemAudioSTT?
// 프로그램 시작 메소드
static func main() {
// ...
let env = ProcessInfo.processInfo.environment
guard let client_id = env["CLIENT_ID"] else {
print("CLIENT_ID이 env에 없습니다.")
return
}
guard let client_secret = env["CLIENT_SECRET"] else {
print("CLIENT_SECRET이 env에 없습니다.")
return
}
Task {
do {
systemAudioSTT = SystemAudioSTT(client_id: client_id, client_secret: client_secret)
try await systemAudioSTT?.start()
} catch {
print(error)
isRunning = false
}
}
RunLoop.main.run()
}
// ...
}
- 설정한 환경변수로 부터 client_id와 client_secret을 얻어 SystemAudioSTT 객체를 생성하고 실행합니다.
- SystemAudioSTT의 start 메소드는 비동기 함수이기에 Task 블럭에 넣어 동기 함수인 main에서 실행가능하게 합니다.
SystemAudioSTT 클래스 - gRPC 초기 설정
// grpc 클라이언트 생성 메소드
private func configGRPC() async throws {
let group = PlatformSupport.makeEventLoopGroup(loopCount: 1)
var configuration = ClientConnection.Configuration.default(
target: .hostAndPort(GRPC_SERVER_HOST, GRPC_SERVER_PORT),
eventLoopGroup: group
)
let customTLS = GRPCTLSConfiguration.makeClientConfigurationBackedByNIOSSL(
trustRoots: .default,
certificateVerification: .none
)
configuration.tlsConfiguration = customTLS
let channel = ClientConnection(configuration: configuration)
self.client = OnlineDecoder_OnlineDecoderNIOClient(channel: channel)
}
// gRPC call을 새로 만들고, 리턴제로 STT Config을 서버에 알리는 메소드
private func setupCall() async throws {
call?.sendEnd(promise: nil)
self.callOptions = CallOptions(customMetadata: [
"Authorization": "Bearer \\(token!.access_token)"
])
self.call = self.client!.decode(callOptions: callOptions) { response in
self.handleSTTResponse(response)
}
let config = OnlineDecoder_DecoderConfig.with {
$0.sampleRate = SAMPLE_RATE
$0.encoding = ENCODING
$0.modelName = MODEL_NAME
$0.useItn = USE_ITN
$0.useDisfluencyFilter = USE_DISFLUENCY_FILTER
$0.keywords = KEYWORDS
}
let initialRequest = OnlineDecoder_DecoderRequest.with {
$0.streamingConfig = config
}
try await self.call?.sendMessage(initialRequest).get()
}
- 비동기적으로 캡처되는 오디오 버퍼를 실시간으로 gRPC 요청을 하기 위해 TCP 방식과 유사하게 gRPC 클라이언트를 생성하고, call을 연결해 call을 통해서 통신을 합니다.
- configGRPC 메소드의 코드와 같이 구현하여 클라이언트를 생성합니다.
- 리턴제로 STT API를 이용하기 위해 데이터 버퍼를 전송하기 전에 sample_rate, encoding 등의 DecoderConfig 옵션을 전송해야하는데, sttConfigRequest 메소드의 구현 방식처럼 initialRequest를 만들어 sendMessage 합니다.
- 필수로 설정해야하는 sampleRate, encoding 이외의 DecoderConfig 옵션을 proto 파일 혹은 하단 링크에서 확인할 수 있습니다.
SystemAudioSTT 클래스 - AccessToken 발급 및 관리
// access_token 확인 메소드
// access_token이 없거나, 유효기한이 지났을 경우 자동 갱신
func checkAccessToken() async throws {
if token == nil || token!.expire_at < Int(Date().timeIntervalSince1970) {
try await getToken()
self.callOptions = CallOptions(customMetadata: [
"Authorization": "Bearer \\(token!.access_token)"
])
self.call = self.client!.decode(callOptions: callOptions) { response in
self.handleSTTResponse(response)
}
}
}
// access_token을 요청하는 메소드
// HTTP 요청으로 client_id와 client_secret으로 OAuth2 인증을 통해 토큰 발급
private func getToken() async throws {
guard let url = URL(string: API_BASE + "/v1/authenticate") else {
throw NSError(domain: "InvalidURL", code: 0, userInfo: nil)
}
let formData: [String: Any] = ["client_id": self.client_id, "client_secret": self.client_secret]
let formDataString = (formData.compactMap({ (key, value) -> String in
return "\\(key)=\\(value)"
}) as Array).joined(separator: "&")
let formEncodedData = formDataString.data(using: .utf8)
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.httpBody = formEncodedData
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw NSError(domain: "InvalidResponse", code: 0, userInfo: nil)
}
self.token = try JSONDecoder().decode(Token.self, from: data)
}
- sendMessage를 하기전에 checkAccessToken 메소드를 실행해서 token 유효 여부를 확인합니다.
- 토큰이 존재하고 유효 기한이 지나지 않았을 경우 그대로 사용하고, 그렇지 않을 경우 getToken 메소드로 http 요청을 하여 토큰을 발급 받습니다.
- 토큰을 발급 받은 후 setupCall 메소드로 call을 갱신합니다.
SystemAudioSTT 클래스 - 오디오 캡처 시작
// 오디오 캡처 및 실시간 STT로 자막 요청 실행 메소드
func start() async throws {
try await configGRPC()
try await getToken()
try await setupCall()
let (configSCK, filterSCK) = try await audioRecorder.setting()
let errorFlag = AtomicBool(false)
for try await chunk in audioRecorder.captureEngine.startCapture(configuration: configSCK, filter: filterSCK) {
if errorFlag.boolValue {
await stop()
break
}
try await checkAccessToken()
let audioContent = OnlineDecoder_DecoderRequest.with {
$0.audioContent = chunk
}
self.call?.sendMessage(audioContent).whenFailure { error in
print("Failed to send message: \\(error)")
errorFlag.boolValue = true
}
}
call?.sendEnd(promise: nil)
}
- start 메소드가 실행되면 위에서 설명하였던대로, gRPC 및 access token 발급을 하고, STT config 설정 정보를 전송합니다.
- 그 후 AudioRecorder를 실행하여 비동기적으로 실행되는 오디오 캡처 버퍼를 for문으로 받고, yield 되어 받은 chunk를 전송합니다.
- chunk를 받을 때마다 선제적으로 checkAccessToken 메소드를 실행하여, 유효기한 여부를 확인하고, 지났을 경우 token을 재발급 받고, call을 갱신한 뒤 전송합니다.
- AudioRecorder가 종료되었을 경우 for문을 탈출하게 되어 gRPC 서버에 종료 명령을 하고 안전하게 종료합니다.
- 장시간 음성 오디오 미전송으로 인한 자동 연결 종료 혹은 기타 gRPC 오류 발생시 for문을 종료하고 탈출합니다.
다만 무한정 연결하는 방식은 불필요한 API 서버 이용을 하게되어, API 사용량이 많이 나올 수 있으므로, 주의 바랍니다.
setupCall 호출시에는 하나의 문장이 setupCall 호출 이전과 후로 나뉘어서 전사될 수 있어, 너무 자주 호출할 경우, STT 정밀도가 떨어질 수 있으니 적절한 주기로 호출하기 바랍니다.
// 주기적으로 재연결하는 샘플 코드
var startTime = Date()
let errorFlag = AtomicBool(false)
for try await chunk in audioRecorder.captureEngine.startCapture(configuration: configSCK, filter: filterSCK) {
if errorFlag.boolValue {
await stop()
break
}
let elapsedTime = startTime.timeIntervalSinceNow
if elapsedTime < -30 { // 30초 주기로 재연결
try await setupCall()
startTime = Date()
}
try await checkAccessToken()
let audioContent = OnlineDecoder_DecoderRequest.with {
$0.audioContent = chunk
}
self.call?.sendMessage(audioContent).whenFailure { error in
print("Failed to send message: \\(error)")
errorFlag.boolValue = true
}
}
SystemAudioSTT 클래스 - 전사 데이터 자막 출력
// 자막 출력 메소드
// isFinal이 아닐 경우 다음 문장 출력시 덮어 쓰기
private func handleSTTResponse(_ response: OnlineDecoder_DecoderResponse) {
for result in response.results {
let sentence = result.alternatives.first?.text ?? ""
clearLine()
if result.isFinal {
print(sentence)
fflush(stdout)
} else {
print(sentence, terminator: "")
fflush(stdout)
}
}
}
// isFinal이 아닌 전사 문장을 지워, 덮어 쓰기 가능하게 하는 메소드
private func clearLine() {
print("\\r", terminator: "")
print("\\u{001B}[2K", terminator: "")
fflush(stdout)
}
- gRPC 서버로부터 응답이 올 경우 call을 생성할 때 연결해놓은 handleSTTResponse 메소드에 전달되어 출력을 합니다.
- isFinal이 false일 경우 지금 전사하고 있는 문장이 종료되지 않아 부정확한 자막을 띄우므로, 캐리지 리턴으로 커서를 문장의 첫부분으로 옮기고, 다음문장 실행시 clearLine으로 덮어쓰기하도록 합니다.
- isFinal이 true일 경우 줄 바꿈을 하여 출력된 문장이 남도록 합니다.
8. 소스 코드 설명 - AudioRecorder.swift
소스 코드
ScreenCaptureKit 프레임워크
- 선택한 Display, Window, Application에 대해서 화면, 오디오를 캡처가능한 프레임워크입니다.
- Mac에서 사용가능한 Apple의 프레임워크로 하단 링크에서 공식 레퍼런스를 확인가능합니다.
- 본 튜토리얼은 리턴제로의 STT API에 대한 가이드를 제공함으로, ScreenCaptureKit에 대한 설명은 중요한 일부 파트에 대해서만 설명하겠습니다.
AudioRecorder - 캡처 스트림 설정
private var streamConfiguration: SCStreamConfiguration {
let streamConfig = SCStreamConfiguration()
streamConfig.capturesAudio = true
streamConfig.excludesCurrentProcessAudio = true
streamConfig.sampleRate = self.sampleRate
return streamConfig
}
- capturesAudio를 true로 하여 오디오를 캡처할 수 있게 설정합니다.
- excludesCurrentProcessAudio를 true로 하면 오디오 캡처 앱의 사운드는 캡처되지 않게할 수 있습니다.
- sampleRate는 SystemAudioSTT 클래스에서 설정한 상수 값을 전달 받아 통일된 값을 사용하게 됩니다.
- 공식 레퍼런스를 참고하여 다른 옵션을 추가로 설정 가능합니다.
AudioRecorder - 캡처 필터 설정
private var contentFilter: SCContentFilter {
var filter: SCContentFilter
guard let display = selectedDisplay else { fatalError("No display selected.") }
let excludedApps = [SCRunningApplication]()
filter = SCContentFilter(display: display, excludingApplications: excludedApps, exceptingWindows: [])
return filter;
}
private func checkAvailableContent() async {
do {
let availableContent = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
availableDisplays = availableContent.displays
selectedDisplay = availableDisplays.first
} catch {
print("캡처 대상 탐색 오류")
}
}
- SCShareableContent는 비동기적으로 불러올 수 있으므로, 비동기 함수를 통해 불러옵니다.
- 튜토리얼에서는 전체 사운드를 녹화하기 위해, display를 선택했고, 필요에 따라 window, application 중에서 선택 가능합니다.
filter = SCContentFilter(display: display, excludingApplications: excludedApps, exceptingWindows: [])
필터를 이렇게 설정하여, 제외되는 앱 없이 모든 앱의 사운드를 캡처할 수 있게 합니다. 필요에 따라 원하는 display, window, application만 캡처 가능합니다.
CaptureEngine 클래스 - 오디오 캡처
// 캡처 실행 메소드
// 비동기적으로 stream으로부터 캡처된 버퍼마다 yield하여 STT 처리할 수 있게하는 generator
func startCapture(configuration: SCStreamConfiguration, filter: SCContentFilter) -> AsyncThrowingStream<Data, Error> {
AsyncThrowingStream<Data, Error> { continuation in
self.continuation = continuation
let output = CaptureEngineStreamOutput(continuation: continuation)
streamOutput = output
streamOutput!.pcmBufferHandler = { continuation.yield($0) }
do {
stream = SCStream(filter: filter, configuration: configuration, delegate: streamOutput)
try stream?.addStreamOutput(streamOutput!, type: .screen, sampleHandlerQueue: videoSampleBufferQueue)
try stream?.addStreamOutput(streamOutput!, type: .audio, sampleHandlerQueue: audioSampleBufferQueue)
stream?.startCapture()
} catch {
continuation.finish(throwing: error)
}
}
}
- AsyncThrowingStream을 이용하여 비동기적으로 얻어지는 버퍼 데이터들을 생성될때마다 yield하는 generator를 만듭니다.
- pcmBufferHandler를 만들어 버퍼가 생성되는 모듈에 넘기는 식으로, 해당 코드에서 생성된 버퍼를 그 자리에서 yield할 수 있게 합니다.
CaptureEngineStreamOutput 클래스 - 오디오 포맷 컨버팅
// 캡처된 오디오를 컨버팅 후 리턴하는 메소드
// 32bit float pcm 오디오를 16bit int pcm 오디오로 컨버팅
// pcmBufferHandler를 통해 yield
private func handleAudio(for buffer: CMSampleBuffer) {
guard let format = CMSampleBufferGetFormatDescription(buffer) else {
print("format description error")
return
}
let sourceFormat = AVAudioFormat(cmAudioFormatDescription: format)
guard let destinationFormat = AVAudioFormat(commonFormat: .pcmFormatInt16,
sampleRate: sourceFormat.sampleRate,
channels: sourceFormat.channelCount,
interleaved: sourceFormat.isInterleaved) else {
print("destination format error")
return
}
guard let converter = AVAudioConverter(from: sourceFormat, to: destinationFormat) else {
print("컨버터를 생성할 수 없습니다.")
return
}
guard let pcmBuffer = buffer.asPCMBuffer else {
print("AVAudioPCMBuffer로 변환할 수 없습니다.")
return
}
let frameCapacity = AVAudioFrameCount(pcmBuffer.frameLength)
guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: destinationFormat, frameCapacity: frameCapacity) else {
print("출력 버퍼를 생성할 수 없습니다.")
return
}
var error: NSError?
let status = converter.convert(to: outputBuffer, error: &error) { inNumPackets, outStatus in
outStatus.pointee = .haveData
return pcmBuffer
}
if status != .haveData {
print("변환 실패: \\(error?.localizedDescription ?? "알 수 없는 오류")")
return
}
let audioBuffer = outputBuffer.audioBufferList.pointee.mBuffers
guard let mData = audioBuffer.mData else {
print("오디오 버퍼 데이터를 가져올 수 없습니다.")
return
}
let dataSize = Int(outputBuffer.frameCapacity * outputBuffer.format.streamDescription.pointee.mBytesPerFrame)
let data = Data(bytes: mData, count: dataSize)
pcmBufferHandler?(data)
}
- SystemAudioSTT 클래스에서 STT에 전달할 오디오 인코딩을 linear16으로 선택했고, 이는 16bit 정수로 저장됩니다.
- 다만, ScreenCaptureKit은 오디오를 32bit 부동소수점으로 저장하기에, AVFAudio 라이브러리의 AVAudioConverter를 이용하여 위와 같은 코드로 컨버팅을 합니다.
- 다른 인코딩 방식을 이용하고 싶다면, 해당 인코딩 포맷에 맞게, 윗 코드의 컨버팅 알고리즘을 수정해서 사용합니다.
9. 마무리
- 오늘은 MacOS에서 시스템 오디오를 실시간 음성인식 API로 자막 요청하기를 예시로 리턴제로의 STT API를 이용해서 만들 수 있는 응용 프로그램에 대해 구현해보았습니다.
- 음성인식을 사용할 수 있는 분야는 무궁무진하게 많고, 튜토리얼 예시를 참고하여 원하는 프로그램을 만드실 수 있으면 좋겠습니다.
- Mac 의존성이 높은 프레임워크를 사용하여 Swift를 사용하였으나, Golang, Python, Java, JavaScript 등 다양한 언어로 리턴제로의 STT API를 사용할 수 있습니다.
- 리턴제로 Developers 사이트의 기본 샘플 코드와, 다른 튜토리얼들 또한 있으니, 그것들을 참고하여 개발하실 수 있습니다.