feat: stabilize voice activity and audio routing

This commit is contained in:
Edison Jwa
2026-05-25 01:19:09 +09:00
parent eb9014cd81
commit 5515ff6643
34 changed files with 3054 additions and 1751 deletions
+106 -65
View File
@@ -65,7 +65,7 @@ pub use chanora_diagnostics::{
};
pub use chanora_protocol::{
ChannelInfo, ChatMessage, ClientInfo, ConnectConfig, DisconnectReason, MessageTarget,
ProtocolError, ServerSnapshot,
ProtocolError, ServerActivity, ServerSnapshot,
};
pub use chanora_storage::{Bookmark, BookmarkRepository, IdentityFileStore};
@@ -255,6 +255,11 @@ pub enum SessionEvent {
/// Target scope (server/channel/private/poke).
target: MessageTarget,
},
/// Human-readable TeamSpeak-style server activity.
ServerActivity {
/// Activity line text.
message: String,
},
/// Audio route changed (speaker/earpiece/BT/wired headset).
AudioRouteChanged {
/// New audio route.
@@ -644,17 +649,32 @@ impl ChanoraSession {
if cfg.identity.is_none() {
let store_guard = self.identity_store.lock().await;
if let Some(store) = store_guard.as_ref() {
match store.load()? {
Some(saved) => {
match store.load() {
Ok(Some(saved)) => {
info!(target: "chanora_core", "reusing persisted identity");
cfg.identity = Some(saved);
}
None => {
Ok(None) => {
let fresh = chanora_protocol::ProtocolClient::generate_identity();
store.save(&fresh)?;
info!(target: "chanora_core", "generated + persisted fresh identity");
if let Err(err) = store.save(&fresh) {
warn!(
target: "chanora_core",
error = %err,
"could not persist fresh identity; continuing with in-memory identity"
);
} else {
info!(target: "chanora_core", "generated + persisted fresh identity");
}
cfg.identity = Some(fresh);
}
Err(err) => {
warn!(
target: "chanora_core",
error = %err,
"identity store read failed; continuing with in-memory identity"
);
cfg.identity = Some(chanora_protocol::ProtocolClient::generate_identity());
}
}
}
}
@@ -770,6 +790,25 @@ impl ChanoraSession {
});
}
if let Some(activity_rx) = client.take_activity_rx() {
let ev_tx = self.events_tx.clone();
tokio::spawn(async move {
use tokio::time::{sleep, Duration};
let mut rx = activity_rx;
loop {
match rx.try_recv() {
Ok(ServerActivity { message }) => {
let _ = ev_tx.send(SessionEvent::ServerActivity { message });
}
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
sleep(Duration::from_millis(200)).await;
}
}
}
});
}
*guard = Some(ConnectedState {
protocol: client,
audio: None,
@@ -904,11 +943,11 @@ impl ChanoraSession {
{
let dev_in = self.preferred_input_device.lock().await;
let dev_out = self.preferred_output_device.lock().await;
if cfg.input_device_name.is_none() {
cfg.input_device_name = dev_in.clone();
if cfg.input_device_id.is_none() {
cfg.input_device_id = dev_in.clone();
}
if cfg.output_device_name.is_none() {
cfg.output_device_name = dev_out.clone();
if cfg.output_device_id.is_none() {
cfg.output_device_id = dev_out.clone();
}
}
@@ -1292,8 +1331,8 @@ impl ChanoraSession {
Ok(())
}
/// Route-change hook (SDD-100/SRS-112). Updates audio processing
/// config and notifies Flutter of the new route.
/// Route-change hook. Updates audio processing config, bounces the
/// platform backend when needed, and notifies Flutter of the new route.
pub async fn ios_handle_route_change(&self, route: AudioRoute) -> Result<(), CoreError> {
let guard = self.inner.lock().await;
if let Some(state) = guard.as_ref() {
@@ -1301,6 +1340,14 @@ impl ChanoraSession {
let mut config = audio.audio_processing_config_snapshot();
config.route = route;
audio.set_audio_processing_config(config)?;
#[cfg(any(target_os = "ios", target_os = "macos"))]
{
audio.ios_restart_voice_unit()?;
}
#[cfg(target_os = "android")]
{
audio.android_restart_voice_unit()?;
}
}
}
let _ = self
@@ -1420,6 +1467,12 @@ impl ChanoraSession {
channel_id: u64,
password: Option<String>,
) -> Result<(), CoreError> {
let join_started = std::time::Instant::now();
info!(
target: "chanora_core",
channel_id,
"voice_join requested"
);
let mut guard = self.inner.lock().await;
let state = guard.as_mut().ok_or(CoreError::NotConnected)?;
let join_start = std::time::Instant::now();
@@ -1461,19 +1514,6 @@ impl ChanoraSession {
.ok_or(CoreError::Invariant(
"missing pending generation after join request",
))?;
let pending_key = state
.join_state
.pending
.filter(|pending| pending.generation == generation)
.map(|pending| channel_join::JoinOutcomeKey {
connection_epoch: pending.connection_epoch,
generation: pending.generation,
request_id: pending.request_id,
})
.ok_or(CoreError::Invariant(
"missing pending key after join request",
))?;
// 1. Send the move command. With send_with_result the
// adapter now correlates against the server's typed
// error reply, so on rejection (no permission, wrong
@@ -1486,7 +1526,7 @@ impl ChanoraSession {
.or_else(|| state.channel_passwords.get(&channel_id).cloned());
if let Err(e) = state
.protocol
.move_to_channel(channel_id, password_to_send)
.queue_move_to_channel(channel_id, password_to_send)
.await
{
// TS3 error 0x0302 = `channel_already_in`: we're already
@@ -1520,9 +1560,11 @@ impl ChanoraSession {
return Err(CoreError::Protocol(e));
}
}
let _ = channel_join::reduce(
&mut state.join_state,
ChannelJoinEvent::ProtocolJoinSucceeded { key: pending_key },
info!(
target: "chanora_core",
channel_id,
elapsed_ms = join_started.elapsed().as_millis() as u64,
"voice_join move_to_channel acknowledged"
);
if let Some(pw) = requested_password {
state.channel_passwords.insert(channel_id, pw);
@@ -1540,37 +1582,26 @@ impl ChanoraSession {
self.voice_selector.set_in_channel(true);
self.emit_voice_state(projection).await;
drop(guard);
// 2. Bring the audio engine up. Tolerate failure: the
// server-side channel move has ALREADY succeeded (step
// 1), so the user is in the channel from every other
// peer's perspective. Failing voice_join hard here would
// leave the UI in a phantom "you're in the channel
// visually but no controls" state because the calling
// Dart code wouldn't receive the VoiceState(true) event
// that gates the AppBar mute icons + the on-screen PTT
// button. Honest behaviour: surface the audio error
// once on the event channel (via the AudioStopped event
// consumers already handle), then continue so the UI
// matches reality — user is in the channel, but mic /
// speakers may be silent until they resolve the audio
// error (e.g. grant mic permission, plug in a working
// device).
if let Err(audio_err) = self.ensure_audio_running().await {
warn!(
target: "chanora_core",
error = %audio_err,
channel_id,
"voice_join: server move succeeded but audio engine \
failed to start; continuing with no-audio in-channel \
state so the UI matches the server-side state"
);
// Tell subscribers the audio engine is not running so
// any audio-stats poll / level meter renders correctly.
// The voice_join itself still resolves Ok below so the
// UI gains the channel + mute + PTT controls.
let _ = self.events_tx.send(SessionEvent::AudioStopped);
}
info!(target: "chanora_core", channel_id, "voice_join accepted by server");
let session = self.clone();
tokio::spawn(async move {
if let Err(audio_err) = session.ensure_audio_running().await {
warn!(
target: "chanora_core",
error = %audio_err,
channel_id,
"voice_join: deferred audio engine start failed after \
server-side join; continuing with no-audio in-channel state"
);
let _ = session.events_tx.send(SessionEvent::AudioStopped);
}
});
info!(
target: "chanora_core",
channel_id,
elapsed_ms = join_started.elapsed().as_millis() as u64,
"voice_join returned to caller"
);
Ok(())
}
@@ -1661,19 +1692,29 @@ impl ChanoraSession {
self.voice_selector.hard_mute()
}
/// Set the preferred input device name (SRS-026). Takes effect
/// Set the preferred input device id (SRS-026). Takes effect
/// on the next audio engine start.
pub async fn set_input_device(&self, name: Option<String>) -> Result<(), CoreError> {
*self.preferred_input_device.lock().await = name;
pub async fn set_input_device(&self, id: Option<String>) -> Result<(), CoreError> {
*self.preferred_input_device.lock().await = id;
Ok(())
}
/// Set the preferred output device name (SRS-026).
pub async fn set_output_device(&self, name: Option<String>) -> Result<(), CoreError> {
*self.preferred_output_device.lock().await = name;
/// Set the preferred output device id (SRS-026).
pub async fn set_output_device(&self, id: Option<String>) -> Result<(), CoreError> {
*self.preferred_output_device.lock().await = id;
Ok(())
}
/// Read the preferred input device id, if one is pinned.
pub async fn preferred_input_device(&self) -> Option<String> {
self.preferred_input_device.lock().await.clone()
}
/// Read the preferred output device id, if one is pinned.
pub async fn preferred_output_device(&self) -> Option<String> {
self.preferred_output_device.lock().await.clone()
}
/// Update the release-tail (SDD-096). Clamped to `0..=500` ms
/// inclusive. Persists best-effort and re-emits voice state.
pub async fn set_release_tail_ms(&self, ms: u32) -> Result<(), CoreError> {