受信音声データ取得機能

重要

この機能は Sora Android SDK 2025.3.0 以降で利用できます。

Sora Android SDK では音声トラックから非圧縮の音声データを取得する仕組みを提供しています。

音声トラックを表す AudioTrack に AudioTrackSink を関連付けると、音声データを音声トラックごとにコールバックで受け取ることができます。

用語

AudioTrack (音声トラック)

AudioTrack は 1 接続に存在する音声のストリーミングデータをコントロールするクラスです。 配信する音声トラックは Sora のルールにより 1 本です。視聴する音声トラックは接続数分あります。

例えば 3 人で通話している場合、配信する音声トラックは 1 本、視聴する音声トラックは 2 本存在します。

AudioTrackSink

AudioTrackSink は受信した音声トラックそれぞれで、実際に受信した音声のコールバックを受け取るインターフェースです。

受信した音声を取得するには、この AudioTrackSink を利用します。

音声データを取得する

Sora Android SDK の AudioTrackSink を利用して受信した音声データを取得するには、 受信したい AudioTrack を取得する必要があります。

AudioTrack の取得は onAddLocalStream または onAddRemoteStream を利用します。

private val channelListener =
    object : SoraMediaChannel.Listener {
        // onAddRemoteStream はリモート(受信用)の MediaStream が準備できたときに呼ばれます。
        // onAddLocalStream も同様の処理になります。
        override fun onAddRemoteStream(
            mediaChannel: SoraMediaChannel,
            ms: MediaStream,
        ) {
            // MediaStream が AudioTrack を持っているかを確認します。
            if (ms.audioTracks.size > 0) {
                // AudioTrack を取得します。
                // Sora では MediaStream に関連付けられる AudioTrack は 1 つまでです。
                val track = ms.audioTracks[0]
            }
        }
    }

取得した AudioTrack に対して AudioTrackSink インターフェースを実装したインスタンスを関連付ける必要があります。

以下のコード例は上の AudioTrack 取得例を拡張したもので、AudioTrackSink の実装と AudioTrack への関連付け処理を追加しています。

import jp.shiguredo.sora.sdk.channel.SoraMediaChannel
import org.webrtc.AudioTrack
import org.webrtc.AudioTrackSink
import org.webrtc.MediaStream
import java.nio.ByteBuffer

// AudioTrackSink を実装した AudioTrackSinkExample class です。
// onData には受け取った音声データの処理を実装します。
class AudioTrackSinkExample(
    private val preferredChannels: Int = -1,
) : AudioTrackSink {
    override fun onData(
        audioData: ByteBuffer,
        bitsPerSample: Int,
        sampleRate: Int,
        numberOfChannels: Int,
        numberOfFrames: Int,
    ) {
        // コールバックで取得した音声データ処理を実装してください。
    }

    // onData で受け取る音声データのチャンネル数を指定するためのメソッドです。
    // `-1` を指定した場合は音声データ規定のチャンネル数になります。
    override fun getPreferredNumberOfChannels(): Int = preferredChannels
}

private val channelListener =
    object : SoraMediaChannel.Listener {
        // リモート (受信用) の MediaStream が準備できたときに呼ばれます。
        // onAddLocalStream も同様の処理になります。
        override fun onAddRemoteStream(
            mediaChannel: SoraMediaChannel,
            ms: MediaStream,
        ) {
            // MediaStream が AudioTrack を持っているかを確認します。
            if (ms.audioTracks.size > 0) {
                // AudioTrack を取得します。
                // Sora では MediaStream に関連付けられる AudioTrack は 1 つまでです。
                val track = ms.audioTracks[0]

                // AudioTrackSink をインスタンス化します。
                val sink = AudioTrackSinkExample()

                // AudioTrack に AudioTrackSink を関連付けます。
                track.addSink(sink)
            }
        }
    }

AudioTrackSink の onData コールバックが呼び出される事で、その AudioTrack に関連付けられているクライアントの音声データを取得することができるようになります。

非圧縮の音声データについて

コールバックに入ってくる音声データは非圧縮の 16 bit PCM (Pulse Code Modulation) 形式です。

onData コールバックについて

AudioTrackSink の onData コールバックには受信した音声データ以外の情報が含まれます。

