Slide 1

Slide 1 text

リアルタイム⾳声処理を題材に、 Kotlin Flowのバッファリングと 実⾏モデルを改めて深掘りする

Slide 2

Slide 2 text

⾃⼰紹介 ● ● ● tkc REALITY株式会社 Androidエンジニア(10年⽬) X(twitter)のすがた tkc@tk_zzzzz 2

Slide 3

Slide 3 text

⾃分好みのアバターで ライブ配信 REALITY(リアリティ)は、 誰でもスマホ1台でアバターの姿になり、 顔出しナシでライブ配信、ゲームやチャットなど コミュニケーションを楽しむことができるアプリで す。 3

Slide 4

Slide 4 text

今⽇話すこと ● ⾳声処理を⼿続き的な設計からKotlin Flowベースの宣⾔的 な設計に移⾏しました ● 設計は綺麗になったが、不具合が多数発⽣した ● 原因はバッファリングと実⾏モデルの理解不⾜ ● 実際の問題を紹介しつつFlowのバッファリング周りについ て改めて⾒ていこうと思います 4

Slide 5

Slide 5 text

1. ⾳声の基礎とAndroidでの再⽣ 2. ⾳声をFlowで扱う 3. 実例:BGMが早送りで再⽣される 4. まとめ 5

Slide 6

Slide 6 text

そもそも⾳って? ⾳は空気の振動。 マイクはこれを電圧の変化として捉え、電気信号に変換する。 グラフにすると、横軸=時間 縦軸=振幅 の波形になる。 = 横軸: 時間 ∕ 縦軸: 振幅 6

Slide 7

Slide 7 text

⾳声をデジタルで扱う [0, 16123, 7640, -6873, -18385, …] = ただの ShortArray ① 波形(アナログ) ② 等間隔で測る=サンプリング ③ 数値の列=PCM 7

Slide 8

Slide 8 text

このアプリが扱う⾳声 48000Hz / ステレオ2ch / 16bit PCM 1 / 48000 秒 縦軸:16bit 65,536段階 … 途中省略 1フレーム = 20ms 8

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

1. ⾳声の基礎とAndroidでの再⽣ 2. ⾳声をFlowで扱う 3. 実例:BGMが早送りで再⽣される 4. まとめ 12

Slide 13

Slide 13 text

リモートからの⾳声 デコード 加⼯ BGM (.mp3) デコード 加⼯ マイク⾳声 Mix 加⼯ スピーカー リモート送信 13

Slide 14

Slide 14 text

リモートからの⾳声 デコード 加⼯ BGM (.mp3) デコード 加⼯ マイク⾳声 Mix 加⼯ スピーカー リモート送信 14

Slide 15

Slide 15 text

リモートからの⾳声 デコード 加⼯ BGM (.mp3) デコード 加⼯ マイク⾳声 Mix 加⼯ スピーカー リモート送信 15

Slide 16

Slide 16 text

リモートからの⾳声 デコード 加⼯ BGM (.mp3) デコード 加⼯ マイク⾳声 Mix 加⼯ スピーカー リモート送信 16

Slide 17

Slide 17 text

リモートからの⾳声 デコード 加⼯ BGM (.mp3) デコード 加⼯ マイク⾳声 Mix 加⼯ スピーカー リモート送信 17

Slide 18

Slide 18 text

⼿続き的な設計:2つのwhileループ ● デコード担当と再⽣担当の2クラスに分かれ、間はChannelで繋ぐ構成 ● 各クラスの中⾝は1つのwhileループであり、処理順序がコードに固定化 ● 機能追加のたびに、ループ処理の本体コードを直接編集する必要が発⽣ LiveBgmPlayer の while (true) ポーズ判定(300msポーリング) AudioDecoder の while (true) Channel capacity=10 MediaCodecの⼿動出し⼊れ アップサンプル → ⾳量 フレーム整形(frameSize単位) ステレオ → 設定チャンネル数 再⽣位置の更新 ローカル再⽣ と 配信送信 リングバッファへ詰め替え 処理の順番はループに固定で、部品として差し替えられなかった 18

