Skip to content

Commit 2fe886b

Browse files
committed
feat(asr): 增加非 Owner 当前候选软截断
1 parent bcb5274 commit 2fe886b

9 files changed

Lines changed: 659 additions & 14 deletions

File tree

main_logic/asr_client/detector_runtime.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,6 +1202,19 @@ def detector_epoch(self) -> int:
12021202
def candidate_open(self) -> bool:
12031203
return self._candidate_open
12041204

1205+
def matches_speaker_shadow_candidate(
1206+
self,
1207+
expected: SpeakerShadowCandidateKey,
1208+
) -> bool:
1209+
"""Confirm one fully-scoped shadow identity is still the live candidate."""
1210+
1211+
return bool(
1212+
not self._closed
1213+
and self._speaker_shadow_state is _SpeakerShadowState.CONFIRMED
1214+
and self._speaker_shadow_candidate == expected
1215+
and expected.detector_epoch == self._detector_epoch
1216+
)
1217+
12051218
@property
12061219
def throttle_shadow_metrics(self) -> ThrottleShadowMetrics:
12071220
return self._throttle_policy.shadow_metrics
@@ -2208,6 +2221,7 @@ def _open_speaker_shadow_candidate(
22082221
detector_epoch=self._detector_epoch,
22092222
shadow_generation=self._speaker_shadow_generation,
22102223
scope=scope,
2224+
candidate_generation=self._candidate_generation,
22112225
)
22122226
self._speaker_shadow_candidate = candidate
22132227
self._speaker_shadow_state = state

main_logic/asr_client/runtime.py

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@
5959
SpeakerVerifierFactory,
6060
SpeakerVerifierRuntime,
6161
)
62+
from main_logic.voice_identity.beta_policy import (
63+
OwnerVoiceBetaDecision,
64+
OwnerVoiceCandidateIdentity,
65+
OwnerVoiceDecisionRecord,
66+
)
67+
from .speaker_shadow import SpeakerShadowCandidateKey
6268
from .transcript import (
6369
TranscriptDispatcher,
6470
TranscriptEnvelope,
@@ -189,6 +195,17 @@ def _init_asr_runtime_state(self) -> None:
189195
self._speaker_verifier_cleanup_tasks: set[asyncio.Task[None]] = set()
190196
self._speaker_verifier_cleanup_failed = False
191197
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
192209
self._asr_transport_selection = None
193210
self._asr_transport_task: asyncio.Task[None] | None = None
194211
self._asr_transport_lock = asyncio.Lock()
@@ -243,6 +260,14 @@ def _ensure_asr_runtime_state(self) -> None:
243260
)
244261
self._asr_audio_sequence = 0
245262
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
246271

247272
def _capture_turn_token(
248273
self,
@@ -402,6 +427,21 @@ async def _dispatch_asr_detector_event(
402427
if stale_metrics is not None:
403428
stale_metrics.detector_stale_event_count += 1
404429
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
405445
lifecycle.metrics.smart_turn_inference_ms = (
406446
detector.smart_turn_evaluation_ms
407447
)
@@ -1069,6 +1109,8 @@ async def _close_independent_asr(
10691109
"""Invalidate callbacks first, then release the detached provider session."""
10701110

10711111
self._ensure_asr_runtime_state()
1112+
self._clear_owner_voice_soft_suppression()
1113+
await self.wait_owner_voice_soft_filter_idle()
10721114
self._asr_session_epoch += 1
10731115
self._asr_audio_generation += 1
10741116
self._asr_transcript_dispatcher.invalidate_all()
@@ -1160,6 +1202,19 @@ async def submit(
11601202
speech_probability = frame.speech_probability
11611203
rnnoise_available = frame.rnnoise_available
11621204
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)
11631218

11641219
try:
11651220
lifecycle = self._asr_lifecycle
@@ -1255,6 +1310,9 @@ def ingress_is_current() -> bool:
12551310
status_code="ASR_ENDPOINTING_FAILED",
12561311
)
12571312
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)
12581316
if not submitted.throttle_available:
12591317
lifecycle.enable_independent_asr_fail_open()
12601318
if (
@@ -1299,6 +1357,17 @@ def ingress_is_current() -> bool:
12991357
status_code="ASR_ENDPOINTING_FAILED",
13001358
)
13011359
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)
13021371
if not detector_result.throttle_available:
13031372
lifecycle.enable_independent_asr_fail_open()
13041373
else:
@@ -1438,6 +1507,171 @@ def ingress_is_current() -> bool:
14381507

14391508
return AsrSubmitResult(AsrSubmitStatus.ACCEPTED)
14401509

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+
14411675
def _ensure_transport_restart_task(self) -> None:
14421676
task = self._asr_transport_task
14431677
if task is not None and not task.done():

main_logic/asr_client/speaker_shadow.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class SpeakerShadowCandidateKey:
1313
detector_epoch: int
1414
shadow_generation: int
1515
scope: Literal["provider_pause", "smart_turn_turn"]
16+
candidate_generation: int = 0
1617

1718

1819
__all__ = ["SpeakerShadowCandidateKey"]

0 commit comments

Comments
 (0)