/**
 * @param audioData PCM 形式の音声データを収めた direct byte buffer。
 * @param bitsPerSample 1 サンプル当たりのビット数。
 *                      libwebrtc では PCM 形式の音声データは 16 bit 固定のため、常に 16 が渡されます。
 * @param sampleRate サンプルレート (単位: Hz)。
 * @param numberOfChannels 音声データのチャンネル数。
 *                         モノラルなら 1、ステレオなら 2 が渡されます。
 * @param numberOfFrames audioData に含まれるフレーム数。
 */
fun onData(
    audioData: ByteBuffer,
    bitsPerSample: Int,
    sampleRate: Int,
    numberOfChannels: Int,
    numberOfFrames: Int
)

注意

onData コールバック実装の注意点

onData は 10 ms ごとに libwebrtc の音声処理スレッド上で呼び出されるコールバックであるため、 onData 内部で時間のかかる処理を行う場合、libwebrtc の音声処理がブロックされてしまいます。そのため onData 内部で時間のかかる処理を行わず、別スレッドで処理を行うようにしてください。

AudioTrackSink と AudioTrack の関連付けについて

AudioTrackSink で取得する音声データにはどの音声トラックの音声データなのかを判断する情報は含まれません。判断するには AudioTrack の情報が必要になります。

AudioTrack を識別するための ID は、onAddRemoteStream に入ってくる MediaStream.id から取得できます。この ID は Sora が生成したコネクション ID です。

注意

onAddLocalStream で取得するローカル (送信用) の MediaStream.id は Sora Android SDK が生成した UUID 文字列になっています。 Sora が生成したコネクション ID ではないため注意してください。

重要

AudioTrackSink と AudioTrack の関連付けは 1:1 を前提に実装されています。1 つの AudioTrackSink を複数の AudioTrack に同時に関連付ける 1:N の利用は想定していません。 一方で、1 本の AudioTrack に対して複数の AudioTrackSink を関連付けることは可能であり、各 AudioTrackSink は独立して同一の AudioTrack から音声データを受け取ります。

以下はリモート (受信用) とローカル (送信用) の両方の MediaStream 取得のタイミングで AudioTrackSink と AudioTrack を関連付ける情報を保持する実装例です。

// AudioTrack と AudioTrackSink の組み合わせを Session としてまとめるためのデータクラスです。
private data class Session(
    val track: AudioTrack,
    val sink: AudioTrackSink,
)

// Session を MediaStream.id や connectionId に関連付けるための変数です。
private val sessions = mutableMapOf<String, Session>()
private var connectionId: String? = null
private var pendingLocalSession: Session? = null

private val channelListener =
    object : SoraMediaChannel.Listener {
        // offer メッセージを受信したときに呼ばれます。
        override fun onOfferMessage(
            mediaChannel: SoraMediaChannel,
            offer: OfferMessage,
        ) {
            // Sora が生成したコネクション ID を取得します。
            connectionId = offer.connectionId
            // onAddLocalStream が先に発火していた場合には pendingLocalSession が存在するため
            // pendingLocalSession を sessions に反映します。
            pendingLocalSession?.let { session ->
                sessions[offer.connectionId] = session
                pendingLocalSession = null
            }
        }

        // リモート (受信用) の MediaStream が準備できたときに呼ばれます。
        override fun onAddRemoteStream(
            mediaChannel: SoraMediaChannel,
            ms: MediaStream,
        ) {
            // MediaStream が AudioTrack を持っているかを確認します。
            if (ms.audioTracks.size > 0) {
                // AudioTrack を取得します。
                // Sora では MediaStream に関連付けられる AudioTrack は 1 つまでです。
                val track = ms.audioTracks[0]

                // AudioTrackSink をインスタンス化します。
                val sink = AudioTrackSinkExample()

                // AudioTrack に AudioTrackSink を関連付けます。
                track.addSink(sink)

                // sessions に AudioTrack と AudioTrackSink の関連付け情報を保存します。
                sessions[ms.id] = Session(track, sink)
            }
        }

        // ローカル (送信用) の MediaStream が準備できたときに呼ばれます。
        override fun onAddLocalStream(
            mediaChannel: SoraMediaChannel,
            ms: MediaStream,
        ) {
            if (ms.audioTracks.size > 0) {
                // onAddRemoteStream と同じ処理をします。
                val track = ms.audioTracks[0]
                val sink = AudioTrackSinkExample()
                track.addSink(sink)

                val session = Session(track, sink)
                // connectionId がまだ取得できていない場合は pending に保持します。
                // pending は onOfferMessage で connectionId を取得したタイミングで sessions に保存されます。
                connectionId?.let { id ->
                    sessions[id] = session
                } ?: run {
                    pendingLocalSession = session
                }
            }
        }
    }