Slide 19

Slide 19 text

宣⾔的な設計:Flowのパイプライン Consumer 使う Producer 作る Intermediary 加⼯する Flow Flow ローカル再⽣ フレーム整形 ⾳源デコード リモート送信 アップサンプル マイク⾳声取得 再⽣位置保持 チャンネル変換 ポーズ制御 19

Slide 20

Slide 20 text

Kotlin Flowの概念ベース https://developer.android.com/kotlin/flow 20

Slide 21

Slide 21 text

部品が差し替え可能になった Consumer 使う Intermediary 加⼯する Producer 作る ⾳源デコード 21

Slide 22

Slide 22 text

部品が差し替え可能になった Consumer 使う Producer 作る Intermediary 加⼯する Flow フレーム整形 ⾳源デコード アップサンプル 22

Slide 23

Slide 23 text

部品が差し替え可能になった Consumer 使う Producer 作る Intermediary 加⼯する Flow フレーム整形 ⾳源デコード アップサンプル 再⽣位置保持 チャンネル変換 ポーズ制御 23

Slide 24

Slide 24 text

部品が差し替え可能になった Consumer 使う Producer 作る Intermediary 加⼯する Flow ローカル再⽣ フレーム整形 リモート送信 アップサンプル ⾳源デコード 再⽣位置保持 チャンネル変換 ポーズ制御 24

Slide 25

Slide 25 text

部品が差し替え可能になった Consumer 使う Producer 作る Intermediary 加⼯する Flow ローカル再⽣ フレーム整形 リモート送信 アップサンプル マイク⾳声取得 再⽣位置保持 チャンネル変換 ポーズ制御 25

Slide 26

Slide 26 text

部品が差し替え可能になった Consumer 使う Producer 作る Intermediary 加⼯する Flow ローカル再⽣ フレーム整形 リモート送信 アップサンプル マイク⾳声取得 チャンネル変換 再⽣位置保持 ポーズ制御 26

Slide 27

Slide 27 text

Producer / Intermediary / Consumer interface IAudioStreamProducer { fun produce(): Flow } interface IAudioStreamIntermediary { fun transform(upstream: Flow): Flow } interface IAudioStreamConsumer { suspend fun consume(data: T) } T は⾳声データ。中⾝はPCMのバイト列を持つ ByteBuffer 27

Slide 28

Slide 28 text

Producer:⾳声データをつくる Producer 作る ⾳源デコード マイク⾳声取得 28

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

Intermediary:流れてくる⾳声の加⼯や制御 Intermediary 加⼯する フレーム整形 アップサンプル チャンネル変換 再⽣位置保持 ポーズ制御 30

Slide 31

Slide 31 text

