独立依赖
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+23
-3
@@ -1,13 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from config import asr_bundle_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _asr_model_path() -> Path:
|
||||
p = asr_bundle_dir().resolve()
|
||||
if not p.is_dir():
|
||||
raise FileNotFoundError(f"ASR model directory missing: {p}")
|
||||
if not (p / "configuration.json").is_file():
|
||||
raise FileNotFoundError(f"ASR bundle incomplete (no configuration.json): {p}")
|
||||
return p
|
||||
|
||||
|
||||
class ASRService:
|
||||
def __init__(self) -> None:
|
||||
self._model = None
|
||||
@@ -21,16 +33,26 @@ class ASRService:
|
||||
if self._ready or self._attempted:
|
||||
return
|
||||
self._attempted = True
|
||||
try:
|
||||
model_dir = _asr_model_path()
|
||||
except FileNotFoundError as exc:
|
||||
self._init_error = str(exc)
|
||||
self._device = "unavailable"
|
||||
logger.error("%s", exc)
|
||||
return
|
||||
|
||||
from funasr import AutoModel
|
||||
from funasr.utils.postprocess_utils import rich_transcription_postprocess
|
||||
|
||||
model_ref = str(model_dir)
|
||||
errors: list[str] = []
|
||||
for device in ("cuda:0", "cpu"):
|
||||
try:
|
||||
self._model = AutoModel(
|
||||
model="FunAudioLLM/SenseVoiceSmall",
|
||||
model=model_ref,
|
||||
device=device,
|
||||
hub="hf",
|
||||
disable_update=True,
|
||||
)
|
||||
self._post = rich_transcription_postprocess
|
||||
self._ready = True
|
||||
@@ -56,8 +78,6 @@ class ASRService:
|
||||
use_itn=True,
|
||||
)
|
||||
return self._post(res[0]["text"])
|
||||
|
||||
# When ASR is unavailable, avoid emitting fake user text that would trigger a bogus LLM reply.
|
||||
return ""
|
||||
|
||||
@property
|
||||
|
||||
+4
-22
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Optional
|
||||
from collections import deque
|
||||
@@ -21,23 +20,6 @@ class LLMService:
|
||||
self._history: deque[dict] = deque(maxlen=12) # 6 turns (user+assistant)
|
||||
self._system_prompt = (settings.llm_system_prompt or "").strip() or "请用中文口语化简短回复,1-2句。"
|
||||
|
||||
if not settings.llm_api_key:
|
||||
self._error = "missing LLM api key: set LLM_API_KEY or DEEPSEEK_API_KEY"
|
||||
return
|
||||
|
||||
# If DeepSeek key is provided but no explicit base URL override,
|
||||
# route to DeepSeek-compatible OpenAI endpoint by default.
|
||||
if (
|
||||
os.getenv("DEEPSEEK_API_KEY")
|
||||
and not os.getenv("LLM_BASE_URL")
|
||||
and not os.getenv("DEEPSEEK_BASE_URL")
|
||||
and (self._base_url.lower() == "https://api.openai.com/v1" or not self._base_url)
|
||||
):
|
||||
self._base_url = "https://api.deepseek.com/v1"
|
||||
|
||||
if not self._model:
|
||||
base = self._base_url.lower()
|
||||
self._model = "deepseek-chat" if "deepseek" in base else "gpt-4o-mini"
|
||||
try:
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
@@ -50,7 +32,7 @@ class LLMService:
|
||||
self._error = None
|
||||
except Exception as exc: # pragma: no cover
|
||||
self._error = str(exc)
|
||||
logger.warning("LLM online mode unavailable: %s", exc)
|
||||
logger.warning("LLM client init failed: %s", exc)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_reply_text(text: str) -> str:
|
||||
@@ -70,7 +52,7 @@ class LLMService:
|
||||
|
||||
async def reply_with_meta(self, text: str) -> tuple[str, str]:
|
||||
if not self._ready or self._client is None:
|
||||
return f"收到:{text}。这是本地兜底回复。", "fallback"
|
||||
return "", "unavailable"
|
||||
|
||||
try:
|
||||
resp = await self._client.chat.completions.create(
|
||||
@@ -86,9 +68,9 @@ class LLMService:
|
||||
return content, "online"
|
||||
except Exception as exc: # pragma: no cover
|
||||
self._error = str(exc)
|
||||
logger.warning("LLM request failed, fallback used: %s", exc)
|
||||
logger.warning("LLM request failed: %s", exc)
|
||||
|
||||
return f"收到:{text}。这是本地兜底回复。", "fallback"
|
||||
return "", "error"
|
||||
|
||||
async def reply(self, text: str) -> str:
|
||||
content, _ = await self.reply_with_meta(text)
|
||||
|
||||
+61
-17
@@ -2,22 +2,49 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from config import settings
|
||||
from config import kokoro_bundle_dir, settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _kokoro_root() -> Path:
|
||||
p = kokoro_bundle_dir().resolve()
|
||||
if not p.is_dir():
|
||||
raise FileNotFoundError(f"Kokoro model directory missing: {p}")
|
||||
cfg = p / "config.json"
|
||||
w0 = p / "kokoro-v1_0.pth"
|
||||
w1 = p / "kokoro-v1_1-zh.pth"
|
||||
weight = w0 if w0.is_file() else (w1 if w1.is_file() else None)
|
||||
if not cfg.is_file() or weight is None:
|
||||
raise FileNotFoundError(f"Kokoro bundle incomplete (need config.json and weight .pth): {p}")
|
||||
return p
|
||||
|
||||
|
||||
class TTSService:
|
||||
def __init__(self) -> None:
|
||||
self._pipeline = None
|
||||
self._kokoro_root: Optional[Path] = None
|
||||
self._ready = False
|
||||
self._attempted = False
|
||||
self._init_error: Optional[str] = None
|
||||
|
||||
def _voice_pt_path(self) -> Path:
|
||||
root = self._kokoro_root
|
||||
if root is None:
|
||||
raise RuntimeError("Kokoro pipeline not initialized")
|
||||
name = (settings.tts_voice or "").strip()
|
||||
if not name:
|
||||
raise ValueError("TTS_VOICE must be set (voice name, e.g. zf_xiaoxiao)")
|
||||
p = root / "voices" / f"{name}.pt"
|
||||
if not p.is_file():
|
||||
raise FileNotFoundError(f"Voice file missing: {p}")
|
||||
return p
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
if self._ready or self._attempted:
|
||||
return
|
||||
@@ -25,11 +52,24 @@ class TTSService:
|
||||
try:
|
||||
import kokoro
|
||||
|
||||
self._pipeline = kokoro.KPipeline(lang_code="z")
|
||||
root = _kokoro_root()
|
||||
w0 = root / "kokoro-v1_0.pth"
|
||||
w1 = root / "kokoro-v1_1-zh.pth"
|
||||
weight = w0 if w0.is_file() else w1
|
||||
hf_repo_id = (
|
||||
"hexgrad/Kokoro-82M-v1.1-zh" if weight.name.startswith("kokoro-v1_1") else "hexgrad/Kokoro-82M"
|
||||
)
|
||||
kmodel = kokoro.KModel(
|
||||
repo_id=hf_repo_id,
|
||||
config=str(root / "config.json"),
|
||||
model=str(weight),
|
||||
)
|
||||
self._pipeline = kokoro.KPipeline(lang_code="z", model=kmodel, repo_id=str(root))
|
||||
self._kokoro_root = root
|
||||
self._ready = True
|
||||
except Exception as exc: # pragma: no cover
|
||||
self._init_error = str(exc)
|
||||
logger.warning("TTS fallback mode: %s", exc)
|
||||
logger.error("TTS init failed: %s", exc)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_text(text: str) -> str:
|
||||
@@ -62,20 +102,24 @@ class TTSService:
|
||||
if not text:
|
||||
return np.zeros(1, dtype=np.float32), 24000
|
||||
self._ensure_loaded()
|
||||
if self._ready and self._pipeline is not None:
|
||||
chunks = []
|
||||
for _, _, audio in self._pipeline(
|
||||
text,
|
||||
voice=settings.tts_voice,
|
||||
speed=max(0.8, min(1.1, settings.tts_speed)),
|
||||
split_pattern=r"\n+",
|
||||
):
|
||||
chunks.append(self._apply_edge_fade(np.asarray(audio, dtype=np.float32), 24000))
|
||||
if chunks:
|
||||
return np.concatenate(chunks), 24000
|
||||
|
||||
# 0.5s silence fallback for flow verification
|
||||
return np.zeros(12000, dtype=np.float32), 24000
|
||||
if not self._ready or self._pipeline is None:
|
||||
return np.zeros(1, dtype=np.float32), 24000
|
||||
try:
|
||||
voice_path = str(self._voice_pt_path())
|
||||
except (OSError, ValueError, RuntimeError) as exc:
|
||||
logger.error("TTS voice: %s", exc)
|
||||
return np.zeros(1, dtype=np.float32), 24000
|
||||
chunks = []
|
||||
for _, _, audio in self._pipeline(
|
||||
text,
|
||||
voice=voice_path,
|
||||
speed=max(0.8, min(1.1, settings.tts_speed)),
|
||||
split_pattern=r"\n+",
|
||||
):
|
||||
chunks.append(self._apply_edge_fade(np.asarray(audio, dtype=np.float32), 24000))
|
||||
if chunks:
|
||||
return np.concatenate(chunks), 24000
|
||||
return np.zeros(1, dtype=np.float32), 24000
|
||||
|
||||
@property
|
||||
def health(self) -> dict:
|
||||
|
||||
+9
-2
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from config import vad_bundle_path
|
||||
|
||||
|
||||
class VADService:
|
||||
def __init__(self) -> None:
|
||||
@@ -15,10 +17,15 @@ class VADService:
|
||||
if self._ready or self._attempted:
|
||||
return
|
||||
self._attempted = True
|
||||
path = vad_bundle_path().resolve()
|
||||
if not path.is_file():
|
||||
self._error = f"missing Silero VAD weights: {path}"
|
||||
return
|
||||
try:
|
||||
from silero_vad import VADIterator, load_silero_vad
|
||||
from silero_vad import VADIterator
|
||||
from silero_vad.utils_vad import init_jit_model
|
||||
|
||||
self._model = load_silero_vad()
|
||||
self._model = init_jit_model(str(path))
|
||||
self._iterator = VADIterator(
|
||||
self._model,
|
||||
threshold=0.65,
|
||||
|
||||
Reference in New Issue
Block a user