AudioTrackSink の関連付け解除について

通常、自分もしくはリモートの配信クライアントがチャネルを離脱した際に、AudioTrack と AudioTrackSink の関連付けは自動で解除されます。

もし明示的に関連付けを解除を行う必要ある場合は AudioTrack.removeSink を利用します。

// AudioTrack と AudioTrackSink の組み合わせを Session としてまとめるためのデータクラスです。
private data class Session(
    val track: AudioTrack,
    val sink: AudioTrackSink,
)

// Session を MediaStream.id に関連付けるための変数です。
private val sessions = mutableMapOf<String, Session>()

private val channelListener =
    object : SoraMediaChannel.Listener {
        // リモート (受信用) の MediaStream が準備できたときに呼ばれます。
        override fun onAddRemoteStream(
            mediaChannel: SoraMediaChannel,
            ms: MediaStream,
        ) {
            // MediaStream が AudioTrack を持っているかを確認します。
            if (ms.audioTracks.size > 0) {
                // AudioTrack を取得します。
                // Sora では MediaStream に関連付けられる AudioTrack は 1 つまでです。
                val track = ms.audioTracks[0]

                // AudioTrackSink をインスタンス化します。
                val sink = AudioTrackSinkExample()

                // AudioTrack に AudioTrackSink を関連付けます。
                track.addSink(sink)

                // sessions に AudioTrack と AudioTrackSink の関連付け情報を保存します。
                sessions[ms.id] = Session(track, sink)
            }
        }
    }

// 指定した connectionId に関連づく AudioTrackSink コールバックを止める関数です。
fun stopSink(connectionId: String) {
    sessions[connectionId]?.let { session ->
        // removeSink を呼び出すことで
        // AudioTrack と AudioTrackSink の関連付けが解除されます。
        session.track.removeSink(session.sink)
    }
}

受信した音声データをクライアント単位で処理するサンプル

実際に AudioTrackSink の onData コールバックを利用して、受信した非圧縮の音声データを Android のストレージに WAV 形式で保存するサンプルコードを紹介します。

受信したクライアント単位の音声データを処理する方法として参考にしてください。

import android.content.Context
import jp.shiguredo.sora.sdk.channel.SoraMediaChannel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.webrtc.AudioTrack
import org.webrtc.AudioTrackSink
import org.webrtc.MediaStream
import java.io.Closeable
import java.io.File
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.channels.FileChannel

/**
 * WebRTC の AudioTrackSink を WAV へ保存する実装例です。
 *
 * @param file              出力先の WAV ファイル。既存内容は truncate(0) で消去されます
 * @param preferredChannels getPreferredNumberOfChannels() の返値。-1 がデフォルト(トラックの規定値から変更しない)
 * @param dispatcher        書き込みワーカーを実行する Dispatcher。共有して直列化しても、個別にして並列化してもOKで、
 *                          この class を利用する側で決定します。
 * @param capacity          Channel のバッファ段数(= 保持できるフレーム数)。この値が大きいほどドロップは減りますが
 *                          書き込み遅延が発生する可能性があるので注意が必要です。
 */
