save code
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+27
-16
@@ -15,25 +15,36 @@ class ASRService:
|
||||
self._ready = False
|
||||
self._attempted = False
|
||||
self._init_error: Optional[str] = None
|
||||
self._device: str = "uninitialized"
|
||||
|
||||
def _ensure_loaded(self) -> None:
|
||||
if self._ready or self._attempted:
|
||||
return
|
||||
self._attempted = True
|
||||
try:
|
||||
from funasr import AutoModel
|
||||
from funasr.utils.postprocess_utils import rich_transcription_postprocess
|
||||
from funasr import AutoModel
|
||||
from funasr.utils.postprocess_utils import rich_transcription_postprocess
|
||||
|
||||
self._model = AutoModel(
|
||||
model="FunAudioLLM/SenseVoiceSmall",
|
||||
device="cuda:0",
|
||||
hub="hf",
|
||||
)
|
||||
self._post = rich_transcription_postprocess
|
||||
self._ready = True
|
||||
except Exception as exc: # pragma: no cover
|
||||
self._init_error = str(exc)
|
||||
logger.warning("ASR fallback mode: %s", exc)
|
||||
errors: list[str] = []
|
||||
for device in ("cuda:0", "cpu"):
|
||||
try:
|
||||
self._model = AutoModel(
|
||||
model="FunAudioLLM/SenseVoiceSmall",
|
||||
device=device,
|
||||
hub="hf",
|
||||
)
|
||||
self._post = rich_transcription_postprocess
|
||||
self._ready = True
|
||||
self._init_error = None
|
||||
self._device = device
|
||||
if device != "cuda:0":
|
||||
logger.warning("ASR running in degraded mode on %s", device)
|
||||
return
|
||||
except Exception as exc: # pragma: no cover
|
||||
errors.append(f"{device}: {exc}")
|
||||
|
||||
self._init_error = " | ".join(errors)
|
||||
self._device = "unavailable"
|
||||
logger.warning("ASR unavailable: %s", self._init_error)
|
||||
|
||||
async def transcribe(self, audio_16k: np.ndarray) -> str:
|
||||
self._ensure_loaded()
|
||||
@@ -46,9 +57,9 @@ class ASRService:
|
||||
)
|
||||
return self._post(res[0]["text"])
|
||||
|
||||
# Fallback for initial bring-up
|
||||
return "(ASR降级)语音已接收"
|
||||
# When ASR is unavailable, avoid emitting fake user text that would trigger a bogus LLM reply.
|
||||
return ""
|
||||
|
||||
@property
|
||||
def health(self) -> dict:
|
||||
return {"ready": self._ready, "attempted": self._attempted, "error": self._init_error}
|
||||
return {"ready": self._ready, "attempted": self._attempted, "device": self._device, "error": self._init_error}
|
||||
|
||||
+20
-3
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Optional
|
||||
from collections import deque
|
||||
|
||||
@@ -18,7 +19,7 @@ class LLMService:
|
||||
self._model = (settings.llm_model or "").strip()
|
||||
self._base_url = (settings.llm_base_url or "").strip()
|
||||
self._history: deque[dict] = deque(maxlen=12) # 6 turns (user+assistant)
|
||||
self._system_prompt = "请用中文口语化简短回复,1-2句。"
|
||||
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"
|
||||
@@ -51,6 +52,22 @@ class LLMService:
|
||||
self._error = str(exc)
|
||||
logger.warning("LLM online mode unavailable: %s", exc)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_reply_text(text: str) -> str:
|
||||
cleaned = (text or "").strip()
|
||||
if not cleaned:
|
||||
return ""
|
||||
cleaned = cleaned.replace("\r", "\n")
|
||||
cleaned = re.sub(r"```.*?```", "", cleaned, flags=re.S)
|
||||
cleaned = re.sub(r"`([^`]*)`", r"\1", cleaned)
|
||||
cleaned = re.sub(r"\*\*(.*?)\*\*", r"\1", cleaned)
|
||||
cleaned = re.sub(r"\*(.*?)\*", r"\1", cleaned)
|
||||
cleaned = re.sub(r"\s*([,。!?;:、,.!?;:])\s*", r"\1", cleaned)
|
||||
cleaned = re.sub(r"(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])", "", cleaned)
|
||||
cleaned = re.sub(r"\n+", " ", cleaned)
|
||||
cleaned = re.sub(r"\s{2,}", " ", cleaned)
|
||||
return cleaned.strip()
|
||||
|
||||
async def reply_with_meta(self, text: str) -> tuple[str, str]:
|
||||
if not self._ready or self._client is None:
|
||||
return f"收到:{text}。这是本地兜底回复。", "fallback"
|
||||
@@ -60,9 +77,9 @@ class LLMService:
|
||||
model=self._model,
|
||||
messages=[{"role": "system", "content": self._system_prompt}, *list(self._history), {"role": "user", "content": text}],
|
||||
max_tokens=settings.llm_max_tokens,
|
||||
temperature=0.7,
|
||||
temperature=max(0.1, min(1.2, settings.llm_temperature)),
|
||||
)
|
||||
content = (resp.choices[0].message.content or "").strip()
|
||||
content = self._normalize_reply_text(resp.choices[0].message.content or "")
|
||||
if content:
|
||||
self._history.append({"role": "user", "content": text})
|
||||
self._history.append({"role": "assistant", "content": content})
|
||||
|
||||
+39
-2
@@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -28,12 +31,46 @@ class TTSService:
|
||||
self._init_error = str(exc)
|
||||
logger.warning("TTS fallback mode: %s", exc)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_text(text: str) -> str:
|
||||
cleaned = (text or "").strip()
|
||||
if not cleaned:
|
||||
return ""
|
||||
cleaned = cleaned.replace("\r", "\n")
|
||||
cleaned = re.sub(r"[\t\f\v]+", " ", cleaned)
|
||||
cleaned = re.sub(r"\s*([,。!?;:、,.!?;:])\s*", r"\1", cleaned)
|
||||
cleaned = re.sub(r"(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])", "", cleaned)
|
||||
cleaned = re.sub(r"\n{2,}", "\n", cleaned)
|
||||
cleaned = re.sub(r"\s{2,}", " ", cleaned)
|
||||
return cleaned.strip()
|
||||
|
||||
@staticmethod
|
||||
def _apply_edge_fade(audio: np.ndarray, sr: int) -> np.ndarray:
|
||||
if audio.size == 0:
|
||||
return audio
|
||||
fade_samples = max(1, min(int(sr * max(settings.tts_fade_ms, 0) / 1000.0), audio.shape[0] // 8))
|
||||
if fade_samples <= 1:
|
||||
return audio
|
||||
out = np.array(audio, copy=True)
|
||||
ramp = np.linspace(0.0, 1.0, fade_samples, dtype=np.float32)
|
||||
out[:fade_samples] *= ramp
|
||||
out[-fade_samples:] *= ramp[::-1]
|
||||
return out
|
||||
|
||||
async def synthesize(self, text: str) -> tuple[np.ndarray, int]:
|
||||
text = self._normalize_text(text)
|
||||
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="zf_xiaoxiao"):
|
||||
chunks.append(audio)
|
||||
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
|
||||
|
||||
|
||||
+3
-3
@@ -21,10 +21,10 @@ class VADService:
|
||||
self._model = load_silero_vad()
|
||||
self._iterator = VADIterator(
|
||||
self._model,
|
||||
threshold=0.5,
|
||||
threshold=0.65,
|
||||
sampling_rate=16000,
|
||||
min_silence_duration_ms=300,
|
||||
speech_pad_ms=30,
|
||||
min_silence_duration_ms=450,
|
||||
speech_pad_ms=80,
|
||||
)
|
||||
self._ready = True
|
||||
except Exception as exc: # pragma: no cover
|
||||
|
||||
Reference in New Issue
Block a user