コンテンツにスキップ

ボイス接続

ヘッドホンで音を聴く Bostie

bostie のボイスエンジンは Rust コアの中で動作します。ボイス WebSocket、UDP トランスポート、20 ms の送信クロック、デコードとリサンプリング、ミキサー、Opus のエンコードとデコード、トランスポート暗号化、DAVE によるエンドツーエンド暗号化はすべてそこで処理されます。Python はエンジンに参加、再生、受信を指示するだけです。Python が各シンクのバッファーを満たしておく必要がある点を除けば、タイミングがイベントループに左右されることはありません。

ボイスには Intents.GUILD_VOICE_STATES が必要で、これは Intents.DEFAULT に含まれています。Bot が持てるボイス接続は、サーバーごとに 1 つまでです。

import logging

import bostie
from bostie import Context, PartialChannel, User, voice
from bostie.voice import DaveTransition, Moved, StateChange

log = logging.getLogger("my-bot")
bot = bostie.Bot(voice=voice.Defaults(timeout=15.0))


@bot.command
async def join(ctx: Context) -> None:
    """Join your voice channel."""
    vc = ctx.voice.current or await ctx.voice.connect()
    dave = vc.dave
    await ctx.respond(
        f"In <#{vc.channel_id}> with {len(vc.members)} others. "
        f"End-to-end encryption: protocol {dave.protocol_version}, "
        f"privacy code {dave.privacy_code}."
    )


@bot.command
async def verify(ctx: Context, user: User) -> None:
    """Show the code to compare with another user in the call."""
    if (vc := ctx.voice.current) is None:
        await ctx.respond("I am not in a voice channel.", ephemeral=True)
        return
    code = await vc.dave.verification_code(user.id)
    await ctx.respond(f"Compare this with {user.mention}: {code}", ephemeral=True)


@bot.command
async def move(ctx: Context, channel: PartialChannel) -> None:
    """Move the bot to another voice channel."""
    if (vc := ctx.voice.current) is not None:
        await vc.move(channel.id)
        await vc.edit(self_mute=False)
    await ctx.respond("Moved.", ephemeral=True)


@bot.command
async def leave(ctx: Context) -> None:
    """Leave the voice channel."""
    if (vc := ctx.voice.current) is not None:
        await vc.disconnect()
    await ctx.respond("Left.", ephemeral=True)


@bot.listen
async def voice_state(event: StateChange) -> None:
    log.info(
        "guild %d: %s -> %s (%s)", event.guild_id, event.old, event.new, event.detail
    )


@bot.listen
async def moved(event: Moved) -> None:
    who = "we" if event.by_us else "a moderator"
    log.info("%s moved the bot to %d", who, event.new_channel_id)


@bot.listen
async def dave(event: DaveTransition) -> None:
    log.info("DAVE protocol %d, epoch %s", event.protocol_version, event.epoch)

接続

await ctx.voice.connect() は、コマンドを実行したユーザーがいるボイスチャンネルまたはステージチャンネルに参加し、音声を双方向に流せるようになった時点で戻ります。ユーザーのチャンネルはキャッシュから探し、サーバーがキャッシュされていなければ Discord に問い合わせます。ユーザーがそのサーバーのどのボイスチャンネルにもいない場合や、インタラクションが DM から来た場合は NotInVoice を送出します。ctx.voice.current は、そのサーバーにおける Bot の接続、または None です。

ハンドラーの外では、await bot.voice.connect(channel_id) で ID を指定してチャンネルに参加します。Bot がすでに接続しているサーバーでは、接続を移動し、指定したオプションだけを適用します。bot.voice.get(guild_id) と bot.voice.connections は開いている接続を返し、await bot.voice.close() は bot.close() と同じように、すべての接続から退出します。

timeout 秒以内に準備が整わない参加は、理由 TIMEOUT の VoiceConnectError を送出します。満員のチャンネルや、Bot に接続権限のないチャンネルへの参加には、Discord は応答を返しません。その他の失敗では、理由、クローズコード、Discord のメッセージを持つ VoiceConnectError が送出されます。

設定

Bot(voice=voice.Defaults(...)) はすべての接続の初期設定を決め、connect() は 1 回の呼び出しに対する接続設定を受け取ります。

voice.Defaults のフィールド 既定値 意味
self_mute、self_deaf False Bot 自身のミュートとスピーカーミュート
timeout 10.0 参加、移動、編集を待つ秒数
server_change_delay 0.0 Discord に従って新しいボイスサーバーへ移る前に待つ秒数
encoder EncoderSettings() Opus エンコーダー。音声の再生を参照
speaking_flags MICROPHONE Bot が再生するときに通知する voice.SpeakingFlags
stage "listen" ステージ上での役割。"listen"、発言をリクエストする "request"、または "speak"
speech_hangover 0.2 話者が話し終えたとみなすまでの無音の秒数。0.02〜10
speech_activity True 発話の開始と終了のイベントを発生させるかどうか
stall_after 2.0 メディアの流れが止まってから MediaStalled が報告するまでの秒数。0.02 以上
sink_buffer、stall 0.2, "hold" すべてのシンクの既定値
receive_buffer、jitter、overflow、receive_stall 2.0, 0.1, "drop_oldest"、"wait" すべてのレシーバーの既定値
crossfade、history 0.0, 0 すべてのキューの既定値