class WAVAudioTrackSink(
    private val file: File,
    private val preferredChannels: Int = -1,
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
    capacity: Int = 8,
) : AudioTrackSink,
    Closeable {
    // onData で入ってきた値を Channel に転送するための data class です。
    private data class AudioData(
        val data: ByteBuffer,
        val bitsPerSample: Int,
        val sampleRate: Int,
        val channels: Int,
        val byteCount: Int,
    )

    // Channel は onData コールバックのスレッドをブロックせずに、
    // 安全に WAV 書き込みワーカーへ音声データを渡すための非同期キューとして振る舞います。
    private val queue: Channel<AudioData> =
        Channel(
            capacity = capacity,
            onBufferOverflow = BufferOverflow.DROP_OLDEST,
        )

    private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
    private val raf: RandomAccessFile = RandomAccessFile(file, "rw")
    private val fc: FileChannel = raf.channel

    @Volatile private var headerWritten: Boolean = false
    private var totalDataBytes: Long = 0
    private var wavSampleRate: Int = 0
    private var wavBitsPerSample: Int = 0
    private var wavChannels: Int = 0

    private val worker: Job

    init {
        fc.truncate(0)
        // queue に積まれたフレームを順に取り出し、単一の I/O スレッドで WAV に書き込みます。
        // onData がどのスレッドから来てもファイルアクセスはここに集約されます。
        worker =
            scope.launch {
                for (f in queue) {
                    // このループは dispatcher が指す別スレッド上で 1 件ずつ処理されるため、
                    // ファイル書き込みは常に逐次的に行われます。
                    if (!headerWritten) {
                        // 最初のフレームのメタ情報を WAV ヘッダーとして採用します。
                        // 以降は異なる設定のフレームを排除してファイル破損を避けます。
                        wavSampleRate = f.sampleRate
                        wavBitsPerSample = f.bitsPerSample
                        wavChannels = f.channels
                        writeWAVHeaderPlaceholder()
                        headerWritten = true
                    } else {
                        // メタ情報が変わったフレームは安全のためスキップします。
                        if (f.sampleRate != wavSampleRate ||
                            f.bitsPerSample != wavBitsPerSample ||
                            f.channels != wavChannels
                        ) {
                            continue
                        }
                    }

                    val buf: ByteBuffer = f.data
                    while (buf.hasRemaining()) {
                        fc.write(buf)
                    }
                    totalDataBytes += f.byteCount
                }
            }
    }

    override fun onData(
        audioData: ByteBuffer,
        bitsPerSample: Int,
        sampleRate: Int,
        numberOfChannels: Int,
        numberOfFrames: Int,
    ) {
        val size = audioData.remaining()
        // onData はリアルタイムスレッドから呼ばれるため、
        // 実際の書き込みは別スレッドのワーカーにキュー経由で委譲します。
        queue.trySend(
            AudioData(
                data = audioData,
                bitsPerSample = bitsPerSample,
                sampleRate = sampleRate,
                channels = numberOfChannels,
                byteCount = size,
            ),
        )
    }

    override fun getPreferredNumberOfChannels(): Int = preferredChannels

    override fun close() {
        queue.close()
        runBlocking { worker.join() }

        if (!headerWritten) {
            // データが来なかった場合でも最小限の WAV を生成します。
            wavSampleRate = 48000
            wavBitsPerSample = 16
            wavChannels = if (preferredChannels == -1) 1 else maxOf(1, preferredChannels)
            writeWAVHeaderPlaceholder()
        }
        updateWAVSizes(totalDataBytes)

        fc.force(true)
        fc.close()
        raf.close()
        // scope は外部 dispatcher の所有権を仮定してキャンセルのみ行います。
        scope.coroutineContext[Job]?.cancel()
    }

    // WAV ファイルのヘッダーを書き込むためのヘルパー関数です。
    private fun writeWAVHeaderPlaceholder() {
        // WAV ヘッダーを書き込むための変数です。
        val header: ByteBuffer = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN)

        // 1 秒あたりに消費するバイト数と、1 サンプルぶんのバイト数を事前に計算しておきます。
        // 後続のチャンクで「この音声は何チャンネルで、何ビットのサンプルが何 Hz で並んでいます」
        // という情報として利用されます。
        val byteRate = wavSampleRate * wavChannels * (wavBitsPerSample / 8)
        val blockAlign = wavChannels * (wavBitsPerSample / 8)

        // ここから RIFF チャンクです。
        // "RIFF" + ファイル全体サイズ + "WAVE" と書くことで、「RIFF 形式の WAVE ファイルです」と宣言します。
        header.put("RIFF".toByteArray(Charsets.US_ASCII))
        header.putInt(36) // 36 + dataSize に後で更新します。
        header.put("WAVE".toByteArray(Charsets.US_ASCII))

        // 次は fmt チャンクです。
        // "fmt " に続けて、PCM であること、チャンネル数、サンプルレート、1 サンプルあたりのビット数など
        // 再生側が音声を解釈するのに必要な基本情報を 16 バイトで並べます。
        header.put("fmt ".toByteArray(Charsets.US_ASCII))
        header.putInt(16) // PCM サブチャンクのサイズです。
        header.putShort(1) // AudioFormat = 1 (PCM) です。
        header.putShort(wavChannels.toShort())
        header.putInt(wavSampleRate)
        header.putInt(byteRate)
        header.putShort(blockAlign.toShort())
        header.putShort(wavBitsPerSample.toShort())

        // 最後に data チャンクです。
        // 実際の音声データがここに続くので、その識別子 "data" とデータサイズを書きます。
        // 録音が終わるまではサイズが分からないため、いったん 0 を入れておき、close 時に書き換えます。
        header.put("data".toByteArray(Charsets.US_ASCII))
        header.putInt(0) // 後で実サイズに更新します。

        header.flip()
        while (header.hasRemaining()) {
            fc.write(header)
        }
    }

    // 書き込みを終了する前に WAV ファイルのサイズ情報を更新する関数です。
    private fun updateWAVSizes(dataSize: Long) {
        val riffSize = (36 + dataSize).toInt()
        val tmp: ByteBuffer = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN)

        // RIFF チャンクサイズをオフセット 4 に書き込みます。
        tmp.clear()
        tmp.putInt(riffSize)
        tmp.flip()
        fc.position(4)
        while (tmp.hasRemaining()) fc.write(tmp)

        // data チャンクサイズをオフセット 40 に書き込みます。
        tmp.clear()
        tmp.putInt(dataSize.toInt())
        tmp.flip()
        fc.position(40)
        while (tmp.hasRemaining()) fc.write(tmp)

        fc.position(fc.size())
    }
}

