fix(voice): recover dialogue after abandoned message recordings

This commit is contained in:
stu2not
2026-10-28 12:22:58 +08:00
parent a02e0d5f13
commit 9ebb1a78ec
10 changed files with 622 additions and 85 deletions
+6
View File
@@ -30,6 +30,12 @@ class AudioSession:
self.session_id = None
self.sample_rate = sample_rate
self.audio_queue = None
self.voice_target = None
self.start_received = False
self.start_ack_sent = False
self.ai_started = False
self.recording_finished = False
self.message_audio_received = False
async def set_interrupted(self, interrupted=True):
+107 -23
View File
@@ -1,59 +1,143 @@
import asyncio
import time
from dataclasses import dataclass
from typing import Dict, Optional
from uuid import uuid4
from config import settings
from services.connection_manager import connection_manager
from services.target_audio_cache import target_audio_cache
from utils.logger import session_logger
VOICE_TARGET_KIND_DEVICE = "device"
VOICE_TARGET_KIND_PARENT = "parent"
@dataclass(frozen=True)
@dataclass
class VoiceTarget:
kind: str
target_device_id: Optional[str] = None
uuid: Optional[str] = None
audio_cache_key: Optional[str] = None
websocket: object = None
session_id: Optional[str] = None
expires_at: float = 0
class DeviceTargetCache:
def __init__(self):
def __init__(self, *, timeout_seconds=None, clock=None):
self.targets: Dict[str, VoiceTarget] = {}
self.lock = asyncio.Lock()
self.timeout_seconds = timeout_seconds if timeout_seconds is not None else settings.voice_target_timeout_seconds
self.clock = clock or time.monotonic
async def _remove_locked(self, device_id, target, reason, *, clear_audio=True):
del self.targets[device_id]
if clear_audio:
await target_audio_cache.clear_audio_data(target.audio_cache_key)
session_logger.info(device_id, target.session_id or "target",
f"留言目标已清除: kind={target.kind}, reason={reason}")
async def _get_locked(self, device_id):
target = self.targets.get(device_id)
if target and self.clock() >= target.expires_at:
await self._remove_locked(device_id, target, "timeout")
return None
return target
async def get_target(self, device_id: str) -> Optional[str]:
async with self.lock:
target = self.targets.get(device_id)
if not target or target.kind != VOICE_TARGET_KIND_DEVICE:
return None
return target.target_device_id
target = await self.get_voice_target(device_id)
return target.target_device_id if target and target.kind == VOICE_TARGET_KIND_DEVICE else None
async def get_voice_target(self, device_id: str) -> Optional[VoiceTarget]:
async with self.lock:
return self.targets.get(device_id)
return await self._get_locked(device_id)
async def _set_target(self, device_id, *, kind, target_device_id=None, uuid=None):
async with self.lock:
old = self.targets.get(device_id)
if old:
await self._remove_locked(device_id, old, "replaced")
self.targets[device_id] = VoiceTarget(
kind=kind, target_device_id=target_device_id, uuid=uuid,
audio_cache_key=f"voice:{device_id}:{uuid4().hex}",
websocket=await connection_manager.get_connection(device_id),
expires_at=self.clock() + self.timeout_seconds,
)
session_logger.info(device_id, "target", f"留言目标已设置: kind={kind}, timeout={self.timeout_seconds}s")
async def set_target(self, device_id: str, target_device_id: str):
async with self.lock:
self.targets[device_id] = VoiceTarget(
kind=VOICE_TARGET_KIND_DEVICE,
target_device_id=target_device_id,
audio_cache_key=target_device_id,
)
await self._set_target(device_id, kind=VOICE_TARGET_KIND_DEVICE, target_device_id=target_device_id)
async def set_parent_target(self, device_id: str, uuid: str):
async with self.lock:
self.targets[device_id] = VoiceTarget(
kind=VOICE_TARGET_KIND_PARENT,
uuid=uuid,
audio_cache_key=f"parent:{device_id}",
)
await self._set_target(device_id, kind=VOICE_TARGET_KIND_PARENT, uuid=uuid)
async def remove_target(self, device_id: str):
async def claim_target(self, device_id, websocket, session_id):
"""Bind one pending intent to one recording on one connection."""
async with self.lock:
if device_id in self.targets:
del self.targets[device_id]
active = await connection_manager.get_connection(device_id)
if active is not None and active is not websocket:
return None
target = await self._get_locked(device_id)
if target is None:
return None
if target.websocket is not None and target.websocket is not websocket:
await self._remove_locked(device_id, target, "connection_changed")
return None
if target.session_id is not None and target.session_id != session_id:
await self._remove_locked(device_id, target, "new_recording")
return None
target.websocket, target.session_id = websocket, session_id
target.expires_at = self.clock() + self.timeout_seconds
session_logger.info(device_id, session_id, f"留言目标已关联录音: kind={target.kind}")
return target
async def bind_connection(self, device_id, websocket):
"""Adopt commands received before connect; discard older socket intents."""
async with self.lock:
if await connection_manager.get_connection(device_id) is not websocket:
return
target = await self._get_locked(device_id)
if target is None:
return
if target.websocket is not None and target.websocket is not websocket:
await self._remove_locked(device_id, target, "connection_changed")
else:
target.websocket = websocket
async def append_audio(self, device_id, target, websocket, session_id, audio):
async with self.lock:
current = await self._get_locked(device_id)
if current is not target or target.websocket is not websocket or target.session_id != session_id:
return False
target.expires_at = self.clock() + self.timeout_seconds
await target_audio_cache.add_audio_data(target.audio_cache_key, audio)
return True
async def take_target(self, device_id, target, websocket, session_id):
"""Consume the exact recording before slow upload/notification work."""
async with self.lock:
current = await self._get_locked(device_id)
if current is not target or target.websocket is not websocket or target.session_id != session_id:
return None
await self._remove_locked(device_id, target, "recording_ended", clear_audio=False)
return target
async def remove_target(self, device_id: str, *, expected=None, websocket=None, reason="cancelled"):
async with self.lock:
target = self.targets.get(device_id)
if target is None or (expected is not None and target is not expected):
return False
if websocket is not None and target.websocket is not websocket:
return False
await self._remove_locked(device_id, target, reason)
return True
async def get_all_targets(self) -> list:
async with self.lock:
for device_id in list(self.targets):
await self._get_locked(device_id)
return list(self.targets.items())
@@ -3,6 +3,7 @@ from config import settings
from services.audio_session import audio_session_manager
from utils.logger import session_logger
from services.conversation_history import conversation_history_manager
from services.device_target_cache import device_target_cache
async def run_in_executor(func, *args):
loop = asyncio.get_running_loop()
@@ -12,6 +13,7 @@ async def background_cleanup_task():
try:
while True:
await asyncio.sleep(settings.cleanup_interval)
await device_target_cache.get_all_targets()
current_time = asyncio.get_running_loop().time()
audio_sessions_to_clean = []
@@ -54,4 +56,4 @@ async def background_cleanup_task():
except asyncio.CancelledError:
session_logger.info("system", "cleanup", "后台清理任务已取消")
except Exception as e:
session_logger.error("system", "cleanup", f"后台清理任务出错: {str(e)}")
session_logger.error("system", "cleanup", f"后台清理任务出错: {str(e)}")