|
59 | 59 | SpeakerVerifierFactory, |
60 | 60 | SpeakerVerifierRuntime, |
61 | 61 | ) |
| 62 | +from main_logic.voice_identity.beta_policy import ( |
| 63 | + OwnerVoiceBetaDecision, |
| 64 | + OwnerVoiceCandidateIdentity, |
| 65 | + OwnerVoiceDecisionRecord, |
| 66 | +) |
| 67 | +from .speaker_shadow import SpeakerShadowCandidateKey |
62 | 68 | from .transcript import ( |
63 | 69 | TranscriptDispatcher, |
64 | 70 | TranscriptEnvelope, |
@@ -189,6 +195,17 @@ def _init_asr_runtime_state(self) -> None: |
189 | 195 | self._speaker_verifier_cleanup_tasks: set[asyncio.Task[None]] = set() |
190 | 196 | self._speaker_verifier_cleanup_failed = False |
191 | 197 | self._speaker_verifier_reconcile_lock = asyncio.Lock() |
| 198 | + self._owner_voice_soft_suppression: ( |
| 199 | + OwnerVoiceCandidateIdentity | None |
| 200 | + ) = None |
| 201 | + self._owner_voice_soft_filter_tasks: set[asyncio.Task[None]] = set() |
| 202 | + self._owner_voice_soft_filter_reset_task: asyncio.Task[None] | None = None |
| 203 | + self._owner_voice_soft_filter_detector: DetectorRuntime | None = None |
| 204 | + self._owner_voice_soft_filter_lifecycle: ( |
| 205 | + VoiceInputLifecycleController | None |
| 206 | + ) = None |
| 207 | + self._owner_voice_soft_rejection_count = 0 |
| 208 | + self._owner_voice_stale_decision_count = 0 |
192 | 209 | self._asr_transport_selection = None |
193 | 210 | self._asr_transport_task: asyncio.Task[None] | None = None |
194 | 211 | self._asr_transport_lock = asyncio.Lock() |
@@ -243,6 +260,14 @@ def _ensure_asr_runtime_state(self) -> None: |
243 | 260 | ) |
244 | 261 | self._asr_audio_sequence = 0 |
245 | 262 | self._asr_pending_detector_candidate = None |
| 263 | + if not hasattr(self, "_owner_voice_soft_suppression"): |
| 264 | + self._owner_voice_soft_suppression = None |
| 265 | + self._owner_voice_soft_filter_tasks = set() |
| 266 | + self._owner_voice_soft_filter_reset_task = None |
| 267 | + self._owner_voice_soft_filter_detector = None |
| 268 | + self._owner_voice_soft_filter_lifecycle = None |
| 269 | + self._owner_voice_soft_rejection_count = 0 |
| 270 | + self._owner_voice_stale_decision_count = 0 |
246 | 271 |
|
247 | 272 | def _capture_turn_token( |
248 | 273 | self, |
@@ -402,6 +427,21 @@ async def _dispatch_asr_detector_event( |
402 | 427 | if stale_metrics is not None: |
403 | 428 | stale_metrics.detector_stale_event_count += 1 |
404 | 429 | return |
| 430 | + if self._owner_voice_soft_suppression is not None and ( |
| 431 | + detector is not self._owner_voice_soft_filter_detector |
| 432 | + or lifecycle is not self._owner_voice_soft_filter_lifecycle |
| 433 | + ): |
| 434 | + self._clear_owner_voice_soft_suppression() |
| 435 | + if self._owner_voice_soft_suppression is not None: |
| 436 | + if ( |
| 437 | + isinstance(event, DetectorActivityEvent) |
| 438 | + and event.activity is SpeechActivityEvent.CANDIDATE_PAUSE |
| 439 | + ): |
| 440 | + await self._finish_owner_voice_soft_suppression( |
| 441 | + detector, |
| 442 | + lifecycle, |
| 443 | + ) |
| 444 | + return |
405 | 445 | lifecycle.metrics.smart_turn_inference_ms = ( |
406 | 446 | detector.smart_turn_evaluation_ms |
407 | 447 | ) |
@@ -1069,6 +1109,8 @@ async def _close_independent_asr( |
1069 | 1109 | """Invalidate callbacks first, then release the detached provider session.""" |
1070 | 1110 |
|
1071 | 1111 | self._ensure_asr_runtime_state() |
| 1112 | + self._clear_owner_voice_soft_suppression() |
| 1113 | + await self.wait_owner_voice_soft_filter_idle() |
1072 | 1114 | self._asr_session_epoch += 1 |
1073 | 1115 | self._asr_audio_generation += 1 |
1074 | 1116 | self._asr_transcript_dispatcher.invalidate_all() |
@@ -1160,6 +1202,19 @@ async def submit( |
1160 | 1202 | speech_probability = frame.speech_probability |
1161 | 1203 | rnnoise_available = frame.rnnoise_available |
1162 | 1204 | rnnoise_evidence = frame.rnnoise_evidence |
| 1205 | + if self._owner_voice_soft_suppression is not None and ( |
| 1206 | + self._asr_detector is not self._owner_voice_soft_filter_detector |
| 1207 | + or self._asr_lifecycle is not self._owner_voice_soft_filter_lifecycle |
| 1208 | + ): |
| 1209 | + self._clear_owner_voice_soft_suppression() |
| 1210 | + reset_task = self._owner_voice_soft_filter_reset_task |
| 1211 | + if ( |
| 1212 | + self._owner_voice_soft_suppression is not None |
| 1213 | + and reset_task is not None |
| 1214 | + and not reset_task.done() |
| 1215 | + ): |
| 1216 | + self._record_owner_voice_suppressed_audio(frame) |
| 1217 | + return AsrSubmitResult(AsrSubmitStatus.ACCEPTED) |
1163 | 1218 |
|
1164 | 1219 | try: |
1165 | 1220 | lifecycle = self._asr_lifecycle |
@@ -1255,6 +1310,9 @@ def ingress_is_current() -> bool: |
1255 | 1310 | status_code="ASR_ENDPOINTING_FAILED", |
1256 | 1311 | ) |
1257 | 1312 | return AsrSubmitResult(AsrSubmitStatus.UNAVAILABLE) |
| 1313 | + if self._owner_voice_soft_suppression is not None: |
| 1314 | + self._record_owner_voice_suppressed_audio(frame) |
| 1315 | + return AsrSubmitResult(AsrSubmitStatus.ACCEPTED) |
1258 | 1316 | if not submitted.throttle_available: |
1259 | 1317 | lifecycle.enable_independent_asr_fail_open() |
1260 | 1318 | if ( |
@@ -1299,6 +1357,17 @@ def ingress_is_current() -> bool: |
1299 | 1357 | status_code="ASR_ENDPOINTING_FAILED", |
1300 | 1358 | ) |
1301 | 1359 | return AsrSubmitResult(AsrSubmitStatus.UNAVAILABLE) |
| 1360 | + if self._owner_voice_soft_suppression is not None: |
| 1361 | + self._record_owner_voice_suppressed_audio(frame) |
| 1362 | + if ( |
| 1363 | + SpeechActivityEvent.CANDIDATE_PAUSE |
| 1364 | + in detector_result.events |
| 1365 | + ): |
| 1366 | + await self._finish_owner_voice_soft_suppression( |
| 1367 | + detector, |
| 1368 | + lifecycle, |
| 1369 | + ) |
| 1370 | + return AsrSubmitResult(AsrSubmitStatus.ACCEPTED) |
1302 | 1371 | if not detector_result.throttle_available: |
1303 | 1372 | lifecycle.enable_independent_asr_fail_open() |
1304 | 1373 | else: |
@@ -1438,6 +1507,171 @@ def ingress_is_current() -> bool: |
1438 | 1507 |
|
1439 | 1508 | return AsrSubmitResult(AsrSubmitStatus.ACCEPTED) |
1440 | 1509 |
|
| 1510 | + def request_owner_voice_candidate_rejection( |
| 1511 | + self, |
| 1512 | + record: OwnerVoiceDecisionRecord, |
| 1513 | + *, |
| 1514 | + active_profile_revision: int | None, |
| 1515 | + ) -> bool: |
| 1516 | + """Fence one exact candidate and detach its Provider transport.""" |
| 1517 | + |
| 1518 | + self._ensure_asr_runtime_state() |
| 1519 | + identity = getattr(record, "identity", None) |
| 1520 | + detector = self._asr_detector |
| 1521 | + lifecycle = self._asr_lifecycle |
| 1522 | + ingress = self._asr_current_ingress_token |
| 1523 | + if ( |
| 1524 | + not isinstance(record, OwnerVoiceDecisionRecord) |
| 1525 | + or record.decision |
| 1526 | + is not OwnerVoiceBetaDecision.REJECT_CURRENT_CANDIDATE |
| 1527 | + or not isinstance(identity, OwnerVoiceCandidateIdentity) |
| 1528 | + or self._owner_voice_soft_suppression is not None |
| 1529 | + or detector is None |
| 1530 | + or lifecycle is None |
| 1531 | + or ingress is None |
| 1532 | + or not self._ingress_token_matches(ingress) |
| 1533 | + or identity.session_id != ingress.connection_id |
| 1534 | + or identity.profile_revision != active_profile_revision |
| 1535 | + or identity.detector_epoch != detector.detector_epoch |
| 1536 | + or lifecycle.snapshot.state |
| 1537 | + not in { |
| 1538 | + VoiceLifecycleState.PREWARMING, |
| 1539 | + VoiceLifecycleState.ACTIVE, |
| 1540 | + } |
| 1541 | + ): |
| 1542 | + self._owner_voice_stale_decision_count += 1 |
| 1543 | + return False |
| 1544 | + try: |
| 1545 | + expected = SpeakerShadowCandidateKey( |
| 1546 | + detector_epoch=identity.detector_epoch, |
| 1547 | + shadow_generation=identity.observation_generation, |
| 1548 | + scope=identity.candidate_scope, |
| 1549 | + candidate_generation=identity.candidate_generation, |
| 1550 | + ) |
| 1551 | + if not detector.matches_speaker_shadow_candidate(expected): |
| 1552 | + self._owner_voice_stale_decision_count += 1 |
| 1553 | + return False |
| 1554 | + except Exception: |
| 1555 | + self._owner_voice_stale_decision_count += 1 |
| 1556 | + return False |
| 1557 | + |
| 1558 | + self._owner_voice_soft_suppression = identity |
| 1559 | + self._owner_voice_soft_filter_detector = detector |
| 1560 | + self._owner_voice_soft_filter_lifecycle = lifecycle |
| 1561 | + self._owner_voice_soft_rejection_count += 1 |
| 1562 | + reserved_final = self._asr_reserved_final_key |
| 1563 | + if reserved_final is not None: |
| 1564 | + self._asr_transcript_dispatcher.release(reserved_final) |
| 1565 | + self._asr_audio_dispatcher.abort( |
| 1566 | + self._asr_audio_dispatcher.active_turn |
| 1567 | + ) |
| 1568 | + self._asr_reserved_final_key = None |
| 1569 | + self._asr_sealed_turn_token = None |
| 1570 | + self._asr_provider_candidate_fence = None |
| 1571 | + self._asr_turn_prepared = False |
| 1572 | + self._asr_received_audio = False |
| 1573 | + self._asr_pending_speech_confirmed = False |
| 1574 | + self._asr_pending_detector_candidate = None |
| 1575 | + self._asr_audio_sequence = 0 |
| 1576 | + self._asr_turn_endpointed_at = None |
| 1577 | + lifecycle.invalidate_audio() |
| 1578 | + watchdog, self._asr_final_watchdog_task = ( |
| 1579 | + self._asr_final_watchdog_task, |
| 1580 | + None, |
| 1581 | + ) |
| 1582 | + if watchdog is not None and watchdog is not asyncio.current_task(): |
| 1583 | + watchdog.cancel() |
| 1584 | + for task_name in ( |
| 1585 | + "_asr_transport_task", |
| 1586 | + "_asr_warm_expiry_task", |
| 1587 | + ): |
| 1588 | + task = getattr(self, task_name, None) |
| 1589 | + setattr(self, task_name, None) |
| 1590 | + if task is not None and task is not asyncio.current_task(): |
| 1591 | + task.cancel() |
| 1592 | + session, self._asr_session = self._asr_session, None |
| 1593 | + lease, self._asr_smart_turn_lease = self._asr_smart_turn_lease, None |
| 1594 | + cleanup = asyncio.create_task( |
| 1595 | + self._reset_owner_voice_rejected_candidate( |
| 1596 | + session, |
| 1597 | + lease, |
| 1598 | + ), |
| 1599 | + name="owner-voice-soft-reject-cleanup", |
| 1600 | + ) |
| 1601 | + self._owner_voice_soft_filter_reset_task = cleanup |
| 1602 | + self._owner_voice_soft_filter_tasks.add(cleanup) |
| 1603 | + cleanup.add_done_callback(self._owner_voice_soft_filter_tasks.discard) |
| 1604 | + return True |
| 1605 | + |
| 1606 | + async def wait_owner_voice_soft_filter_idle(self) -> None: |
| 1607 | + tasks = tuple(self._owner_voice_soft_filter_tasks) |
| 1608 | + if tasks: |
| 1609 | + await asyncio.gather(*tasks, return_exceptions=True) |
| 1610 | + |
| 1611 | + async def _reset_owner_voice_rejected_candidate( |
| 1612 | + self, |
| 1613 | + session: Any, |
| 1614 | + lease: SmartTurnLease | None, |
| 1615 | + ) -> None: |
| 1616 | + for action in ( |
| 1617 | + None if lease is None else lease.release, |
| 1618 | + None if session is None else session.close, |
| 1619 | + ): |
| 1620 | + if action is None: |
| 1621 | + continue |
| 1622 | + try: |
| 1623 | + await action() |
| 1624 | + except asyncio.CancelledError: |
| 1625 | + raise |
| 1626 | + except Exception: |
| 1627 | + continue |
| 1628 | + |
| 1629 | + async def _finish_owner_voice_soft_suppression( |
| 1630 | + self, |
| 1631 | + detector: DetectorRuntime, |
| 1632 | + lifecycle: VoiceInputLifecycleController, |
| 1633 | + ) -> None: |
| 1634 | + if ( |
| 1635 | + self._owner_voice_soft_suppression is None |
| 1636 | + or detector is not self._asr_detector |
| 1637 | + or lifecycle is not self._asr_lifecycle |
| 1638 | + ): |
| 1639 | + if self._owner_voice_soft_suppression is not None: |
| 1640 | + self._clear_owner_voice_soft_suppression() |
| 1641 | + return |
| 1642 | + reset_task = self._owner_voice_soft_filter_reset_task |
| 1643 | + if reset_task is not None and not reset_task.done(): |
| 1644 | + await asyncio.gather(reset_task, return_exceptions=True) |
| 1645 | + try: |
| 1646 | + await detector.reset() |
| 1647 | + except Exception: |
| 1648 | + self._clear_owner_voice_soft_suppression() |
| 1649 | + return |
| 1650 | + if detector is not self._asr_detector or lifecycle is not self._asr_lifecycle: |
| 1651 | + self._clear_owner_voice_soft_suppression() |
| 1652 | + return |
| 1653 | + lifecycle.invalidate_audio() |
| 1654 | + self._clear_owner_voice_soft_suppression() |
| 1655 | + |
| 1656 | + def _clear_owner_voice_soft_suppression(self) -> None: |
| 1657 | + self._owner_voice_soft_suppression = None |
| 1658 | + self._owner_voice_soft_filter_detector = None |
| 1659 | + self._owner_voice_soft_filter_lifecycle = None |
| 1660 | + self._owner_voice_soft_filter_reset_task = None |
| 1661 | + |
| 1662 | + def _record_owner_voice_suppressed_audio( |
| 1663 | + self, |
| 1664 | + frame: ProcessedVoiceFrame, |
| 1665 | + ) -> None: |
| 1666 | + lifecycle = self._asr_lifecycle |
| 1667 | + if lifecycle is None: |
| 1668 | + return |
| 1669 | + duration_ms = ( |
| 1670 | + len(frame.pcm16) * 1_000 // (max(1, frame.sample_rate_hz) * 2) |
| 1671 | + ) |
| 1672 | + lifecycle.metrics.add_local_audio(duration_ms) |
| 1673 | + lifecycle.metrics.add_suppressed_audio(duration_ms) |
| 1674 | + |
1441 | 1675 | def _ensure_transport_restart_task(self) -> None: |
1442 | 1676 | task = self._asr_transport_task |
1443 | 1677 | if task is not None and not task.done(): |
|
0 commit comments