Intermediary override fun transform(upstream: Flow): Flow { 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

Slide 32

Slide 32 text

デコーダ出⼒ 整形後のフレーム どれも同じ⼤きさ = 20ms 分 32

Slide 33

Slide 33 text

Intermediaryの実装例:フレーム整形 override fun transform(upstream: Flow): Flow { 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

Slide 34

Slide 34 text

Intermediaryの実装例:フレーム整形 override fun transform(upstream: Flow): Flow { 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 } 34

Slide 35

Slide 35 text

リングバッファとは ⼀時的にデータをためておくバッファ機構 ① 書き込みは順に進む ② 末尾まで⾏くと先頭へ戻る ③ 戻った先のデータは上書きされる 30スロット 書き込み位置 35

Slide 36

Slide 36 text

Consumer:流れてきた⾳声を使う Consumer 使う ローカル再⽣ リモート送信 36

Slide 37

Slide 37 text

Consumer:端末で⾳声を再⽣する override suspend fun consume(data:DualChannelAudioBuffer) { val stereoBuffer = data.stereoBuffer // 再生用 audioTrack.write( stereoBuffer, stereoBuffer.remaining(), AudioTrack.WRITE_BLOCKING) 37

Slide 38

Slide 38 text

Consumer:端末で⾳声を再⽣する override suspend fun consume(data:DualChannelAudioBuffer) { val stereoBuffer = data.stereoBuffer // 再生用 audioTrack.write( stereoBuffer, stereoBuffer.remaining(), AudioTrack.WRITE_BLOCKING) 38

Slide 39

Slide 39 text

パイプラインの組み⽴て 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

Slide 40

Slide 40 text

パイプラインの組み⽴て 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

Slide 41

Slide 41 text

パイプラインの組み⽴て 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

Slide 42

Slide 42 text

パイプラインの組み⽴て 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

Slide 43

Slide 43 text

パイプラインの組み⽴て 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

Slide 44

Slide 44 text

パイプラインの組み⽴て 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

Slide 45

Slide 45 text

パイプラインの組み⽴て 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のラッパー 45

Slide 46

Slide 46 text

パイプラインの組み⽴て 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のラッパー 46

Slide 47

Slide 47 text

1. ⾳声の基礎とAndroidでの再⽣ 2. ⾳声をFlowで扱う 3. 実例:BGMが早送りで再⽣される 3-1 どこが怪しいか 3-2 shareIn を調べる 3-3 原因と対処 4. まとめ 47

Slide 48

Slide 48 text

症状 ● 配信中のBGMが早送りで再⽣される ● AudioTrackに渡す直前のPCMを調べると同じ曲が通常 より短く再⽣されていることがわかった 48

Slide 49

Slide 49 text

リモートからの⾳声 デコード 加⼯ BGM (.mp3) デコード 加⼯ スピーカー ここでのPCMを確認 マイク⾳声 Mix 加⼯ リモート送信 49

Slide 50

Slide 50 text

リモートからの⾳声 デコード 加⼯ BGM (.mp3) デコード 加⼯ スピーカー ここでのPCMを確認 マイク⾳声 Mix 加⼯ リモート送信 50

Slide 51

Slide 51 text

パイプラインを調査する 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) } // 再生へ 51

Slide 52

Slide 52 text

処理の流れは? ⾳声処理 ローカル再⽣ ⾳声処理 リモート送信 ローカル再⽣ ⾳声処理 リモート送信 52

Slide 53

Slide 53 text

処理の流れは? ⾳声処理 ローカル再⽣ ⾳声処理 リモート送信 ローカル再⽣ ⾳声処理 リモート送信 53

Slide 54

Slide 54 text

この形の流れにするのが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) } // 再生へ 54

Slide 55

Slide 55 text

ShareInを使わない場合は ⾳源.mp3 デコード 変換 再⽣ 同⼀の処理が複数⾏われる ⾳源.mp3 デコード 変換 配信 55

Slide 56

Slide 56 text

ShareInを使って分岐する 再⽣ ⾳源.mp3 デコード 変換 配信 56

Slide 57

Slide 57 text

Flowは cold と hot に分かれる 比較項目 Cold Flow Hot Flow 生成タイミング collect() 呼び出し時に毎回生成開始 コレクトの有無に関わらず生 成・動作 データ保持 状態を持たない(過去値は保 持しない) 最新値や履歴( Replay)を保 持可能 コレクターの関係 1対1(コレクターごとに独立し て実行) 1対多(複数のコレクターでス トリーム共有) 代表的な API flow { ... }, flowOf(), map StateFlow, SharedFlow 停止タイミング コレクトをキャンセルすると処 理も停止 コレクト停止後もバックグラウ ンドで動作し得る 57

Slide 58

Slide 58 text

Flowは cold と hot に分かれる ⾳声処理 ローカル再⽣ Cold ⾳声処理 リモート送信 ローカル再⽣ Hot ⾳声処理 リモート送信 58

Slide 59

Slide 59 text

Flowは cold と hot に分かれる ⾳声処理 Cold ⾳声処理 ⾳声処理 ローカル再⽣ リモート送信 New Consumer ローカル再⽣ Hot ⾳声処理 リモート送信 New Consumer 59

