feat(audio): channel sample_rate caps Opus bandwidth (narrowband/wideband)
Some checks failed
Build Linux Binaries / linux/amd64 (push) Has been cancelled
Build Linux Binaries / linux/arm64 (push) Has been cancelled

The per-channel sample_rate field was inert after pinning the codec to
48 kHz. Make it meaningful without changing the 48 kHz clock: carry it as
OpusParams::max_bandwidth_hz and apply OPUS_SET_MAX_BANDWIDTH in
OpusEncoder::init (8000->narrowband, 16000->wideband, 24000->super-wideband,
48000->full). A low-bitrate room can now shed out-of-band content while
every endpoint keeps a single 48 kHz clock.

Make sample_rate channel-authoritative on the server: conn_session no
longer overrides effective sample_rate with the client's always-48000
request (it now behaves like frame_ms/mode). vc_get_stream_audio_config
reports the channel's configured rate for own streams too.

New ctest channel_samplerate: a 7 kHz tone is attenuated ~1000x on an
8 kHz (narrowband) channel vs a 48 kHz (full-band) channel, proving the cap
is in effect. ctest --preset dev 26/26.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 16:56:49 +02:00
parent a460009a2f
commit e155e342f4
9 changed files with 379 additions and 15 deletions

View File

@@ -4,6 +4,19 @@ namespace voicecat::codec {
#ifdef VOICECAT_HAS_OPUS
// Map an intended channel/capture sample rate (Hz) to the Opus max-bandwidth constant. The
// codec always runs at 48 kHz internally (docs/voice.md §3); this caps the bandwidth the
// encoder will select so a channel can request narrowband/wideband audio for low-bitrate rooms.
// 0 (unset) and >= 48000 map to FULLBAND, which is the encoder default (i.e. a no-op cap).
static opus_int32 opus_max_bandwidth_for(uint32_t rate_hz) {
if (rate_hz == 0) return OPUS_BANDWIDTH_FULLBAND;
if (rate_hz <= 8000) return OPUS_BANDWIDTH_NARROWBAND; // ~4 kHz audio
if (rate_hz <= 12000) return OPUS_BANDWIDTH_MEDIUMBAND; // ~6 kHz
if (rate_hz <= 16000) return OPUS_BANDWIDTH_WIDEBAND; // ~8 kHz
if (rate_hz <= 24000) return OPUS_BANDWIDTH_SUPERWIDEBAND; // ~12 kHz
return OPUS_BANDWIDTH_FULLBAND; // ~20 kHz
}
// ── OpusEncoder ──────────────────────────────────────────────────────────────
bool OpusEncoder::init(const OpusParams& p) {
@@ -27,6 +40,7 @@ bool OpusEncoder::init(const OpusParams& p) {
}
opus_encoder_ctl(enc_, OPUS_SET_BITRATE(static_cast<opus_int32>(p.bitrate_bps)));
opus_encoder_ctl(enc_, OPUS_SET_MAX_BANDWIDTH(opus_max_bandwidth_for(p.max_bandwidth_hz)));
opus_encoder_ctl(enc_, OPUS_SET_COMPLEXITY(static_cast<opus_int32>(p.complexity)));
opus_encoder_ctl(enc_, OPUS_SET_INBAND_FEC(p.fec ? 1 : 0));
opus_encoder_ctl(enc_, OPUS_SET_DTX(p.dtx ? 1 : 0));

View File

@@ -26,6 +26,11 @@ enum class OpusApplication {
struct OpusParams {
uint32_t sample_rate = 48000;
// Intended channel/capture sample rate (Hz), used ONLY to cap the encoder's audio bandwidth
// (narrowband/wideband/…) via OPUS_SET_MAX_BANDWIDTH. The codec always runs at 48 kHz
// internally (docs/voice.md §3); this lets a low-bitrate channel constrain encoded bandwidth
// without changing the PCM clock. 0 = unset → full band. Decoder ignores it.
uint32_t max_bandwidth_hz = 0;
uint32_t bitrate_bps = 24000;
uint32_t frame_ms = 20;
bool stereo = false;

View File

@@ -946,10 +946,12 @@ voicecat::codec::OpusParams opus_params_from_audio_config(const voicecat::v1::Au
voicecat::codec::OpusParams p;
// Opus always runs at 48 kHz internally (docs/voice.md §3): the whole AudioEngine clock is
// 48 kHz and external PCM is fed at 48 kHz, so the codec must match regardless of what a
// channel advertises. Honoring a non-48k effective sample_rate here would create an encoder
// expecting e.g. 16k PCM while being fed 48k frames — wrong pitch/duration. The wire
// sample_rate field mainly tags narrowband intent; Opus handles that via bitrate at 48k.
// channel advertises. Honoring a non-48k effective sample_rate as the codec rate would
// create an encoder expecting e.g. 16k PCM while being fed 48k frames — wrong pitch/duration.
// Instead the channel's sample_rate is carried as max_bandwidth_hz and caps the encoder's
// selected audio bandwidth (narrowband/wideband/…) — a low-bitrate room still benefits.
p.sample_rate = 48000;
p.max_bandwidth_hz = a.sample_rate(); // 0 = unset → full band
p.bitrate_bps = a.bitrate_bps() ? a.bitrate_bps() : 24000;
p.frame_ms = a.frame_ms() ? a.frame_ms() : 20;
p.stereo = (a.mode() == voicecat::v1::MODE_STEREO);
@@ -1524,7 +1526,9 @@ vc_result vc_client::get_stream_audio_config(uint32_t user_id, uint32_t stream_i
const auto& p = ls->effective_params;
out->codec = 0;
out->mode = p.stereo ? 1u : 0u;
out->sample_rate = p.sample_rate;
// Report the channel's configured sample_rate (carried as max_bandwidth_hz), not the
// fixed 48 kHz codec clock — matches what the remote-stream path below reports.
out->sample_rate = p.max_bandwidth_hz ? p.max_bandwidth_hz : p.sample_rate;
out->bitrate_bps = p.bitrate_bps;
out->frame_ms = p.frame_ms;
out->application = static_cast<uint32_t>(p.application);