수신과 녹음¶

vc.receive()는 리시버를 엽니다. 리시버는 봇이 듣는 오디오를 순회하는, 크기가 제한된 비동기 이터레이터입니다. 화자마다 따로 도착하며, Rust에서 복호화하고 순서를 바로잡은 뒤 디코딩합니다. 한 연결에서 여러 리시버를 열 수 있고, 각 리시버는 자신만의 포맷과 필터를 가집니다.
import logging
import bostie
from bostie import Context, voice
log = logging.getLogger("my-bot")
bot = bostie.Bot()
@bot.command
async def meter(ctx: Context, seconds: int = 10) -> None:
"""Log how loud each speaker is for a few seconds."""
vc = ctx.voice.current or await ctx.voice.connect()
await ctx.respond("Listening.")
loudest: dict[int, float] = {}
async with vc.receive(voice.PCM(rate=16_000, channels=1)) as frames:
async for frame in frames:
if frame.user_id is not None and frame.level is not None:
level = max(loudest.get(frame.user_id, frame.level), frame.level)
loudest[frame.user_id] = level
if frame.offset >= seconds * 48_000:
break
for user_id, level in loudest.items():
log.info("user %d peaked at %.1f dBFS", user_id, level)
@bot.command
async def transcribe(ctx: Context) -> None:
"""Collect what each person says, one utterance at a time."""
vc = ctx.voice.current or await ctx.voice.connect()
await ctx.respond("Transcribing until the bot leaves.")
rx = vc.receive(voice.PCM(rate=16_000, channels=1), markers=True)
async for said in voice.utterances(rx, min_silence=0.6, max_length=20.0):
log.info(
"user %d spoke %.1f s (%d bytes of 16 kHz mono)",
said.user_id,
said.duration,
len(said.data),
)
포맷¶
vc.receive(format)은 voice.ReceiveFormat에 맞는 voice.Receiver를 반환합니다.
vc.receive(format) |
항목 | 용도 |
|---|---|---|
voice.PCM(rate=48000, channels=2, sample="s16le") |
그 포맷으로 디코딩된 샘플을 담은 Frame. 샘플레이트는 voice.DECODE_RATES 중 하나로, 8, 12, 16, 24, 48 kHz |
듣기, 음성 인식, 음량 측정 |
voice.Opus() |
data가 Opus 패킷 하나인 Frame |
디코딩 없이 녹음하거나 전달하기 |
voice.Packets() |
지터 버퍼를 거치기 전, 복호화되어 도착한 순서대로인 Packet |
직접 만든 지터 버퍼, RTP 헤더 확장 |
Frame에는 user_id, ssrc, RTP timestamp, 리시버의 48 kHz 클록 기준인 offset과 samples, received_at, data, lost(손실된 패킷을 감추려고 Opus가 만들어 낸 오디오면 참)가 있고, PCM이면 프레임의 RMS를 dBFS로 나타낸 level도 있습니다. 클록은 리시버가 열릴 때 시작하므로, 서로 다른 사용자의 프레임을 offset으로 맞춰 정렬할 수 있습니다. Packet에는 user_id, ssrc, sequence, timestamp, received_at, opus 페이로드, RTP 헤더 extensions가 있습니다.
들을 사용자 고르기¶
users=[...]는 지정한 사용자만 남기고,exclude=[...]는 일부를 뺍니다.include_self=True는 봇 자신의 오디오를 추가합니다.mix=True(PCM과 Opus)는 모두를 믹싱한 스트림 하나를 전달하며,user_id는None입니다.markers=True는 프레임 사이에SpeechStart,SpeechStop,UserLeave이벤트를 넣습니다. 각 이벤트에는 같은 클록 기준의offset이 있으므로, 소비자는 시간을 직접 재지 않고도 말이 어디서 시작하고 끝나는지 알 수 있습니다.
설정¶
vc.receive는 다음 키워드 인수를 받습니다. Packets()에는 지터 버퍼가 없으므로 jitter를 받지 않습니다.
| 설정 | 기본값 | 의미 |
|---|---|---|
buffer |
Defaults.receive_buffer, 2.0 |
느린 소비자를 위해 리시버가 담아 두는 항목의 길이(초), 0.02~60 |
overflow |
Defaults.overflow, "drop_oldest" |
버퍼가 가득 찼을 때 가장 오래된 항목을 버릴지, 아니면 "drop_newest"로 가장 새로운 항목을 버릴지 |
jitter |
Defaults.jitter, 0.1 |
늦게 오는 패킷을 기다렸다가 계속 진행하기까지의 시간(초), 0~1. PCM과 Opus만 해당 |
stall |
Defaults.receive_stall, "wait" |
연결이 재연결될 때: "wait"는 리시버를 열어 두고, "end"는 끝냅니다 |
읽기¶
async for item in rx는 항목을 기다리며, 리시버가 끝나면 멈춥니다. async with rx:는 블록이 끝날 때 리시버를 닫습니다.
rx.get_nowait()는 다음 항목을 반환합니다. 대기 중인 항목이 없으면voice.Empty를, 리시버가 끝났으면voice.ReceiverClosed를 발생시킵니다.rx.queued는 가장 오래된 대기 항목이 기다린 시간, 즉 소비자의 지연입니다.rx.clear()는 대기 중인 항목을 모두 버리고 실시간으로 이어 갑니다.await rx.mark()는 호출 전에 받은 오디오가 대기열에 들어가거나 버려지면True를 반환하고, 1.2초에 지터의 두 배를 더한 시간이 지나면False를 반환합니다. 기다림 하나를 취소해도 다른 기다림의 마크는 남습니다.rx.stats()는ReceiverStats를 반환합니다. 전달된 항목 수delivered, 넘쳐서 버려진 수dropped, 도중에 손실된 패킷 수lost가 담깁니다.rx.close()는 수신을 멈춥니다.voice.ClosedReason인rx.closed_reason은 리시버가 끝난 이유를 알려 줍니다:CLOSED,DISCONNECTED,MOVED(봇이 채널을 옮김),RECONNECTING(stall="end"일 때).
관리자가 봇을 헤드셋 음소거할 수 있으며, 그러면 Discord는 봇에 오디오를 보내지 않습니다. bostie는 봇이 헤드셋 음소거된 상태에서 리시버가 열릴 때와, 리시버가 열려 있는 동안 봇이 헤드셋 음소거될 때 경고를 로그에 남깁니다.
중계하기¶
리시버와 싱크를 직접 연결해 한 통화의 오디오를 다른 통화로 옮길 수 있습니다.
from bostie import voice
async def relay_call(
source: voice.VoiceConnection, target: voice.VoiceConnection
) -> None:
async with source.receive(voice.PCM(), mix=True) as rx, target.sink() as out:
async for frame in rx:
await out.write(frame.data)
async def relay_speaker(
source: voice.VoiceConnection, target: voice.VoiceConnection, user_id: int
) -> None:
async with (
source.receive(voice.Opus(), users=[user_id]) as rx,
target.sink(voice.Opus()) as out,
):
async for frame in rx:
if not frame.lost:
await out.write(frame.data)
믹싱된 프레임에는 user_id, ssrc, timestamp가 없으며, 믹스에는 마커를 실을 수 없습니다. 손실된 Opus 프레임은 넘겨줄 패킷이 없으므로 data가 비어 있습니다. relay_speaker처럼 Opus 싱크에 쓰기 전에 건너뛰세요. 양쪽 모두 기본값이 48 kHz 스테레오 16비트 PCM이므로, PCM 리시버는 기본 싱크에 바로 쓸 수 있습니다.
말하기 감지¶
speech_activity가 켜져 있는 동안 모든 연결에서 Rust가 말하기를 감지합니다. 사용자는 첫 오디오와 함께 말하기 시작하고, speech_hangover초(기본값 0.2) 동안 무음이 이어지면 말을 멈춘 것으로 봅니다. vc.speaking에는 지금 말하는 중인 사용자가 담기고, SpeechStart와 SpeechStop 이벤트가 리스너에 이를 알리며, markers=True로 연 리시버는 스트림 안에 이를 싣습니다. speech_activity가 꺼져 있고 열린 리시버도 없으면 bostie는 들어오는 오디오를 복호화조차 하지 않습니다.
voice.utterances(rx, min_silence=0.8, max_length=30.0)는 PCM 리시버의 프레임을 Utterance로 묶습니다. Utterance는 한 사용자가 말을 시작해서 끝낼 때까지의 발화로, 리시버 포맷의 data, offset, duration, 손실된 길이(초)인 lost를 담습니다. 발화는 min_silence초 동안 말이 없거나 max_length초에 이르면 끝납니다. mix 없이 markers=True로 연 PCM 리시버가 필요합니다. 또는 dBFS 단위의 level을 넘기면, 마커 대신 그 값으로 무엇을 말로 볼지 정합니다.
녹음¶
import bostie
from bostie import Context, voice
bot = bostie.Bot()
@bot.command
async def record_call(ctx: Context, minutes: float = 5.0) -> None:
"""Record the whole call as one WAV file, for at most a few minutes."""
vc = ctx.voice.current or await ctx.voice.connect()
await ctx.respond("Recording. Everyone in the call can see the bot.")
recorder = voice.Recorder(
vc,
"recordings/{guild_id}/call.wav",
mix=True,
max_duration=minutes * 60,
max_size=200_000_000,
)
files = await recorder.wait()
await ctx.followup(f"Saved {files[None]}; {recorder.stats().lost} packets lost.")
voice.Recorder(vc, path)는 사용자마다 각자의 파일에 녹음하고, mix=True이면 통화 전체를 파일 하나에 녹음합니다. 경로는 {guild_id}와 {user_id}를 쓰는 템플릿입니다. 사용자별 파일에는 필수이고 mix=True에서는 거부되며, 확장자가 컨테이너를 정합니다.
.ogg또는.opus는 Ogg Opus로 씁니다. 사용자별 녹음에서는 받은 Opus 패킷을 디코딩 없이 그대로 씁니다..wav는 레코더의format에 맞춰 16비트 PCM으로 쓰며, 기본값은 48 kHz 스테레오입니다. 샘플레이트는voice.DECODE_RATES중 하나입니다.
기본값인 align=True이면 빈 구간을 무음으로 채우므로, 모든 파일이 녹음 시작 시점에 시작해 서로 정렬됩니다. users, exclude, include_self는 리시버에서처럼 녹음할 사용자를 고릅니다. 디스크가 잠시 멈출 수 있으므로 buffer(30초)와 jitter(0.3초)는 리시버의 기본값보다 큽니다. 녹음은 max_duration초가 지나거나, 파일 크기가 max_size바이트에 이르거나, 연결이 끝나면 스스로 끝납니다.
await recorder.stop()은 이미 들은 오디오를 다 쓴 뒤 녹음을 끝내고, 사용자 ID를 Path에 매핑한 dict로 파일을 반환합니다. 믹싱된 파일의 키는 None입니다. await recorder.wait()는 녹음이 스스로 끝나기를 기다립니다. recorder.files는 지금까지 쓴 파일을 나열하고, recorder.stats()는 리시버의 카운터를 반환합니다. 여기서 dropped는 디스크가 buffer보다 오래 뒤처졌다는 뜻입니다.
위 코드처럼, 봇이 녹음하고 있다는 사실을 통화 참가자에게 알리세요.