/**
 * AudioTrack と AudioTrackSink の関連付けを管理しながら、アプリ固有ストレージへ WAV を保存する例です。
 *
 * @param appContext Activity や Service から渡した applicationContext。
 *                   filesDir 配下に保存するため追加のパーミッションは不要です。
 */
class WAVAudioTrackSinkManager(
    private val appContext: Context,
) {
    // AudioTrack と AudioTrackSink の組み合わせを Session としてまとめるためのデータクラスです。
    private data class Session(
        val track: AudioTrack,
        val sink: WAVAudioTrackSink,
    )

    // Session を MediaStream.id に関連付けるための変数です。
    private val sessions = mutableMapOf<String, Session>()

    val channelListener: SoraMediaChannel.Listener =
        object : SoraMediaChannel.Listener {
            // リモート (受信用) の MediaStream が準備できたときに呼ばれます。
            override fun onAddRemoteStream(
                mediaChannel: SoraMediaChannel,
                ms: MediaStream,
            ) {
                // MediaStream が AudioTrack を持っているかを確認します。
                if (ms.audioTracks.size > 0) {
                    // AudioTrack を取得します。
                    // Sora では MediaStream に関連付けられる AudioTrack は 1 つまでです。
                    val track = ms.audioTracks[0]

                    // filesDir/sora_audio 以下に WAV を保存します。
                    val outputDir = File(appContext.filesDir, "sora_audio").apply { mkdirs() }
                    val sink = WAVAudioTrackSink(File(outputDir, "${ms.id}.wav"))

                    // AudioTrack に AudioTrackSink を関連付けます。
                    track.addSink(sink)

                    // sessions に AudioTrack と AudioTrackSink の関連付け情報を保存します。
                    sessions[ms.id] = Session(track, sink)
                }
            }

            // リモート (受信用) の MediaStream が削除されたときに呼ばれます。
            override fun onRemoveRemoteStream(
                mediaChannel: SoraMediaChannel,
                label: String,
            ) {
                // label は MediaStream.id と同一です。
                sessions.remove(label)?.let { session ->
                    // removeSink を呼び出すと AudioTrack と AudioTrackSink の関連付けが解除されます。
                    session.track.removeSink(session.sink)

                    // WAVAudioTrackSink の終了処理を呼び出します。
                    // これで WAV ファイルに保存が完了します。
                    session.sink.close()
                }
            }
        }
}
© Copyright 2018-2025, Shiguredo Inc. Created using Sphinx 8.2.3