Slide 60

Slide 60 text

だから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) } // 再生へ 60

Slide 61

Slide 61 text

分岐をやめてColdにすると問題が再現しなくなった 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) } // 再生へ 61

Slide 62

Slide 62 text

1. ⾳声の基礎とAndroidでの再⽣ 2. ⾳声をFlowで扱う 3. 実例:BGMが早送りで再⽣される 3-1 どこが怪しいか 3-2 shareIn を調べる 3-3 原因と対処 4. まとめ 62

Slide 63

Slide 63 text

shareInを詳しく⾒る cold Flow を hot な SharedFlow に変換する関数 63

Slide 64

Slide 64 text

shareInを詳しく⾒る ⽣成‧維持のコストが⾼い cold Flow があり、その値を収集したい購読者が複数いる場合に有効 → 今回の⾳声処理パイプラインは、まさにこの条件に当てはまる 64

Slide 65

Slide 65 text

shareIn が返す SharedFlow とは MutableSharedFlow( replay: Int = 0, extraBufferCapacity: Int = 0, onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND ) バッファの⼤きさと、あふれたときの挙動を、作るときに指定できる 65

Slide 66

Slide 66 text

バッファなし:extraBufferCapacity = 0 val sharedFlow = MutableSharedFlow(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") } 66

Slide 67

Slide 67 text

バッファなし:Producer が直接 Consumer に 渡す Producer (送信) Consumer (受信) A,B,C の値を連続送信 処理が遅い (1秒) flow.emit(i) delay(1000) 67

Slide 68

Slide 68 text

バッファなし - Step 1 extraBufferCapacity = 0 ▼ 順序 → Step 1 Producer A バッファ — Consumer A を受信 Step 2 Step 3 Step 4 Step 1 Producer が A を送信。Consumer が直接 A を受け取って処理を開始します。 68

Slide 69

Slide 69 text

バッファなし - 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 は即座にサスペンドします。 69

Slide 70

Slide 70 text

バッファなし - 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 送信が完了します。 70

Slide 71

Slide 71 text

バッファなし - 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 を処理中のため再びサスペンド。バッファが無いと毎回⾜⽌めされま す。 71

Slide 72

Slide 72 text

バッファあり:extraBufferCapacity = 2 // バッファ枠を 2 つ確保 val flow = MutableSharedFlow(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") } 72

Slide 73

Slide 73 text

バッファあり:間にバッファが⼊る Buffer (バッファ枠: 2) Producer (送信) Consumer (受信) 1..4 の値を連続送信 flow.emit(i) 処理が遅い (1秒) 空き枠 1 空き枠 2 delay(1000) 73

Slide 74

Slide 74 text

バッファあり - 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 を受け取って処理を開始します(バッファは空のまま)。 74

Slide 75

Slide 75 text

バッファあり - 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 は⽌まらず先へ進めます。 75

Slide 76

Slide 76 text

バッファあり - 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個)に達して満杯になります。 76

Slide 77

Slide 77 text

バッファあり - 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 を処理 中です。 77

Slide 78

Slide 78 text

バッファあり - 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 が⼊り、サスペンドが解けます。 78

Slide 79

Slide 79 text

バッファあり - 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 ] で再び満杯。ふたたびサスペンドします。 79

Slide 80

Slide 80 text

バッファの⼤きさ = 読まれていない値を溜めておける数 Producer Consumer バッファ 0 Consumerが処理を終えるまでProducerは次の値をEmitできない Producer Consumer バッファ 2 バッファが満杯になるまでProducerは次の値をEmitできる。 バッファが満杯になればProducerはバッファが空くまで次の値を Emitできない。 80

Slide 81

Slide 81 text

