🎶

KotlinからWhisperAPIを使う

2023/12/20に公開

はじめに

Turing株式会社のアドベントカレンダー20日目!
今回は、UXチームの井上(@yoinoue5212)が担当します!

UXチームではAndroidベースのIVIシステム開発を進めています。
ちなみに、IVIシステムとは、速度計などを表示する計器クラスター部分と、ナビやエンタメを提供するセンターディスプレイ部分を総称した概念です。

UXを意識するうえで、より簡単に、よりシンプルに、デバイスを操作するための方法として、"OK, Google. ~やって"のようなボイスアシスタント機能が考えられます。

本記事では、そんな便利なボイスアシスタントを作るための第一歩である音声認識に関連して、あまり情報のないKotlinからOpenAI社WhisperのAPIを使う方法について説明しようと思います。このタスク、録音・ファイル化・WebAPIと意外と複合的な理解が必要となるため、どれか一つを必要とする誰かの支えとなることを願い、一連の内容を記事に残そうと思います。
なお、他の音声認識の手段としては、SpeechRecognizerクラスを使う方法、TFモデルをオンデバイスで動かす方法なども考えられます。

全体の流れ

大きな流れはシンプルです。

  1. 録音する
  2. WhisperAPIの対応する音声フォーマットにする
  3. APIを叩く
  4. 結果を取得する

これで喋った内容が文字列として取得できます。

音声ファイルの準備

まず、1~2に該当する音声のファイル化の部分を行います。

Androidで録音を行う場合、AudioRecordMediaRecorderの2つのクラスが存在します。

  • AudioRecord(doc): 音声データそのものを扱いリアルタイムでの処理が可能だが、ファイル化は別途必要
  • MediaRecorder(doc): ファイル化まで丸っと実行できるが、音声処理は行えず手動での録音の停止処理が必要

今回は、ユーザーの発話終了と同時に録音がストップ&ファイル化されて欲しいので、リアルタイムに処理を行うことのできるAudioRecordクラスを使います。
録音にはRECORD_AUDIO権限が必要です。

AndroidManifest.xml
<uses-permission android:name="android.permission.RECORD_AUDIO" />

AudioRecordでマイクから録音を行うサンプルコードです。

import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaFormat
import android.media.MediaRecorder

class RecordConversation() {
    // AudioRecord用の設定値
    private val audioSource = MediaRecorder.AudioSource.MIC
    private val sampleRate = 16000
    private val channel = AudioFormat.CHANNEL_IN_MONO
    private val audioFormat = AudioFormat.ENCODING_PCM_16BIT
    private val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channel, audioFormat)
    // インスタンスを生成
    private val audioRecord = AudioRecord(audioSource, sampleRate, channel, audioFormat, bufferSize)
    // 発話終了までを録音する関数
    fun capture(): ByteArray {
        // 結果保存用
        val conversationBuffer = LinkedList<Byte>()
        // 録音開始
        audioRecord.start()
        // 一時的な音声データ
	val buffer = ByteArray(bufferSize)
	// 発話終了まで録音
	while (audioRecord.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
	    val readSize = audioRecord.read(buffer, 0, AudioConfig.BUFFER_SIZE) ?: continue
            if (readSize > 0) {
		conversationBuffer.addAll(buffer.toList())
		// 発話終了判定を行う
                if (someVADDetectionFunc(conversationBuffer, ...)) {
                    break
                }
            }
	}
	return conversationBuffer.toByteArray()
    }
}

someVADDetectionFunc()という部分では、VAD(VoiceActivityDetection)という処理を行うことで発話終了判定を行うことを想定しています。長くなるためここでは省略させていただきますが、以下が参考になります。
https://github.com/gkonovalov/android-vad

次に、取得した音声をWhisperAPIの対応するフォーマットでファイル化します。

File uploads are currently limited to 25 MB and the following input file types are supported: mp3, mp4, mpeg, mpga, m4a, wav, and webm.

