refactor(backend): 启动环境初始化与资源项目化(零跨项目依赖)
- main.py:uv run main.py 一键启动——读取 backend/.env、TORCH_HOME/NLTK_DATA 指向项目内缓存、certifi SSL + NO_PROXY 网络直连、默认开启 s2s,启动横幅 - vendor/:补丁后 s2s 云化栈移入项目(backend/vendor/s2s-cloud),pyproject sources 改相对路径 - .gitignore:忽略 .torch-cache / nltk_data 缓存 - AGENTS.md:进程管理铁律(AI 不启动/重启服务,由用户操作)
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Global MLX lock to prevent concurrent Metal/MLX model access.
|
||||
|
||||
MLX models (STT, LLM, TTS) cannot be used concurrently from multiple threads
|
||||
on Apple Silicon due to Metal command buffer limitations. This module provides
|
||||
a global lock that all MLX handlers should acquire before using their models.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import types
|
||||
from threading import Lock, RLock, current_thread, get_ident
|
||||
from time import perf_counter
|
||||
from typing import Literal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global reentrant lock for MLX model access
|
||||
# Using RLock (reentrant lock) so the same thread can acquire it multiple times
|
||||
_mlx_lock = RLock()
|
||||
_mlx_lock_state = Lock()
|
||||
_lock_owner_ident: int | None = None
|
||||
_lock_owner_thread: str | None = None
|
||||
_lock_owner_handler: str | None = None
|
||||
_lock_acquired_at: float | None = None
|
||||
_lock_depth = 0
|
||||
|
||||
|
||||
def _owner_snapshot(now: float | None = None) -> str:
|
||||
if _lock_owner_ident is None:
|
||||
return "none"
|
||||
elapsed = ""
|
||||
if _lock_acquired_at is not None:
|
||||
elapsed = f" for {(now or perf_counter()) - _lock_acquired_at:.3f}s"
|
||||
return f"{_lock_owner_handler} on {_lock_owner_thread}{elapsed}"
|
||||
|
||||
|
||||
def _record_lock_acquired(handler_name: str) -> int:
|
||||
global _lock_acquired_at, _lock_depth, _lock_owner_handler, _lock_owner_ident, _lock_owner_thread
|
||||
ident = get_ident()
|
||||
thread_name = current_thread().name
|
||||
now = perf_counter()
|
||||
with _mlx_lock_state:
|
||||
if _lock_owner_ident == ident:
|
||||
_lock_depth += 1
|
||||
else:
|
||||
_lock_owner_ident = ident
|
||||
_lock_owner_thread = thread_name
|
||||
_lock_owner_handler = handler_name
|
||||
_lock_acquired_at = now
|
||||
_lock_depth = 1
|
||||
return _lock_depth
|
||||
|
||||
|
||||
def _record_lock_released(handler_name: str) -> tuple[int, float | None]:
|
||||
global _lock_acquired_at, _lock_depth, _lock_owner_handler, _lock_owner_ident, _lock_owner_thread
|
||||
ident = get_ident()
|
||||
now = perf_counter()
|
||||
with _mlx_lock_state:
|
||||
if _lock_owner_ident != ident:
|
||||
logger.warning(
|
||||
"%s: MLX lock release requested by non-owner thread; owner=%s",
|
||||
handler_name,
|
||||
_owner_snapshot(now),
|
||||
)
|
||||
return _lock_depth, None
|
||||
|
||||
hold_s = None if _lock_acquired_at is None else now - _lock_acquired_at
|
||||
_lock_depth -= 1
|
||||
depth = _lock_depth
|
||||
if _lock_depth <= 0:
|
||||
_lock_owner_ident = None
|
||||
_lock_owner_thread = None
|
||||
_lock_owner_handler = None
|
||||
_lock_acquired_at = None
|
||||
_lock_depth = 0
|
||||
return depth, hold_s
|
||||
|
||||
|
||||
def acquire_mlx_lock(timeout: float | None = None, handler_name: str = "Unknown") -> bool:
|
||||
"""
|
||||
Acquire the global MLX lock.
|
||||
|
||||
Args:
|
||||
timeout: Optional timeout in seconds
|
||||
handler_name: Name of the handler acquiring the lock (for logging)
|
||||
|
||||
Returns:
|
||||
True if lock was acquired, False if timeout occurred
|
||||
"""
|
||||
with _mlx_lock_state:
|
||||
owner_before = _owner_snapshot()
|
||||
logger.debug("%s: Attempting to acquire MLX lock (owner=%s)", handler_name, owner_before)
|
||||
start = perf_counter()
|
||||
acquired = _mlx_lock.acquire(timeout=timeout) if timeout else _mlx_lock.acquire(blocking=True)
|
||||
wait_s = perf_counter() - start
|
||||
|
||||
if acquired:
|
||||
depth = _record_lock_acquired(handler_name)
|
||||
if wait_s >= 0.25:
|
||||
logger.info(
|
||||
"%s: MLX lock acquired after %.2fs (previous_owner=%s, depth=%d)",
|
||||
handler_name,
|
||||
wait_s,
|
||||
owner_before,
|
||||
depth,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"%s: MLX lock acquired after %.3fs (previous_owner=%s, depth=%d)",
|
||||
handler_name,
|
||||
wait_s,
|
||||
owner_before,
|
||||
depth,
|
||||
)
|
||||
else:
|
||||
with _mlx_lock_state:
|
||||
owner_after = _owner_snapshot()
|
||||
logger.warning(
|
||||
"%s: Failed to acquire MLX lock after %.3fs (timeout=%s, owner=%s)",
|
||||
handler_name,
|
||||
wait_s,
|
||||
timeout,
|
||||
owner_after,
|
||||
)
|
||||
|
||||
return acquired
|
||||
|
||||
|
||||
def release_mlx_lock(handler_name: str = "Unknown") -> None:
|
||||
"""
|
||||
Release the global MLX lock.
|
||||
|
||||
Args:
|
||||
handler_name: Name of the handler releasing the lock (for logging)
|
||||
"""
|
||||
try:
|
||||
depth, hold_s = _record_lock_released(handler_name)
|
||||
_mlx_lock.release()
|
||||
if hold_s is not None and depth == 0 and hold_s >= 0.25:
|
||||
logger.info("%s: MLX lock released after holding %.2fs", handler_name, hold_s)
|
||||
else:
|
||||
logger.debug(
|
||||
"%s: MLX lock released%s",
|
||||
handler_name,
|
||||
"" if hold_s is None else f" after holding {hold_s:.3f}s (depth={depth})",
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.error(f"{handler_name}: Failed to release MLX lock: {e}")
|
||||
|
||||
|
||||
class MLXLockContext:
|
||||
"""Context manager for MLX lock."""
|
||||
|
||||
def __init__(self, handler_name: str = "Unknown", timeout: float | None = None) -> None:
|
||||
self.handler_name = handler_name
|
||||
self.timeout = timeout
|
||||
self.acquired = False
|
||||
|
||||
def __enter__(self) -> bool:
|
||||
self.acquired = acquire_mlx_lock(timeout=self.timeout, handler_name=self.handler_name)
|
||||
return self.acquired
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: types.TracebackType | None,
|
||||
) -> Literal[False]:
|
||||
if self.acquired:
|
||||
release_mlx_lock(handler_name=self.handler_name)
|
||||
return False # Do not suppress exceptions
|
||||
@@ -0,0 +1,39 @@
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ThreadManager:
|
||||
"""
|
||||
Manages multiple threads used to execute given handler tasks.
|
||||
"""
|
||||
|
||||
def __init__(self, handlers: Sequence[Any]) -> None:
|
||||
self.handlers = handlers
|
||||
self.threads: list[threading.Thread] = []
|
||||
|
||||
def start(self) -> None:
|
||||
for handler in self.handlers:
|
||||
thread = threading.Thread(target=handler.run)
|
||||
thread.daemon = False # Ensure threads are waited for on shutdown
|
||||
self.threads.append(thread)
|
||||
thread.start()
|
||||
|
||||
def wait(self) -> None:
|
||||
for thread in self.threads:
|
||||
thread.join()
|
||||
|
||||
def stop(self) -> None:
|
||||
# Signal all handlers to stop
|
||||
for handler in self.handlers:
|
||||
handler.stop_event.set()
|
||||
|
||||
# Wait for all threads to finish with timeout
|
||||
for i, thread in enumerate(self.threads):
|
||||
if thread.is_alive():
|
||||
thread.join(timeout=5.0)
|
||||
if thread.is_alive():
|
||||
logger.warning(f"Thread {i} ({thread.name}) did not terminate within timeout")
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openai.types.realtime.realtime_response_create_params import RealtimeResponseCreateParams
|
||||
|
||||
|
||||
def response_wants_audio(response: RealtimeResponseCreateParams | None) -> bool:
|
||||
"""Whether a response should produce audio (and audio events) vs. text only.
|
||||
|
||||
Mirrors the OpenAI realtime semantics for ``output_modalities``: an absent
|
||||
value (``None``) or an empty list, or an explicit ``"audio"`` entry means
|
||||
audio; a non-empty list without ``"audio"`` (e.g. ``["text"]``) means text
|
||||
only.
|
||||
"""
|
||||
if response is None:
|
||||
return True
|
||||
mods = response.output_modalities
|
||||
return not mods or "audio" in mods
|
||||
|
||||
|
||||
def is_out_of_band(response: RealtimeResponseCreateParams | None) -> bool:
|
||||
"""Whether a response is *out-of-band* (``conversation="none"``).
|
||||
|
||||
Out-of-band responses are generated against a temporary context and are
|
||||
never threaded into the default conversation: their ``input`` (if any)
|
||||
seeds a throwaway chat, their assistant output is not committed back, and
|
||||
their ``conversation_id`` is reported as ``null``. Any other ``conversation``
|
||||
value (``"auto"``, ``None``, or an arbitrary id) is treated as in-band.
|
||||
"""
|
||||
return response is not None and response.conversation == "none"
|
||||
|
||||
|
||||
def next_power_of_2(x: int) -> int:
|
||||
return 1 if x == 0 else 2 ** (x - 1).bit_length()
|
||||
|
||||
|
||||
def _generate_id(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def int2float(sound: np.ndarray) -> np.ndarray:
|
||||
"""
|
||||
Taken from https://github.com/snakers4/silero-vad
|
||||
"""
|
||||
|
||||
abs_max = np.abs(sound).max()
|
||||
sound = sound.astype("float32")
|
||||
if abs_max > 0:
|
||||
sound *= 1 / 32768
|
||||
sound = sound.squeeze() # depends on the use case
|
||||
return sound
|
||||
Reference in New Issue
Block a user