shareInで作られたSharedFlowのバッファは? val sharedFlow = producer.produce() .intermediateWith(FrameSizeChunkAudioStreamIntermediary(30, frameSize)) .intermediateWith(UpSampleIntermediary(...)) .intermediateWith(playbackPositionTrackingIntermediary) .intermediateWith(stereoToConfiguredChannelIntermediary) .shareIn(this) launch { sharedFlow.consumeWith(liveStreamingSendConsumer) } 配信へ launch { sharedFlow.consumeWith(audioTrackWriteConsumer) } // // AudioTrackへ 81

Slide 82

Slide 82 text

バッファを指定する引数はない public fun Flow.shareIn( scope: CoroutineScope, started: SharingStarted, replay: Int = 0 ): SharedFlow shareIn で決められるのは、購読のタイミングと再送の件数だけ 82

Slide 83

Slide 83 text

ドキュメントを⾒る https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/share-in.html 83

Slide 84

Slide 84 text

ドキュメントを⾒る shareIn 演算子は、アップストリーム(元のFlow)を別のコルー チンで実行し、buffer 演算子の説明にある通り、replay のサ イズまたはデフォルト値のいずれか大きい方のバッファを使用し てアップストリームからの送信値をバッファリングします。 このデフォルトのバッファリングは、shareIn の直前に buffer や conflate を明示的に設定することで上書きできます。例え ば以下のようになります。 https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/share-in.html 84

Slide 85

Slide 85 text

ドキュメントを⾒る https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.channels/-channe l/-factory/-b-u-f-f-e-r-e-d.html 85

Slide 86

Slide 86 text

デフォルトで64件 For BufferOverflow.SUSPEND (the default buffer overflow strategy), the default capacity is 64, https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.channels/-channe l/-factory/-b-u-f-f-e-r-e-d.html 86

Slide 87

Slide 87 text

1. ⾳声の基礎とAndroidでの再⽣ 2. ⾳声をFlowで扱う 3. 実例:BGMが早送りで再⽣される 3-1 どこが怪しいか 3-2 shareIn を調べる 3-3 原因と対処 4. まとめ 87

Slide 88

Slide 88 text

他にもバッファがあった val sharedFlow = producer.produce() .intermediateWith(FrameSizeChunkAudioStreamIntermediary(30, frameSize)) .intermediateWith(UpSampleIntermediary(...)) ↑リングバッファ 30スロット .intermediateWith(playbackPositionTrackingIntermediary) .intermediateWith(stereoToConfiguredChannelIntermediary) .shareIn(this) ← SharedFlow のバッファ 64件(デフォルト) launch { sharedFlow.consumeWith(liveStreamingSendConsumer) } launch { sharedFlow.consumeWith(audioTrackWriteConsumer) } // 配信へ // AudioTrackへ 88

Slide 89

Slide 89 text

上流と下流、両⽅にバッファがあった ⾳源デコード フレーム整形 その他の加⼯ shareIn ローカル再⽣ / リモート送信 30 SharedFlow のバッファ 64件 リングバッファ 30スロット 89

Slide 90

Slide 90 text

SharedFlow に並ぶ参照(最⼤ 64 個) 参照1 が指すのは slot 1 書き込み位置 1 1 … … 64 slot 1 Step 1 — スロット1にフレーム1が書かれる リングバッファ ⼀周 = 30スロット Producer が slot 1 にフレーム1を書き、その参照を SharedFlow へ渡す。 90

Slide 91

Slide 91 text

SharedFlow に並ぶ参照(最⼤ 64 個) 参照1 が指すのは slot 1 1 1 2 3 … 8 … 64 slot 1 Step 2 - バッファがあるのでEmitは続く バッファが64あるので Producer はEmitを続ける。 書き込み位置は円をぐるりと進んでいく。 リングバッファ ⼀周 = 30スロット 書き込み位置 91

Slide 92

Slide 92 text

SharedFlow に並ぶ参照(最⼤ 64 個) 参照1 が指すのは slot 1 書き込み位置 1 1 2 3 … 30 … 64 slot 1 Step 3 — リングバッファの30スロットが埋まる リングバッファ ⼀周 = 30スロット Producerが30回Emitしてリングバッファのスロット がすべて埋まった。 しかし、SharedFlow側のバッファはまだ空きがある ため、Emitが続く。 92