リモートのAPIを叩くためファイルサイズは可能な限り小さい方が扱いやすい、つまり圧縮形式のファイルにしたいです。音声だけなのでm4aでもいいのですが、より一般的なmp4フォーマットでのファイル化をしてみようと思います。扱うクラスはMediaCodecMediaMuxerの2つです。

  • MediaCodec(doc)
    低レベルなメディアコーデック(encoder, decoder)にアクセスするためのクラスで、圧縮フォーマットなどの指定をした上でデータを入れることで圧縮(encode)解凍(decode)してくれます。また、この圧縮/解凍を行うデータを保持するbufferをCodecは複数持っており、外部からのデータの入出力とは非同期でこの処理を行うようになっています。
    今回は、圧縮をするのでencoderの方を使います。圧縮フォーマットはmp4と対応するAACを指定します。
  • MediaMuxer(doc)
    MediaCodecでエンコードされたバイトデータからmp4などのコンテナにデータを詰めてファイル化する役割を担います。

以下が、先ほど録音した生の音声データをMediaCodecとMediaMuxerを使ってmp4ファイル化するサンプルコードです。

import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.MediaMuxer
import java.nio.ByteBuffer
import java.nio.file.Path
import kotlin.io.path.createTempFile
import kotlin.io.path.pathString

class AudioFileConverter() {
    // MediaCodec用の設定値
    private val mimeType = MediaFormat.MIMETYPE_AUDIO_AAC
    private val sampleRate = 16000
    private val bitRate = 16000
    private val channelCount = 1 // mono = 1, stereo = 2
    private val waitBufferTimeout = 5000L // encode処理のタイムアウト時間
    private val mediaFormat: MediaFormat = MediaFormat.createAudioFormat(mimeType, sampleRate, channelCount)
    .apply {
        setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
        setInteger(MediaFormat.KEY_BIT_RATE, bitRate)
    }
    // MediaCodecのインスタンスを生成
    private val mediaCodec: MediaCodec = MediaCodec.createEncoderByType(AudioConfig.MIME_TYPE)
    .apply {
        configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
        start()
    }
    // MediaCodecの保有するbufferステータス確認用
    private val bufferInfo: MediaCodec.BufferInfo = MediaCodec.BufferInfo()
    // MediaMuxer用の設定値
    private val outputFilePath = createTempFile(suffix=".mp4")
    private val mediaFormat = mediaCodec.outputFormat
    // MediaMuxerのインスタンスを生成
    private val mediaMuxer =  MediaMuxer(outputFilePath.pathString, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
    .apply {
        addTrack(mediaFormat)
	start()
    }
    
    // 録音データからmp4ファイルにする関数
    fun convertToMP4(conversationBuffer: ByteArray): Path {
        var inputIndex = 0
	// すべての音声データの圧縮・保存を行う
        while (inputIndex < inputAudioData.size) {
	    // MediaCodecを使ってAACフォーマットで圧縮
            inputIndex += feedDataToEncoder(inputAudioData, inputIndex)
            // MediaMuxerでmp4コンテナに詰める
	    writeEncodedDataToContainer()
        }
	return outFilePath
    }
    
    private fun feedDataToEncoder(inputAudioData: ByteArray, inputIndex: Int): Int {
        // 空いてるCodecのbufferのindexを取得
        val inputBufferIndex = mediaCodec.dequeueInputBuffer(waitBufferTimeout)
        // bufferのindexが正常だった場合に処理を行う
        if (inputBufferIndex >= 0) {
	    // buffer自体を取得
            val inputBuffer = mediaCodec.getInputBuffer(inputBufferIndex) ?: return 0
	    // bufferの限界サイズまで音声データを詰める
            val remainDataSize = inputAudioData.size - inputIndex
            val feededDataSize = min(inputBuffer.limit(), remainDataSize)
            // MediaCodecのbufferに音声データを入力できる場合に処理を行う
            if (feededDataSize > 0) {
                // MediaCodecのbufferに音声データを入力する
                inputBuffer.put(inputAudioData, inputIndex, feededDataSize)
                // データ全体での再生時の時刻を計算
		val presentationTimeUs = 1000000.0 * (inputIndex / audioFormat) / sampleRate
		// encode処理を行う
                mediaCodec.queueInputBuffer(inputBufferIndex, 0, feededDataSize, presentationTimeUs, 0)
            }
            return feededDataSize
        }
        return 0
    }
    
    private fun writeEncodedDataToContainer() {
        // Codecからencode済みのbufferのindexを取得
        var outputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, waitBufferTimeout)
	// encode済みのデータがある限りMediaMuxerを使ってコンテナに詰める
        while (outputBufferIndex != MediaCodec.INFO_TRY_AGAIN_LATER) {
	    // encode済みのデータを取得
            val encodedData = mediaCodec.getOutputBuffer(outputBufferIndex) ?: break
            // MediaMuxerにencode済みデータを渡してコンテナに詰めてもらう
	    encodedData.position(bufferInfo.offset)
            encodedData.limit(bufferInfo.offset + bufferInfo.size)
            mediaMuxer.writeSampleData(audioTrackIndex, encodedData, bufferInfo)
	    // コンテナへ詰め終わったbufferを廃棄
            mediaCodec.releaseOutputBuffer(outputBufferIndex, false)
	    // 次にencode済みbufferのindexを取得
            outputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, waitBufferTimeout)
        }
    }
}

