feat: stabilize voice activity and audio routing
This commit is contained in:
+106
-65
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user