Files
voice-cat/server/src/session_registry.cpp

548 lines
19 KiB
C++
Raw Normal View History

2026-06-15 23:48:44 +02:00
#include "session_registry.h"
#ifdef VOICECAT_HAS_NET
#include <atomic>
fix(net): broadcast LEFT on disconnect, add keepalive/reaper, cap PLC hiss Three reported bugs traced to one root cause plus two missing designed features: 1. Stale users + eternal PLC hiss (root cause): ConnSession::close() silently erased dropped users without broadcasting UserEvent::LEFT, so peers never learned the user left and their audio engines never called remove_stream — Opus PLC synthesized comfort noise forever. Fix: broadcast_left() helper + close() broadcasts LEFT before erasing. 2. PLC cap (defense-in-depth): on_playback now caps pure PLC at ~2s, then emits digital silence so a stale stream can never hiss forever even if remove_stream is skipped. Resets automatically on fresh packets. 3. No timeout / no ping: client never sent Ping, server had no last_seen / reaper, so half-open connections (NAT timeout, wifi loss, sleep) left ghost users forever. Fix: client Ping every 15s with RTT measurement, ConnSession::last_seen bumped on every inbound TCP/UDP frame, steady_timer reaper sweeps every 15s and drops sessions older than 45s (configurable via server::Config). 4. UDP KEEPALIVE: client sends plaintext kFrameKeepalive every 5s; server bumps last_seen + echoes back. Keeps NAT bindings alive and lets media activity defer the reaper independently of TCP. 5. Graceful client disconnect: vc_disconnect() sends Disconnect{code=0} via a flag-based io-thread exit (no double-close race); server handles client-sent Disconnect with immediate close() + LEFT broadcast. 3 new tests: disconnect_left, plc_cap, reaper_timeout. 21/21 ctest green. Docs: protocol.md §6/§7, voice.md §6, architecture.md §5, PROGRESS.md.
2026-06-18 01:18:33 +02:00
#include <chrono>
2026-06-15 23:48:44 +02:00
#include <mutex>
#include <shared_mutex>
#include "conn_session.h"
namespace voicecat::server {
SessionRegistry::SessionRegistry(std::shared_ptr<Database> db) : db_(std::move(db)) {}
void SessionRegistry::load_channels() {
std::unique_lock lk(mu_);
channels_.clear();
auto records = db_->list_channels();
if (records.empty()) {
// First run: seed the default channel tree.
seed_default_channels();
records = db_->list_channels();
}
uint32_t max_id = 2;
for (auto& rec : records) {
ChannelEntry entry;
entry.proto.set_id(rec.id);
entry.proto.set_parent_id(rec.parent_id);
entry.proto.set_name(rec.name);
entry.proto.set_topic(rec.topic);
entry.proto.set_password_protected(rec.password_protected);
entry.proto.set_max_users(rec.max_users);
entry.proto.set_type(rec.type);
*entry.proto.mutable_audio() = rec.audio;
entry.proto.set_order(rec.sort_order);
max_id = std::max(max_id, rec.id);
channels_[rec.id] = std::move(entry);
}
next_channel_id_ = max_id + 1;
}
void SessionRegistry::seed_default_channels() {
// Lobby: speech profile.
ChannelRecord lobby;
lobby.id = 1;
lobby.name = "Lobby";
lobby.type = voicecat::v1::CHANNEL_PERMANENT;
lobby.sort_order = 0;
lobby.max_users = 20;
feat(M3): multi-stream & per-channel tuning Implements docs/roadmap.md M3: multiple concurrent streams per user (MIC + SCREEN_AUDIO + AUX_DEVICE), independent per-stream receiver gain/mute/noise- reduction, talk indicators, and enforced per-channel Opus configurability (mono/stereo, bitrate, frame size, FEC/DTX, application). Bugs fixed along the way (found while implementing, not pre-existing scope): - Server hard-coded stream_id=1 for every announce, so a second stream from the same user silently overwrote the first in SessionRegistry::set_user_stream. Now a per-session counter (ConnSession::next_stream_id_); handle_stream_stop validates against announced_stream_ids_ before clearing. - Client dropped mode/dtx/complexity/application from effective_audio even for the single M2 stream -- only sample_rate/bitrate_bps/frame_ms/fec were ever applied to OpusParams. Fixed on both the send (handle_stream_announce_result) and receive (sync_remote_streams) paths via a shared opus_params_from_audio_config() helper. - OpusEncoder always used OPUS_APPLICATION_VOIP; added OpusParams::application and wired it through. - on_playback's per-stream decode passed the wrong frame_size to opus_decode (total samples instead of samples-per-channel), which would have overflowed the decode buffer for any stereo stream. - teardown_voice() raced when called concurrently from run_io()'s own cleanup and from disconnect() on a different thread -- both could see udp_thread_/talk_timer_thread_ as joinable() at once and race to join() the same std::thread (intermittent std::system_error under ctest). Fixed with a teardown_mu_ guard instead of carrying the flake forward. New: - Per-channel AudioConfig: SessionRegistry now seeds Lobby (mono/24kbps/VOIP/ FEC+DTX) and a new "Music Room" channel (stereo/128kbps/AUDIO/no DTX); handle_stream_announce enforces the channel's config, clamping (not overriding) bitrate_bps to its ceiling. - core/src/core/client.h/.cpp: local-stream state is now a std::unordered_map<int, LocalStream> keyed by vc_stream_kind, with request_id-correlated announce/result handling (request_id already round-tripped on the wire; just wasn't read before). on_capture_frame is kind-aware and upmixes mono capture to stereo when a stream's config calls for it. set_self_mute's mic_muted now only gates the MIC kind. NS is wired through set_remote_stream. New run_talk_timer() thread emits VC_EVENT_TALK_STATE from both remote and local edge detection. - core/src/audio/audio_engine.h/.cpp: kind-keyed injection taps (inject_capture), stereo-to-mono downmix at the decode/mix boundary, RemoteStream gains recv_ns (lazy ApmProcessor) + noise_reduction_enabled and last_voice_ms/talking; new set_stream_noise_reduction() and poll_talk_transitions(). - core/src/session/session.h/.cpp: Stream now carries the full AudioConfig, not just sample_rate/frame_ms. - New additive C ABI (core/include/voicecat.h): vc_audio_config + vc_get_stream_audio_config (effective Opus config for any stream you own or a peer's); vc_test_inject_capture (test-only synthetic PCM injection, clearly marked, mirrors AudioEngine::inject_capture). - tests/test_m3_multistream.cpp: the M3 exit criterion through the real ABI (mirrors test_voice_client_abi.cpp's approach, not raw sockets) -- two concurrent local streams, independent gain/mute/NS control, per-channel config divergence via vc_get_stream_audio_config, talk indicators. Explicitly out of scope for this pass (tracked in PROGRESS.md, not silently dropped): VAD/PTT input gate + device enumeration; real WASAPI loopback capture for SCREEN_AUDIO (synthetic injection only); true stereo playback output (AudioEngine's mixer/output device stays mono -- Opus itself is fully stereo-correct on the wire). ctest --test-dir build/m1-dev: 11/11 green, verified across 3 consecutive full-suite runs plus 8 standalone runs of the new test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:12:37 +02:00
{
auto& a = lobby.audio;
a.set_codec(0);
a.set_mode(voicecat::v1::MODE_MONO);
a.set_sample_rate(48000);
a.set_bitrate_bps(24000);
a.set_frame_ms(20);
a.set_application(voicecat::v1::OPUS_VOIP);
a.set_fec(true);
a.set_expected_packet_loss(10);
a.set_dtx(true);
a.set_complexity(5);
feat(M3): multi-stream & per-channel tuning Implements docs/roadmap.md M3: multiple concurrent streams per user (MIC + SCREEN_AUDIO + AUX_DEVICE), independent per-stream receiver gain/mute/noise- reduction, talk indicators, and enforced per-channel Opus configurability (mono/stereo, bitrate, frame size, FEC/DTX, application). Bugs fixed along the way (found while implementing, not pre-existing scope): - Server hard-coded stream_id=1 for every announce, so a second stream from the same user silently overwrote the first in SessionRegistry::set_user_stream. Now a per-session counter (ConnSession::next_stream_id_); handle_stream_stop validates against announced_stream_ids_ before clearing. - Client dropped mode/dtx/complexity/application from effective_audio even for the single M2 stream -- only sample_rate/bitrate_bps/frame_ms/fec were ever applied to OpusParams. Fixed on both the send (handle_stream_announce_result) and receive (sync_remote_streams) paths via a shared opus_params_from_audio_config() helper. - OpusEncoder always used OPUS_APPLICATION_VOIP; added OpusParams::application and wired it through. - on_playback's per-stream decode passed the wrong frame_size to opus_decode (total samples instead of samples-per-channel), which would have overflowed the decode buffer for any stereo stream. - teardown_voice() raced when called concurrently from run_io()'s own cleanup and from disconnect() on a different thread -- both could see udp_thread_/talk_timer_thread_ as joinable() at once and race to join() the same std::thread (intermittent std::system_error under ctest). Fixed with a teardown_mu_ guard instead of carrying the flake forward. New: - Per-channel AudioConfig: SessionRegistry now seeds Lobby (mono/24kbps/VOIP/ FEC+DTX) and a new "Music Room" channel (stereo/128kbps/AUDIO/no DTX); handle_stream_announce enforces the channel's config, clamping (not overriding) bitrate_bps to its ceiling. - core/src/core/client.h/.cpp: local-stream state is now a std::unordered_map<int, LocalStream> keyed by vc_stream_kind, with request_id-correlated announce/result handling (request_id already round-tripped on the wire; just wasn't read before). on_capture_frame is kind-aware and upmixes mono capture to stereo when a stream's config calls for it. set_self_mute's mic_muted now only gates the MIC kind. NS is wired through set_remote_stream. New run_talk_timer() thread emits VC_EVENT_TALK_STATE from both remote and local edge detection. - core/src/audio/audio_engine.h/.cpp: kind-keyed injection taps (inject_capture), stereo-to-mono downmix at the decode/mix boundary, RemoteStream gains recv_ns (lazy ApmProcessor) + noise_reduction_enabled and last_voice_ms/talking; new set_stream_noise_reduction() and poll_talk_transitions(). - core/src/session/session.h/.cpp: Stream now carries the full AudioConfig, not just sample_rate/frame_ms. - New additive C ABI (core/include/voicecat.h): vc_audio_config + vc_get_stream_audio_config (effective Opus config for any stream you own or a peer's); vc_test_inject_capture (test-only synthetic PCM injection, clearly marked, mirrors AudioEngine::inject_capture). - tests/test_m3_multistream.cpp: the M3 exit criterion through the real ABI (mirrors test_voice_client_abi.cpp's approach, not raw sockets) -- two concurrent local streams, independent gain/mute/NS control, per-channel config divergence via vc_get_stream_audio_config, talk indicators. Explicitly out of scope for this pass (tracked in PROGRESS.md, not silently dropped): VAD/PTT input gate + device enumeration; real WASAPI loopback capture for SCREEN_AUDIO (synthetic injection only); true stereo playback output (AudioEngine's mixer/output device stays mono -- Opus itself is fully stereo-correct on the wire). ctest --test-dir build/m1-dev: 11/11 green, verified across 3 consecutive full-suite runs plus 8 standalone runs of the new test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:12:37 +02:00
}
// Music Room: stereo/music profile.
ChannelRecord music;
music.id = 2;
music.name = "Music Room";
music.type = voicecat::v1::CHANNEL_PERMANENT;
music.sort_order = 1;
feat(M3): multi-stream & per-channel tuning Implements docs/roadmap.md M3: multiple concurrent streams per user (MIC + SCREEN_AUDIO + AUX_DEVICE), independent per-stream receiver gain/mute/noise- reduction, talk indicators, and enforced per-channel Opus configurability (mono/stereo, bitrate, frame size, FEC/DTX, application). Bugs fixed along the way (found while implementing, not pre-existing scope): - Server hard-coded stream_id=1 for every announce, so a second stream from the same user silently overwrote the first in SessionRegistry::set_user_stream. Now a per-session counter (ConnSession::next_stream_id_); handle_stream_stop validates against announced_stream_ids_ before clearing. - Client dropped mode/dtx/complexity/application from effective_audio even for the single M2 stream -- only sample_rate/bitrate_bps/frame_ms/fec were ever applied to OpusParams. Fixed on both the send (handle_stream_announce_result) and receive (sync_remote_streams) paths via a shared opus_params_from_audio_config() helper. - OpusEncoder always used OPUS_APPLICATION_VOIP; added OpusParams::application and wired it through. - on_playback's per-stream decode passed the wrong frame_size to opus_decode (total samples instead of samples-per-channel), which would have overflowed the decode buffer for any stereo stream. - teardown_voice() raced when called concurrently from run_io()'s own cleanup and from disconnect() on a different thread -- both could see udp_thread_/talk_timer_thread_ as joinable() at once and race to join() the same std::thread (intermittent std::system_error under ctest). Fixed with a teardown_mu_ guard instead of carrying the flake forward. New: - Per-channel AudioConfig: SessionRegistry now seeds Lobby (mono/24kbps/VOIP/ FEC+DTX) and a new "Music Room" channel (stereo/128kbps/AUDIO/no DTX); handle_stream_announce enforces the channel's config, clamping (not overriding) bitrate_bps to its ceiling. - core/src/core/client.h/.cpp: local-stream state is now a std::unordered_map<int, LocalStream> keyed by vc_stream_kind, with request_id-correlated announce/result handling (request_id already round-tripped on the wire; just wasn't read before). on_capture_frame is kind-aware and upmixes mono capture to stereo when a stream's config calls for it. set_self_mute's mic_muted now only gates the MIC kind. NS is wired through set_remote_stream. New run_talk_timer() thread emits VC_EVENT_TALK_STATE from both remote and local edge detection. - core/src/audio/audio_engine.h/.cpp: kind-keyed injection taps (inject_capture), stereo-to-mono downmix at the decode/mix boundary, RemoteStream gains recv_ns (lazy ApmProcessor) + noise_reduction_enabled and last_voice_ms/talking; new set_stream_noise_reduction() and poll_talk_transitions(). - core/src/session/session.h/.cpp: Stream now carries the full AudioConfig, not just sample_rate/frame_ms. - New additive C ABI (core/include/voicecat.h): vc_audio_config + vc_get_stream_audio_config (effective Opus config for any stream you own or a peer's); vc_test_inject_capture (test-only synthetic PCM injection, clearly marked, mirrors AudioEngine::inject_capture). - tests/test_m3_multistream.cpp: the M3 exit criterion through the real ABI (mirrors test_voice_client_abi.cpp's approach, not raw sockets) -- two concurrent local streams, independent gain/mute/NS control, per-channel config divergence via vc_get_stream_audio_config, talk indicators. Explicitly out of scope for this pass (tracked in PROGRESS.md, not silently dropped): VAD/PTT input gate + device enumeration; real WASAPI loopback capture for SCREEN_AUDIO (synthetic injection only); true stereo playback output (AudioEngine's mixer/output device stays mono -- Opus itself is fully stereo-correct on the wire). ctest --test-dir build/m1-dev: 11/11 green, verified across 3 consecutive full-suite runs plus 8 standalone runs of the new test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:12:37 +02:00
{
auto& a = music.audio;
a.set_codec(0);
a.set_mode(voicecat::v1::MODE_STEREO);
a.set_sample_rate(48000);
a.set_bitrate_bps(128000);
a.set_frame_ms(20);
a.set_application(voicecat::v1::OPUS_AUDIO);
a.set_fec(false);
a.set_expected_packet_loss(0);
a.set_dtx(false);
a.set_complexity(8);
feat(M3): multi-stream & per-channel tuning Implements docs/roadmap.md M3: multiple concurrent streams per user (MIC + SCREEN_AUDIO + AUX_DEVICE), independent per-stream receiver gain/mute/noise- reduction, talk indicators, and enforced per-channel Opus configurability (mono/stereo, bitrate, frame size, FEC/DTX, application). Bugs fixed along the way (found while implementing, not pre-existing scope): - Server hard-coded stream_id=1 for every announce, so a second stream from the same user silently overwrote the first in SessionRegistry::set_user_stream. Now a per-session counter (ConnSession::next_stream_id_); handle_stream_stop validates against announced_stream_ids_ before clearing. - Client dropped mode/dtx/complexity/application from effective_audio even for the single M2 stream -- only sample_rate/bitrate_bps/frame_ms/fec were ever applied to OpusParams. Fixed on both the send (handle_stream_announce_result) and receive (sync_remote_streams) paths via a shared opus_params_from_audio_config() helper. - OpusEncoder always used OPUS_APPLICATION_VOIP; added OpusParams::application and wired it through. - on_playback's per-stream decode passed the wrong frame_size to opus_decode (total samples instead of samples-per-channel), which would have overflowed the decode buffer for any stereo stream. - teardown_voice() raced when called concurrently from run_io()'s own cleanup and from disconnect() on a different thread -- both could see udp_thread_/talk_timer_thread_ as joinable() at once and race to join() the same std::thread (intermittent std::system_error under ctest). Fixed with a teardown_mu_ guard instead of carrying the flake forward. New: - Per-channel AudioConfig: SessionRegistry now seeds Lobby (mono/24kbps/VOIP/ FEC+DTX) and a new "Music Room" channel (stereo/128kbps/AUDIO/no DTX); handle_stream_announce enforces the channel's config, clamping (not overriding) bitrate_bps to its ceiling. - core/src/core/client.h/.cpp: local-stream state is now a std::unordered_map<int, LocalStream> keyed by vc_stream_kind, with request_id-correlated announce/result handling (request_id already round-tripped on the wire; just wasn't read before). on_capture_frame is kind-aware and upmixes mono capture to stereo when a stream's config calls for it. set_self_mute's mic_muted now only gates the MIC kind. NS is wired through set_remote_stream. New run_talk_timer() thread emits VC_EVENT_TALK_STATE from both remote and local edge detection. - core/src/audio/audio_engine.h/.cpp: kind-keyed injection taps (inject_capture), stereo-to-mono downmix at the decode/mix boundary, RemoteStream gains recv_ns (lazy ApmProcessor) + noise_reduction_enabled and last_voice_ms/talking; new set_stream_noise_reduction() and poll_talk_transitions(). - core/src/session/session.h/.cpp: Stream now carries the full AudioConfig, not just sample_rate/frame_ms. - New additive C ABI (core/include/voicecat.h): vc_audio_config + vc_get_stream_audio_config (effective Opus config for any stream you own or a peer's); vc_test_inject_capture (test-only synthetic PCM injection, clearly marked, mirrors AudioEngine::inject_capture). - tests/test_m3_multistream.cpp: the M3 exit criterion through the real ABI (mirrors test_voice_client_abi.cpp's approach, not raw sockets) -- two concurrent local streams, independent gain/mute/NS control, per-channel config divergence via vc_get_stream_audio_config, talk indicators. Explicitly out of scope for this pass (tracked in PROGRESS.md, not silently dropped): VAD/PTT input gate + device enumeration; real WASAPI loopback capture for SCREEN_AUDIO (synthetic injection only); true stereo playback output (AudioEngine's mixer/output device stays mono -- Opus itself is fully stereo-correct on the wire). ctest --test-dir build/m1-dev: 11/11 green, verified across 3 consecutive full-suite runs plus 8 standalone runs of the new test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:12:37 +02:00
}
std::string err;
db_->create_channel(lobby, "", err);
db_->create_channel(music, "", err);
2026-06-15 23:48:44 +02:00
}
uint64_t SessionRegistry::register_session(std::weak_ptr<ConnSession> session) {
std::unique_lock lk(mu_);
uint64_t id = next_session_id_++;
sessions_[id] = std::move(session);
return id;
}
void SessionRegistry::unregister_session(uint64_t session_id) {
std::unique_lock lk(mu_);
sessions_.erase(session_id);
session_permissions_.erase(session_id);
// Free the per-session UDP/media state too. These maps are keyed by
// endpoint/token/ssrc (not session id), so scan-and-erase by value. Leaving them
// behind leaks entries and lets a stale endpoint/token resolve toward a dead
// session across reconnects (e.g. a wifi-handoff rebind from a new port).
auto erase_by_value = [session_id](auto& map) {
for (auto it = map.begin(); it != map.end();) {
if (it->second == session_id)
it = map.erase(it);
else
++it;
}
};
erase_by_value(udp_endpoints_);
erase_by_value(udp_tokens_);
erase_by_value(ssrc_to_session_);
2026-06-15 23:48:44 +02:00
}
uint32_t SessionRegistry::add_user(uint64_t session_id, const voicecat::v1::User& user) {
std::unique_lock lk(mu_);
uint32_t uid = next_user_id_++;
UserEntry entry;
entry.proto = user;
entry.proto.set_id(uid);
entry.proto.set_channel_id(1); // start in Lobby
entry.session_id = session_id;
users_[uid] = std::move(entry);
return uid;
}
void SessionRegistry::remove_user(uint32_t user_id) {
std::unique_lock lk(mu_);
users_.erase(user_id);
}
bool SessionRegistry::set_user_channel(uint32_t user_id, uint32_t channel_id) {
std::unique_lock lk(mu_);
auto ch_it = channels_.find(channel_id);
if (ch_it == channels_.end()) return false;
auto user_it = users_.find(user_id);
if (user_it == users_.end()) return false;
user_it->second.proto.set_channel_id(channel_id);
return true;
}
std::vector<voicecat::v1::Channel> SessionRegistry::channel_snapshot() const {
std::shared_lock lk(mu_);
std::vector<voicecat::v1::Channel> result;
result.reserve(channels_.size());
for (auto& [id, entry] : channels_) result.push_back(entry.proto);
return result;
}
std::vector<voicecat::v1::User> SessionRegistry::user_snapshot() const {
std::shared_lock lk(mu_);
std::vector<voicecat::v1::User> result;
result.reserve(users_.size());
for (auto& [id, entry] : users_) result.push_back(entry.proto);
return result;
}
std::optional<voicecat::v1::User> SessionRegistry::user_snapshot_user(uint32_t user_id) const {
std::shared_lock lk(mu_);
auto it = users_.find(user_id);
if (it == users_.end()) return std::nullopt;
return it->second.proto;
}
std::optional<std::string> SessionRegistry::user_nickname(uint32_t user_id) const {
std::shared_lock lk(mu_);
auto it = users_.find(user_id);
if (it == users_.end()) return std::nullopt;
return it->second.proto.nickname();
}
2026-06-15 23:48:44 +02:00
std::vector<std::shared_ptr<ConnSession>> SessionRegistry::resolve_text_targets(
uint64_t sender_session_id, voicecat::v1::TextScope scope, uint32_t target_id) const {
std::shared_lock lk(mu_);
std::vector<std::shared_ptr<ConnSession>> targets;
fix(protocol): deliver self-initiated state changes to the actor too A connected Windows client would randomly snap from its joined channel back to Lobby. Root cause was a state-sync inconsistency, not a drop: the server delivered self-initiated state changes (channel join/leave, stream announce/stop) only as a private *Result to the actor and broadcast the authoritative UserEvent::UPDATED to everyone else. The core never applied the result to its SessionModel, so vc_list_users() kept self in the old channel; the Windows HandleUserUpdated rebuilds _currentChannelId from vc_list_users() on any user's UPDATED event, so the next unrelated event surfaced the stale self-channel. Fix, per the response-vs-broadcast contract now documented in docs/protocol.md §6: the *Result is pure ack/correlation/actor-private payload; the resulting state change is broadcast to every client INCLUDING the actor, and clients apply it to their local model rather than re-deriving own state from a *Result. - server: join/leave/stream announce+stop broadcast with exclude=0 - server: text fan-out includes the sender (channel + private echo) - core: response handlers no longer mutate session_model_ - windows: drop optimistic text echo; render own message via the relay - docs/protocol.md §6: document the response-vs-broadcast contract Registry-level admin broadcasts (move/mute/kick/channel CRUD) already used exclude=0 and were correct. ctest build/m1-dev 18/18 green; VoiceCat.App builds 0 warnings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 20:48:50 +02:00
auto add_session = [&](uint64_t sid) {
auto sit = sessions_.find(sid);
if (sit == sessions_.end()) return;
if (auto sess = sit->second.lock()) targets.push_back(sess);
};
2026-06-15 23:48:44 +02:00
if (scope == voicecat::v1::TEXT_CHANNEL) {
fix(protocol): deliver self-initiated state changes to the actor too A connected Windows client would randomly snap from its joined channel back to Lobby. Root cause was a state-sync inconsistency, not a drop: the server delivered self-initiated state changes (channel join/leave, stream announce/stop) only as a private *Result to the actor and broadcast the authoritative UserEvent::UPDATED to everyone else. The core never applied the result to its SessionModel, so vc_list_users() kept self in the old channel; the Windows HandleUserUpdated rebuilds _currentChannelId from vc_list_users() on any user's UPDATED event, so the next unrelated event surfaced the stale self-channel. Fix, per the response-vs-broadcast contract now documented in docs/protocol.md §6: the *Result is pure ack/correlation/actor-private payload; the resulting state change is broadcast to every client INCLUDING the actor, and clients apply it to their local model rather than re-deriving own state from a *Result. - server: join/leave/stream announce+stop broadcast with exclude=0 - server: text fan-out includes the sender (channel + private echo) - core: response handlers no longer mutate session_model_ - windows: drop optimistic text echo; render own message via the relay - docs/protocol.md §6: document the response-vs-broadcast contract Registry-level admin broadcasts (move/mute/kick/channel CRUD) already used exclude=0 and were correct. ctest build/m1-dev 18/18 green; VoiceCat.App builds 0 warnings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 20:48:50 +02:00
// Fan out to everyone in the target channel, INCLUDING the sender, so the sender's
// own client renders the message through the same authoritative relay everyone else
// gets (no optimistic local echo). See docs/protocol.md §6.
2026-06-15 23:48:44 +02:00
for (auto& [uid, entry] : users_) {
if (entry.proto.channel_id() != target_id) continue;
fix(protocol): deliver self-initiated state changes to the actor too A connected Windows client would randomly snap from its joined channel back to Lobby. Root cause was a state-sync inconsistency, not a drop: the server delivered self-initiated state changes (channel join/leave, stream announce/stop) only as a private *Result to the actor and broadcast the authoritative UserEvent::UPDATED to everyone else. The core never applied the result to its SessionModel, so vc_list_users() kept self in the old channel; the Windows HandleUserUpdated rebuilds _currentChannelId from vc_list_users() on any user's UPDATED event, so the next unrelated event surfaced the stale self-channel. Fix, per the response-vs-broadcast contract now documented in docs/protocol.md §6: the *Result is pure ack/correlation/actor-private payload; the resulting state change is broadcast to every client INCLUDING the actor, and clients apply it to their local model rather than re-deriving own state from a *Result. - server: join/leave/stream announce+stop broadcast with exclude=0 - server: text fan-out includes the sender (channel + private echo) - core: response handlers no longer mutate session_model_ - windows: drop optimistic text echo; render own message via the relay - docs/protocol.md §6: document the response-vs-broadcast contract Registry-level admin broadcasts (move/mute/kick/channel CRUD) already used exclude=0 and were correct. ctest build/m1-dev 18/18 green; VoiceCat.App builds 0 warnings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 20:48:50 +02:00
add_session(entry.session_id);
2026-06-15 23:48:44 +02:00
}
} else if (scope == voicecat::v1::TEXT_PRIVATE) {
fix(protocol): deliver self-initiated state changes to the actor too A connected Windows client would randomly snap from its joined channel back to Lobby. Root cause was a state-sync inconsistency, not a drop: the server delivered self-initiated state changes (channel join/leave, stream announce/stop) only as a private *Result to the actor and broadcast the authoritative UserEvent::UPDATED to everyone else. The core never applied the result to its SessionModel, so vc_list_users() kept self in the old channel; the Windows HandleUserUpdated rebuilds _currentChannelId from vc_list_users() on any user's UPDATED event, so the next unrelated event surfaced the stale self-channel. Fix, per the response-vs-broadcast contract now documented in docs/protocol.md §6: the *Result is pure ack/correlation/actor-private payload; the resulting state change is broadcast to every client INCLUDING the actor, and clients apply it to their local model rather than re-deriving own state from a *Result. - server: join/leave/stream announce+stop broadcast with exclude=0 - server: text fan-out includes the sender (channel + private echo) - core: response handlers no longer mutate session_model_ - windows: drop optimistic text echo; render own message via the relay - docs/protocol.md §6: document the response-vs-broadcast contract Registry-level admin broadcasts (move/mute/kick/channel CRUD) already used exclude=0 and were correct. ctest build/m1-dev 18/18 green; VoiceCat.App builds 0 warnings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 20:48:50 +02:00
// target_id is the recipient user_id: deliver to the recipient and echo to the
// sender (skip the echo if they messaged themselves, to avoid a duplicate).
2026-06-15 23:48:44 +02:00
auto user_it = users_.find(target_id);
fix(protocol): deliver self-initiated state changes to the actor too A connected Windows client would randomly snap from its joined channel back to Lobby. Root cause was a state-sync inconsistency, not a drop: the server delivered self-initiated state changes (channel join/leave, stream announce/stop) only as a private *Result to the actor and broadcast the authoritative UserEvent::UPDATED to everyone else. The core never applied the result to its SessionModel, so vc_list_users() kept self in the old channel; the Windows HandleUserUpdated rebuilds _currentChannelId from vc_list_users() on any user's UPDATED event, so the next unrelated event surfaced the stale self-channel. Fix, per the response-vs-broadcast contract now documented in docs/protocol.md §6: the *Result is pure ack/correlation/actor-private payload; the resulting state change is broadcast to every client INCLUDING the actor, and clients apply it to their local model rather than re-deriving own state from a *Result. - server: join/leave/stream announce+stop broadcast with exclude=0 - server: text fan-out includes the sender (channel + private echo) - core: response handlers no longer mutate session_model_ - windows: drop optimistic text echo; render own message via the relay - docs/protocol.md §6: document the response-vs-broadcast contract Registry-level admin broadcasts (move/mute/kick/channel CRUD) already used exclude=0 and were correct. ctest build/m1-dev 18/18 green; VoiceCat.App builds 0 warnings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 20:48:50 +02:00
if (user_it != users_.end()) add_session(user_it->second.session_id);
if (user_it == users_.end() || user_it->second.session_id != sender_session_id)
add_session(sender_session_id);
2026-06-15 23:48:44 +02:00
}
return targets;
}
void SessionRegistry::broadcast(const voicecat::v1::Envelope& env,
uint64_t exclude_session_id) const {
std::shared_lock lk(mu_);
broadcast_unlocked(env, exclude_session_id);
}
void SessionRegistry::broadcast_unlocked(const voicecat::v1::Envelope& env,
uint64_t exclude_session_id) const {
2026-06-15 23:48:44 +02:00
for (auto& [sid, weak] : sessions_) {
if (sid == exclude_session_id) continue;
if (auto sess = weak.lock()) sess->send_envelope(env);
}
}
fix(net): broadcast LEFT on disconnect, add keepalive/reaper, cap PLC hiss Three reported bugs traced to one root cause plus two missing designed features: 1. Stale users + eternal PLC hiss (root cause): ConnSession::close() silently erased dropped users without broadcasting UserEvent::LEFT, so peers never learned the user left and their audio engines never called remove_stream — Opus PLC synthesized comfort noise forever. Fix: broadcast_left() helper + close() broadcasts LEFT before erasing. 2. PLC cap (defense-in-depth): on_playback now caps pure PLC at ~2s, then emits digital silence so a stale stream can never hiss forever even if remove_stream is skipped. Resets automatically on fresh packets. 3. No timeout / no ping: client never sent Ping, server had no last_seen / reaper, so half-open connections (NAT timeout, wifi loss, sleep) left ghost users forever. Fix: client Ping every 15s with RTT measurement, ConnSession::last_seen bumped on every inbound TCP/UDP frame, steady_timer reaper sweeps every 15s and drops sessions older than 45s (configurable via server::Config). 4. UDP KEEPALIVE: client sends plaintext kFrameKeepalive every 5s; server bumps last_seen + echoes back. Keeps NAT bindings alive and lets media activity defer the reaper independently of TCP. 5. Graceful client disconnect: vc_disconnect() sends Disconnect{code=0} via a flag-based io-thread exit (no double-close race); server handles client-sent Disconnect with immediate close() + LEFT broadcast. 3 new tests: disconnect_left, plc_cap, reaper_timeout. 21/21 ctest green. Docs: protocol.md §6/§7, voice.md §6, architecture.md §5, PROGRESS.md.
2026-06-18 01:18:33 +02:00
std::vector<std::shared_ptr<ConnSession>> SessionRegistry::find_stale_sessions(
int64_t max_age_ms) const {
std::shared_lock lk(mu_);
auto now_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count();
std::vector<std::shared_ptr<ConnSession>> stale;
for (auto& [sid, weak] : sessions_) {
auto sess = weak.lock();
if (!sess) continue;
int64_t seen = sess->last_seen_ms();
if (seen == 0) continue; // not yet initialized — skip (shouldn't happen after begin())
if (now_ms - seen > max_age_ms) stale.push_back(sess);
}
return stale;
}
// ── Permissions ───────────────────────────────────────────────────────────────
void SessionRegistry::set_session_permissions(uint64_t session_id,
const voicecat::v1::Permissions& perms) {
std::unique_lock lk(mu_);
session_permissions_[session_id] = perms;
}
std::optional<voicecat::v1::Permissions> SessionRegistry::get_session_permissions(
uint64_t session_id) const {
std::shared_lock lk(mu_);
auto it = session_permissions_.find(session_id);
if (it == session_permissions_.end()) return std::nullopt;
return it->second;
}
// ── Moderation ────────────────────────────────────────────────────────────────
std::shared_ptr<ConnSession> SessionRegistry::find_session_by_user_id(uint32_t user_id) const {
std::shared_lock lk(mu_);
auto it = users_.find(user_id);
if (it == users_.end()) return nullptr;
auto sit = sessions_.find(it->second.session_id);
if (sit == sessions_.end()) return nullptr;
return sit->second.lock();
}
namespace {
voicecat::v1::Envelope make_left_event(uint32_t user_id, const std::string& reason) {
voicecat::v1::Envelope env;
auto* ue = env.mutable_user_event();
ue->set_kind(voicecat::v1::UserEvent::LEFT);
ue->mutable_user()->set_id(user_id);
ue->set_left_id(user_id);
ue->set_reason(reason); // M5 additive field
return env;
}
}
bool SessionRegistry::kick_user(uint32_t user_id, const std::string& reason) {
auto target = find_session_by_user_id(user_id);
if (!target) return false;
{
std::shared_lock lk(mu_);
broadcast(make_left_event(user_id, reason), /*exclude*/ 0);
}
// Close outside the registry lock: close() may call back into unregister_session().
target->send_disconnect_and_close(2, reason); // code 2 = kicked
return true;
}
fix(net): broadcast LEFT on disconnect, add keepalive/reaper, cap PLC hiss Three reported bugs traced to one root cause plus two missing designed features: 1. Stale users + eternal PLC hiss (root cause): ConnSession::close() silently erased dropped users without broadcasting UserEvent::LEFT, so peers never learned the user left and their audio engines never called remove_stream — Opus PLC synthesized comfort noise forever. Fix: broadcast_left() helper + close() broadcasts LEFT before erasing. 2. PLC cap (defense-in-depth): on_playback now caps pure PLC at ~2s, then emits digital silence so a stale stream can never hiss forever even if remove_stream is skipped. Resets automatically on fresh packets. 3. No timeout / no ping: client never sent Ping, server had no last_seen / reaper, so half-open connections (NAT timeout, wifi loss, sleep) left ghost users forever. Fix: client Ping every 15s with RTT measurement, ConnSession::last_seen bumped on every inbound TCP/UDP frame, steady_timer reaper sweeps every 15s and drops sessions older than 45s (configurable via server::Config). 4. UDP KEEPALIVE: client sends plaintext kFrameKeepalive every 5s; server bumps last_seen + echoes back. Keeps NAT bindings alive and lets media activity defer the reaper independently of TCP. 5. Graceful client disconnect: vc_disconnect() sends Disconnect{code=0} via a flag-based io-thread exit (no double-close race); server handles client-sent Disconnect with immediate close() + LEFT broadcast. 3 new tests: disconnect_left, plc_cap, reaper_timeout. 21/21 ctest green. Docs: protocol.md §6/§7, voice.md §6, architecture.md §5, PROGRESS.md.
2026-06-18 01:18:33 +02:00
void SessionRegistry::broadcast_left(uint32_t user_id, const std::string& reason) {
std::shared_lock lk(mu_);
broadcast_unlocked(make_left_event(user_id, reason), /*exclude*/ 0);
}
bool SessionRegistry::ban_user(uint32_t user_id, const std::string& reason, int64_t expires_at) {
{
std::unique_lock lk(mu_);
auto it = users_.find(user_id);
if (it != users_.end()) {
std::string err;
db_->ban_create("user_id", std::to_string(user_id), reason, expires_at, err);
}
}
return kick_user(user_id, reason);
}
bool SessionRegistry::set_server_mute(uint32_t user_id, bool muted, bool deafened) {
std::unique_lock lk(mu_);
auto it = users_.find(user_id);
if (it == users_.end()) return false;
it->second.proto.set_server_muted(muted);
it->second.proto.set_server_deafened(deafened);
voicecat::v1::Envelope env;
auto* ue = env.mutable_user_event();
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
*ue->mutable_user() = it->second.proto;
broadcast_unlocked(env, 0);
return true;
}
bool SessionRegistry::move_user(uint32_t user_id, uint32_t channel_id) {
std::unique_lock lk(mu_);
auto ch_it = channels_.find(channel_id);
if (ch_it == channels_.end()) return false;
auto user_it = users_.find(user_id);
if (user_it == users_.end()) return false;
user_it->second.proto.set_channel_id(channel_id);
voicecat::v1::Envelope env;
auto* ue = env.mutable_user_event();
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
*ue->mutable_user() = user_it->second.proto;
broadcast_unlocked(env, 0);
return true;
}
// ── Channel CRUD ──────────────────────────────────────────────────────────────
uint32_t SessionRegistry::create_channel(const voicecat::v1::Channel& ch,
const std::string& password,
std::string& error) {
ChannelRecord rec;
rec.parent_id = ch.parent_id();
rec.name = ch.name();
rec.topic = ch.topic();
rec.max_users = ch.max_users();
rec.type = ch.type();
rec.audio = ch.audio();
rec.sort_order = ch.order();
auto result = db_->create_channel(rec, password, error);
if (!result) return 0;
std::unique_lock lk(mu_);
uint32_t id = result->id;
ChannelEntry entry;
entry.proto = ch;
entry.proto.set_id(id);
entry.proto.set_password_protected(result->password_protected);
voicecat::v1::Envelope env;
auto* ce = env.mutable_channel_event();
ce->set_kind(voicecat::v1::ChannelEvent::CREATED);
*ce->mutable_channel() = entry.proto;
channels_[id] = std::move(entry);
next_channel_id_ = std::max(next_channel_id_, id + 1);
broadcast_unlocked(env, 0);
return id;
}
bool SessionRegistry::update_channel(const voicecat::v1::Channel& ch,
const std::string& password,
std::string& error) {
ChannelRecord rec;
rec.id = ch.id();
rec.parent_id = ch.parent_id();
rec.name = ch.name();
rec.topic = ch.topic();
rec.max_users = ch.max_users();
rec.type = ch.type();
rec.audio = ch.audio();
rec.sort_order = ch.order();
if (!db_->update_channel(rec, password, error)) return false;
std::unique_lock lk(mu_);
auto it = channels_.find(ch.id());
if (it == channels_.end()) {
error = "channel not found";
return false;
}
it->second.proto = ch;
it->second.proto.set_password_protected(
!password.empty() || db_->get_channel(ch.id())->password_protected);
voicecat::v1::Envelope env;
auto* ce = env.mutable_channel_event();
ce->set_kind(voicecat::v1::ChannelEvent::UPDATED);
*ce->mutable_channel() = it->second.proto;
broadcast_unlocked(env, 0);
return true;
}
bool SessionRegistry::delete_channel(uint32_t channel_id, std::string& error) {
if (channel_id == 1) { error = "cannot delete root channel"; return false; }
if (!db_->delete_channel(channel_id, error)) return false;
std::unique_lock lk(mu_);
channels_.erase(channel_id);
// Move any users left in the deleted channel to Lobby.
for (auto& [uid, entry] : users_) {
if (entry.proto.channel_id() != channel_id) continue;
entry.proto.set_channel_id(1);
voicecat::v1::Envelope uev;
auto* ue = uev.mutable_user_event();
ue->set_kind(voicecat::v1::UserEvent::UPDATED);
*ue->mutable_user() = entry.proto;
broadcast_unlocked(uev, 0);
}
voicecat::v1::Envelope env;
auto* ce = env.mutable_channel_event();
ce->set_kind(voicecat::v1::ChannelEvent::DELETED);
ce->set_deleted_id(channel_id);
broadcast_unlocked(env, 0);
return true;
}
std::optional<voicecat::v1::Channel> SessionRegistry::get_channel(uint32_t channel_id) const {
std::shared_lock lk(mu_);
auto it = channels_.find(channel_id);
if (it == channels_.end()) return std::nullopt;
return it->second.proto;
}
bool SessionRegistry::check_channel_password(uint32_t channel_id,
const std::string& password) const {
return db_->check_channel_password(channel_id, password);
}
// ── M2: UDP / media ───────────────────────────────────────────────────────────
void SessionRegistry::register_udp_token(const std::array<uint8_t, 16>& token,
uint64_t session_id) {
std::unique_lock lk(mu_);
udp_tokens_[token] = session_id;
}
std::shared_ptr<ConnSession> SessionRegistry::find_by_udp_token(
const std::array<uint8_t, 16>& token) const {
std::shared_lock lk(mu_);
auto it = udp_tokens_.find(token);
if (it == udp_tokens_.end()) return nullptr;
auto sit = sessions_.find(it->second);
if (sit == sessions_.end()) return nullptr;
return sit->second.lock();
}
void SessionRegistry::register_udp_endpoint(asio::ip::udp::endpoint ep,
uint64_t session_id) {
std::unique_lock lk(mu_);
udp_endpoints_[ep] = session_id;
}
std::shared_ptr<ConnSession> SessionRegistry::find_by_udp_endpoint(
const asio::ip::udp::endpoint& ep) const {
std::shared_lock lk(mu_);
auto it = udp_endpoints_.find(ep);
if (it == udp_endpoints_.end()) return nullptr;
auto sit = sessions_.find(it->second);
if (sit == sessions_.end()) return nullptr;
return sit->second.lock();
}
uint32_t SessionRegistry::assign_ssrc(uint64_t session_id) {
uint32_t ssrc = next_ssrc_.fetch_add(1, std::memory_order_relaxed);
std::unique_lock lk(mu_);
ssrc_to_session_[ssrc] = session_id;
return ssrc;
}
std::vector<std::shared_ptr<ConnSession>> SessionRegistry::find_channel_sessions(
uint32_t channel_id, uint64_t exclude_session_id) const {
std::shared_lock lk(mu_);
std::vector<std::shared_ptr<ConnSession>> result;
for (auto& [uid, entry] : users_) {
if (entry.proto.channel_id() != channel_id) continue;
if (entry.session_id == exclude_session_id) continue;
auto sit = sessions_.find(entry.session_id);
if (sit == sessions_.end()) continue;
if (auto sess = sit->second.lock()) result.push_back(sess);
}
return result;
}
std::optional<voicecat::v1::User> SessionRegistry::set_user_stream(
uint32_t user_id, const voicecat::v1::StreamInfo& info) {
std::unique_lock lk(mu_);
auto it = users_.find(user_id);
if (it == users_.end()) return std::nullopt;
auto* streams = it->second.proto.mutable_streams();
for (int i = 0; i < streams->size(); ++i) {
if (streams->Get(i).stream_id() == info.stream_id()) {
*streams->Mutable(i) = info;
return it->second.proto;
}
}
*streams->Add() = info;
return it->second.proto;
}
std::optional<voicecat::v1::User> SessionRegistry::clear_user_stream(uint32_t user_id,
uint32_t stream_id) {
std::unique_lock lk(mu_);
auto it = users_.find(user_id);
if (it == users_.end()) return std::nullopt;
auto* streams = it->second.proto.mutable_streams();
for (int i = 0; i < streams->size(); ++i) {
if (streams->Get(i).stream_id() == stream_id) {
streams->erase(streams->begin() + i);
break;
}
}
return it->second.proto;
}
uint32_t SessionRegistry::user_channel(uint32_t user_id) const {
std::shared_lock lk(mu_);
auto it = users_.find(user_id);
return (it == users_.end()) ? 0 : it->second.proto.channel_id();
}
feat(M3): multi-stream & per-channel tuning Implements docs/roadmap.md M3: multiple concurrent streams per user (MIC + SCREEN_AUDIO + AUX_DEVICE), independent per-stream receiver gain/mute/noise- reduction, talk indicators, and enforced per-channel Opus configurability (mono/stereo, bitrate, frame size, FEC/DTX, application). Bugs fixed along the way (found while implementing, not pre-existing scope): - Server hard-coded stream_id=1 for every announce, so a second stream from the same user silently overwrote the first in SessionRegistry::set_user_stream. Now a per-session counter (ConnSession::next_stream_id_); handle_stream_stop validates against announced_stream_ids_ before clearing. - Client dropped mode/dtx/complexity/application from effective_audio even for the single M2 stream -- only sample_rate/bitrate_bps/frame_ms/fec were ever applied to OpusParams. Fixed on both the send (handle_stream_announce_result) and receive (sync_remote_streams) paths via a shared opus_params_from_audio_config() helper. - OpusEncoder always used OPUS_APPLICATION_VOIP; added OpusParams::application and wired it through. - on_playback's per-stream decode passed the wrong frame_size to opus_decode (total samples instead of samples-per-channel), which would have overflowed the decode buffer for any stereo stream. - teardown_voice() raced when called concurrently from run_io()'s own cleanup and from disconnect() on a different thread -- both could see udp_thread_/talk_timer_thread_ as joinable() at once and race to join() the same std::thread (intermittent std::system_error under ctest). Fixed with a teardown_mu_ guard instead of carrying the flake forward. New: - Per-channel AudioConfig: SessionRegistry now seeds Lobby (mono/24kbps/VOIP/ FEC+DTX) and a new "Music Room" channel (stereo/128kbps/AUDIO/no DTX); handle_stream_announce enforces the channel's config, clamping (not overriding) bitrate_bps to its ceiling. - core/src/core/client.h/.cpp: local-stream state is now a std::unordered_map<int, LocalStream> keyed by vc_stream_kind, with request_id-correlated announce/result handling (request_id already round-tripped on the wire; just wasn't read before). on_capture_frame is kind-aware and upmixes mono capture to stereo when a stream's config calls for it. set_self_mute's mic_muted now only gates the MIC kind. NS is wired through set_remote_stream. New run_talk_timer() thread emits VC_EVENT_TALK_STATE from both remote and local edge detection. - core/src/audio/audio_engine.h/.cpp: kind-keyed injection taps (inject_capture), stereo-to-mono downmix at the decode/mix boundary, RemoteStream gains recv_ns (lazy ApmProcessor) + noise_reduction_enabled and last_voice_ms/talking; new set_stream_noise_reduction() and poll_talk_transitions(). - core/src/session/session.h/.cpp: Stream now carries the full AudioConfig, not just sample_rate/frame_ms. - New additive C ABI (core/include/voicecat.h): vc_audio_config + vc_get_stream_audio_config (effective Opus config for any stream you own or a peer's); vc_test_inject_capture (test-only synthetic PCM injection, clearly marked, mirrors AudioEngine::inject_capture). - tests/test_m3_multistream.cpp: the M3 exit criterion through the real ABI (mirrors test_voice_client_abi.cpp's approach, not raw sockets) -- two concurrent local streams, independent gain/mute/NS control, per-channel config divergence via vc_get_stream_audio_config, talk indicators. Explicitly out of scope for this pass (tracked in PROGRESS.md, not silently dropped): VAD/PTT input gate + device enumeration; real WASAPI loopback capture for SCREEN_AUDIO (synthetic injection only); true stereo playback output (AudioEngine's mixer/output device stays mono -- Opus itself is fully stereo-correct on the wire). ctest --test-dir build/m1-dev: 11/11 green, verified across 3 consecutive full-suite runs plus 8 standalone runs of the new test. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 14:12:37 +02:00
std::optional<voicecat::v1::AudioConfig> SessionRegistry::channel_audio_config(
uint32_t channel_id) const {
std::shared_lock lk(mu_);
auto it = channels_.find(channel_id);
if (it == channels_.end()) return std::nullopt;
return it->second.proto.audio();
}
2026-06-15 23:48:44 +02:00
} // namespace voicecat::server
#endif // VOICECAT_HAS_NET