connect() は self_mute、self_deaf、timeout、server_change_delay、encoder、speaking_flags、stage、speech_hangover、speech_activity、stall_after をキーワード引数として受け取ります。省略した引数や None を渡した引数は既定値のままです。connect_external() は最初の 3 つを、move() は timeout を、edit() は self_mute、self_deaf、timeout を受け取ります。不正な値は、Defaults を作成した時点で ValueError を送出します。文字列の設定にはリテラル型 voice.StageRole、voice.SinkStall、voice.ReceiveStall、voice.Overflow、voice.Sample、voice.OpusApplication、voice.OpusBandwidth、voice.OpusSignal があります。ctx.voice は voice.ContextVoice、bot.voice は voice.VoiceManager です。

接続オブジェクト

VoiceConnection 値
guild_id、channel_id 接続している場所
state voice.State。CONNECTING、READY、RESUMING、RECONNECTING、DISCONNECTED のいずれか
members 通話にいる他のユーザーの ID
speaking 現在話しているユーザーと、話し始めた時点の time.monotonic() の対応
ssrcs 通話内の各 SSRC とそのユーザー ID の対応
self_mute、self_deaf Discord が適用した、Bot 自身のミュートとスピーカーミュート
mute、deaf、suppress モデレーターが Bot をミュートまたはスピーカーミュートしたかどうか、あるいはこのステージで Bot が発言できないかどうか
latency WebSocket と UDP の往復時間(秒)を持つ Latency。計測されるまでは None
dave 通話のエンドツーエンド暗号化。後述
sinks 開いているシンク。古い順
encoder、speaking_flags、speech_hangover、speech_activity、stall_after 接続中に変更可能
  • await vc.move(channel_id) はサーバー内の別のボイスチャンネルまたはステージチャンネルに移動し、移動先でメディアが流れ始めた時点で戻ります。
  • await vc.edit(self_mute=..., self_deaf=...) は Bot 自身のミュートまたはスピーカーミュートを変更し(指定しなかったほうはそのまま)、Discord が適用した時点で戻ります。
  • await vc.stage("request") はステージで発言をリクエストし、"speak" は(権限があれば)発言し、"listen" はオーディエンスに戻ります。
  • await vc.wait_until(voice.State.READY) は特定の状態になるまで待ち、先に接続が終了した場合は VoiceNotConnected を送出します。
  • vc.reconnect() はボイス WebSocket を切断し、新しい WebSocket で再開します。
  • vc.stop() はすべてのシンクを閉じるので、何も再生されなくなります。
  • await vc.disconnect() はチャンネルから退出し、接続が終了した時点(最大 2 秒後)で戻ります。

終了した接続に対する呼び出しは VoiceNotConnected を送出します。

接続は自己修復します。切断された WebSocket は再開し、失われたセッションは再接続します。モデレーターが Bot を移動した場合は新しいチャンネルに追従し、Moved イベントを送ります。モデレーターが Bot を切断したりチャンネルを削除したりすると、接続は理由 REMOVED で終了します。サーバーミュートされている間やステージで発言を抑制されている間に Bot が再生したとき、またスピーカーミュート中にレシーバーが待機しているとき、bostie は警告を出します。

エンドツーエンド暗号化

bostie は、Discord のボイス用エンドツーエンド暗号化である DAVE に、設定なしで対応しています。通話の暗号化グループに参加し、送信する音声を暗号化し、受信した音声を復号します。vc.dave はその状態を voice.Dave として報告します。

  • protocol_version は使用中の DAVE プロトコルで、使っていなければ 0 です。
  • epoch はグループのメンバーが変わった回数を数えます。
  • privacy_code は、Discord クライアントがその通話について表示するコードです。
  • await vc.dave.verification_code(user_id) は、他のユーザーと通話の外で照合するためのコードを返します。これにより、間に第三者がいないことを確認できます。

DaveTransition イベントは変更のたびに報告されます。DAVE を必須とするチャンネルに、DAVE に対応していないクライアントから接続すると、理由 DAVE_REQUIRED で切断されます。DAVE の処理に失敗すると DaveError が送出されるか、接続が DAVE_FAILED で終了します。

イベント

