Share
Embed
リアルタイム音声処理を題材に、Kotlin Flowのバッファリングと実行モデルを改めて深掘りする
GREE Tech Conference 2026で発表された資料です。
https://techcon.gree.jp/2026/session/TrackC-4
Transcript
75 slides
-
今⽇話すこと • ⾳声処理を⼿続き的な設計からKotlin Flowベースの宣⾔的 な設計に移⾏しました • 設計は綺麗になったが、不具合が多数発⽣した • Flowのバッファリングに関して理解が不⼗分だったことが 原因のひとつ
• ⾳声処理のリファクタを説明しつつ、Flowのバッファリン グ周りについて説明していきます 4 -
⾳声をデジタルで扱う [0, 16123, 7640, -6873, -18385, …] = ただの ShortArray
① 波形(アナログ) ② 等間隔で測る=サンプリング ③ 数値の列=PCM 7 -
このアプリが扱う⾳声 48000Hz / ステレオ2ch / 16bit PCM 1 / 48000
秒 縦軸:16bit 65,536段階 … 途中省略 1フレーム = 20ms 8 -
Androidで⾳声を再⽣するには? val format = AudioFormat.Builder() .setSampleRate(48000) .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO) .setEncoding(AudioFormat.ENCODING_PCM_16BIT) .build() val
audioTrack = AudioTrack.Builder().setAudioFormat(format).build() audioTrack.write(pcm, size, AudioTrack.WRITE_BLOCKING) OSに音声データを渡す必要がある。その受け口が AudioTrack 9 -
Androidで⾳声を再⽣するには? val format = AudioFormat.Builder() .setSampleRate(48000) .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO) .setEncoding(AudioFormat.ENCODING_PCM_16BIT) .build() val
audioTrack = AudioTrack.Builder().setAudioFormat(format).build() audioTrack.write(pcm, size, AudioTrack.WRITE_BLOCKING) サンプルレート・チャンネル数・ビット深度は、作るときに決める 10 -
Androidで⾳声を再⽣するには? val format = AudioFormat.Builder() .setSampleRate(48000) .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO) .setEncoding(AudioFormat.ENCODING_PCM_16BIT) .build() val
audioTrack = AudioTrack.Builder().setAudioFormat(format).build() audioTrack.write(pcm, size, AudioTrack.WRITE_BLOCKING) サンプルレート・チャンネル数・ビット深度は、作るときに決める 11 -
リモートからの⾳声 デコード 加⼯ BGM (.mp3) デコード 加⼯ マイク⾳声 Mix 加⼯
スピーカー リモート送信 13 -
リモートからの⾳声 デコード 加⼯ BGM (.mp3) デコード 加⼯ マイク⾳声 Mix 加⼯
スピーカー リモート送信 14 -
リモートからの⾳声 デコード 加⼯ BGM (.mp3) デコード 加⼯ マイク⾳声 Mix 加⼯
スピーカー リモート送信 15 -
リモートからの⾳声 デコード 加⼯ BGM (.mp3) デコード 加⼯ マイク⾳声 Mix 加⼯
スピーカー リモート送信 16 -
リモートからの⾳声 デコード 加⼯ BGM (.mp3) デコード 加⼯ マイク⾳声 Mix 加⼯
スピーカー リモート送信 17 -
⼿続き的な設計:2つのwhileループ • デコード担当と再⽣担当の2クラスに分かれ、間はChannelで繋ぐ構成 • 各クラスの中⾝は1つのwhileループであり、処理順序がコードに固定化 • 機能追加のたびに、ループ処理の本体コードを直接編集する必要が発⽣ LiveBgmPlayer の while
(true) ポーズ判定(300msポーリング) AudioDecoder の while (true) Channel capacity=10 MediaCodecの⼿動出し⼊れ アップサンプル → ⾳量 フレーム整形(frameSize単位) ステレオ → 設定チャンネル数 再⽣位置の更新 ローカル再⽣ と 配信送信 リングバッファへ詰め替え 処理の順番はループに固定で、部品として差し替えられなかった 18 -
宣⾔的な設計:Flowのパイプライン Consumer 使う Producer 作る Intermediary 加⼯する Flow Flow ローカル再⽣
フレーム整形 ⾳源デコード リモート送信 アップサンプル マイク⾳声取得 再⽣位置保持 チャンネル変換 ポーズ制御 19 -
部品が差し替え可能になった Consumer 使う Producer 作る Intermediary 加⼯する Flow フレーム整形 ⾳源デコード
アップサンプル 再⽣位置保持 チャンネル変換 ポーズ制御 23 -
部品が差し替え可能になった Consumer 使う Producer 作る Intermediary 加⼯する Flow ローカル再⽣ フレーム整形
リモート送信 アップサンプル ⾳源デコード 再⽣位置保持 チャンネル変換 ポーズ制御 24 -
部品が差し替え可能になった Consumer 使う Producer 作る Intermediary 加⼯する Flow ローカル再⽣ フレーム整形
リモート送信 アップサンプル マイク⾳声取得 再⽣位置保持 チャンネル変換 ポーズ制御 25 -
部品が差し替え可能になった Consumer 使う Producer 作る Intermediary 加⼯する Flow ローカル再⽣ フレーム整形
リモート送信 アップサンプル マイク⾳声取得 チャンネル変換 再⽣位置保持 ポーズ制御 26 -
Producer / Intermediary / Consumer interface IAudioStreamProducer<T> { fun produce():
Flow<T> } interface IAudioStreamIntermediary<TU, TD> { fun transform(upstream: Flow<TU>): Flow<TD> } interface IAudioStreamConsumer<T> { suspend fun consume(data: T) } T は⾳声データ。中⾝はPCMのバイト列を持つ ByteBuffer 27 -
Producerの実装例:⾳声ファイルをデコードする class MediaDecodeAudioStreamProducer(...) : IAudioStreamProducer<...> { override fun produce(): Flow<...>
= flow { val decoder = MediaCodec.createDecoderByType(mimeType)... while (!isDoneRead || !isDoneWrite) { yield() decoder.enqueueSampleIfPossible(...) // 読む when (val r = decoder.dequeueSampleIfPossible()) { is DequeuedSample -> emit(...) // 流す else -> {} } } } } 29 -
Intermediary override fun transform(upstream: Flow<StreamingAudioData.MediaCodecExtractedResult>): Flow<StreamingAudioData.MediaCodecExtractedResult> { return upstream.transform {
result -> var offset = 0 while (ary.size - offset >= frameSizeInBytes) { val frameBuffer = audioRingBufferHolder.getNextBuffer().apply { clear() put(ary, offset, frameSizeInBytes) flip() } emit( StreamingAudioData.MediaCodecExtractedResult(...), ) offset += frameSizeInBytes } 31 -
Intermediaryの実装例:フレーム整形 override fun transform(upstream: Flow<StreamingAudioData.MediaCodecExtractedResult>): Flow<StreamingAudioData.MediaCodecExtractedResult> { return upstream.transform {
result -> var offset = 0 while (ary.size - offset >= frameSizeInBytes) { val frameBuffer = audioRingBufferHolder.getNextBuffer().apply { clear() put(ary, offset, frameSizeInBytes) flip() } emit( StreamingAudioData.MediaCodecExtractedResult(...), ) offset += frameSizeInBytes } 33 -
Consumer:端末で⾳声を再⽣する override suspend fun consume(data:DualChannelAudioBuffer) { val stereoBuffer = data.stereoBuffer
// 再生用 audioTrack.write( stereoBuffer, stereoBuffer.remaining(), AudioTrack.WRITE_BLOCKING) 35 -
Consumer:端末で⾳声を再⽣する override suspend fun consume(data:DualChannelAudioBuffer) { val stereoBuffer = data.stereoBuffer
// 再生用 audioTrack.write( stereoBuffer, stereoBuffer.remaining(), AudioTrack.WRITE_BLOCKING) 36 -
パイプラインの組み⽴て val sharedFlow = producer.produce() .intermediateWith(FrameSizeChunkAudioStreamIntermediary(30, frameSize)) .intermediateWith(UpSampleIntermediary(...)) .intermediateWith(playbackPositionTrackingIntermediary) .intermediateWith(stereoToConfiguredChannelIntermediary)
.shareIn(this) // ← 1本の上流を共有する launch { sharedFlow.consumeWith(liveStreamingSendConsumer) } // 配信へ launch { sharedFlow.consumeWith(audioTrackWriteConsumer) } // 再生へ • • intermediateWith / consumeWith は数珠つなぎの形で記述できるようにするための自作の 拡張関数。 それぞれは単純な transformとconsumeのラッパー 37 -
パイプラインの組み⽴て val sharedFlow = producer.produce() .intermediateWith(FrameSizeChunkAudioStreamIntermediary(30, frameSize)) .intermediateWith(UpSampleIntermediary(...)) .intermediateWith(playbackPositionTrackingIntermediary) .intermediateWith(stereoToConfiguredChannelIntermediary)
.shareIn(this) // ← 1本の上流を共有する launch { sharedFlow.consumeWith(liveStreamingSendConsumer) } // 配信へ launch { sharedFlow.consumeWith(audioTrackWriteConsumer) } // 再生へ • • intermediateWith / consumeWith は数珠つなぎの形で記述できるようにするための自作の 拡張関数。 それぞれは単純な transformとconsumeのラッパー 38 -
パイプラインの組み⽴て val sharedFlow = producer.produce() .intermediateWith(FrameSizeChunkAudioStreamIntermediary(30, frameSize)) .intermediateWith(UpSampleIntermediary(...)) .intermediateWith(playbackPositionTrackingIntermediary) .intermediateWith(stereoToConfiguredChannelIntermediary)
.shareIn(this) // ← 1本の上流を共有する launch { sharedFlow.consumeWith(liveStreamingSendConsumer) } // 配信へ launch { sharedFlow.consumeWith(audioTrackWriteConsumer) } // 再生へ • • intermediateWith / consumeWith は数珠つなぎの形で記述できるようにするための自作の 拡張関数。 それぞれは単純な transformとconsumeのラッパー 39 -
パイプラインの組み⽴て val sharedFlow = producer.produce() .intermediateWith(FrameSizeChunkAudioStreamIntermediary(30, frameSize)) .intermediateWith(UpSampleIntermediary(...)) .intermediateWith(playbackPositionTrackingIntermediary) .intermediateWith(stereoToConfiguredChannelIntermediary)
.shareIn(this) // ← 1本の上流を共有する launch { sharedFlow.consumeWith(liveStreamingSendConsumer) } // 配信へ launch { sharedFlow.consumeWith(audioTrackWriteConsumer) } // 再生へ • • intermediateWith / consumeWith は数珠つなぎの形で記述できるようにするための自作の 拡張関数。 それぞれは単純な transformとconsumeのラッパー 40 -
パイプラインの組み⽴て val sharedFlow = producer.produce() .intermediateWith(FrameSizeChunkAudioStreamIntermediary(30, frameSize)) .intermediateWith(UpSampleIntermediary(...)) .intermediateWith(playbackPositionTrackingIntermediary) .intermediateWith(stereoToConfiguredChannelIntermediary)
.shareIn(this) // ← 1本の上流を共有する launch { sharedFlow.consumeWith(liveStreamingSendConsumer) } // 配信へ launch { sharedFlow.consumeWith(audioTrackWriteConsumer) } // 再生へ • • intermediateWith / consumeWith は数珠つなぎの形で記述できるようにするための自作の 拡張関数。 それぞれは単純な transformとconsumeのラッパー 41 -
パイプラインの組み⽴て val sharedFlow = producer.produce() .intermediateWith(FrameSizeChunkAudioStreamIntermediary(30, frameSize)) .intermediateWith(UpSampleIntermediary(...)) .intermediateWith(playbackPositionTrackingIntermediary) .intermediateWith(stereoToConfiguredChannelIntermediary)
.shareIn(this) // ← 1本の上流を共有する launch { sharedFlow.consumeWith(liveStreamingSendConsumer) } // 配信へ launch { sharedFlow.consumeWith(audioTrackWriteConsumer) } // 再生へ • • intermediateWith / consumeWith は数珠つなぎの形で記述できるようにするための自作の 拡張関数。 それぞれは単純な transformとconsumeのラッパー 42 -
パイプラインの組み⽴て val sharedFlow = producer.produce() .intermediateWith(FrameSizeChunkAudioStreamIntermediary(30, frameSize)) .intermediateWith(UpSampleIntermediary(...)) .intermediateWith(playbackPositionTrackingIntermediary) .intermediateWith(stereoToConfiguredChannelIntermediary)
.shareIn(this) // ← 1本の上流を共有する launch { sharedFlow.consumeWith(liveStreamingSendConsumer) } // 配信へ launch { sharedFlow.consumeWith(audioTrackWriteConsumer) } // 再生へ • • intermediateWith / consumeWith は数珠つなぎの形で記述できるようにするための自作の 拡張関数。 それぞれは単純な transformとconsumeのラッパー 43 -
パイプラインの組み⽴て val sharedFlow = producer.produce() .intermediateWith(FrameSizeChunkAudioStreamIntermediary(30, frameSize)) .intermediateWith(UpSampleIntermediary(...)) .intermediateWith(playbackPositionTrackingIntermediary) .intermediateWith(stereoToConfiguredChannelIntermediary)
.shareIn(this) // ← 1本の上流を共有する launch { sharedFlow.consumeWith(liveStreamingSendConsumer) } // 配信へ launch { sharedFlow.consumeWith(audioTrackWriteConsumer) } // 再生へ • • intermediateWith / consumeWith は数珠つなぎの形で記述できるようにするための自作の 拡張関数。 それぞれは単純な transformとconsumeのラッパー 44 -
val sharedFlow = producer.produce() .intermediateWith(FrameSizeChunkAudioStreamIntermediary(30, frameSize)) .intermediateWith(UpSampleIntermediary(...)) .intermediateWith(playbackPositionTrackingIntermediary) .intermediateWith(stereoToConfiguredChannelIntermediary) .shareIn(this)
// ← 1本の上流を共有する launch { sharedFlow.consumeWith(liveStreamingSendConsumer) } // 配信へ launch { sharedFlow.consumeWith(audioTrackWriteConsumer) } // 再生へ 46 -
この形の流れにするのがshareIn val sharedFlow = producer.produce() .intermediateWith(FrameSizeChunkAudioStreamIntermediary(30, frameSize)) .intermediateWith(UpSampleIntermediary(...)) .intermediateWith(playbackPositionTrackingIntermediary) .intermediateWith(stereoToConfiguredChannelIntermediary)
.shareIn(this) // ← 1本の上流を共有する launch { sharedFlow.consumeWith(liveStreamingSendConsumer) } // 配信へ launch { sharedFlow.consumeWith(audioTrackWriteConsumer) } // 再生へ 49 -
Flowは cold と hot に分かれる 比較項目 Cold Flow Hot Flow
生成タイミング collect() 呼び出し時に毎回生成開始 コレクトの有無に関わらず生 成・動作 データ保持 状態を持たない(過去値は保 持しない) 最新値や履歴( Replay)を保 持可能 コレクターの関係 1対1(コレクターごとに独立し て実行) 1対多(複数のコレクターでス トリーム共有) 代表的な API flow { ... }, flowOf(), map StateFlow, SharedFlow 停止タイミング コレクトをキャンセルすると処 理も停止 コレクト停止後もバックグラウ ンドで動作し得る 52 -
Flowは cold と hot に分かれる ⾳声処理 ローカル再⽣ Cold ⾳声処理 リモート送信
ローカル再⽣ Hot ⾳声処理 リモート送信 53 -
Flowは cold と hot に分かれる ⾳声処理 Cold ⾳声処理 ⾳声処理 ローカル再⽣
リモート送信 New Consumer ローカル再⽣ Hot ⾳声処理 リモート送信 New Consumer 54 -
shareIn が返す SharedFlow とは MutableSharedFlow<T>( replay: Int = 0, extraBufferCapacity:
Int = 0, onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND ) バッファの⼤きさと、あふれたときの挙動を、作るときに指定できる 57 -
バッファなし:extraBufferCapacity = 0 val sharedFlow = MutableSharedFlow<Int>(extraBufferCapacity = 0) launch
{ sharedFlow.collect { value -> println(" [Collector] 受信: $value") delay(1000) } } // 接続待ち delay(1000) for (i in 1..3) { println("[Emit] 送信開始: $i") sharedFlow.emit(i) // コレクターの処理完了までサスペンドして待つ println("[Emit] 送信完了: $i") } 58 -
バッファなし:Producer が直接 Consumer に 渡す Producer (送信) Consumer (受信) A,B,C
の値を連続送信 処理が遅い (1秒) flow.emit(i) delay(1000) 59 -
バッファなし - Step 1 extraBufferCapacity = 0 ▼ 順序 →
Step 1 Producer A バッファ — Consumer A を受信 Step 2 Step 3 Step 4 Step 1 Producer が A を送信。Consumer が直接 A を受け取って処理を開始します。 60 -
バッファなし - Step 2 extraBufferCapacity = 0 ▼ 順序 →
Step 1 Producer A Step 2 Step 3 Step 4 B SUSPEND バッファ — — Consumer A を受信 A を処理中… Step 2 — サスペンド発⽣ Consumer が A を処理中のため B を受け取れません。バッファが無いので、Producer は即座にサスペンドします。 61 -
バッファなし - Step 3 extraBufferCapacity = 0 ▼ 順序 →
Step 1 Producer A Step 2 B Step 3 Step 4 B 送信完了 SUSPEND バッファ — — — Consumer A を受信 A を処理中… B を受信 Step 3 — サスペンド解消 Consumer が A を終えて B を受け取ります。待たされていた Producer の B 送信が完了します。 62 -
バッファなし - Step 4 extraBufferCapacity = 0 ▼ 順序 →
Step 1 Producer A Step 2 B Step 3 B 送信完了 SUSPEND Step 4 C SUSPEND バッファ — — — — Consumer A を受信 A を処理中… B を受信 B を処理中… Step 4 — 再びサスペンド Producer が C を送ろうとしますが、Consumer が B を処理中のため再びサスペンド。バッファが無いと毎回⾜⽌めされま す。 63 -
バッファあり:extraBufferCapacity = 2 // バッファ枠を 2 つ確保 val flow =
MutableSharedFlow<Int>(extraBufferCapacity = 2) // 処理の遅いコレクター( 1秒かかる) launch { flow.collect { value -> println(" [Collector] 受信: $value") delay(1000) } } delay(100) // 接続待ち for (i in 1..4) { println("[Emit] 送信開始: $i") flow.emit(i) // バッファに空きがあれば待たずに即完了する println("[Emit] 送信完了: $i") } 64 -
バッファあり:間にバッファが⼊る Buffer (バッファ枠: 2) Producer (送信) Consumer (受信) 1..4 の値を連続送信
flow.emit(i) 処理が遅い (1秒) 空き枠 1 空き枠 2 delay(1000) 65 -
バッファあり - Step 1 extraBufferCapacity = 2 ▼ 順序 →
Step 1 Producer A バッファ — Consumer A を受信 Step 2 Step 3 Step 4 Step 5 Step 6 Step 1 Producer が A を送信。Consumer が直接 A を受け取って処理を開始します(バッファは空のまま)。 66 -
バッファあり - Step 2 extraBufferCapacity = 2 ▼ 順序 →
Step 1 Step 2 Producer A B バッファ — B Consumer A を受信 A を処理中… Step 3 Step 4 Step 5 Step 6 Step 2 Consumer が A を処理中に Producer が B を送信。バッファに [ B ] がたまり、Producer は⽌まらず先へ進めます。 67 -
バッファあり - Step 3 extraBufferCapacity = 2 ▼ 順序 →
Step 1 Step 2 Step 3 Producer A B C バッファ — B B, C (満杯) Consumer A を受信 A を処理中… A を処理中… Step 4 Step 5 Step 6 Step 3 Producer が C を送信。バッファに [ B, C ] が⼊り、容量の上限(2個)に達して満杯になります。 68 -
バッファあり - Step 4 extraBufferCapacity = 2 ▼ 順序 →
Step 1 Step 2 Step 3 Producer A B C Step 4 Step 5 Step 6 D SUSPEND バッファ — B B, C (満杯) B, C (満杯) Consumer A を受信 A を処理中… A を処理中… A を処理中… Step 4 — サスペンド発⽣ Producer が D を送ろうとしますが、バッファが満杯のため送信が中断(サスペンド)されます。Consumer はまだ A を処理 中です。 69 -
バッファあり - Step 5 extraBufferCapacity = 2 ▼ 順序 →
Step 1 Step 2 Step 3 Producer A B C Step 4 D Step 5 Step 6 D 送信完了 SUSPEND バッファ — B B, C (満杯) B, C (満杯) C, D Consumer A を受信 A を処理中… A を処理中… A を処理中… B を処理中… Step 5 — サスペンド解消 Consumer が A を終えて B を受け取り、バッファから B が消えます。空いた枠に D が⼊り、サスペンドが解けます。 70 -
バッファあり - Step 6 extraBufferCapacity = 2 ▼ 順序 →
Step 1 Step 2 Step 3 Producer A B C Step 4 D Step 5 D 送信完了 SUSPEND Step 6 E SUSPEND バッファ — B B, C (満杯) B, C (満杯) C, D C, D (満杯) Consumer A を受信 A を処理中… A を処理中… A を処理中… B を処理中… B を処理中… Step 6 — 再びサスペンド Producer が E を送ろうとしますが、バッファは [ C, D ] で再び満杯。ふたたびサスペンドします。 71 -
バッファの⼤きさ = 読まれていない値を溜めておける数 Producer Consumer バッファ 0 Consumerが処理を終えるまでProducerは次の値をEmitできない Producer Consumer
バッファ 2 バッファが満杯になるまでProducerは次の値をEmitできる。 バッファが満杯になればProducerはバッファが空くまで次の値を Emitできない。 72