Slide 93

Slide 93 text

SharedFlow に並ぶ参照(最⼤ 64 個) 参照1 が指すのは slot 1 書き込み位置 31 1 2 3 … 31 … 64 slot 1 Step 4 — 上書きされる リングバッファ ⼀周 = 30スロット まだ渡していないフレーム1の上に、フレーム31が書 き込まれる。 93

Slide 94

Slide 94 text

SharedFlow に並ぶ参照(最⼤ 64 個) 参照1 が指すのは slot 1 書き込み位置 31 1 2 3 … 31 … 64 slot 1 Step 5 — Consumerは上書き後に読んでいた リングバッファ ⼀周 = 30スロット Consumer が読むのは参照1。しかし指す先の中⾝ は、もうフレーム31になっている。 再⽣すべきデータが上書きされた結果、早送りに聞こえた 94

Slide 95

Slide 95 text

対処:バッファを持たない⾃作ラッパー fun Flow.shareInWithNoExtraBuffer( scope: CoroutineScope, ): SharedFlow { val shared = MutableSharedFlow( replay = 0, extraBufferCapacity = 0, ) scope.launch { collect { shared.emit(it) } } return shared } emit が全Subscriberの消費を待つようになるので、 上流は先へ進めない 95

Slide 96

Slide 96 text

上書きが起きなくなる SharedFlow のバッファ: 0 — 参照を置く場所がない Producer ● 書き込み位置 1 Consumer emit 中 — 受け取られるまで返らない ● 読み出し位置 Step 1 — emit が⽌まる リングバッファ ⼀周 = 30スロット バッファが 0 なので、emit は Consumer が受け取る まで返らない。Producer は次のスロットへ進めな い。 96

Slide 97

Slide 97 text

上書きが起きなくなる SharedFlow のバッファ: 0 — 参照を置く場所がない Producer ● 書き込み位置 2 Consumer emit 中 — 受け取られるまで返らない ● 読み出し位置 Step 2 — 1つ進んで、また⽌まる リングバッファ ⼀周 = 30スロット Consumer が受け取ると emit が返り、Producer は次 の1つだけ書ける。ふたつの位置は隣り合ったまま離 れない。 97

Slide 98

Slide 98 text

shareIn()でもバッファ数を指定する⽅法があった upstream .buffer(0) .shareIn(scope, SharingStarted.Eagerly, replay = 0) buffer(b).shareIn(...) で extraBufferCapacity = b になる ⾃前でラップする必要はなかった 98

Slide 99

Slide 99 text

64件を⾒つけたのと同じページ shareIn 演算子は、アップストリーム(元のFlow)を別のコルー チンで実行し、buffer 演算子の説明にある通り、replay のサ イズまたはデフォルト値のいずれか大きい方のバッファを使用し てアップストリームからの送信値をバッファリングします。 このデフォルトのバッファリングは、 shareIn の直前に buffer や conflate を明示的に設定することで上書きでき ます。例えば以下のようになります。 https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/share-in.html 99

Slide 100

Slide 100 text

ケース2 送信⾳声に⾳⾶びが乗る 100

Slide 101

Slide 101 text

1. ⾳声の基礎とAndroidでの再⽣ 2. ⾳声をFlowで扱う 3. 実例:BGMが早送りで再⽣される 4. まとめ 101

Slide 102

Slide 102 text

まとめ ● ⾃分が書いた Flow は cold か hot か ○ hot なら、バッファがいくつあるかを意識する ● ⾳声処理でリングバッファがある処理をSharedFlowで扱う場合、バッファ数に気 をつける。 ○ スロット数より⼤きいと、読まれる前に上書きされる場合がある。 Flowをなんとなくで使わず、バッファリングまで含めて理解して使うことで 複雑な処理を安全に扱うことができます。 102

Slide 103

Slide 103 text

ご清聴ありがとうございました