Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 73 additions & 20 deletions desktop/src-tauri/src/huddle/playout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ const SPEAKER_LEVEL_TICK_MS: u64 = 50;
/// Per-peer arrival window for the TTS interrupt frame counter.
const FRAME_WINDOW: std::time::Duration = std::time::Duration::from_millis(500);
const REMOTE_RELEASE_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(500);
/// Match Mobile's speaking treatment: an open microphone can emit continuous
/// non-DTX Opus for room tone, so packet type alone is not evidence of speech.
const REMOTE_SPEECH_LEVEL_DBOV: i8 = -55;
/// Playout clock: NetEq emits 10 ms frames, so we tick at 10 ms.
const PLAYOUT_TICK_MS: u64 = 10;

Expand Down Expand Up @@ -86,19 +89,30 @@ fn normalized_speaker_level(level_dbov: i8) -> f32 {
((f32::from(level_dbov) + 60.0) / 48.0).clamp(0.0, 1.0)
}

fn is_remote_speech_frame(is_dtx: bool, level_dbov: i8) -> bool {
!is_dtx && level_dbov >= REMOTE_SPEECH_LEVEL_DBOV
}

fn update_remote_release_deadline(
peer: u8,
is_dtx: bool,
is_speech: bool,
remote_floor_owners: &std::collections::HashSet<u8>,
deadlines: &mut std::collections::HashMap<u8, tokio::time::Instant>,
now: tokio::time::Instant,
) {
if !is_dtx {
deadlines.remove(&peer);
} else if remote_floor_owners.contains(&peer) {
deadlines
.entry(peer)
.or_insert(now + REMOTE_RELEASE_DEBOUNCE);
if remote_floor_owners.contains(&peer) {
if is_speech {
// Refresh from audible speech itself. Some mobile capture paths
// stop producing packets once speech ends, so waiting for a DTX
// or quiet packet can otherwise hold the human floor forever.
deadlines.insert(peer, now + REMOTE_RELEASE_DEBOUNCE);
} else {
// Preserve the deadline from the last audible frame. Continuous
// room-tone packets must not keep extending the human floor.
deadlines
.entry(peer)
.or_insert(now + REMOTE_RELEASE_DEBOUNCE);
}
}
}

Expand Down Expand Up @@ -437,19 +451,20 @@ pub(crate) async fn run_playout_recv_loop(
continue;
}
let is_dtx = (header.flags & FLAG_DTX) != 0;
// Only count non-DTX arrivals toward the UI's
// active-speaker set. DTX/comfort packets are emitted
// by an idle peer to keep the codec alive — they
// don't mean the peer is speaking, and shouldn't
// make their tile flash for the 500 ms speaker tick.
let is_remote_speech =
is_remote_speech_frame(is_dtx, header.level_dbov);
// Only count audible arrivals toward the UI's
// active-speaker set. An open mobile microphone can
// continuously emit non-DTX room tone, so require an
// audible level before treating a packet as speech.
update_remote_release_deadline(
peer_idx,
is_dtx,
is_remote_speech,
&remote_floor_owners,
&mut remote_release_deadlines,
tokio::time::Instant::now(),
);
if !is_dtx {
if is_remote_speech {
active_indices.insert(peer_idx);
let level = normalized_speaker_level(header.level_dbov);
speaker_levels
Expand Down Expand Up @@ -497,7 +512,7 @@ pub(crate) async fn run_playout_recv_loop(
// Count only remote-human speech toward floor onset.
// Agent audio still plays, but it must not acquire the
// human floor or suppress another agent's response.
if !is_dtx && remote_human {
if is_remote_speech && remote_human {
if last_frame_reset.elapsed() >= FRAME_WINDOW {
frame_counts.clear();
last_frame_reset = tokio::time::Instant::now();
Expand All @@ -507,6 +522,14 @@ pub(crate) async fn run_playout_recv_loop(
if *count >= REMOTE_SPEECH_THRESHOLD {
human_floor.enter_remote(peer_idx);
remote_floor_owners.insert(peer_idx);
// The threshold-crossing frame is processed
// before this peer becomes an owner. Arm its
// release here so silence need not arrive in a
// later packet to let queued TTS continue.
remote_release_deadlines.insert(
peer_idx,
tokio::time::Instant::now() + REMOTE_RELEASE_DEBOUNCE,
);
if tts_active.load(Ordering::Acquire) {
tts_cancel.store(true, Ordering::Release);
}
Expand Down Expand Up @@ -647,18 +670,18 @@ mod tests {
use super::*;

#[test]
fn continuous_dtx_does_not_extend_remote_floor_deadline() {
fn continuous_silence_does_not_extend_remote_floor_deadline() {
let peer = 7;
let started = tokio::time::Instant::now();
let owners = std::collections::HashSet::from([peer]);
let mut deadlines = std::collections::HashMap::new();

update_remote_release_deadline(peer, true, &owners, &mut deadlines, started);
update_remote_release_deadline(peer, false, &owners, &mut deadlines, started);
let armed = deadlines[&peer];
for elapsed_ms in [100, 200, 300, 400] {
update_remote_release_deadline(
peer,
true,
false,
&owners,
&mut deadlines,
started + std::time::Duration::from_millis(elapsed_ms),
Expand All @@ -678,18 +701,48 @@ mod tests {
}

#[test]
fn dtx_from_non_owner_does_not_arm_remote_floor_deadline() {
fn last_speech_frame_arms_remote_floor_release_without_follow_up_audio() {
let peer = 7;
let started = tokio::time::Instant::now();
let owners = std::collections::HashSet::from([peer]);
let mut deadlines = std::collections::HashMap::new();

update_remote_release_deadline(peer, true, &owners, &mut deadlines, started);
let armed = started + REMOTE_RELEASE_DEBOUNCE;
assert_eq!(deadlines[&peer], armed);

let human_floor = HumanFloor::new();
human_floor.enter_remote(peer);
let mut owners = owners;
release_expired_remote_floors(armed, &mut owners, &mut deadlines, &human_floor);

assert!(!human_floor.is_blocked());
assert!(owners.is_empty());
assert!(deadlines.is_empty());
}

#[test]
fn silence_from_non_owner_does_not_arm_remote_floor_deadline() {
let mut deadlines = std::collections::HashMap::new();
update_remote_release_deadline(
7,
true,
false,
&std::collections::HashSet::new(),
&mut deadlines,
tokio::time::Instant::now(),
);
assert!(deadlines.is_empty());
}

#[test]
fn remote_speech_requires_non_dtx_audio_above_the_activity_floor() {
assert!(!is_remote_speech_frame(true, 0));
assert!(!is_remote_speech_frame(false, -127));
assert!(!is_remote_speech_frame(false, -56));
assert!(is_remote_speech_frame(false, -55));
assert!(is_remote_speech_frame(false, -12));
}

#[test]
fn speaker_level_maps_conversational_range() {
assert_eq!(normalized_speaker_level(-127), 0.0);
Expand Down
1 change: 1 addition & 0 deletions mobile/lib/features/channels/channel_detail_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.dart';
import '../../shared/widgets/bouncing_dots_indicator.dart';
import '../../shared/widgets/concentric_sheet_surface.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
part of '../channel_detail_page.dart';

const _huddleAgentResponseResetDelay = Duration(milliseconds: 1200);

class _HuddleCallAvatar extends HookConsumerWidget {
const _HuddleCallAvatar({
required this.pubkey,
required this.profile,
required this.fallbackLabel,
required this.active,
required this.speakerLevel,
required this.preparingResponse,
required this.onTap,
this.isSelf = false,
this.frameSize = _huddleAvatarFrameSize,
Expand All @@ -17,13 +20,70 @@ class _HuddleCallAvatar extends HookConsumerWidget {
final String? fallbackLabel;
final bool active;
final double speakerLevel;
final bool preparingResponse;
final VoidCallback? onTap;
final bool isSelf;
final double frameSize;

@override
Widget build(BuildContext context, WidgetRef ref) {
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final responseHasStarted = useState(false);
final responseResetTimer = useRef<Timer?>(null);
// Tracks the previous working signal so we can detect its edges, and
// whether the working signal has cleared since audio latched the response.
// A completed working cycle distinguishes a genuinely new turn from late
// typing that merely trails the turn already spoken.
final wasPreparing = useRef(false);
final workingCycleCompleted = useRef(false);
useEffect(() {
final preparingStarted = preparingResponse && !wasPreparing.value;
final preparingCleared = !preparingResponse && wasPreparing.value;
wasPreparing.value = preparingResponse;
if (active) {
// Audio for the current turn latches that a response has begun and
// starts a fresh turn, so any prior working cycle no longer applies.
responseResetTimer.value?.cancel();
responseResetTimer.value = null;
responseHasStarted.value = true;
workingCycleCompleted.value = false;
} else if (preparingStarted &&
responseHasStarted.value &&
workingCycleCompleted.value) {
// A new working turn began after the previous turn's working signal
// already cleared, so the "already spoke" suppression no longer
// applies — allow the preparing indicator to show again.
responseResetTimer.value?.cancel();
responseResetTimer.value = null;
responseHasStarted.value = false;
workingCycleCompleted.value = false;
} else if (preparingResponse) {
// Working signal (including late typing for the turn just spoken) holds
// the suppression alive; keep the reset timer cancelled.
responseResetTimer.value?.cancel();
responseResetTimer.value = null;
Comment on lines +60 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear the response latch when a new turn begins

When an agent begins another turn before its previous typing entry clears, preparingResponse remains true because typing entries are upserted with an eight-second TTL; this branch cancels the reset timer without clearing responseHasStarted. Consequently, after the agent has spoken once, a rapid follow-up response keeps showing the avatar instead of the preparation dots. Fresh evidence in the revised state machine is this new timer-cancellation path, so the latch needs to distinguish a new typing turn rather than relying only on a false interval.

Useful? React with 👍 / 👎.

} else if (responseHasStarted.value && responseResetTimer.value == null) {
responseResetTimer.value = Timer(_huddleAgentResponseResetDelay, () {
responseResetTimer.value = null;
responseHasStarted.value = false;
workingCycleCompleted.value = false;
});
}
// Record that this turn's working signal has completed a cycle once it
// clears after audio latched, so the next working turn is not mistaken
// for trailing typing.
if (preparingCleared && responseHasStarted.value) {
workingCycleCompleted.value = true;
}
return null;
}, [active, preparingResponse, responseHasStarted.value]);
useEffect(
() =>
() => responseResetTimer.value?.cancel(),
const [],
);
final showPreparingResponse =
preparingResponse && !active && !responseHasStarted.value;
final scale = frameSize / _huddleAvatarFrameSize;
final avatarRadius = _huddleAvatarRadius * scale;
final speakingRingSize = _huddleSpeakingRingSize * scale;
Expand Down Expand Up @@ -65,12 +125,22 @@ class _HuddleCallAvatar extends HookConsumerWidget {
isSelf: isSelf,
);

final semanticStates = [
label,
if (showPreparingResponse) 'preparing a response',
if (active) 'speaking',
].join(', ');

return SizedBox(
width: frameSize,
child: Semantics(
label: active ? '$label, speaking' : label,
label: semanticStates,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the preparation announcement in a live region

When a screen reader is enabled and an agent enters the preparing state, the enclosing Semantics node suppresses all descendant semantics via excludeSemantics: true, so the BouncingDotsIndicator's liveRegion announcement is discarded. The remaining outer label changes but is not itself a live region, meaning this otherwise visual-only status may not be announced until the participant is focused again; mark the outer node as live while preparing or preserve the child's live-region semantics.

Useful? React with 👍 / 👎.

hint: onTap == null ? null : 'Tap to focus participant',
button: onTap != null,
// The outer node excludes descendant semantics, so the child
// indicator's live region never reaches assistive tech. Promote this
// node to a live region while preparing so the label change announces.
liveRegion: showPreparingResponse,
onTap: onTap,
excludeSemantics: true,
child: GestureDetector(
Expand Down Expand Up @@ -121,15 +191,46 @@ class _HuddleCallAvatar extends HookConsumerWidget {
),
),
),
AvatarImage(
imageUrl: profile?.avatarUrl,
radius: avatarRadius,
backgroundColor: context.colors.primaryContainer,
fallback: Icon(
LucideIcons.userRound,
size: fallbackIconSize,
color: context.colors.onPrimaryContainer,
),
AnimatedSwitcher(
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 180),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) =>
FadeTransition(opacity: animation, child: child),
child: showPreparingResponse
? Container(
key: ValueKey(
'huddle-agent-preparing-response-$pubkey',
),
width: avatarRadius * 2,
height: avatarRadius * 2,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: context.colors.primaryContainer,
),
alignment: Alignment.center,
child: BouncingDotsIndicator(
color: context.colors.onPrimaryContainer,
dotSize: 6 * scale,
gap: 4 * scale,
semanticLabel:
'$label is preparing a response',
),
)
: AvatarImage(
key: ValueKey('huddle-avatar-image-$pubkey'),
imageUrl: profile?.avatarUrl,
radius: avatarRadius,
backgroundColor:
context.colors.primaryContainer,
fallback: Icon(
LucideIcons.userRound,
size: fallbackIconSize,
color: context.colors.onPrimaryContainer,
),
),
),
],
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class _HuddleCallParticipants extends StatelessWidget {
required this.localPubkey,
required this.activeSpeakerPubkeys,
required this.speakerLevels,
required this.workingAgentPubkeys,
required this.retryTooltip,
required this.retryIcon,
required this.onRetry,
Expand All @@ -25,6 +26,7 @@ class _HuddleCallParticipants extends StatelessWidget {
final String? localPubkey;
final Set<String> activeSpeakerPubkeys;
final Map<String, double> speakerLevels;
final Set<String> workingAgentPubkeys;
final String retryTooltip;
final IconData retryIcon;
final VoidCallback onRetry;
Expand Down Expand Up @@ -126,6 +128,7 @@ class _HuddleCallParticipants extends StatelessWidget {
speakerLevel: localPubkey == null
? 0
: speakerLevels[localPubkey] ?? 0,
preparingResponse: false,
isSelf: true,
onTap: null,
),
Expand All @@ -145,6 +148,7 @@ class _HuddleCallParticipants extends StatelessWidget {
fallbackLabels: fallbackLabels,
activeSpeakerPubkeys: activeSpeakerPubkeys,
speakerLevels: speakerLevels,
workingAgentPubkeys: workingAgentPubkeys,
movementDuration: movementDuration,
entryDuration: entryDuration,
exitDuration: exitDuration,
Expand Down
Loading
Loading