これでやっと、発話区間のみが録音された音声ファイル(.mp4)が生成されているはずです。adb pullして再生してみて下さい。

WhisperAPI

次に3~4のAPIを叩いて結果を取得する部分です。
openAIのAPIを叩くにはAPIキーの登録が必要です。
https://nicecamera.kidsplates.jp/help/feature/ai-kata/openapi_apikey/
このキーはlocal.propertiesにおいて、ビルド時に読み込むことで安全に扱うことができます。

local.properties
openai_api_key=sk-...
build.gradle.kts
import java.util.Properties
...
android {
    defaultConfig {
        val properties = Properties()
        val propertiesFile = project.rootProject.file("local.properties")
        if (propertiesFile.exists()) {
            properties.load(propertiesFile.inputStream())
        }
        val openAiApiKey = properties.getProperty("openai_api_key")
        buildConfigField("String", "OPENAI_API_KEY", "\"$openAiApiKey\"")
    }
}

kotlinからopenAIのAPIを直接叩くにはRetrofitライブラリを使うことで出来ます。権限はINTERNETが必要です。

AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />

エンドポイント、Request、Responseの中身は公式のAPIドキュメントを参照する必要があります。ファイルの送信を行う際には、OkHttp3のMultipartBodyを使うことで送信できます
https://platform.openai.com/docs/api-reference/audio/createTranscription

import kotlinx.serialization.Serializable
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.http.Headers
import retrofit2.http.Multipart
import retrofit2.http.POST

interface OpenAiService {
    @Multipart
    @Headers("Authorization: Bearer ${BuildConfig.OPENAI_API_KEY}")
    @POST("v1/audio/transcriptions")
    suspend fun transcribeAudioFile(
        @Part file: MultipartBody.Part,
        @Part("model") model: RequestBody,
    ): TranscriptionResponse
    
    // レスポンスのモデルを定義
    @Serializable
    data class TranscriptionResponse(
        val text: String,
    )
}

あとは、適当な場所でリクエストを作って、APIを叩き、レスポンスを受け取ることで、Whisperによって音声ファイルから文字起こしされた文章が手に入ります。

class SpeechToText @Inject constructor(
    private val openAiService: OpenAiService,    
) {
    fun speechToText(audioFilePath: String): String {
        val audioFile = File(audioFilePath.toString())
        val mediaType = "mp4".toMediaTypeOrNull()
        val requestBody = audioFile.asRequestBody(mediaType)
        val part = MultipartBody.Part.createFormData("file", audioFile.name, requestBody)
        val model = "whisper-1".toRequestBody("text/plain".toMediaTypeOrNull())
	
	return openAiService.transcribeAudioFile(part, model).text
    }
}

おわりに

本記事では、KotlinからWisperAPIを叩くをテーマに、録音・ファイル化・Retrofitという部分を詳細なコメント付き実装を含めてご紹介しました。特にファイル化の部分は意外とややこしい内容なので、本記事の内容がどなたかの参考になれば幸いです。
Turingではエンジニアを絶賛募集中ですので、ご興味ある方はぜひ!
https://www.turing-motors.com/jobs

後続の記事にバトンタッチ!
https://www.turing-motors.com/adventcalendar2023

Tech Blog - Turing

Discussion