KotlinからWhisperAPIを使う
はじめに
Turing株式会社のアドベントカレンダー20日目!
今回は、UXチームの井上(@yoinoue5212)が担当します!
UXチームではAndroidベースのIVIシステム開発を進めています。
ちなみに、IVIシステムとは、速度計などを表示する計器クラスター部分と、ナビやエンタメを提供するセンターディスプレイ部分を総称した概念です。
UXを意識するうえで、より簡単に、よりシンプルに、デバイスを操作するための方法として、"OK, Google. ~やって"のようなボイスアシスタント機能が考えられます。
本記事では、そんな便利なボイスアシスタントを作るための第一歩である音声認識に関連して、あまり情報のないKotlinからOpenAI社WhisperのAPIを使う方法について説明しようと思います。このタスク、録音・ファイル化・WebAPIと意外と複合的な理解が必要となるため、どれか一つを必要とする誰かの支えとなることを願い、一連の内容を記事に残そうと思います。
なお、他の音声認識の手段としては、SpeechRecognizer
クラスを使う方法、TFモデルをオンデバイスで動かす方法なども考えられます。
全体の流れ
大きな流れはシンプルです。
- 録音する
- WhisperAPIの対応する音声フォーマットにする
- APIを叩く
- 結果を取得する
これで喋った内容が文字列として取得できます。
音声ファイルの準備
まず、1~2に該当する音声のファイル化の部分を行います。
Androidで録音を行う場合、AudioRecord
とMediaRecorder
の2つのクラスが存在します。
-
AudioRecord
(doc): 音声データそのものを扱いリアルタイムでの処理が可能だが、ファイル化は別途必要 -
MediaRecorder
(doc): ファイル化まで丸っと実行できるが、音声処理は行えず手動での録音の停止処理が必要
今回は、ユーザーの発話終了と同時に録音がストップ&ファイル化されて欲しいので、リアルタイムに処理を行うことのできるAudioRecord
クラスを使います。
録音にはRECORD_AUDIO
権限が必要です。
<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)という処理を行うことで発話終了判定を行うことを想定しています。長くなるためここでは省略させていただきますが、以下が参考になります。
次に、取得した音声を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
フォーマットでのファイル化をしてみようと思います。扱うクラスはMediaCodec
、MediaMuxer
の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キーの登録が必要です。
このキーはlocal.properties
において、ビルド時に読み込むことで安全に扱うことができます。
openai_api_key=sk-...
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
が必要です。
<uses-permission android:name="android.permission.INTERNET" />
エンドポイント、Request、Responseの中身は公式のAPIドキュメントを参照する必要があります。ファイルの送信を行う際には、OkHttp3のMultipartBodyを使うことで送信できます。
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ではエンジニアを絶賛募集中ですので、ご興味ある方はぜひ!
後続の記事にバトンタッチ!
Discussion