ボイス接続のイベントは @bot.listen を通じて届きます。どれも guild_id を持つ voice.VoiceEvent です。DISCONNECTED への StateChange は voice.DisconnectReason を持ちます。値は REQUESTED(disconnect() が実行された)、REMOVED(モデレーターが Bot を切断した、またはチャンネルを削除した)、CALL_TERMINATED、RATE_LIMITED、TIMEOUT、SESSION_INVALID、AUTHENTICATION_FAILED、DAVE_REQUIRED、DAVE_FAILED、PROTOCOL_ERROR、CLOSED(Bot が終了した)のいずれかです。

イベント フィールド 送られるタイミング
StateChange old、new、reason、code、detail 接続の状態が変わったとき
Moved old_channel_id、new_channel_id、by_us move() またはモデレーターによって Bot が移動したとき
DaveTransition protocol_version、epoch、transition_id エンドツーエンド暗号化が変わったとき
MediaStalled、MediaResumed direction(voice.Direction.SEND または RECEIVE), duration 送信または受信が止まったとき、または再開したとき
UserJoin、UserLeave user_id ユーザーが通話に参加または退出したとき
SpeakingUpdate user_id、ssrc、flags ユーザーが自分の SSRC と発話フラグを通知したとき
SpeechStart、SpeechStop user_id、ssrc、at、終了時は duration も ユーザーが話し始めたとき、または話し終えたとき
RtcpPacket packet_type、count、ssrc、user_id、reports、data ボイスサーバーが RTCP パケットを送ったとき。reports 内の各 ReceptionReport は、1 つのストリームの fraction_lost、lost、highest_sequence、jitter、last_sender_report、delay を持ちます

これらは bostie.voice にあります。SpeechStart と SpeechStop は、speech_activity がオンの間だけ発生します。

メディアの停滞

import logging

import bostie
from bostie import voice

log = logging.getLogger("my-bot")
bot = bostie.Bot(voice=voice.Defaults(stall_after=1.0))


@bot.listen
async def media_flow(event: voice.MediaStalled | voice.MediaResumed) -> None:
    change = "stalled" if isinstance(event, voice.MediaStalled) else "resumed"
    log.warning(
        "guild %d: %s %s after %.1f s",
        event.guild_id,
        event.direction,
        change,
        event.duration,
    )

direction が SEND の MediaStalled は、再接続中や DAVE の切り替え中などに、シンクで待機している音声が stall_after 秒のあいだ送り出せないときに発生します。RECEIVE の場合は、開いているレシーバーにパケットが届いているのに、その間どれも開くことも再生することもできない状態です。メディアが再び流れると MediaResumed が続き、その duration は停滞が始まった時点から数えます。stall_after は、voice.Defaults、connect(stall_after=...)、vc.stall_after で別の値を指定しない限り 2 秒です。

空のチャンネルからの退出

Bot は切断するまでチャンネルに残ります。最後のユーザーが退出したときに一緒に退出するには、次のようにします。

import bostie
from bostie import voice

bot = bostie.Bot()


@bot.listen
async def leave_when_alone(event: voice.UserLeave) -> None:
    vc = bot.voice.get(event.guild_id)
    if vc is not None and not vc.members:
        await vc.disconnect()

エラー

ボイスのエラーは VoiceError です。VoiceConnectError(準備が整う前に参加が終了した。reason、code、detail を持つ)、NotInVoice、VoiceNotConnected、AlreadyConnected、VoiceQueueFull(接続を待っているコマンドが多すぎる)、DaveError、そして音声関連の SourceError、SinkClosed、BufferFull、Empty、ReceiverClosed があります。音声関連のエラーは次のページ以降で説明します。

外部のボイスエンジン

import logging

import bostie
from bostie.events import VoiceServerUpdate

log = logging.getLogger("my-bot")
bot = bostie.Bot()


async def hand_to_engine(guild_id: int, channel_id: int) -> None:
    external = await bot.voice.connect_external(guild_id, channel_id)
    log.info(
        "session %s on %s",
        external.state.voice_state.session_id,
        external.server.endpoint,
    )
    async with external.updates() as updates:
        async for update in updates:
            if isinstance(update, VoiceServerUpdate):
                log.info("new voice server %s", update.endpoint)
            elif update.voice_state.channel_id is None:
                break
    await external.disconnect()

await bot.voice.connect_external(guild_id, channel_id) は、別の場所で動くボイスエンジンのためにチャンネルに参加し、エンジンが接続に使う state(VoiceStateUpdate)と server(VoiceServerUpdate)を持つ ExternalVoice を返します。以降、bostie はそのサーバーで音声を送信しません。async with external.updates() as updates: はそのサーバーの以降のボイスサーバーと Bot 自身のボイス状態を届け、await external.disconnect() はチャンネルから退出します。bostie が独自の接続を動かしているサーバーでは AlreadyConnected を送出します。引数として self_mute、self_deaf、timeout を受け取ります。

バージョン

voice.versions() は、bostie、libopus、DAVE ライブラリのバージョンと、ビルドに組み込まれた最も新しい DAVE プロトコルのバージョンを返します。