시스템 오디오를 실시간 음성인식 API로 자막 요청하기 (for MacOS with Swift)

리턴제로 STT API는 스트리밍 기능을 지원하여, 녹음된 파일 뿐만 아니라 실시간 음성을 자막으로 만들 수 있습니다. PC에서 출력되는 소리를 실시간으로 자막으로 만들어보며, 스트리밍 STT 사용법에 대한 튜토리얼을 소개하겠습니다.

시스템 오디오를 실시간 음성인식 API로 자막 요청하기 (for MacOS with Swift)

by Keylime

리턴제로는 빠르고 정확한 STT(Speech To Text) 음성인식 API를 제공합니다. 음성인식을 활용할 수 있는 방법은 무궁무진하게 많고, 개발자들은 리턴제로의 STT API를 이용하여, 자신만의 프로그램에 음성인식 기능을 손쉽게 넣을 수 있습니다.

이번 튜토리얼에서는 MacOS에서 시스템 오디오를 실시간 음성인식 API로 자막 요청하기를 주제로 선정하여, Swift 언어로 STT API 사용방법 소개 및 샘플 코드를 소개하는 튜토리얼을 진행하겠습니다.

리턴제로의 STT API은 2가지 방식이 있습니다.

  1. 음성파일을 한 번에 텍스트로 변환하는 일반 STT
  2. 음성을 분할하여 전송하여 실시간으로 변환하는 스트리밍 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 개발자 애플리케이션 등록

  1. 리턴제로 Developers 사이트 에서 회원가입
  2. MY 콘솔 이동 후 내 애플리케이션에서 새 등록을 클릭하여 애플리케이션 생성
  3. 창을 닫기 전에 반드시 CLIENT ID와 CLIENT SECRET 메모
    1. 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/ 에 저장합니다.
openapi-grpc/protos/vito-stt-client.proto at main · vito-ai/openapi-grpc
openapi-grpc. Contribute to vito-ai/openapi-grpc development by creating an account on GitHub.
  • 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 파일의 주석 또는 하단 링크에서 사용가능한 인코딩 포맷 목록을 확인할 수 있습니다.
스트리밍 STT | RTZR STT OpenAPI
본 문서는 오디오 스트리밍 입력을 텍스트로 변환할 수 있는 스트리밍 STT API를 구현하는 방식에 대한 가이드를 제공합니다.
  • 기타 DecoderConfig들을 수정해 STT API의 추가적인 기능들을 이용하실 수 있습니다. 하단 링크에서 자세한 설명을 확인할 수 있습니다.
스트리밍 STT - GRPC | RTZR STT OpenAPI
본 문서는 스트리밍 STT 중에서 GRPC로 구현하는 방식에 대한 가이드를 제공합니다.

프로젝트 빌드 및 실행

  • 프로젝트 폴더에서 다음과 같은 명령어로 프로젝트 빌드
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 링크로부터 튜토리얼 소스코드를 다운 받을 수 있습니다.
GitHub - vito-ai/swift-tutorial
Contribute to vito-ai/swift-tutorial development by creating an account on 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 대신 plistxcconfig 등의 방식을 대신 선택 가능합니다.

오디오 캡처 및 실시간 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 파일 혹은 하단 링크에서 확인할 수 있습니다.
스트리밍 STT - GRPC | RTZR STT OpenAPI
본 문서는 스트리밍 STT 중에서 GRPC로 구현하는 방식에 대한 가이드를 제공합니다.

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 연결을 원할 경우, 주기적으로 start 메소드의 for문 안에서 setupCall 메소드를 호출하여, gRPC 재연결을 하는 방식으로 연장가능합니다.

다만 무한정 연결하는 방식은 불필요한 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의 프레임워크로 하단 링크에서 공식 레퍼런스를 확인가능합니다.
ScreenCaptureKit | Apple Developer Documentation
Filter and select screen content and stream it to your app.
  • 본 튜토리얼은 리턴제로의 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 사이트의 기본 샘플 코드와, 다른 튜토리얼들 또한 있으니, 그것들을 참고하여 개발하실 수 있습니다.
음성인식 API 시작하기 | RTZR STT OpenAPI
RTZR STT OpenAPI는 눈으로 보는 통화 VITO, 회의내용을 자산으로 만드는 Callabo, AI 숏폼 서비스 를 서비스 하는 리턴제로의 우수한 음성인식 기능을 API로 제공합니다.
Tutorial - 기업을 위한 음성 AI - 리턴제로 blog

Great! You’ve successfully signed up.

Welcome back! You've successfully signed in.

You've successfully subscribed to 기업을 위한 음성 AI - 리턴제로 blog.

Success! Check your email for magic link to sign-in.

Success! Your billing info has been updated.

Your billing was not updated.