Initial SmartMeeting deployment
42
.gitignore
vendored
Normal file
@ -0,0 +1,42 @@
|
||||
# Runtime data
|
||||
deploy/data/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Logs and build output
|
||||
*.log
|
||||
logs/
|
||||
deploy/build*.log
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
*.env.local
|
||||
|
||||
# Backups and generated platform files
|
||||
.flatten-backup-*/
|
||||
.flatten-backup-local-*/
|
||||
.DS_Store
|
||||
.claude/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.bak
|
||||
*.bak-*
|
||||
|
||||
# Audio/media uploads and test captures
|
||||
*.wav
|
||||
*.mp3
|
||||
*.m4a
|
||||
*.webm
|
||||
*.ogg
|
||||
*.flac
|
||||
|
||||
# Node/Python caches
|
||||
node_modules/
|
||||
.venv/
|
||||
venv/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Large local models
|
||||
models/
|
||||
611
asr/main.py
Normal file
@ -0,0 +1,611 @@
|
||||
"""
|
||||
FunASR Paraformer 语音识别 FastAPI 服务
|
||||
|
||||
ASR 模型: speech_seaco_paraformer_large_asr_nat-zh-cn-16k-common-vocab8404-pytorch
|
||||
VAD 模型: speech_fsmn_vad_zh-cn-16k-common-pytorch
|
||||
PUNC 模型: punc_ct-transformer_zh-cn-common-vocab272727-pytorch
|
||||
端口: 59805
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import tempfile
|
||||
import logging
|
||||
import subprocess
|
||||
import wave
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
from funasr import AutoModel
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
_CN_NUM = {
|
||||
"零": 0,
|
||||
"一": 1,
|
||||
"幺": 1,
|
||||
"二": 2,
|
||||
"两": 2,
|
||||
"三": 3,
|
||||
"四": 4,
|
||||
"五": 5,
|
||||
"六": 6,
|
||||
"七": 7,
|
||||
"八": 8,
|
||||
"九": 9,
|
||||
}
|
||||
|
||||
_CN_UNIT = {
|
||||
"十": 10,
|
||||
"百": 100,
|
||||
"千": 1000,
|
||||
"万": 10000,
|
||||
"亿": 100000000,
|
||||
}
|
||||
|
||||
_CN_PATTERN = re.compile(r"[零一幺二两三四五六七八九十百千万亿]+")
|
||||
|
||||
|
||||
def _cn2num(s: str) -> int:
|
||||
total, section, num = 0, 0, 0
|
||||
|
||||
for ch in s:
|
||||
if ch in _CN_NUM:
|
||||
num = _CN_NUM[ch]
|
||||
elif ch in _CN_UNIT:
|
||||
unit = _CN_UNIT[ch]
|
||||
if unit >= 10000:
|
||||
section = (section + (num or 1)) * unit
|
||||
total += section
|
||||
section = 0
|
||||
else:
|
||||
section += (num or 1) * unit
|
||||
num = 0
|
||||
|
||||
return total + section + num
|
||||
|
||||
|
||||
def cn_to_arabic(text: str) -> str:
|
||||
return _CN_PATTERN.sub(lambda m: str(_cn2num(m.group())), text)
|
||||
|
||||
|
||||
def read_txt_file(file_path: str) -> str:
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
return file.read()
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"找不到热词文件: {file_path}")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.warning(f"读取热词文件失败: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Paraformer ASR API",
|
||||
description="基于 FunASR Paraformer 的中文语音识别服务",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
|
||||
model = None
|
||||
VOICE_DETECTED_URL = os.environ.get("VOICE_DETECTED_URL", "http://127.0.0.1:18000")
|
||||
VOICEPRINT_STORE = os.environ.get("VOICEPRINT_STORE", "/tmp/asr_voiceprints.json")
|
||||
VOICEPRINT_MATCH_THRESHOLD = float(os.environ.get("VOICEPRINT_MATCH_THRESHOLD", "0.45"))
|
||||
|
||||
|
||||
def _voiceprint_key(database_name: str, collection_name: str) -> str:
|
||||
return f"{database_name or 'NB'}::{collection_name or 'voice'}"
|
||||
|
||||
|
||||
def _load_voiceprints() -> dict:
|
||||
if not os.path.exists(VOICEPRINT_STORE):
|
||||
return {}
|
||||
try:
|
||||
with open(VOICEPRINT_STORE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception as e:
|
||||
logger.warning(f"读取声纹库失败,将使用空库: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _save_voiceprints(data: dict):
|
||||
os.makedirs(os.path.dirname(VOICEPRINT_STORE) or ".", exist_ok=True)
|
||||
tmp_path = f"{VOICEPRINT_STORE}.tmp"
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False)
|
||||
os.replace(tmp_path, VOICEPRINT_STORE)
|
||||
|
||||
|
||||
def _extract_embedding(audio_path: str, filename: str) -> list:
|
||||
with open(audio_path, "rb") as f:
|
||||
response = requests.post(
|
||||
f"{VOICE_DETECTED_URL.rstrip('/')}/extract_embedding",
|
||||
files={"wav_file": (filename or "voice.wav", f, "audio/wav")},
|
||||
timeout=120,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
embedding = payload.get("embedding")
|
||||
if not isinstance(embedding, list):
|
||||
raise ValueError("voice-dected 返回中没有有效 embedding")
|
||||
return embedding
|
||||
|
||||
|
||||
def _convert_to_embedding_wav(audio_path: str) -> str:
|
||||
output = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
|
||||
output_path = output.name
|
||||
output.close()
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
audio_path,
|
||||
"-acodec",
|
||||
"pcm_s16le",
|
||||
"-ar",
|
||||
"16000",
|
||||
"-ac",
|
||||
"1",
|
||||
output_path,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
return output_path
|
||||
except Exception:
|
||||
if os.path.exists(output_path):
|
||||
os.unlink(output_path)
|
||||
raise
|
||||
|
||||
|
||||
def _cosine_similarity(a: list, b: list) -> float:
|
||||
if not a or not b or len(a) != len(b):
|
||||
return -1.0
|
||||
dot = sum(float(x) * float(y) for x, y in zip(a, b))
|
||||
norm_a = sum(float(x) * float(x) for x in a) ** 0.5
|
||||
norm_b = sum(float(y) * float(y) for y in b) ** 0.5
|
||||
if norm_a == 0 or norm_b == 0:
|
||||
return -1.0
|
||||
return dot / (norm_a * norm_b)
|
||||
|
||||
|
||||
def _match_voiceprint(audio_path: str, filename: str, database_name: str = "NB", collection_name: str = "voice"):
|
||||
store = _load_voiceprints()
|
||||
records = store.get(_voiceprint_key(database_name, collection_name), {})
|
||||
if not records:
|
||||
return None, None
|
||||
|
||||
embedding_path = _convert_to_embedding_wav(audio_path)
|
||||
try:
|
||||
embedding = _extract_embedding(embedding_path, "match.wav")
|
||||
finally:
|
||||
if os.path.exists(embedding_path):
|
||||
os.unlink(embedding_path)
|
||||
|
||||
best_name = None
|
||||
best_score = -1.0
|
||||
|
||||
for name, record in records.items():
|
||||
score = _cosine_similarity(embedding, record.get("embedding", []))
|
||||
if score > best_score:
|
||||
best_name = name
|
||||
best_score = score
|
||||
|
||||
logger.info(f"声纹匹配结果: name={best_name}, score={best_score:.4f}, threshold={VOICEPRINT_MATCH_THRESHOLD}")
|
||||
if best_name and best_score >= VOICEPRINT_MATCH_THRESHOLD:
|
||||
return best_name, best_score
|
||||
return None, best_score
|
||||
|
||||
|
||||
def load_model():
|
||||
global model
|
||||
|
||||
if model is None:
|
||||
logger.info("正在加载 Paraformer 模型...")
|
||||
model = AutoModel(
|
||||
model="/app/models/seacomodel",
|
||||
vad_model="/app/models/vadmodel",
|
||||
punc_model="/app/models/ctpuncmodel",
|
||||
)
|
||||
logger.info("模型加载完成")
|
||||
|
||||
return model
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
load_model()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Paraformer ASR 服务运行中",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
async def run_asr_websocket(websocket: WebSocket):
|
||||
await websocket.accept(subprotocol="binary")
|
||||
audio_chunks = []
|
||||
|
||||
try:
|
||||
while True:
|
||||
message = await websocket.receive()
|
||||
|
||||
if "bytes" in message and message["bytes"]:
|
||||
audio_chunks.append(message["bytes"])
|
||||
continue
|
||||
|
||||
if "text" in message and message["text"]:
|
||||
try:
|
||||
payload = json.loads(message["text"])
|
||||
except Exception:
|
||||
payload = {}
|
||||
|
||||
if payload.get("type") == "stop" or payload.get("is_speaking") is False:
|
||||
break
|
||||
continue
|
||||
|
||||
if message.get("type") == "websocket.disconnect":
|
||||
break
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
|
||||
text = ""
|
||||
tmp_path = None
|
||||
try:
|
||||
if audio_chunks:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
with wave.open(tmp_path, "wb") as wav_file:
|
||||
wav_file.setnchannels(1)
|
||||
wav_file.setsampwidth(2)
|
||||
wav_file.setframerate(16000)
|
||||
wav_file.writeframes(b"".join(audio_chunks))
|
||||
|
||||
result = await run_asr(tmp_path, language="zh", response_format="json")
|
||||
if isinstance(result, dict):
|
||||
text = result.get("text", "")
|
||||
|
||||
await websocket.send_json({
|
||||
"mode": "2pass-offline",
|
||||
"text": text,
|
||||
"is_final": True,
|
||||
})
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@app.websocket("/ws/asr")
|
||||
async def websocket_asr(websocket: WebSocket):
|
||||
await run_asr_websocket(websocket)
|
||||
|
||||
|
||||
@app.websocket("/")
|
||||
async def websocket_asr_root(websocket: WebSocket):
|
||||
await run_asr_websocket(websocket)
|
||||
|
||||
|
||||
def make_speakr_response(text: str, language: str = "zh", speaker: str = "SPEAKER_00"):
|
||||
text = (text or "").strip()
|
||||
return {
|
||||
"text": text,
|
||||
"language": language,
|
||||
"segments": [
|
||||
{
|
||||
"start": None,
|
||||
"end": None,
|
||||
"text": text,
|
||||
"speaker": speaker or "SPEAKER_00",
|
||||
}
|
||||
] if text else [],
|
||||
}
|
||||
|
||||
|
||||
async def run_asr(
|
||||
audio_path: str,
|
||||
language: str = "zh",
|
||||
response_format: str = "json",
|
||||
):
|
||||
try:
|
||||
hotword = read_txt_file("/app/hotword.txt")
|
||||
result = model.generate(input=audio_path, hotword=hotword)
|
||||
|
||||
if result and len(result) > 0:
|
||||
text = result[0].get("text", "")
|
||||
else:
|
||||
text = ""
|
||||
|
||||
text = cn_to_arabic(text)
|
||||
logger.info(f"识别结果: {text}")
|
||||
|
||||
if response_format == "text":
|
||||
return PlainTextResponse(content=text)
|
||||
|
||||
return {
|
||||
"text": text,
|
||||
"language": language,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"识别出错: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"识别失败: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/api/v1/audio/transcriptions")
|
||||
async def transcribe(
|
||||
request: Request,
|
||||
file: Optional[UploadFile] = File(None),
|
||||
language: Optional[str] = Form("zh"),
|
||||
response_format: Optional[str] = Form("json"),
|
||||
):
|
||||
"""
|
||||
语音转文字接口,兼容两种方式:
|
||||
|
||||
1. multipart/form-data 上传文件:
|
||||
- file: 音频文件
|
||||
- language: 默认 zh
|
||||
- response_format: json/text
|
||||
|
||||
2. application/json 传服务器本地文件路径:
|
||||
{
|
||||
"file_path": "/app/audio/test.wav",
|
||||
"language": "zh",
|
||||
"response_format": "json"
|
||||
}
|
||||
"""
|
||||
|
||||
tmp_path = None
|
||||
should_delete = False
|
||||
|
||||
try:
|
||||
content_type = request.headers.get("content-type", "").lower()
|
||||
|
||||
if "application/json" in content_type:
|
||||
body = await request.json()
|
||||
|
||||
file_path = body.get("file_path")
|
||||
language = body.get("language", language)
|
||||
response_format = body.get("response_format", response_format)
|
||||
|
||||
if not file_path:
|
||||
raise HTTPException(status_code=400, detail="缺少 file_path")
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=400, detail=f"文件不存在: {file_path}")
|
||||
|
||||
if not os.path.isfile(file_path):
|
||||
raise HTTPException(status_code=400, detail=f"不是有效文件: {file_path}")
|
||||
|
||||
audio_path = file_path
|
||||
logger.info(f"处理服务器本地文件: {audio_path}")
|
||||
|
||||
else:
|
||||
if file is None:
|
||||
raise HTTPException(status_code=400, detail="缺少上传文件 file")
|
||||
|
||||
suffix = os.path.splitext(file.filename or "")[1] or ".wav"
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||
content = await file.read()
|
||||
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="上传文件为空")
|
||||
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
should_delete = True
|
||||
|
||||
audio_path = tmp_path
|
||||
logger.info(
|
||||
f"处理上传文件: {file.filename}, 大小: {len(content)} bytes, 临时路径: {audio_path}"
|
||||
)
|
||||
|
||||
return await run_asr(
|
||||
audio_path=audio_path,
|
||||
language=language or "zh",
|
||||
response_format=response_format or "json",
|
||||
)
|
||||
|
||||
finally:
|
||||
if should_delete and tmp_path and os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
@app.post("/v1/audio/transcriptions")
|
||||
async def transcribe_v1(
|
||||
request: Request,
|
||||
file: Optional[UploadFile] = File(None),
|
||||
language: Optional[str] = Form("zh"),
|
||||
response_format: Optional[str] = Form("json"),
|
||||
):
|
||||
return await transcribe(
|
||||
request=request,
|
||||
file=file,
|
||||
language=language,
|
||||
response_format=response_format,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/asr")
|
||||
async def transcribe_speakr_compatible(
|
||||
audio_file: UploadFile = File(...),
|
||||
language: Optional[str] = Form("zh"),
|
||||
database_name: Optional[str] = Form("NB"),
|
||||
collection_name: Optional[str] = Form("voice"),
|
||||
):
|
||||
"""
|
||||
Speakr 兼容接口:
|
||||
- 上传字段:audio_file
|
||||
- 返回字段:segments,其中每个 segment 含 text/speaker/start/end
|
||||
"""
|
||||
|
||||
tmp_path = None
|
||||
|
||||
try:
|
||||
suffix = os.path.splitext(audio_file.filename or "")[1] or ".wav"
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||
content = await audio_file.read()
|
||||
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="上传文件为空")
|
||||
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
logger.info(
|
||||
f"处理 Speakr /asr 上传文件: {audio_file.filename}, 大小: {len(content)} bytes, 临时路径: {tmp_path}"
|
||||
)
|
||||
|
||||
result = await run_asr(
|
||||
audio_path=tmp_path,
|
||||
language=language or "zh",
|
||||
response_format="json",
|
||||
)
|
||||
speaker_name = "SPEAKER_00"
|
||||
try:
|
||||
matched_name, score = _match_voiceprint(
|
||||
tmp_path,
|
||||
audio_file.filename or "audio.wav",
|
||||
database_name or "NB",
|
||||
collection_name or "voice",
|
||||
)
|
||||
if matched_name:
|
||||
speaker_name = matched_name
|
||||
except Exception as e:
|
||||
logger.warning(f"声纹匹配失败,使用默认说话人: {e}")
|
||||
|
||||
return make_speakr_response(
|
||||
text=result.get("text", "") if isinstance(result, dict) else "",
|
||||
language=result.get("language", language or "zh") if isinstance(result, dict) else language or "zh",
|
||||
speaker=speaker_name,
|
||||
)
|
||||
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
@app.get("/get_voice_name")
|
||||
async def get_voice_name(
|
||||
database_name: str = "NB",
|
||||
collection_name: str = "voice",
|
||||
):
|
||||
store = _load_voiceprints()
|
||||
records = store.get(_voiceprint_key(database_name, collection_name), {})
|
||||
return {
|
||||
"code": 0,
|
||||
"success": True,
|
||||
"message": "ok",
|
||||
"data": [
|
||||
{"name": name, "voice_name": name}
|
||||
for name in sorted(records.keys())
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/voice_insert")
|
||||
async def voice_insert(
|
||||
voice_name: str = Form(...),
|
||||
database_name: str = Form("NB"),
|
||||
collection_name: str = Form("voice"),
|
||||
voice_file: UploadFile = File(...),
|
||||
):
|
||||
tmp_path = None
|
||||
|
||||
try:
|
||||
suffix = os.path.splitext(voice_file.filename or "")[1] or ".wav"
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||
content = await voice_file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="上传声纹文件为空")
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
embedding = _extract_embedding(tmp_path, voice_file.filename or f"{voice_name}.wav")
|
||||
store = _load_voiceprints()
|
||||
key = _voiceprint_key(database_name, collection_name)
|
||||
records = store.setdefault(key, {})
|
||||
records[voice_name] = {
|
||||
"name": voice_name,
|
||||
"database_name": database_name,
|
||||
"collection_name": collection_name,
|
||||
"embedding": embedding,
|
||||
}
|
||||
_save_voiceprints(store)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"success": True,
|
||||
"message": "voice inserted",
|
||||
"data": {"name": voice_name, "embedding_size": len(embedding)},
|
||||
}
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"调用 voice-dected 失败: {e}")
|
||||
raise HTTPException(status_code=502, detail=f"voice-dected 调用失败: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"写入声纹失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"写入声纹失败: {e}")
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
@app.delete("/delete_voice")
|
||||
async def delete_voice(
|
||||
name: str,
|
||||
database_name: str = "NB",
|
||||
collection_name: str = "voice",
|
||||
):
|
||||
store = _load_voiceprints()
|
||||
key = _voiceprint_key(database_name, collection_name)
|
||||
records = store.setdefault(key, {})
|
||||
existed = name in records
|
||||
records.pop(name, None)
|
||||
_save_voiceprints(store)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"success": True,
|
||||
"message": "voice deleted" if existed else "voice not found",
|
||||
"data": {"name": name, "deleted": existed},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host="0.0.0.0",
|
||||
port=59805,
|
||||
reload=False,
|
||||
workers=1,
|
||||
log_level="info",
|
||||
)
|
||||
40
config.env
Normal file
@ -0,0 +1,40 @@
|
||||
# Shared runtime config for /storage01/home/xll/202607/voice/docker-compose.yml
|
||||
|
||||
# Speakr / ASR
|
||||
USE_ASR_ENDPOINT=true
|
||||
ASR_BASE_URL=http://asr-test:59805
|
||||
ASR_DIARIZE=true
|
||||
ASR_MIN_SPEAKERS=1
|
||||
ASR_MAX_SPEAKERS=10
|
||||
|
||||
# LLM used by Speakr summaries and chat
|
||||
TEXT_MODEL_BASE_URL=http://192.168.0.46:59800/v1
|
||||
TEXT_MODEL_NAME=46-qwen3.5-35B
|
||||
TEXT_MODEL_API_KEY=none
|
||||
|
||||
# Realtime WebSocket bridge
|
||||
TARGET_WS_URL=ws://asr-test:59805/ws/asr
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
LLM_API_KEY=none
|
||||
LLM_BASE_URL=http://192.168.0.46:59800/v1
|
||||
LLM_MODEL=46-qwen3.5-35B
|
||||
USE_LLM_ROLE_IDENTIFICATION=false
|
||||
LLM_ROLE_CANDIDATES=主持人,正方辩手,反方辩手,计时员,评委,嘉宾,未知
|
||||
LLM_ROLE_MIN_TEXT_LEN=8
|
||||
LLM_ROLE_CACHE_CONFIDENCE=0.82
|
||||
LLM_ROLE_HISTORY_MAX_CHARS=1200
|
||||
|
||||
# ASR voiceprint adapter
|
||||
VOICE_DETECTED_URL=http://voice-dected:8000
|
||||
VOICEPRINT_STORE=/data/asr_voiceprints.json
|
||||
VOICEPRINT_MATCH_THRESHOLD=0.45
|
||||
|
||||
# fastapi_wss legacy/local ASR-with-speaker helper
|
||||
ASR_LOCAL_URL=http://asr-test:59805/asr
|
||||
SPEAKER_DET_URL=http://voice-dected:8000/extract_embedding
|
||||
|
||||
# Runtime
|
||||
DEVICE=cuda
|
||||
CUDA_VISIBLE_DEVICES=0
|
||||
NVIDIA_VISIBLE_DEVICES=7
|
||||
PYTHONUNBUFFERED=1
|
||||
41
deploy/docker-compose.yml
Normal file
@ -0,0 +1,41 @@
|
||||
services:
|
||||
speakr:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/speakr.runtime.Dockerfile
|
||||
image: voice-speakr:runtime
|
||||
container_name: voice-speakr
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
environment:
|
||||
PYTHONPATH: /app
|
||||
SQLALCHEMY_DATABASE_URI: sqlite:////data/instance/transcriptions.db
|
||||
UPLOAD_FOLDER: /data/uploads
|
||||
USE_ASR_ENDPOINT: "true"
|
||||
ASR_BASE_URL: http://192.168.0.46:59805
|
||||
TEXT_MODEL_BASE_URL: http://192.168.0.46:59800/v1
|
||||
TEXT_MODEL_NAME: 46-qwen3.5-35B
|
||||
TEXT_MODEL_API_KEY: none
|
||||
volumes:
|
||||
- ../speakr:/app
|
||||
- ./data/speakr/uploads:/data/uploads
|
||||
- ./data/speakr/instance:/data/instance
|
||||
ports:
|
||||
- "8899:8899"
|
||||
depends_on:
|
||||
- voice-dected
|
||||
|
||||
voice-dected:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/voice-dected.runtime.Dockerfile
|
||||
image: voice-dected:runtime
|
||||
container_name: voice-dected
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
environment:
|
||||
PYTHONPATH: /app
|
||||
volumes:
|
||||
- ../voice-dected/src:/app
|
||||
ports:
|
||||
- "18000:8000"
|
||||
29
deploy/speakr.runtime.Dockerfile
Normal file
@ -0,0 +1,29 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PYTHONPATH=/app \
|
||||
FLASK_APP=src/app.py \
|
||||
SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db \
|
||||
UPLOAD_FOLDER=/data/uploads
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
libgomp1 \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY speakr/requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --upgrade pip setuptools wheel \
|
||||
&& pip install --index-url https://download.pytorch.org/whl/cpu torch \
|
||||
&& pip install -r /tmp/requirements.txt \
|
||||
&& rm -rf /root/.cache/pip
|
||||
|
||||
RUN mkdir -p /data/uploads /data/instance
|
||||
|
||||
EXPOSE 8899
|
||||
|
||||
CMD ["sh", "-c", "mkdir -p /data/uploads /data/instance && python -c \"from src.app import app; from src.clients import db; app.app_context().push(); db.create_all()\" && exec gunicorn --workers ${GUNICORN_WORKERS:-3} --threads ${GUNICORN_THREADS:-8} --bind 0.0.0.0:8899 --timeout ${GUNICORN_TIMEOUT:-600} src.app:app"]
|
||||
7
deploy/start.sh
Executable file
@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
mkdir -p data/speakr/uploads data/speakr/instance
|
||||
docker compose up -d --build
|
||||
docker compose ps
|
||||
27
deploy/voice-dected.runtime.Dockerfile
Normal file
@ -0,0 +1,27 @@
|
||||
FROM python:3.8-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PYTHONPATH=/app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libsndfile1 \
|
||||
sox \
|
||||
libsox-dev \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install --upgrade pip setuptools wheel \
|
||||
&& pip install --index-url https://download.pytorch.org/whl/cpu torch torchaudio
|
||||
|
||||
COPY voice-dected/requirements.txt /tmp/requirements.txt
|
||||
RUN grep -v -E '^[[:space:]]*(torch|torchaudio)[[:space:]]*$' /tmp/requirements.txt > /tmp/requirements-no-torch.txt \
|
||||
&& pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r /tmp/requirements-no-torch.txt \
|
||||
&& rm -rf /root/.cache/pip
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
128
docker-compose.yml
Normal file
@ -0,0 +1,128 @@
|
||||
services:
|
||||
speakr:
|
||||
image: voice-speakr:runtime
|
||||
container_name: voice-speakr
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
env_file:
|
||||
- ./config.env
|
||||
environment:
|
||||
PYTHONPATH: /app
|
||||
SQLALCHEMY_DATABASE_URI: sqlite:////data/instance/transcriptions.db
|
||||
UPLOAD_FOLDER: /data/uploads
|
||||
USE_ASR_ENDPOINT: "true"
|
||||
ASR_BASE_URL: http://asr-test:59805
|
||||
volumes:
|
||||
- ./speakr:/app
|
||||
- ./deploy/data/speakr/uploads:/data/uploads
|
||||
- ./deploy/data/speakr/instance:/data/instance
|
||||
ports:
|
||||
- "8899:8899"
|
||||
depends_on:
|
||||
- asr-test
|
||||
- voice-dected
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- >
|
||||
mkdir -p /data/uploads /data/instance &&
|
||||
python -c "from src.app import app; from src.clients import db; app.app_context().push(); db.create_all()" &&
|
||||
exec gunicorn --workers $${GUNICORN_WORKERS:-3} --threads $${GUNICORN_THREADS:-8}
|
||||
--bind 0.0.0.0:8899 --timeout $${GUNICORN_TIMEOUT:-600} src.app:app
|
||||
|
||||
voice-dected:
|
||||
image: voice-dected:runtime
|
||||
container_name: voice-dected
|
||||
restart: unless-stopped
|
||||
working_dir: /app
|
||||
env_file:
|
||||
- ./config.env
|
||||
environment:
|
||||
PYTHONPATH: /app
|
||||
volumes:
|
||||
- ./voice-dected/src:/app
|
||||
ports:
|
||||
- "18000:8000"
|
||||
command:
|
||||
- uvicorn
|
||||
- app:app
|
||||
- --host
|
||||
- 0.0.0.0
|
||||
- --port
|
||||
- "8000"
|
||||
|
||||
asr-test:
|
||||
image: paraformer-asr:latest
|
||||
container_name: asr-test
|
||||
restart: unless-stopped
|
||||
runtime: nvidia
|
||||
gpus:
|
||||
- driver: nvidia
|
||||
device_ids:
|
||||
- "7"
|
||||
capabilities:
|
||||
- gpu
|
||||
working_dir: /app
|
||||
env_file:
|
||||
- ./config.env
|
||||
environment:
|
||||
DEVICE: cuda
|
||||
CUDA_VISIBLE_DEVICES: "0"
|
||||
NVIDIA_VISIBLE_DEVICES: "7"
|
||||
VOICE_DETECTED_URL: http://voice-dected:8000
|
||||
VOICEPRINT_STORE: /data/asr_voiceprints.json
|
||||
volumes:
|
||||
- ./asr/main.py:/app/main.py:ro
|
||||
- /storage01/home/xll/audio:/app/audio:ro
|
||||
- /storage01/home/xll/weixiu-hj/seacomodel:/app/models/seacomodel:ro
|
||||
- /storage01/home/xll/weixiu-hj/vadmodel:/app/models/vadmodel:ro
|
||||
- /storage01/home/xll/weixiu-hj/ctpuncmodel:/app/models/ctpuncmodel:ro
|
||||
- /storage01/home/xll/weixiu-hj/speech_seaco_paraformer_large_asr_nat-zh-cn-16k-common-vocab8404-pytorch/example/hotword.txt:/app/hotword.txt:ro
|
||||
- ./deploy/data/asr:/data
|
||||
ports:
|
||||
- "59805:59805"
|
||||
depends_on:
|
||||
- voice-dected
|
||||
command:
|
||||
- uvicorn
|
||||
- main:app
|
||||
- --host
|
||||
- 0.0.0.0
|
||||
- --port
|
||||
- "59805"
|
||||
- --workers
|
||||
- "1"
|
||||
|
||||
fastapi-wss:
|
||||
image: fastapi-wss:runtime
|
||||
container_name: fastapi-wss
|
||||
restart: unless-stopped
|
||||
working_dir: /app/src
|
||||
env_file:
|
||||
- ./config.env
|
||||
environment:
|
||||
TARGET_WS_URL: ws://asr-test:59805/ws/asr
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
volumes:
|
||||
- ./fastapi_wss:/app
|
||||
ports:
|
||||
- "10095:10095"
|
||||
depends_on:
|
||||
- asr-test
|
||||
- redis
|
||||
command:
|
||||
- python
|
||||
- a2a_wss.py
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: voice-redis
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./deploy/data/redis:/data
|
||||
ports:
|
||||
- "16380:6379"
|
||||
command:
|
||||
- redis-server
|
||||
- --appendonly
|
||||
- "yes"
|
||||
5
fastapi_wss/.env.example
Normal file
@ -0,0 +1,5 @@
|
||||
TARGET_WS_URL= ws://10.100.3.22:10095
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
LLM_API_KEY=http://192.168.0.46:59800/v1
|
||||
LLM_BASE_URL=http://192.168.0.46:59800/v1
|
||||
LLM_MODEL=
|
||||
29
fastapi_wss/Dockerfile
Normal file
@ -0,0 +1,29 @@
|
||||
# 使用轻量级的 Python 3.10 镜像
|
||||
FROM python:3.10-slim
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 设置环境变量
|
||||
# 1. 确保 Python 输出直接打印到终端,不进行缓冲(方便查看日志)
|
||||
# 2. 避免生成 pyc 文件
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# 复制依赖文件并安装
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt && \
|
||||
apt-get update && \
|
||||
apt-get install -y vim && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 复制当前目录下的所有代码到容器中
|
||||
COPY . .
|
||||
|
||||
WORKDIR /app/src
|
||||
|
||||
# 暴露端口 (对应代码中的 LOCAL_PORT)
|
||||
EXPOSE 10095
|
||||
|
||||
# 启动 a2a_wss 服务
|
||||
CMD ["python", "a2a_wss.py"]
|
||||
33
fastapi_wss/requirements.txt
Normal file
@ -0,0 +1,33 @@
|
||||
# WebSocket 智能语义分析服务 - 依赖包列表
|
||||
#
|
||||
# 安装方法:
|
||||
# pip install -r requirements.txt
|
||||
#
|
||||
# Python 版本要求:3.8+
|
||||
|
||||
# WebSocket 通信库
|
||||
websockets>=11.0
|
||||
|
||||
# 异步 HTTP 客户端(可选,用于扩展功能)
|
||||
aiohttp>=3.8.0
|
||||
|
||||
# Redis 异步客户端
|
||||
redis>=5.0.0
|
||||
|
||||
# Redis C 扩展(性能优化)
|
||||
hiredis
|
||||
|
||||
# OpenAI API 客户端(用于调用大语言模型)
|
||||
openai>=1.0.0
|
||||
|
||||
# 数值计算库(用于未来的特征处理)
|
||||
numpy
|
||||
python-dotenv
|
||||
# 注意:以下为 Python 标准库,无需安装:
|
||||
# - asyncio (异步编程)
|
||||
# - json (JSON 处理)
|
||||
# - os (操作系统接口)
|
||||
# - re (正则表达式)
|
||||
# - logging (日志记录)
|
||||
# - typing (类型提示)
|
||||
# - dataclasses (数据类)
|
||||
425
fastapi_wss/src/README.md
Normal file
@ -0,0 +1,425 @@
|
||||
# WebSocket 智能语义分析服务 - 长期记忆增强版
|
||||
|
||||
## 项目概述
|
||||
|
||||
这是一个基于 WebSocket 和 Agent-to-Agent (A2A) 架构的智能语音识别服务,具备说话人识别和长期记忆功能。系统能够在同一场会议中,通过学习每个说话人的发言特征,持续提高说话人识别的准确率。
|
||||
|
||||
## 核心特性
|
||||
|
||||
### 1. 实时语音识别处理
|
||||
- 接收前端音频流,转发到 ASR 引擎
|
||||
- 支持双通道模式(2pass)和 PCM 音频格式
|
||||
- 实时推送识别结果到前端
|
||||
|
||||
### 2. 多 Agent 协作架构
|
||||
- **AuditorAgent**: 判断说话人是否完成发言
|
||||
- **ProfilerAgent**: 识别说话人身份(支持长期记忆)
|
||||
|
||||
### 3. 长期记忆功能 ⭐
|
||||
- 为每个说话人建立特征档案
|
||||
- 持续学习说话人的发言风格和用词习惯
|
||||
- 利用会议历史上下文提高识别准确率
|
||||
|
||||
### 4. 完善的日志系统 📝
|
||||
- 自动创建日志目录,分级记录运行信息
|
||||
- 控制台输出:INFO 级别(运行状态)
|
||||
- 完整日志文件:DEBUG 级别(详细调试信息)
|
||||
- 错误日志文件:ERROR 级别(错误和异常)
|
||||
- 所有日志包含时间戳和上下文信息
|
||||
|
||||
---
|
||||
|
||||
## 日志系统说明
|
||||
|
||||
### 日志文件位置
|
||||
|
||||
程序运行时会在项目根目录下创建 `logs` 文件夹,包含以下日志文件:
|
||||
|
||||
```
|
||||
logs/
|
||||
├── a2a_wss.log # 完整日志(DEBUG 级别)
|
||||
└── a2a_wss_error.log # 错误日志(ERROR 级别)
|
||||
```
|
||||
|
||||
### 日志级别说明
|
||||
|
||||
| 级别 | 说明 | 使用场景 | 输出位置 |
|
||||
|------|------|----------|----------|
|
||||
| **DEBUG** | 调试信息 | 详细的程序执行流程、变量值等 | 仅日志文件 |
|
||||
| **INFO** | 一般信息 | 关键业务流程、状态变更 | 控制台 + 日志文件 |
|
||||
| **ERROR** | 错误信息 | 异常、错误堆栈 | 控制台 + 日志文件 + 错误日志 |
|
||||
|
||||
### 日志内容示例
|
||||
|
||||
```
|
||||
2025-01-07 14:30:15 - root - INFO - 启动 WebSocket 智能语义分析服务: ws://0.0.0.0:10095
|
||||
2025-01-07 14:30:15 - root - INFO - Agent 模型: Qwen3-32B
|
||||
2025-01-07 14:30:15 - root - INFO - ASR 服务地址: ws://localhost:59805/ws/asr
|
||||
2025-01-07 14:30:15 - root - INFO - Redis 地址: redis://localhost:6379/0
|
||||
2025-01-07 14:30:20 - root - INFO - 客户端已连接
|
||||
2025-01-07 14:30:20 - root - INFO - 已连接到 ASR 服务: ws://localhost:59805/ws/asr
|
||||
2025-01-07 14:30:22 - root - DEBUG - 收到 ASR 识别结果: 各位好,今天我们召开月度工作总结会议...
|
||||
2025-01-07 14:30:23 - root - INFO - [Auditor] 发言完成检测通过 - 会话ID: room_101
|
||||
2025-01-07 14:30:23 - root - DEBUG - 获取说话人特征库 - 会话ID: room_101, 说话人数量: 0
|
||||
2025-01-07 14:30:24 - root - INFO - [Profiler] 识别说话人: 主持人 - 会话ID: room_101
|
||||
```
|
||||
|
||||
### 日志配置
|
||||
|
||||
日志系统在代码中自动配置(无需手动配置),包含:
|
||||
|
||||
1. **日志格式**:`时间 - 记录器名称 - 级别 - 消息`
|
||||
2. **编码**:UTF-8(支持中文)
|
||||
3. **自动创建目录**:首次运行时自动创建 `logs` 文件夹
|
||||
4. **错误追踪**:ERROR 级别日志包含完整的异常堆栈信息
|
||||
|
||||
### 日志查看建议
|
||||
|
||||
- **实时监控**:`tail -f logs/a2a_wss.log`
|
||||
- **查看错误**:`cat logs/a2a_wss_error.log`
|
||||
- **搜索关键词**:`grep "识别说话人" logs/a2a_wss.log`
|
||||
- **统计分析**:`grep "ERROR" logs/a2a_wss_error.log | wc -l`
|
||||
|
||||
---
|
||||
|
||||
## 长期记忆功能详解
|
||||
|
||||
### 功能说明
|
||||
|
||||
长期记忆功能使得系统能够在同一场会议中,记住每个说话人的发言特征,并利用这些历史信息来辅助后续的说话人识别。
|
||||
|
||||
### 工作流程
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ 开始会议 │
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 接收语音片段 │
|
||||
└──────┬──────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 累积到缓冲区 │
|
||||
└──────┬──────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ Auditor: 判断发言 │
|
||||
│ 是否完成 │
|
||||
└──────┬──────────────┘
|
||||
│
|
||||
▼ (发言完成)
|
||||
┌──────────────────────────────┐
|
||||
│ 获取长期记忆 │
|
||||
│ (所有说话人的历史特征) │
|
||||
└──────┬───────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ Profiler: 识别说话人 │
|
||||
│ - 分析会议历史记录 │
|
||||
│ - 对比说话人特征库 │
|
||||
│ - 判断对话流程和上下文 │
|
||||
└──────┬───────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────┐
|
||||
│ 更新长期记忆 │
|
||||
│ (保存当前发言到特征库) │
|
||||
└──────┬───────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────┐
|
||||
│ 推送结果到前端 │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### 核心改进
|
||||
|
||||
#### 1. Redis 存储扩展
|
||||
|
||||
**新增方法**:
|
||||
|
||||
- `save_speaker_profile(sid, speaker, text, features)`: 保存说话人特征到长期记忆
|
||||
- 保留每个说话人最近 10 条发言记录
|
||||
- 存储发言文本片段(前 200 字符)
|
||||
- 记录时间戳和可选的特征向量
|
||||
|
||||
- `get_speaker_profiles(sid)`: 获取所有说话人的历史特征
|
||||
- 返回字典格式:`{说话人: [历史记录列表]}`
|
||||
- 用于 Agent 分析时参考
|
||||
|
||||
#### 2. ProfilerAgent 增强
|
||||
|
||||
**改进的识别逻辑**:
|
||||
|
||||
- **历史记录分析**:查看最近 15 条对话,理解会议上下文
|
||||
- **对话流程分析**:判断当前发言是对之前谁的话题的回应
|
||||
- **说话风格匹配**:对比每个角色的用词习惯、句式结构、语气
|
||||
- **角色职责判断**:根据发言内容是否符合角色职责范围
|
||||
- **话题连贯性**:当前发言与该角色历史话题的连贯性
|
||||
|
||||
**提示词优化**:
|
||||
|
||||
```python
|
||||
# 增强版系统提示词包含4个分析维度:
|
||||
1. 对话流程分析
|
||||
2. 说话风格匹配
|
||||
3. 角色职责判断
|
||||
4. 话题连贯性
|
||||
```
|
||||
|
||||
#### 3. A2AOrchestrator 集成
|
||||
|
||||
在 `process_asr_fragment()` 方法中集成长期记忆:
|
||||
|
||||
```python
|
||||
# 1. 获取长期记忆(说话人特征库)
|
||||
speaker_profiles = await self.redis.get_speaker_profiles(session_id)
|
||||
|
||||
# 2. 使用长期记忆识别说话人
|
||||
speaker = await self.profiler.identify(buf, history, speaker_profiles)
|
||||
|
||||
# 3. 保存到长期记忆(持续学习)
|
||||
await self.redis.save_speaker_profile(session_id, speaker, buf)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `TARGET_WS_URL` | ASR 服务的 WebSocket 地址 | `ws://localhost:59805/ws/asr` |
|
||||
| `REDIS_URL` | Redis 连接地址 | `redis://localhost:6379/0` |
|
||||
| `LLM_API_KEY` | 大语言模型 API Key | `sk-xxxx` |
|
||||
| `LLM_BASE_URL` | 大语言模型服务地址 | `http://172.18.127.124:9997/v1` |
|
||||
| `LLM_MODEL` | 使用的模型名称 | `Qwen3-32B` |
|
||||
|
||||
### 候选说话人列表
|
||||
|
||||
在代码中配置(`CANDIDATE_SPEAKERS`):
|
||||
```python
|
||||
CANDIDATE_SPEAKERS = [
|
||||
"局长",
|
||||
"主持人",
|
||||
"商务专员",
|
||||
"市场专员",
|
||||
"控制要素席",
|
||||
"空中侦察席"
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Redis 数据结构
|
||||
|
||||
### 键命名规范
|
||||
|
||||
```
|
||||
meeting:buffer:{session_id} # 会话文本缓冲
|
||||
meeting:history:{session_id} # 会话历史记录
|
||||
meeting:speaker_profile:{session_id}:{speaker} # 说话人特征库
|
||||
```
|
||||
|
||||
### 数据类型
|
||||
|
||||
- **缓冲区**: String (累积的未完成发言)
|
||||
- **历史记录**: List (最多 20 条)
|
||||
- **说话人特征**: List (每个说话人最多 10 条)
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 安装依赖
|
||||
|
||||
首先确保已安装 Python 3.8+,然后安装所需依赖:
|
||||
|
||||
```bash
|
||||
# 在项目根目录下执行
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
# 在 src 目录下执行
|
||||
python a2a_wss.py
|
||||
```
|
||||
|
||||
服务将监听 `ws://0.0.0.0:10095`
|
||||
|
||||
### 连接和消息格式
|
||||
|
||||
**前端发送配置**:
|
||||
```json
|
||||
{
|
||||
"mode": "2pass",
|
||||
"chunk_size": [10, 10, 10],
|
||||
"wav_format": "pcm"
|
||||
}
|
||||
```
|
||||
|
||||
**前端发送音频**: 二进制 PCM 数据
|
||||
|
||||
**后端返回结果**:
|
||||
```json
|
||||
{
|
||||
"mode": "offline-speaker",
|
||||
"type": "asr_with_speaker",
|
||||
"segments": [{
|
||||
"id": 0,
|
||||
"speaker": "局长",
|
||||
"embedding": null,
|
||||
"seek": 0,
|
||||
"full_text": "今天我们讨论一下市场推广方案"
|
||||
}],
|
||||
"speaker": "局长"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 长期记忆的工作示例
|
||||
|
||||
### 会议初期(无记忆)
|
||||
|
||||
```
|
||||
历史记录: [暂无]
|
||||
说话人特征库: [暂无]
|
||||
|
||||
当前发言: "各位好,今天我们召开月度工作总结会议"
|
||||
|
||||
识别结果: 主持人 (基于内容特征)
|
||||
```
|
||||
|
||||
### 会议中期(有部分记忆)
|
||||
|
||||
```
|
||||
历史记录:
|
||||
[1] 主持人: 各位好,今天我们召开月度工作总结会议
|
||||
[2] 局长: 本月工作总体进展顺利
|
||||
[3] 商务专员: 我们完成了三个重要合同的签署
|
||||
[4] 市场专员: 市场推广活动覆盖了五个新城市
|
||||
|
||||
说话人特征库:
|
||||
- 【主持人】(发言1次): 各位好,今天我们召开月度工作总结会议
|
||||
- 【局长】(发言1次): 本月工作总体进展顺利
|
||||
- 【商务专员】(发言1次): 我们完成了三个重要合同的签署
|
||||
- 【市场专员】(发言1次): 市场推广活动覆盖了五个新城市
|
||||
|
||||
当前发言: "关于下个月的计划,我们需要重点关注客户反馈"
|
||||
|
||||
识别结果: 市场专员
|
||||
分析:
|
||||
- 延续了市场专员之前的话题(市场推广)
|
||||
- 符合市场专员的职责范围(客户反馈)
|
||||
- 用词风格与历史记录匹配
|
||||
```
|
||||
|
||||
### 会议后期(丰富记忆)
|
||||
|
||||
```
|
||||
历史记录: [15条完整对话记录]
|
||||
|
||||
说话人特征库:
|
||||
- 【局长】(发言4次): 本月工作总体进展顺利; 很好,各部门配合默契;
|
||||
这次会议很有成效; 最后强调一下安全问题
|
||||
- 【主持人】(发言3次): 各位好,今天我们召开月度工作总结会议;
|
||||
下面请商务专员汇报; 时间关系,今天的会议就到这里
|
||||
- 【商务专员】(发言5次): 我们完成了三个重要合同的签署;
|
||||
这三个合同总金额达500万; 客户对我们的方案很满意;
|
||||
下季度重点跟进大客户; 需要技术部门的支持
|
||||
...
|
||||
|
||||
当前发言: "技术部门会全力支持商务工作,确保项目交付质量"
|
||||
|
||||
识别结果: (未出现在候选列表中,或需要进一步判断)
|
||||
分析:
|
||||
- 提到了"技术部门",可能是技术相关角色
|
||||
- 对之前商务专员的话题做出回应
|
||||
- 风式偏向技术人员的特点(强调"质量"、"交付")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 性能优化
|
||||
|
||||
### 1. 数据限制
|
||||
- 历史记录:最多保留 20 条
|
||||
- 每个说话人特征:最多保留 10 条
|
||||
- LLM 上下文:使用最近 15 条历史记录
|
||||
|
||||
### 2. 异步处理
|
||||
- Agent 处理使用后台任务,不阻塞主循环
|
||||
- 连接关闭时等待任务完成(最多 5 秒)
|
||||
|
||||
### 3. Redis 连接池
|
||||
- 使用连接池提高并发性能
|
||||
- 自动编码/解码 JSON 数据
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Python 3.8+**
|
||||
- **websockets**: WebSocket 通信
|
||||
- **redis**: 会话状态和长期记忆存储
|
||||
- **openai**: 大语言模型调用
|
||||
- **logging**: 日志记录和系统监控
|
||||
- **asyncio**: 异步编程
|
||||
|
||||
---
|
||||
|
||||
## 代码结构
|
||||
|
||||
```
|
||||
a2a_wss.py
|
||||
├── 基础配置 (1-48行)
|
||||
├── RedisManager 类 (52-199行)
|
||||
│ ├── 会话缓冲管理
|
||||
│ ├── 历史记录管理
|
||||
│ └── 说话人特征库管理 ⭐新增
|
||||
├── Agent 基类 (154-208行)
|
||||
├── AuditorAgent 类 (211-235行)
|
||||
├── ProfilerAgent 类 (238-369行) ⭐增强
|
||||
│ ├── 长期记忆提示词
|
||||
│ ├── 会议历史分析
|
||||
│ └── 说话人特征匹配
|
||||
├── A2AOrchestrator 类 (372-343行) ⭐集成
|
||||
│ └── process_asr_fragment() 方法
|
||||
└── WebSocket 处理 (352-450行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **Redis 依赖**: 确保 Redis 服务正常运行
|
||||
2. **LLM 可用性**: 确保大语言模型服务可访问
|
||||
3. **内存管理**: 长时间运行的会议会累积数据,建议定期清理
|
||||
4. **识别准确率**: 长期记忆随时间累积,会议初期识别准确率可能较低
|
||||
|
||||
---
|
||||
|
||||
## 未来改进方向
|
||||
|
||||
1. **跨会议记忆**: 支持多场会议之间的说话人特征迁移
|
||||
2. **特征工程**: 提取更多说话人特征(语速、停顿模式等)
|
||||
3. **置信度评分**: 为每次识别添加置信度分数
|
||||
4. **自适应学习**: 根据识别准确率动态调整特征权重
|
||||
5. **多模态融合**: 结合声学特征提高识别准确率
|
||||
|
||||
---
|
||||
|
||||
## 作者和许可
|
||||
|
||||
本项目为智能语音识别系统的核心组件,采用 Agent-to-Agent 编排模式。
|
||||
|
||||
最后更新: 2025-01-07
|
||||
769
fastapi_wss/src/a2a_wss copy.py
Normal file
@ -0,0 +1,769 @@
|
||||
"""
|
||||
WebSocket 智能语义分析服务
|
||||
|
||||
功能说明:
|
||||
1. 作为 WebSocket 代理,接收前端音频数据并转发到 ASR 引擎
|
||||
2. 实时处理 ASR 识别结果,应用多 Agent 协作(A2A)模式
|
||||
3. Agent 功能:
|
||||
- AuditorAgent: 判断说话人是否完成发言
|
||||
- ProfilerAgent: 识别当前说话人的身份
|
||||
4. 使用 Redis 存储会话缓冲和历史记录
|
||||
5. 将处理后的带说话人身份的结果推送回前端
|
||||
|
||||
架构模式:Agent-to-Agent (A2A) 编排
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import websockets
|
||||
import re
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Set
|
||||
from dataclasses import dataclass
|
||||
from dotenv import load_dotenv
|
||||
|
||||
try:
|
||||
import redis.asyncio as redis
|
||||
except ModuleNotFoundError:
|
||||
redis = None
|
||||
|
||||
try:
|
||||
from openai import AsyncOpenAI
|
||||
except ModuleNotFoundError:
|
||||
AsyncOpenAI = None
|
||||
# ========================
|
||||
# 1. 日志配置
|
||||
# ========================
|
||||
ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
|
||||
load_dotenv(ENV_PATH)
|
||||
|
||||
|
||||
def get_env(name: str, default: str) -> str:
|
||||
return os.getenv(name, default).strip()
|
||||
|
||||
|
||||
def get_env_bool(name: str, default: bool = False) -> bool:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def setup_logging():
|
||||
"""配置日志系统"""
|
||||
# 创建日志目录
|
||||
log_dir = "logs"
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
# 配置日志格式
|
||||
log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
date_format = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
# 配置根日志记录器
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# 清除现有处理器
|
||||
logger.handlers.clear()
|
||||
|
||||
# 控制台处理器
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.INFO)
|
||||
console_formatter = logging.Formatter(log_format, datefmt=date_format)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 文件处理器(所有日志)
|
||||
file_handler = logging.FileHandler(
|
||||
f"{log_dir}/a2a_wss.log",
|
||||
encoding="utf-8"
|
||||
)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_formatter = logging.Formatter(log_format, datefmt=date_format)
|
||||
file_handler.setFormatter(file_formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# 错误日志文件处理器
|
||||
error_handler = logging.FileHandler(
|
||||
f"{log_dir}/a2a_wss_error.log",
|
||||
encoding="utf-8"
|
||||
)
|
||||
error_handler.setLevel(logging.ERROR)
|
||||
error_formatter = logging.Formatter(log_format, datefmt=date_format)
|
||||
error_handler.setFormatter(error_formatter)
|
||||
logger.addHandler(error_handler)
|
||||
|
||||
return logger
|
||||
|
||||
# 初始化日志系统
|
||||
logger = setup_logging()
|
||||
|
||||
# ========================
|
||||
# 2. 基础配置
|
||||
# ========================
|
||||
|
||||
# 目标 ASR 服务的 WebSocket 地址(负责语音识别)
|
||||
# 注意:移除末尾的斜杠,避免路径匹配问题
|
||||
TARGET_WS_URL = get_env("TARGET_WS_URL", "ws://localhost:59805/ws/asr")
|
||||
|
||||
# 本地服务监听地址和端口(接收前端连接)
|
||||
LOCAL_HOST = get_env("LOCAL_HOST", "0.0.0.0")
|
||||
LOCAL_PORT = int(get_env("LOCAL_PORT", "10095"))
|
||||
|
||||
# Redis 连接配置(用于存储会话状态和历史记录)
|
||||
REDIS_URL = get_env("REDIS_URL", "redis://localhost:6379/0")
|
||||
|
||||
# LLM(大语言模型)配置(用于 Agent 推理)
|
||||
LLM_API_KEY = get_env("LLM_API_KEY", "none")
|
||||
LLM_BASE_URL = get_env("LLM_BASE_URL", "http://192.168.0.46:59800/v1")
|
||||
LLM_MODEL = get_env("LLM_MODEL", "46-qwen3.5-35B")
|
||||
USE_LLM_ROLE_IDENTIFICATION = get_env_bool("USE_LLM_ROLE_IDENTIFICATION", False)
|
||||
# 候选说话人列表(用于身份识别 Agent)
|
||||
CANDIDATE_SPEAKERS = ["局长", "主持人", "商务专员", "市场专员", "控制要素席", "空中侦察席"]
|
||||
|
||||
# ========================
|
||||
# 3. Redis 管理器
|
||||
# ========================
|
||||
|
||||
class RedisManager:
|
||||
"""
|
||||
Redis 会话管理器
|
||||
|
||||
职责:
|
||||
- 管理与 Redis 的连接池
|
||||
- 存储每个会话的文本缓冲(累积不完整的识别片段)
|
||||
- 存储历史对话记录(用于上下文理解)
|
||||
"""
|
||||
|
||||
def __init__(self, url):
|
||||
"""
|
||||
初始化 Redis 连接池
|
||||
|
||||
Args:
|
||||
url: Redis 连接 URL
|
||||
"""
|
||||
if redis is None:
|
||||
raise RuntimeError("redis package is not installed")
|
||||
# 使用连接池提高并发性能,decode_responses=True 自动将字节转为字符串
|
||||
self.pool = redis.ConnectionPool.from_url(url, decode_responses=True)
|
||||
# 键前缀,用于命名隔离,避免与其他业务冲突
|
||||
self.prefix = "meeting:"
|
||||
|
||||
async def _get_conn(self):
|
||||
"""从连接池获取一个 Redis 连接"""
|
||||
return redis.Redis(connection_pool=self.pool)
|
||||
|
||||
async def get_buffer(self, sid):
|
||||
"""
|
||||
获取指定会话的文本缓冲
|
||||
|
||||
Args:
|
||||
sid: 会话 ID(Session ID)
|
||||
|
||||
Returns:
|
||||
该会话当前累积的文本片段
|
||||
"""
|
||||
r = await self._get_conn()
|
||||
val = await r.get(f"{self.prefix}buffer:{sid}") or ""
|
||||
return val
|
||||
|
||||
async def append_buffer(self, sid, text):
|
||||
"""
|
||||
向会话缓冲追加新的文本片段
|
||||
|
||||
Args:
|
||||
sid: 会话 ID
|
||||
text: 新识别的文本片段
|
||||
"""
|
||||
r = await self._get_conn()
|
||||
# 追加文本并添加空格分隔
|
||||
await r.append(f"{self.prefix}buffer:{sid}", text + " ")
|
||||
|
||||
async def clear_buffer(self, sid):
|
||||
"""
|
||||
清空指定会话的缓冲区
|
||||
|
||||
用途:当判断发言完成后,清空缓冲准备下一次发言
|
||||
|
||||
Args:
|
||||
sid: 会话 ID
|
||||
"""
|
||||
r = await self._get_conn()
|
||||
await r.delete(f"{self.prefix}buffer:{sid}")
|
||||
|
||||
async def push_history(self, sid, role, content):
|
||||
"""
|
||||
将完成的对话推入历史记录
|
||||
|
||||
Args:
|
||||
sid: 会话 ID
|
||||
role: 说话人角色
|
||||
content: 对话内容
|
||||
"""
|
||||
r = await self._get_conn()
|
||||
key = f"{self.prefix}history:{sid}"
|
||||
# 将对话记录推入列表右端
|
||||
await r.rpush(key, json.dumps({"role": role, "content": content}))
|
||||
# 只保留最近 20 条记录,避免历史过长影响性能
|
||||
await r.ltrim(key, -20, -1)
|
||||
|
||||
async def get_history(self, sid):
|
||||
"""
|
||||
获取会话的历史对话记录
|
||||
|
||||
Args:
|
||||
sid: 会话 ID
|
||||
|
||||
Returns:
|
||||
历史对话列表,每项包含 role 和 content
|
||||
"""
|
||||
r = await self._get_conn()
|
||||
items = await r.lrange(f"{self.prefix}history:{sid}", 0, -1)
|
||||
return [json.loads(i) for i in items]
|
||||
|
||||
async def save_speaker_profile(self, sid, speaker, text, features=None):
|
||||
"""
|
||||
保存说话人的特征到长期记忆
|
||||
|
||||
Args:
|
||||
sid: 会话 ID
|
||||
speaker: 说话人名称
|
||||
text: 发言文本
|
||||
features: 可选的特征字典(如关键词、语速等)
|
||||
"""
|
||||
r = await self._get_conn()
|
||||
key = f"{self.prefix}speaker_profile:{sid}:{speaker}"
|
||||
|
||||
# 构造特征记录
|
||||
profile_entry = {
|
||||
"text": text[:200], # 只保留前200个字符作为示例
|
||||
"timestamp": asyncio.get_event_loop().time(),
|
||||
"features": features or {}
|
||||
}
|
||||
|
||||
# 将记录推入该说话人的历史列表
|
||||
await r.rpush(key, json.dumps(profile_entry))
|
||||
# 只保留最近10条该说话人的记录
|
||||
await r.ltrim(key, -10, -1)
|
||||
|
||||
async def get_speaker_profiles(self, sid):
|
||||
"""
|
||||
获取所有说话人的历史特征记录
|
||||
|
||||
Args:
|
||||
sid: 会话 ID
|
||||
|
||||
Returns:
|
||||
字典,key为说话人名称,value为该说话人的历史记录列表
|
||||
"""
|
||||
r = await self._get_conn()
|
||||
# 扫描所有说话人的profile键
|
||||
pattern = f"{self.prefix}speaker_profile:{sid}:*"
|
||||
profiles = {}
|
||||
|
||||
async for key in r.scan_iter(match=pattern):
|
||||
# 从键名中提取说话人名称
|
||||
speaker = key.decode() if isinstance(key, bytes) else key
|
||||
speaker = speaker.split(":")[-1]
|
||||
|
||||
# 获取该说话人的所有历史记录
|
||||
items = await r.lrange(key, 0, -1)
|
||||
profiles[speaker] = [json.loads(i) for i in items]
|
||||
|
||||
return profiles
|
||||
|
||||
async def close(self):
|
||||
"""关闭连接池,释放资源(修复 DeprecationWarning)"""
|
||||
await self.pool.disconnect()
|
||||
|
||||
# ========================
|
||||
# 3. Agent 实现
|
||||
# ========================
|
||||
|
||||
class BaseAgent:
|
||||
"""
|
||||
Agent 基类
|
||||
|
||||
提供所有 Agent 的通用能力:
|
||||
- 调用 LLM 进行推理
|
||||
- 提取 LLM 返回的 XML 标签结果
|
||||
"""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI):
|
||||
"""
|
||||
初始化 Agent
|
||||
|
||||
Args:
|
||||
client: OpenAI 异步客户端
|
||||
"""
|
||||
self.client = client
|
||||
|
||||
async def _call_llm(self, system_prompt: str, user_content: str) -> str:
|
||||
"""
|
||||
调用大语言模型进行推理
|
||||
|
||||
Args:
|
||||
system_prompt: 系统提示词(定义 Agent 的角色和行为)
|
||||
user_content: 用户输入内容
|
||||
|
||||
Returns:
|
||||
LLM 返回的文本结果,失败时返回空字符串
|
||||
"""
|
||||
try:
|
||||
response = await self.client.chat.completions.create(
|
||||
model=LLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_content}
|
||||
],
|
||||
temperature=0.1, # 低温度使输出更确定、一致
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
except Exception as e:
|
||||
logger.error(f"LLM调用失败: {e}", exc_info=True)
|
||||
return ""
|
||||
|
||||
def _extract_result(self, text: str) -> str:
|
||||
"""
|
||||
从 LLM 返回结果中提取 <result> 标签内容
|
||||
|
||||
Args:
|
||||
text: LLM 返回的完整文本
|
||||
|
||||
Returns:
|
||||
提取的结果,如果没有标签则返回原文
|
||||
"""
|
||||
match = re.search(r'<result>(.*?)</result>', text, re.DOTALL | re.IGNORECASE)
|
||||
return match.group(1).strip() if match else text.strip()
|
||||
|
||||
|
||||
class AuditorAgent(BaseAgent):
|
||||
"""
|
||||
审计 Agent:判断当前发言是否完成
|
||||
|
||||
功能:
|
||||
- 分析累积的文本缓冲
|
||||
- 判断说话人是否已经完成一次完整的发言
|
||||
- 判断依据:语义完整性、停顿暗示等
|
||||
"""
|
||||
|
||||
# 系统提示词:定义 Agent 的角色和输出格式
|
||||
SYSTEM_PROMPT = "你是一个对话状态监测专家。请判断发言是否结束,放入 <result>true/false</result> 中。"
|
||||
|
||||
async def check(self, text: str) -> bool:
|
||||
"""
|
||||
判断给定文本是否代表一次完整的发言
|
||||
|
||||
Args:
|
||||
text: 当前累积的文本片段
|
||||
|
||||
Returns:
|
||||
True 表示发言完成,False 表示发言未完成(继续累积)
|
||||
"""
|
||||
res = await self._call_llm(self.SYSTEM_PROMPT, f"内容:{text}")
|
||||
return "true" in self._extract_result(res).lower()
|
||||
|
||||
|
||||
class ProfilerAgent(BaseAgent):
|
||||
"""
|
||||
分析 Agent:识别说话人身份
|
||||
|
||||
功能:
|
||||
- 基于文本内容和说话风格识别说话人
|
||||
- 利用历史对话上下文提高识别准确率
|
||||
- 利用长期记忆(说话人特征库)增强识别
|
||||
- 从预定义的候选说话人列表中选择
|
||||
"""
|
||||
|
||||
# 系统提示词:定义识别任务和候选说话人列表
|
||||
SYSTEM_PROMPT = f"你是一个会议身份识别专家。请从 {CANDIDATE_SPEAKERS} 中选择发言人,放入 <result>名称</result> 中。"
|
||||
|
||||
# 增强版提示词:结合长期记忆
|
||||
SYSTEM_PROMPT_WITH_MEMORY = f"""你是一个会议身份识别专家。请根据以下信息识别发言人:
|
||||
|
||||
候选说话人列表:{CANDIDATE_SPEAKERS}
|
||||
|
||||
=== 会议历史记录(按时间顺序)===
|
||||
{{history}}
|
||||
|
||||
=== 说话人特征库(长期记忆)===
|
||||
{{speaker_profiles}}
|
||||
|
||||
=== 当前待识别的发言 ===
|
||||
{{current_text}}
|
||||
|
||||
=== 识别指南 ===
|
||||
请仔细分析并综合判断:
|
||||
|
||||
1. **对话流程分析**:
|
||||
- 查看历史记录中的对话顺序和上下文
|
||||
- 判断当前发言是对之前谁的话题的回应、延续或补充
|
||||
- 注意对话的逻辑关系和互动模式
|
||||
|
||||
2. **说话风格匹配**:
|
||||
- 每个角色的用词习惯、句式结构、语气的特点
|
||||
- 专有名词、技术术语的使用习惯
|
||||
- 提问方式、陈述方式、指令方式的差异
|
||||
|
||||
3. **角色职责判断**:
|
||||
- 根据各角色的职责范围判断发言内容的合理性
|
||||
- 例如:局长通常做总结和决策,主持人负责控场,专员负责具体业务汇报
|
||||
|
||||
4. **话题连贯性**:
|
||||
- 当前发言与该角色之前发言的话题是否连贯
|
||||
- 是否体现了该角色应有的专业领域关注点
|
||||
|
||||
将识别结果放入 <result>说话人名称</result> 中。"""
|
||||
|
||||
async def identify(self, text: str, history: List[Dict], speaker_profiles: Dict = None) -> str:
|
||||
"""
|
||||
识别当前文本的说话人身份
|
||||
|
||||
Args:
|
||||
text: 当前发言文本
|
||||
history: 历史对话记录(用于上下文理解)
|
||||
speaker_profiles: 说话人特征库(长期记忆),key为说话人名称,value为历史记录列表
|
||||
|
||||
Returns:
|
||||
识别出的说话人名称
|
||||
"""
|
||||
# 如果有长期记忆,使用增强版提示词
|
||||
if speaker_profiles:
|
||||
# 使用更多历史记录(最多15条),保持完整的会议上下文
|
||||
h_str = self._format_history(history[-15:])
|
||||
|
||||
# 构建说话人特征库摘要
|
||||
profiles_summary = self._format_profiles(speaker_profiles)
|
||||
|
||||
prompt = self.SYSTEM_PROMPT_WITH_MEMORY.format(
|
||||
history=h_str or "暂无历史记录",
|
||||
speaker_profiles=profiles_summary,
|
||||
current_text=text
|
||||
)
|
||||
|
||||
res = await self._call_llm(prompt, "")
|
||||
return self._extract_result(res)
|
||||
else:
|
||||
# 原有的简单识别逻辑
|
||||
h_str = "\n".join([f"{h['role']}: {h['content']}" for h in history[-5:]])
|
||||
res = await self._call_llm(self.SYSTEM_PROMPT, f"历史:{h_str}\n当前:{text}")
|
||||
return self._extract_result(res)
|
||||
|
||||
def _format_history(self, history: List[Dict]) -> str:
|
||||
"""
|
||||
格式化历史对话记录,添加序号和时间顺序
|
||||
|
||||
Args:
|
||||
history: 历史对话列表
|
||||
|
||||
Returns:
|
||||
格式化后的对话文本
|
||||
"""
|
||||
if not history:
|
||||
return "暂无历史记录"
|
||||
|
||||
lines = []
|
||||
for idx, h in enumerate(history, 1):
|
||||
content_preview = h['content'][:150] # 只显示前150字符
|
||||
if len(h['content']) > 150:
|
||||
content_preview += "..."
|
||||
lines.append(f"[{idx}] {h['role']}: {content_preview}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_profiles(self, speaker_profiles: Dict) -> str:
|
||||
"""
|
||||
格式化说话人特征库为可读文本
|
||||
|
||||
Args:
|
||||
speaker_profiles: 说话人特征字典
|
||||
|
||||
Returns:
|
||||
格式化后的文本摘要
|
||||
"""
|
||||
if not speaker_profiles:
|
||||
return "暂无说话人特征记录"
|
||||
|
||||
summary_lines = []
|
||||
for speaker, profiles in speaker_profiles.items():
|
||||
# 提取该说话人最近5条发言的文本片段
|
||||
recent_texts = [p['text'] for p in profiles[-5:]]
|
||||
texts_str = "; ".join(recent_texts)
|
||||
|
||||
# 统计该说话人的发言次数
|
||||
speaking_count = len(profiles)
|
||||
|
||||
summary_lines.append(f"- 【{speaker}】(发言{speaking_count}次): {texts_str}")
|
||||
|
||||
return "\n".join(summary_lines)
|
||||
|
||||
# ========================
|
||||
# 4. A2A 编排器
|
||||
# ========================
|
||||
|
||||
class A2AOrchestrator:
|
||||
"""
|
||||
Agent-to-Agent 编排器
|
||||
|
||||
职责:
|
||||
- 协调多个 Agent 的协作流程
|
||||
- 处理 ASR 识别结果,依次调用 Auditor 和 Profiler
|
||||
- 管理会话状态(缓冲和历史记录)
|
||||
- 将最终结果推送给前端
|
||||
"""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, redis_mgr: RedisManager):
|
||||
"""
|
||||
初始化编排器
|
||||
|
||||
Args:
|
||||
client: OpenAI 异步客户端
|
||||
redis_mgr: Redis 管理器实例
|
||||
"""
|
||||
self.redis = redis_mgr
|
||||
self.auditor = AuditorAgent(client) # 审计 Agent
|
||||
self.profiler = ProfilerAgent(client) # 分析 Agent
|
||||
|
||||
async def process_asr_fragment(self, session_id: str, fragment: str, websocket):
|
||||
"""
|
||||
处理 ASR 识别的文本片段(核心流程)
|
||||
|
||||
流程:
|
||||
1. 将新片段追加到缓冲区
|
||||
2. 让 Auditor 判断发言是否完成
|
||||
3. 如果完成,让 Profiler 使用长期记忆识别说话人
|
||||
4. 将识别结果保存到长期记忆
|
||||
5. 将结果存入历史,清空缓冲
|
||||
6. 推送结果到前端
|
||||
|
||||
Args:
|
||||
session_id: 会话 ID
|
||||
fragment: ASR 识别的新文本片段
|
||||
websocket: 前端 WebSocket 连接(用于推送结果)
|
||||
"""
|
||||
# 1. 追加新片段到缓冲区
|
||||
await self.redis.append_buffer(session_id, fragment)
|
||||
buf = await self.redis.get_buffer(session_id)
|
||||
|
||||
# 2. 检查发言是否完成
|
||||
if await self.auditor.check(buf):
|
||||
logger.info(f"[Auditor] 发言完成检测通过 - 会话ID: {session_id}")
|
||||
|
||||
# 3. 获取长期记忆(说话人特征库)
|
||||
speaker_profiles = await self.redis.get_speaker_profiles(session_id)
|
||||
logger.debug(f"获取说话人特征库 - 会话ID: {session_id}, 说话人数量: {len(speaker_profiles)}")
|
||||
|
||||
# 4. 识别说话人身份(使用长期记忆)
|
||||
history = await self.redis.get_history(session_id)
|
||||
speaker = await self.profiler.identify(buf, history, speaker_profiles)
|
||||
logger.info(f"[Profiler] 识别说话人: {speaker} - 会话ID: {session_id}")
|
||||
|
||||
# 5. 保存到长期记忆(更新说话人特征库)
|
||||
await self.redis.save_speaker_profile(session_id, speaker, buf)
|
||||
|
||||
# 6. 保存到历史记录并清空缓冲
|
||||
await self.redis.push_history(session_id, speaker, buf)
|
||||
await self.redis.clear_buffer(session_id)
|
||||
|
||||
# 7. 构造结果消息
|
||||
segments = [{
|
||||
"id": 0,
|
||||
"speaker": speaker,
|
||||
"embedding": None, # 占位(预留字段)
|
||||
"seek": 0,
|
||||
"full_text": buf.strip()
|
||||
}]
|
||||
|
||||
msg = {
|
||||
"mode": "offline-speaker",
|
||||
"type": "asr_with_speaker",
|
||||
"segments": segments,
|
||||
"speaker": speaker
|
||||
}
|
||||
|
||||
# 推送结果到前端(失败时静默处理)
|
||||
try:
|
||||
await websocket.send(json.dumps(msg, ensure_ascii=False))
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def build_acoustic_speaker_message(data: Dict) -> Optional[Dict]:
|
||||
text = (data.get("text") or "").strip()
|
||||
speaker = (data.get("spk_name") or "").strip()
|
||||
if not text or not speaker or speaker.lower() == "unknown":
|
||||
return None
|
||||
|
||||
segment = {
|
||||
"id": 0,
|
||||
"speaker": speaker,
|
||||
"embedding": None,
|
||||
"seek": 0,
|
||||
"full_text": text,
|
||||
"start": None,
|
||||
"end": None,
|
||||
}
|
||||
msg = {
|
||||
"mode": "offline-speaker",
|
||||
"type": "asr_with_speaker",
|
||||
"source": "acoustic",
|
||||
"speaker": speaker,
|
||||
"spk_score": data.get("spk_score"),
|
||||
"text": text,
|
||||
"wav_name": data.get("wav_name"),
|
||||
"is_final": data.get("is_final", True),
|
||||
"segments": [segment],
|
||||
}
|
||||
if "timestamp" in data:
|
||||
msg["timestamp"] = data["timestamp"]
|
||||
if "sentence_info" in data:
|
||||
msg["sentence_info"] = data["sentence_info"]
|
||||
return msg
|
||||
|
||||
|
||||
# ========================
|
||||
# 5. WebSocket 处理与任务追踪
|
||||
# ========================
|
||||
|
||||
async def forward_audio(websocket):
|
||||
"""
|
||||
WebSocket 连接处理函数(核心入口)
|
||||
|
||||
职责:
|
||||
1. 接受前端 WebSocket 连接
|
||||
2. 建立到 ASR 服务的下游连接
|
||||
3. 双向转发消息(前端 -> ASR,ASR -> 前端)
|
||||
4. 拦截 ASR 结果并触 Agent 处理
|
||||
5. 确保所有后台任务完成后才退出(防止数据丢失)
|
||||
|
||||
Args:
|
||||
websocket: 前端 WebSocket 连接对象
|
||||
"""
|
||||
logger.info("客户端已连接")
|
||||
|
||||
client = None
|
||||
redis_mgr = None
|
||||
orchestrator = None
|
||||
use_llm_role_identification = USE_LLM_ROLE_IDENTIFICATION
|
||||
|
||||
if use_llm_role_identification:
|
||||
if AsyncOpenAI is None:
|
||||
logger.error("openai package is not installed; LLM role identification disabled")
|
||||
use_llm_role_identification = False
|
||||
elif redis is None:
|
||||
logger.error("redis package is not installed; LLM role identification disabled")
|
||||
use_llm_role_identification = False
|
||||
else:
|
||||
# 初始化 LLM 客户端、Redis 管理器和编排器
|
||||
client = AsyncOpenAI(api_key=LLM_API_KEY, base_url=LLM_BASE_URL)
|
||||
redis_mgr = RedisManager(REDIS_URL)
|
||||
orchestrator = A2AOrchestrator(client, redis_mgr)
|
||||
|
||||
# 用于追踪后台任务,防止连接关闭时任务被强制销毁
|
||||
background_tasks: Set[asyncio.Task] = set()
|
||||
|
||||
try:
|
||||
# 连接到目标 ASR 服务
|
||||
# 添加连接参数以提高鲁棒性
|
||||
async with websockets.connect(
|
||||
TARGET_WS_URL,
|
||||
close_timeout=10, # 关闭超时
|
||||
ping_timeout=20, # ping 超时
|
||||
max_queue=1024, # 消息队列大小
|
||||
subprotocols=["binary"],
|
||||
) as target_ws:
|
||||
logger.info(f"已连接到 ASR 服务: {TARGET_WS_URL}")
|
||||
|
||||
# 发送 ASR 配置(双通道模式,PCM 音频格式)
|
||||
config = {"mode": "2pass", "chunk_size": [10, 10, 10], "wav_format": "pcm"}
|
||||
await target_ws.send(json.dumps(config))
|
||||
logger.debug(f"发送 ASR 配置: {config}")
|
||||
|
||||
async def frontend_to_target():
|
||||
"""
|
||||
上行转发:前端音频数据 -> ASR 服务
|
||||
"""
|
||||
async for message in websocket:
|
||||
await target_ws.send(message)
|
||||
|
||||
async def target_to_frontend():
|
||||
"""
|
||||
下行转发:ASR 识别结果 -> 前端
|
||||
同时拦截结果触发 Agent 处理
|
||||
"""
|
||||
async for message in target_ws:
|
||||
# 1. 透传 ASR 原始结果到前端(字幕显示)
|
||||
await websocket.send(message)
|
||||
|
||||
# 2. 如果是文本消息,尝试触发 Agent 处理
|
||||
if isinstance(message, str):
|
||||
try:
|
||||
data = json.loads(message)
|
||||
# 只处理最终识别结果(2pass-offline 模式)
|
||||
if data.get("mode") == "2pass-offline":
|
||||
text = data.get("text", "").strip()
|
||||
speaker_msg = build_acoustic_speaker_message(data)
|
||||
if speaker_msg is not None:
|
||||
await websocket.send(json.dumps(speaker_msg, ensure_ascii=False))
|
||||
|
||||
if text and use_llm_role_identification and orchestrator is not None:
|
||||
logger.debug(f"收到 ASR 识别结果: {text[:50]}...")
|
||||
# 创建后台任务处理 Agent 逻辑(不阻塞主循环)
|
||||
t = asyncio.create_task(
|
||||
orchestrator.process_asr_fragment("room_101", text, websocket)
|
||||
)
|
||||
background_tasks.add(t)
|
||||
# 任务完成后自动从追踪集合中移除
|
||||
t.add_done_callback(background_tasks.discard)
|
||||
except Exception as e:
|
||||
logger.error(f"处理 ASR 结果时出错: {e}", exc_info=True)
|
||||
|
||||
# 并发运行两个方向的数据流
|
||||
await asyncio.gather(frontend_to_target(), target_to_frontend())
|
||||
|
||||
except websockets.exceptions.InvalidMessage as e:
|
||||
logger.error(f"WebSocket 握手失败 - ASR 服务可能未正常运行或 URL 配置错误: {e}")
|
||||
logger.error(f"请检查: 1) ASR 服务是否在 {TARGET_WS_URL} 正常运行")
|
||||
logger.error(f" 2) ASR 服务的 URL 路径是否正确")
|
||||
logger.error(f" 3) ASR 服务是否需要特定的认证或配置")
|
||||
except ConnectionRefusedError:
|
||||
logger.error(f"连接被拒绝 - 无法连接到 ASR 服务 {TARGET_WS_URL}")
|
||||
logger.error(f"请检查 ASR 服务是否已启动")
|
||||
except Exception as e:
|
||||
logger.error(f"连接错误: {type(e).__name__}: {e}", exc_info=True)
|
||||
finally:
|
||||
# --- 优雅退出处理 ---
|
||||
if background_tasks:
|
||||
logger.info(f"等待 {len(background_tasks)} 个后台 Agent 任务完成...")
|
||||
# 等待所有剩余任务跑完,最多等 5 秒(防止无限等待)
|
||||
await asyncio.wait(background_tasks, timeout=5)
|
||||
logger.debug("所有后台任务已完成或超时")
|
||||
|
||||
# 释放资源
|
||||
if client is not None:
|
||||
await client.close()
|
||||
if redis_mgr is not None:
|
||||
await redis_mgr.close()
|
||||
logger.info("客户端已断开连接")
|
||||
|
||||
|
||||
async def main():
|
||||
"""
|
||||
主函数:启动 WebSocket 服务器
|
||||
"""
|
||||
logger.info(f"启动 WebSocket 智能语义分析服务: ws://{LOCAL_HOST}:{LOCAL_PORT}")
|
||||
logger.info(f"Agent 模型: {LLM_MODEL}")
|
||||
logger.info(f"ASR 服务地址: {TARGET_WS_URL}")
|
||||
logger.info(f"Redis 地址: {REDIS_URL}")
|
||||
# 启动服务器并持续运行(asyncio.Future() 永不完成)
|
||||
async with websockets.serve(forward_audio, LOCAL_HOST, LOCAL_PORT):
|
||||
await asyncio.Future()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
958
fastapi_wss/src/a2a_wss.py
Normal file
@ -0,0 +1,958 @@
|
||||
"""
|
||||
WebSocket 智能语义分析服务
|
||||
|
||||
功能说明:
|
||||
1. 作为 WebSocket 代理,接收前端音频数据并转发到 ASR 引擎
|
||||
2. 实时处理 ASR 识别结果,应用多 Agent 协作(A2A)模式
|
||||
3. Agent 功能:
|
||||
- AuditorAgent: 判断说话人是否完成发言
|
||||
- ProfilerAgent: 识别当前说话人的身份
|
||||
4. 使用 Redis 存储会话缓冲和历史记录
|
||||
5. 将处理后的带说话人身份的结果推送回前端
|
||||
|
||||
架构模式:Agent-to-Agent (A2A) 编排
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import websockets
|
||||
import re
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict, Set
|
||||
from dataclasses import dataclass
|
||||
from dotenv import load_dotenv
|
||||
|
||||
try:
|
||||
import redis.asyncio as redis
|
||||
except ModuleNotFoundError:
|
||||
redis = None
|
||||
|
||||
try:
|
||||
from openai import AsyncOpenAI
|
||||
except ModuleNotFoundError:
|
||||
AsyncOpenAI = None
|
||||
# ========================
|
||||
# 1. 日志配置
|
||||
# ========================
|
||||
ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
|
||||
load_dotenv(ENV_PATH)
|
||||
|
||||
|
||||
def get_env(name: str, default: str) -> str:
|
||||
return os.getenv(name, default).strip()
|
||||
|
||||
|
||||
def get_env_bool(name: str, default: bool = False) -> bool:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def get_env_list(name: str, default: str) -> List[str]:
|
||||
raw = get_env(name, default)
|
||||
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||
|
||||
|
||||
def setup_logging():
|
||||
"""配置日志系统"""
|
||||
# 创建日志目录
|
||||
log_dir = "logs"
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
|
||||
# 配置日志格式
|
||||
log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
date_format = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
# 配置根日志记录器
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# 清除现有处理器
|
||||
logger.handlers.clear()
|
||||
|
||||
# 控制台处理器
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.INFO)
|
||||
console_formatter = logging.Formatter(log_format, datefmt=date_format)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 文件处理器(所有日志)
|
||||
file_handler = logging.FileHandler(
|
||||
f"{log_dir}/a2a_wss.log",
|
||||
encoding="utf-8"
|
||||
)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_formatter = logging.Formatter(log_format, datefmt=date_format)
|
||||
file_handler.setFormatter(file_formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# 错误日志文件处理器
|
||||
error_handler = logging.FileHandler(
|
||||
f"{log_dir}/a2a_wss_error.log",
|
||||
encoding="utf-8"
|
||||
)
|
||||
error_handler.setLevel(logging.ERROR)
|
||||
error_formatter = logging.Formatter(log_format, datefmt=date_format)
|
||||
error_handler.setFormatter(error_formatter)
|
||||
logger.addHandler(error_handler)
|
||||
|
||||
return logger
|
||||
|
||||
# 初始化日志系统
|
||||
logger = setup_logging()
|
||||
|
||||
# ========================
|
||||
# 2. 基础配置
|
||||
# ========================
|
||||
|
||||
# 目标 ASR 服务的 WebSocket 地址(负责语音识别)
|
||||
# 注意:移除末尾的斜杠,避免路径匹配问题
|
||||
TARGET_WS_URL = get_env("TARGET_WS_URL", "ws://localhost:59805/ws/asr")
|
||||
|
||||
# 本地服务监听地址和端口(接收前端连接)
|
||||
LOCAL_HOST = get_env("LOCAL_HOST", "0.0.0.0")
|
||||
LOCAL_PORT = int(get_env("LOCAL_PORT", "10095"))
|
||||
|
||||
# Redis 连接配置(用于存储会话状态和历史记录)
|
||||
REDIS_URL = get_env("REDIS_URL", "redis://localhost:6379/0")
|
||||
|
||||
# LLM(大语言模型)配置(用于 Agent 推理)
|
||||
LLM_API_KEY = get_env("LLM_API_KEY", "none")
|
||||
LLM_BASE_URL = get_env("LLM_BASE_URL", "http://192.168.0.46:59800/v1")
|
||||
LLM_MODEL = get_env("LLM_MODEL", "46-qwen3.5-35B")
|
||||
USE_LLM_ROLE_IDENTIFICATION = get_env_bool("USE_LLM_ROLE_IDENTIFICATION", False)
|
||||
LLM_ROLE_CANDIDATES = get_env_list(
|
||||
"LLM_ROLE_CANDIDATES",
|
||||
"主持人,正方辩手,反方辩手,计时员,评委,嘉宾,未知",
|
||||
)
|
||||
LLM_ROLE_MIN_TEXT_LEN = int(get_env("LLM_ROLE_MIN_TEXT_LEN", "8"))
|
||||
LLM_ROLE_CACHE_CONFIDENCE = float(get_env("LLM_ROLE_CACHE_CONFIDENCE", "0.82"))
|
||||
LLM_ROLE_HISTORY_MAX_CHARS = int(get_env("LLM_ROLE_HISTORY_MAX_CHARS", "1200"))
|
||||
# 候选说话人列表(用于身份识别 Agent)
|
||||
CANDIDATE_SPEAKERS = ["局长", "主持人", "商务专员", "市场专员", "控制要素席", "空中侦察席"]
|
||||
|
||||
# ========================
|
||||
# 3. Redis 管理器
|
||||
# ========================
|
||||
|
||||
class RedisManager:
|
||||
"""
|
||||
Redis 会话管理器
|
||||
|
||||
职责:
|
||||
- 管理与 Redis 的连接池
|
||||
- 存储每个会话的文本缓冲(累积不完整的识别片段)
|
||||
- 存储历史对话记录(用于上下文理解)
|
||||
"""
|
||||
|
||||
def __init__(self, url):
|
||||
"""
|
||||
初始化 Redis 连接池
|
||||
|
||||
Args:
|
||||
url: Redis 连接 URL
|
||||
"""
|
||||
if redis is None:
|
||||
raise RuntimeError("redis package is not installed")
|
||||
# 使用连接池提高并发性能,decode_responses=True 自动将字节转为字符串
|
||||
self.pool = redis.ConnectionPool.from_url(url, decode_responses=True)
|
||||
# 键前缀,用于命名隔离,避免与其他业务冲突
|
||||
self.prefix = "meeting:"
|
||||
|
||||
async def _get_conn(self):
|
||||
"""从连接池获取一个 Redis 连接"""
|
||||
return redis.Redis(connection_pool=self.pool)
|
||||
|
||||
async def get_buffer(self, sid):
|
||||
"""
|
||||
获取指定会话的文本缓冲
|
||||
|
||||
Args:
|
||||
sid: 会话 ID(Session ID)
|
||||
|
||||
Returns:
|
||||
该会话当前累积的文本片段
|
||||
"""
|
||||
r = await self._get_conn()
|
||||
val = await r.get(f"{self.prefix}buffer:{sid}") or ""
|
||||
return val
|
||||
|
||||
async def append_buffer(self, sid, text):
|
||||
"""
|
||||
向会话缓冲追加新的文本片段
|
||||
|
||||
Args:
|
||||
sid: 会话 ID
|
||||
text: 新识别的文本片段
|
||||
"""
|
||||
r = await self._get_conn()
|
||||
# 追加文本并添加空格分隔
|
||||
await r.append(f"{self.prefix}buffer:{sid}", text + " ")
|
||||
|
||||
async def clear_buffer(self, sid):
|
||||
"""
|
||||
清空指定会话的缓冲区
|
||||
|
||||
用途:当判断发言完成后,清空缓冲准备下一次发言
|
||||
|
||||
Args:
|
||||
sid: 会话 ID
|
||||
"""
|
||||
r = await self._get_conn()
|
||||
await r.delete(f"{self.prefix}buffer:{sid}")
|
||||
|
||||
async def push_history(self, sid, role, content):
|
||||
"""
|
||||
将完成的对话推入历史记录
|
||||
|
||||
Args:
|
||||
sid: 会话 ID
|
||||
role: 说话人角色
|
||||
content: 对话内容
|
||||
"""
|
||||
r = await self._get_conn()
|
||||
key = f"{self.prefix}history:{sid}"
|
||||
# 将对话记录推入列表右端
|
||||
await r.rpush(key, json.dumps({"role": role, "content": content}))
|
||||
# 只保留最近 20 条记录,避免历史过长影响性能
|
||||
await r.ltrim(key, -20, -1)
|
||||
|
||||
async def get_history(self, sid):
|
||||
"""
|
||||
获取会话的历史对话记录
|
||||
|
||||
Args:
|
||||
sid: 会话 ID
|
||||
|
||||
Returns:
|
||||
历史对话列表,每项包含 role 和 content
|
||||
"""
|
||||
r = await self._get_conn()
|
||||
items = await r.lrange(f"{self.prefix}history:{sid}", 0, -1)
|
||||
return [json.loads(i) for i in items]
|
||||
|
||||
async def save_speaker_profile(self, sid, speaker, text, features=None):
|
||||
"""
|
||||
保存说话人的特征到长期记忆
|
||||
|
||||
Args:
|
||||
sid: 会话 ID
|
||||
speaker: 说话人名称
|
||||
text: 发言文本
|
||||
features: 可选的特征字典(如关键词、语速等)
|
||||
"""
|
||||
r = await self._get_conn()
|
||||
key = f"{self.prefix}speaker_profile:{sid}:{speaker}"
|
||||
|
||||
# 构造特征记录
|
||||
profile_entry = {
|
||||
"text": text[:200], # 只保留前200个字符作为示例
|
||||
"timestamp": asyncio.get_event_loop().time(),
|
||||
"features": features or {}
|
||||
}
|
||||
|
||||
# 将记录推入该说话人的历史列表
|
||||
await r.rpush(key, json.dumps(profile_entry))
|
||||
# 只保留最近10条该说话人的记录
|
||||
await r.ltrim(key, -10, -1)
|
||||
|
||||
async def get_speaker_profiles(self, sid):
|
||||
"""
|
||||
获取所有说话人的历史特征记录
|
||||
|
||||
Args:
|
||||
sid: 会话 ID
|
||||
|
||||
Returns:
|
||||
字典,key为说话人名称,value为该说话人的历史记录列表
|
||||
"""
|
||||
r = await self._get_conn()
|
||||
# 扫描所有说话人的profile键
|
||||
pattern = f"{self.prefix}speaker_profile:{sid}:*"
|
||||
profiles = {}
|
||||
|
||||
async for key in r.scan_iter(match=pattern):
|
||||
# 从键名中提取说话人名称
|
||||
speaker = key.decode() if isinstance(key, bytes) else key
|
||||
speaker = speaker.split(":")[-1]
|
||||
|
||||
# 获取该说话人的所有历史记录
|
||||
items = await r.lrange(key, 0, -1)
|
||||
profiles[speaker] = [json.loads(i) for i in items]
|
||||
|
||||
return profiles
|
||||
|
||||
async def close(self):
|
||||
"""关闭连接池,释放资源(修复 DeprecationWarning)"""
|
||||
await self.pool.disconnect()
|
||||
|
||||
# ========================
|
||||
# 3. Agent 实现
|
||||
# ========================
|
||||
|
||||
class BaseAgent:
|
||||
"""
|
||||
Agent 基类
|
||||
|
||||
提供所有 Agent 的通用能力:
|
||||
- 调用 LLM 进行推理
|
||||
- 提取 LLM 返回的 XML 标签结果
|
||||
"""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI):
|
||||
"""
|
||||
初始化 Agent
|
||||
|
||||
Args:
|
||||
client: OpenAI 异步客户端
|
||||
"""
|
||||
self.client = client
|
||||
|
||||
async def _call_llm(self, system_prompt: str, user_content: str) -> str:
|
||||
"""
|
||||
调用大语言模型进行推理
|
||||
|
||||
Args:
|
||||
system_prompt: 系统提示词(定义 Agent 的角色和行为)
|
||||
user_content: 用户输入内容
|
||||
|
||||
Returns:
|
||||
LLM 返回的文本结果,失败时返回空字符串
|
||||
"""
|
||||
try:
|
||||
response = await self.client.chat.completions.create(
|
||||
model=LLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_content}
|
||||
],
|
||||
temperature=0.1, # 低温度使输出更确定、一致
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
except Exception as e:
|
||||
logger.error(f"LLM调用失败: {e}", exc_info=True)
|
||||
return ""
|
||||
|
||||
def _extract_result(self, text: str) -> str:
|
||||
"""
|
||||
从 LLM 返回结果中提取 <result> 标签内容
|
||||
|
||||
Args:
|
||||
text: LLM 返回的完整文本
|
||||
|
||||
Returns:
|
||||
提取的结果,如果没有标签则返回原文
|
||||
"""
|
||||
match = re.search(r'<result>(.*?)</result>', text, re.DOTALL | re.IGNORECASE)
|
||||
return match.group(1).strip() if match else text.strip()
|
||||
|
||||
|
||||
class AuditorAgent(BaseAgent):
|
||||
"""
|
||||
审计 Agent:判断当前发言是否完成
|
||||
|
||||
功能:
|
||||
- 分析累积的文本缓冲
|
||||
- 判断说话人是否已经完成一次完整的发言
|
||||
- 判断依据:语义完整性、停顿暗示等
|
||||
"""
|
||||
|
||||
# 系统提示词:定义 Agent 的角色和输出格式
|
||||
SYSTEM_PROMPT = "你是一个对话状态监测专家。请判断发言是否结束,放入 <result>true/false</result> 中。"
|
||||
|
||||
async def check(self, text: str) -> bool:
|
||||
"""
|
||||
判断给定文本是否代表一次完整的发言
|
||||
|
||||
Args:
|
||||
text: 当前累积的文本片段
|
||||
|
||||
Returns:
|
||||
True 表示发言完成,False 表示发言未完成(继续累积)
|
||||
"""
|
||||
res = await self._call_llm(self.SYSTEM_PROMPT, f"内容:{text}")
|
||||
return "true" in self._extract_result(res).lower()
|
||||
|
||||
|
||||
class ProfilerAgent(BaseAgent):
|
||||
"""
|
||||
分析 Agent:识别说话人身份
|
||||
|
||||
功能:
|
||||
- 基于文本内容和说话风格识别说话人
|
||||
- 利用历史对话上下文提高识别准确率
|
||||
- 利用长期记忆(说话人特征库)增强识别
|
||||
- 从预定义的候选说话人列表中选择
|
||||
"""
|
||||
|
||||
# 系统提示词:定义识别任务和候选说话人列表
|
||||
SYSTEM_PROMPT = f"你是一个会议身份识别专家。请从 {CANDIDATE_SPEAKERS} 中选择发言人,放入 <result>名称</result> 中。"
|
||||
|
||||
# 增强版提示词:结合长期记忆
|
||||
SYSTEM_PROMPT_WITH_MEMORY = f"""你是一个会议身份识别专家。请根据以下信息识别发言人:
|
||||
|
||||
候选说话人列表:{CANDIDATE_SPEAKERS}
|
||||
|
||||
=== 会议历史记录(按时间顺序)===
|
||||
{{history}}
|
||||
|
||||
=== 说话人特征库(长期记忆)===
|
||||
{{speaker_profiles}}
|
||||
|
||||
=== 当前待识别的发言 ===
|
||||
{{current_text}}
|
||||
|
||||
=== 识别指南 ===
|
||||
请仔细分析并综合判断:
|
||||
|
||||
1. **对话流程分析**:
|
||||
- 查看历史记录中的对话顺序和上下文
|
||||
- 判断当前发言是对之前谁的话题的回应、延续或补充
|
||||
- 注意对话的逻辑关系和互动模式
|
||||
|
||||
2. **说话风格匹配**:
|
||||
- 每个角色的用词习惯、句式结构、语气的特点
|
||||
- 专有名词、技术术语的使用习惯
|
||||
- 提问方式、陈述方式、指令方式的差异
|
||||
|
||||
3. **角色职责判断**:
|
||||
- 根据各角色的职责范围判断发言内容的合理性
|
||||
- 例如:局长通常做总结和决策,主持人负责控场,专员负责具体业务汇报
|
||||
|
||||
4. **话题连贯性**:
|
||||
- 当前发言与该角色之前发言的话题是否连贯
|
||||
- 是否体现了该角色应有的专业领域关注点
|
||||
|
||||
将识别结果放入 <result>说话人名称</result> 中。"""
|
||||
|
||||
async def identify(self, text: str, history: List[Dict], speaker_profiles: Dict = None) -> str:
|
||||
"""
|
||||
识别当前文本的说话人身份
|
||||
|
||||
Args:
|
||||
text: 当前发言文本
|
||||
history: 历史对话记录(用于上下文理解)
|
||||
speaker_profiles: 说话人特征库(长期记忆),key为说话人名称,value为历史记录列表
|
||||
|
||||
Returns:
|
||||
识别出的说话人名称
|
||||
"""
|
||||
# 如果有长期记忆,使用增强版提示词
|
||||
if speaker_profiles:
|
||||
# 使用更多历史记录(最多15条),保持完整的会议上下文
|
||||
h_str = self._format_history(history[-15:])
|
||||
|
||||
# 构建说话人特征库摘要
|
||||
profiles_summary = self._format_profiles(speaker_profiles)
|
||||
|
||||
prompt = self.SYSTEM_PROMPT_WITH_MEMORY.format(
|
||||
history=h_str or "暂无历史记录",
|
||||
speaker_profiles=profiles_summary,
|
||||
current_text=text
|
||||
)
|
||||
|
||||
res = await self._call_llm(prompt, "")
|
||||
return self._extract_result(res)
|
||||
else:
|
||||
# 原有的简单识别逻辑
|
||||
h_str = "\n".join([f"{h['role']}: {h['content']}" for h in history[-5:]])
|
||||
res = await self._call_llm(self.SYSTEM_PROMPT, f"历史:{h_str}\n当前:{text}")
|
||||
return self._extract_result(res)
|
||||
|
||||
def _format_history(self, history: List[Dict]) -> str:
|
||||
"""
|
||||
格式化历史对话记录,添加序号和时间顺序
|
||||
|
||||
Args:
|
||||
history: 历史对话列表
|
||||
|
||||
Returns:
|
||||
格式化后的对话文本
|
||||
"""
|
||||
if not history:
|
||||
return "暂无历史记录"
|
||||
|
||||
lines = []
|
||||
for idx, h in enumerate(history, 1):
|
||||
content_preview = h['content'][:150] # 只显示前150字符
|
||||
if len(h['content']) > 150:
|
||||
content_preview += "..."
|
||||
lines.append(f"[{idx}] {h['role']}: {content_preview}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_profiles(self, speaker_profiles: Dict) -> str:
|
||||
"""
|
||||
格式化说话人特征库为可读文本
|
||||
|
||||
Args:
|
||||
speaker_profiles: 说话人特征字典
|
||||
|
||||
Returns:
|
||||
格式化后的文本摘要
|
||||
"""
|
||||
if not speaker_profiles:
|
||||
return "暂无说话人特征记录"
|
||||
|
||||
summary_lines = []
|
||||
for speaker, profiles in speaker_profiles.items():
|
||||
# 提取该说话人最近5条发言的文本片段
|
||||
recent_texts = [p['text'] for p in profiles[-5:]]
|
||||
texts_str = "; ".join(recent_texts)
|
||||
|
||||
# 统计该说话人的发言次数
|
||||
speaking_count = len(profiles)
|
||||
|
||||
summary_lines.append(f"- 【{speaker}】(发言{speaking_count}次): {texts_str}")
|
||||
|
||||
return "\n".join(summary_lines)
|
||||
|
||||
# ========================
|
||||
# 4. A2A 编排器
|
||||
# ========================
|
||||
|
||||
class A2AOrchestrator:
|
||||
"""
|
||||
Agent-to-Agent 编排器
|
||||
|
||||
职责:
|
||||
- 协调多个 Agent 的协作流程
|
||||
- 处理 ASR 识别结果,依次调用 Auditor 和 Profiler
|
||||
- 管理会话状态(缓冲和历史记录)
|
||||
- 将最终结果推送给前端
|
||||
"""
|
||||
|
||||
def __init__(self, client: AsyncOpenAI, redis_mgr: RedisManager):
|
||||
"""
|
||||
初始化编排器
|
||||
|
||||
Args:
|
||||
client: OpenAI 异步客户端
|
||||
redis_mgr: Redis 管理器实例
|
||||
"""
|
||||
self.redis = redis_mgr
|
||||
self.auditor = AuditorAgent(client) # 审计 Agent
|
||||
self.profiler = ProfilerAgent(client) # 分析 Agent
|
||||
|
||||
async def process_asr_fragment(self, session_id: str, fragment: str, websocket):
|
||||
"""
|
||||
处理 ASR 识别的文本片段(核心流程)
|
||||
|
||||
流程:
|
||||
1. 将新片段追加到缓冲区
|
||||
2. 让 Auditor 判断发言是否完成
|
||||
3. 如果完成,让 Profiler 使用长期记忆识别说话人
|
||||
4. 将识别结果保存到长期记忆
|
||||
5. 将结果存入历史,清空缓冲
|
||||
6. 推送结果到前端
|
||||
|
||||
Args:
|
||||
session_id: 会话 ID
|
||||
fragment: ASR 识别的新文本片段
|
||||
websocket: 前端 WebSocket 连接(用于推送结果)
|
||||
"""
|
||||
# 1. 追加新片段到缓冲区
|
||||
await self.redis.append_buffer(session_id, fragment)
|
||||
buf = await self.redis.get_buffer(session_id)
|
||||
|
||||
# 2. 检查发言是否完成
|
||||
if await self.auditor.check(buf):
|
||||
logger.info(f"[Auditor] 发言完成检测通过 - 会话ID: {session_id}")
|
||||
|
||||
# 3. 获取长期记忆(说话人特征库)
|
||||
speaker_profiles = await self.redis.get_speaker_profiles(session_id)
|
||||
logger.debug(f"获取说话人特征库 - 会话ID: {session_id}, 说话人数量: {len(speaker_profiles)}")
|
||||
|
||||
# 4. 识别说话人身份(使用长期记忆)
|
||||
history = await self.redis.get_history(session_id)
|
||||
speaker = await self.profiler.identify(buf, history, speaker_profiles)
|
||||
logger.info(f"[Profiler] 识别说话人: {speaker} - 会话ID: {session_id}")
|
||||
|
||||
# 5. 保存到长期记忆(更新说话人特征库)
|
||||
await self.redis.save_speaker_profile(session_id, speaker, buf)
|
||||
|
||||
# 6. 保存到历史记录并清空缓冲
|
||||
await self.redis.push_history(session_id, speaker, buf)
|
||||
await self.redis.clear_buffer(session_id)
|
||||
|
||||
# 7. 构造结果消息
|
||||
segments = [{
|
||||
"id": 0,
|
||||
"speaker": speaker,
|
||||
"embedding": None, # 占位(预留字段)
|
||||
"seek": 0,
|
||||
"full_text": buf.strip()
|
||||
}]
|
||||
|
||||
msg = {
|
||||
"mode": "offline-speaker",
|
||||
"type": "asr_with_speaker",
|
||||
"segments": segments,
|
||||
"speaker": speaker
|
||||
}
|
||||
|
||||
# 推送结果到前端(失败时静默处理)
|
||||
try:
|
||||
await websocket.send(json.dumps(msg, ensure_ascii=False))
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def build_acoustic_speaker_message(data: Dict) -> Optional[Dict]:
|
||||
text = (data.get("text") or "").strip()
|
||||
speaker = (data.get("spk_name") or "").strip()
|
||||
if not text or not speaker or speaker.lower() == "unknown":
|
||||
return None
|
||||
|
||||
segment = {
|
||||
"id": 0,
|
||||
"speaker": speaker,
|
||||
"embedding": None,
|
||||
"seek": 0,
|
||||
"full_text": text,
|
||||
"start": None,
|
||||
"end": None,
|
||||
}
|
||||
msg = {
|
||||
"mode": "offline-speaker",
|
||||
"type": "asr_with_speaker",
|
||||
"source": "acoustic",
|
||||
"speaker": speaker,
|
||||
"spk_score": data.get("spk_score"),
|
||||
"text": text,
|
||||
"wav_name": data.get("wav_name"),
|
||||
"is_final": data.get("is_final", True),
|
||||
"segments": [segment],
|
||||
}
|
||||
if "timestamp" in data:
|
||||
msg["timestamp"] = data["timestamp"]
|
||||
if "sentence_info" in data:
|
||||
msg["sentence_info"] = data["sentence_info"]
|
||||
return msg
|
||||
|
||||
|
||||
MOJIBAKE_MARKERS = ("锛", "涓", "灏", "鍒", "鏄", "鎴", "浣", "鐨", "璇", "濂", "绗")
|
||||
|
||||
|
||||
def maybe_repair_mojibake(text: str) -> str:
|
||||
if not text:
|
||||
return text
|
||||
marker_count = sum(text.count(marker) for marker in MOJIBAKE_MARKERS)
|
||||
if marker_count < 3:
|
||||
return text
|
||||
try:
|
||||
repaired = text.encode("gbk").decode("utf-8")
|
||||
except UnicodeError:
|
||||
return text
|
||||
repaired_marker_count = sum(repaired.count(marker) for marker in MOJIBAKE_MARKERS)
|
||||
return repaired if repaired_marker_count < marker_count else text
|
||||
|
||||
|
||||
def _extract_json_object(text: str) -> Dict:
|
||||
if not text:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start == -1 or end == -1 or end <= start:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(text[start:end + 1])
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
def _safe_confidence(value, default: float = 0.0) -> float:
|
||||
try:
|
||||
confidence = float(value)
|
||||
except (TypeError, ValueError):
|
||||
confidence = default
|
||||
return max(0.0, min(1.0, confidence))
|
||||
|
||||
|
||||
class RoleIdentifier:
|
||||
def __init__(self, client):
|
||||
self.client = client
|
||||
self.profiles: Dict[str, Dict] = {}
|
||||
|
||||
def _get_profile(self, speaker: str) -> Dict:
|
||||
return self.profiles.setdefault(
|
||||
speaker,
|
||||
{"texts": [], "role": None, "confidence": 0.0, "reason": ""},
|
||||
)
|
||||
|
||||
def _append_text(self, profile: Dict, text: str):
|
||||
if not text:
|
||||
return
|
||||
profile["texts"].append(text)
|
||||
joined = "\n".join(profile["texts"])
|
||||
while len(joined) > LLM_ROLE_HISTORY_MAX_CHARS and len(profile["texts"]) > 1:
|
||||
profile["texts"].pop(0)
|
||||
joined = "\n".join(profile["texts"])
|
||||
|
||||
async def identify(self, speaker: str, text: str) -> Optional[Dict]:
|
||||
clean_text = maybe_repair_mojibake(text.strip())
|
||||
profile = self._get_profile(speaker)
|
||||
self._append_text(profile, clean_text)
|
||||
|
||||
cached_role = profile.get("role")
|
||||
cached_confidence = _safe_confidence(profile.get("confidence"))
|
||||
if cached_role and cached_confidence >= LLM_ROLE_CACHE_CONFIDENCE:
|
||||
return {
|
||||
"role": cached_role,
|
||||
"role_confidence": cached_confidence,
|
||||
"role_reason": profile.get("reason", ""),
|
||||
"role_source": "llm_text_cached",
|
||||
"llm_text": clean_text,
|
||||
}
|
||||
|
||||
if len(clean_text) < LLM_ROLE_MIN_TEXT_LEN:
|
||||
return None
|
||||
|
||||
system_prompt = (
|
||||
"你是会议/辩论转写的角色识别器。"
|
||||
"只能根据文本内容、说话顺序和同一声纹说话人的历史文本判断角色。"
|
||||
"不要修改 speaker 编号;speaker_1、speaker_2 是声纹身份,不是角色。"
|
||||
"候选角色只能从给定列表选择。"
|
||||
"只返回 JSON,不要输出解释文本。"
|
||||
'JSON 格式:{"role":"候选角色之一","confidence":0.0,"reason":"一句话原因"}'
|
||||
)
|
||||
user_payload = {
|
||||
"acoustic_speaker": speaker,
|
||||
"candidate_roles": LLM_ROLE_CANDIDATES,
|
||||
"speaker_history": profile["texts"][-6:],
|
||||
"current_text": clean_text,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.client.chat.completions.create(
|
||||
model=LLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": json.dumps(user_payload, ensure_ascii=False)},
|
||||
],
|
||||
temperature=0.0,
|
||||
)
|
||||
content = response.choices[0].message.content or ""
|
||||
parsed = _extract_json_object(content)
|
||||
role = str(parsed.get("role") or "").strip()
|
||||
if role not in LLM_ROLE_CANDIDATES:
|
||||
role = "未知" if "未知" in LLM_ROLE_CANDIDATES else LLM_ROLE_CANDIDATES[-1]
|
||||
confidence = _safe_confidence(parsed.get("confidence"))
|
||||
reason = str(parsed.get("reason") or "").strip()[:160]
|
||||
|
||||
profile["role"] = role
|
||||
profile["confidence"] = confidence
|
||||
profile["reason"] = reason
|
||||
|
||||
return {
|
||||
"role": role,
|
||||
"role_confidence": confidence,
|
||||
"role_reason": reason,
|
||||
"role_source": "llm_text",
|
||||
"llm_text": clean_text,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"LLM角色判断失败: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def build_role_enriched_message(speaker_msg: Dict, role_result: Dict) -> Dict:
|
||||
msg = dict(speaker_msg)
|
||||
msg["type"] = "asr_with_speaker_role"
|
||||
msg["source"] = "acoustic+llm"
|
||||
msg["role"] = role_result.get("role")
|
||||
msg["role_confidence"] = role_result.get("role_confidence")
|
||||
msg["role_reason"] = role_result.get("role_reason")
|
||||
msg["role_source"] = role_result.get("role_source")
|
||||
|
||||
segments = []
|
||||
for segment in speaker_msg.get("segments", []):
|
||||
seg = dict(segment)
|
||||
seg["role"] = msg["role"]
|
||||
seg["role_confidence"] = msg["role_confidence"]
|
||||
seg["role_reason"] = msg["role_reason"]
|
||||
segments.append(seg)
|
||||
msg["segments"] = segments
|
||||
return msg
|
||||
|
||||
|
||||
# ========================
|
||||
# 5. WebSocket 处理与任务追踪
|
||||
# ========================
|
||||
|
||||
async def forward_audio(websocket):
|
||||
"""
|
||||
WebSocket 连接处理函数(核心入口)
|
||||
|
||||
职责:
|
||||
1. 接受前端 WebSocket 连接
|
||||
2. 建立到 ASR 服务的下游连接
|
||||
3. 双向转发消息(前端 -> ASR,ASR -> 前端)
|
||||
4. 拦截 ASR 结果并触 Agent 处理
|
||||
5. 确保所有后台任务完成后才退出(防止数据丢失)
|
||||
|
||||
Args:
|
||||
websocket: 前端 WebSocket 连接对象
|
||||
"""
|
||||
logger.info("客户端已连接")
|
||||
|
||||
client = None
|
||||
redis_mgr = None
|
||||
role_identifier = None
|
||||
use_llm_role_identification = USE_LLM_ROLE_IDENTIFICATION
|
||||
|
||||
if use_llm_role_identification:
|
||||
if AsyncOpenAI is None:
|
||||
logger.error("openai package is not installed; LLM role identification disabled")
|
||||
use_llm_role_identification = False
|
||||
else:
|
||||
client = AsyncOpenAI(api_key=LLM_API_KEY, base_url=LLM_BASE_URL)
|
||||
role_identifier = RoleIdentifier(client)
|
||||
|
||||
# 用于追踪后台任务,防止连接关闭时任务被强制销毁
|
||||
background_tasks: Set[asyncio.Task] = set()
|
||||
|
||||
try:
|
||||
# 连接到目标 ASR 服务
|
||||
# 添加连接参数以提高鲁棒性
|
||||
async with websockets.connect(
|
||||
TARGET_WS_URL,
|
||||
close_timeout=10, # 关闭超时
|
||||
ping_interval=None,
|
||||
ping_timeout=None,
|
||||
max_queue=1024, # 消息队列大小
|
||||
subprotocols=["binary"],
|
||||
) as target_ws:
|
||||
logger.info(f"已连接到 ASR 服务: {TARGET_WS_URL}")
|
||||
|
||||
# 发送 ASR 配置(双通道模式,PCM 音频格式)
|
||||
config = {"mode": "2pass", "chunk_size": [10, 10, 10], "wav_format": "pcm"}
|
||||
await target_ws.send(json.dumps(config))
|
||||
logger.debug(f"发送 ASR 配置: {config}")
|
||||
|
||||
async def frontend_to_target():
|
||||
"""
|
||||
上行转发:前端音频数据 -> ASR 服务
|
||||
"""
|
||||
try:
|
||||
async for message in websocket:
|
||||
await target_ws.send(message)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.info("客户端连接已关闭,停止上行转发")
|
||||
finally:
|
||||
try:
|
||||
await target_ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def send_role_update(speaker_msg: Dict):
|
||||
if role_identifier is None:
|
||||
return
|
||||
try:
|
||||
role_result = await role_identifier.identify(
|
||||
speaker_msg.get("speaker", ""),
|
||||
speaker_msg.get("text", ""),
|
||||
)
|
||||
if not role_result:
|
||||
return
|
||||
role_msg = build_role_enriched_message(speaker_msg, role_result)
|
||||
await websocket.send(json.dumps(role_msg, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
logger.error(f"发送LLM角色结果失败: {e}", exc_info=True)
|
||||
|
||||
async def target_to_frontend():
|
||||
"""
|
||||
下行转发:ASR 识别结果 -> 前端
|
||||
同时拦截结果触发 Agent 处理
|
||||
"""
|
||||
try:
|
||||
async for message in target_ws:
|
||||
# 1. 透传 ASR 原始结果到前端(字幕显示)
|
||||
try:
|
||||
await websocket.send(message)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.info("客户端连接已关闭,停止下行转发")
|
||||
break
|
||||
|
||||
# 2. 如果是文本消息,尝试触发 Agent 处理
|
||||
if isinstance(message, str):
|
||||
try:
|
||||
data = json.loads(message)
|
||||
# 只处理最终识别结果(2pass-offline 模式)
|
||||
if data.get("mode") == "2pass-offline":
|
||||
speaker_msg = build_acoustic_speaker_message(data)
|
||||
if speaker_msg is not None:
|
||||
await websocket.send(json.dumps(speaker_msg, ensure_ascii=False))
|
||||
if use_llm_role_identification and role_identifier is not None:
|
||||
t = asyncio.create_task(send_role_update(speaker_msg))
|
||||
background_tasks.add(t)
|
||||
t.add_done_callback(background_tasks.discard)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.info("客户端连接已关闭,停止发送增强结果")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"处理 ASR 结果时出错: {e}", exc_info=True)
|
||||
except websockets.exceptions.ConnectionClosed as e:
|
||||
logger.info(f"ASR 服务连接已关闭: {type(e).__name__}: {e}")
|
||||
finally:
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 并发运行两个方向的数据流
|
||||
await asyncio.gather(frontend_to_target(), target_to_frontend())
|
||||
|
||||
except websockets.exceptions.InvalidMessage as e:
|
||||
logger.error(f"WebSocket 握手失败 - ASR 服务可能未正常运行或 URL 配置错误: {e}")
|
||||
logger.error(f"请检查: 1) ASR 服务是否在 {TARGET_WS_URL} 正常运行")
|
||||
logger.error(f" 2) ASR 服务的 URL 路径是否正确")
|
||||
logger.error(f" 3) ASR 服务是否需要特定的认证或配置")
|
||||
except ConnectionRefusedError:
|
||||
logger.error(f"连接被拒绝 - 无法连接到 ASR 服务 {TARGET_WS_URL}")
|
||||
logger.error(f"请检查 ASR 服务是否已启动")
|
||||
except Exception as e:
|
||||
logger.error(f"连接错误: {type(e).__name__}: {e}", exc_info=True)
|
||||
finally:
|
||||
# --- 优雅退出处理 ---
|
||||
if background_tasks:
|
||||
logger.info(f"等待 {len(background_tasks)} 个后台 Agent 任务完成...")
|
||||
# 等待所有剩余任务跑完,最多等 5 秒(防止无限等待)
|
||||
await asyncio.wait(background_tasks, timeout=5)
|
||||
logger.debug("所有后台任务已完成或超时")
|
||||
|
||||
# 释放资源
|
||||
if client is not None:
|
||||
await client.close()
|
||||
if redis_mgr is not None:
|
||||
await redis_mgr.close()
|
||||
logger.info("客户端已断开连接")
|
||||
|
||||
|
||||
async def main():
|
||||
"""
|
||||
主函数:启动 WebSocket 服务器
|
||||
"""
|
||||
logger.info(f"启动 WebSocket 智能语义分析服务: ws://{LOCAL_HOST}:{LOCAL_PORT}")
|
||||
logger.info(f"Agent 模型: {LLM_MODEL}")
|
||||
logger.info(f"ASR 服务地址: {TARGET_WS_URL}")
|
||||
logger.info(f"Redis 地址: {REDIS_URL}")
|
||||
# 启动服务器并持续运行(asyncio.Future() 永不完成)
|
||||
async with websockets.serve(forward_audio, LOCAL_HOST, LOCAL_PORT, ping_interval=None):
|
||||
await asyncio.Future()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
271
fastapi_wss/src/agent_wss.py
Normal file
@ -0,0 +1,271 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import websockets
|
||||
import redis.asyncio as redis
|
||||
import re
|
||||
from openai import AsyncOpenAI # ⬅️ 引入 OpenAI 异步客户端
|
||||
|
||||
# ========================
|
||||
# 基础配置
|
||||
# ========================
|
||||
TARGET_WS_URL = os.getenv("TARGET_WS_URL", "ws://localhost:59805/ws/asr")
|
||||
LOCAL_HOST = "0.0.0.0"
|
||||
LOCAL_PORT = 10095
|
||||
|
||||
# Redis
|
||||
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/0")
|
||||
REDIS_KEY_PREFIX = "meeting:"
|
||||
|
||||
# LLM 配置 (适配 OpenAI SDK)
|
||||
# 如果是 OpenAI 官方,Base URL 默认即可;如果是 DeepSeek/阿里等,需修改 BASE_URL
|
||||
LLM_API_KEY = os.getenv("LLM_API_KEY", "sk-xxxx")
|
||||
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "http://172.18.127.124:9997/v1")
|
||||
LLM_MODEL = os.getenv("LLM_MODEL", "Qwen3-32B")
|
||||
|
||||
# 候选人
|
||||
CANDIDATE_SPEAKERS = ["局长", "主持人", "商务专员", "市场专员"]
|
||||
DEFAULT_SESSION_ID = "room_101"
|
||||
|
||||
# ASR 初始化配置
|
||||
config = {
|
||||
"chunk_size": [10, 10, 10],
|
||||
"wav_name": "h5",
|
||||
"is_speaking": True,
|
||||
"wav_format": "pcm",
|
||||
"chunk_interval": 10,
|
||||
"itn": True,
|
||||
"mode": "2pass",
|
||||
"hotwords": "",
|
||||
}
|
||||
|
||||
# ========================
|
||||
# Redis 操作封装 (保持不变)
|
||||
# ========================
|
||||
async def append_to_buffer(session_id, text):
|
||||
"""将新的片段追加到暂存区"""
|
||||
r = redis.from_url(REDIS_URL, decode_responses=True)
|
||||
key = f"{REDIS_KEY_PREFIX}buffer:{session_id}"
|
||||
await r.append(key, text + " ")
|
||||
current_buffer = await r.get(key)
|
||||
await r.close()
|
||||
return current_buffer
|
||||
|
||||
async def clear_buffer(session_id):
|
||||
"""清空暂存区"""
|
||||
r = redis.from_url(REDIS_URL, decode_responses=True)
|
||||
key = f"{REDIS_KEY_PREFIX}buffer:{session_id}"
|
||||
await r.delete(key)
|
||||
await r.close()
|
||||
|
||||
async def save_history(session_id, role, content):
|
||||
"""归档历史记录"""
|
||||
r = redis.from_url(REDIS_URL, decode_responses=True)
|
||||
key = f"{REDIS_KEY_PREFIX}history:{session_id}"
|
||||
record = json.dumps({"role": role, "content": content})
|
||||
await r.rpush(key, record)
|
||||
await r.ltrim(key, -20, -1)
|
||||
await r.close()
|
||||
|
||||
async def get_history(session_id):
|
||||
"""获取上下文"""
|
||||
r = redis.from_url(REDIS_URL, decode_responses=True)
|
||||
key = f"{REDIS_KEY_PREFIX}history:{session_id}"
|
||||
items = await r.lrange(key, 0, -1)
|
||||
await r.close()
|
||||
return [json.loads(i) for i in items]
|
||||
|
||||
# ========================
|
||||
# 通用 LLM 调用函数 (改为 OpenAI SDK)
|
||||
# ========================
|
||||
async def call_llm(client: AsyncOpenAI, system_prompt: str, user_text: str):
|
||||
"""
|
||||
使用 OpenAI SDK 发起调用
|
||||
"""
|
||||
try:
|
||||
response = await client.chat.completions.create(
|
||||
model=LLM_MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_text}
|
||||
],
|
||||
temperature=0.1,
|
||||
)
|
||||
|
||||
content = response.choices[0].message.content
|
||||
print(f"🤖 LLM 返回: {content}")
|
||||
return content
|
||||
except Exception as e:
|
||||
print(f"❌ OpenAI API 调用异常: {e}")
|
||||
return None
|
||||
|
||||
# ========================
|
||||
# Agent A: 完结判断 (The Auditor)
|
||||
# ========================
|
||||
async def agent_check_completion(text_buffer, client: AsyncOpenAI):
|
||||
"""
|
||||
判断当前的文本累积是否已经构成了一个完整的发言回合。
|
||||
"""
|
||||
system_prompt = """
|
||||
#ROLE
|
||||
你是一个对话状态判断助手。你的任务是判断当前的发言内容是否已经结束。
|
||||
#RULE
|
||||
1. **强制标准**:如果内容中包含 "发言完毕"、"讲完了"、"汇报完毕" 等明确结束语,必须返回 true。
|
||||
2. **语义标准**:如果内容完整且看似一段独立的话结束,返回 true;如果看似还在列举或句子未完,返回 false。
|
||||
3. 如果内容仅仅是很短的一句 "嗯"、"好的",视作未结束或无需独立归档。
|
||||
4. 请将结果用 <result> </result>包裹着。
|
||||
|
||||
#EXAMPLES
|
||||
示例1:空中侦察席报告,今日预计飞行287架航班,空气湿度适宜,安全风险较小。 <result>true</result>
|
||||
示例2:控制要素席报告,我这边已经完成了所有的准备工作.<result>false<result>
|
||||
示例3:下面有请局长发言 <result>false</result>
|
||||
示例4:地面侦察席报告目前没有发现敌对情况.<result>false</result>
|
||||
"""
|
||||
|
||||
result = await call_llm(client, system_prompt, f"当前累积内容:{text_buffer}")
|
||||
if result:
|
||||
# 更健壮的版本(容忍某些格式错误)
|
||||
match = re.search(r'<result>([^<]*)(?:</result>|<result>|$)', result, re.IGNORECASE)
|
||||
if match:
|
||||
content = match.group(1).strip()
|
||||
else:
|
||||
content = False # 或者根据你的逻辑处理未匹配的情况
|
||||
return content
|
||||
return False
|
||||
|
||||
# ========================
|
||||
# Agent B: 身份识别 (The Profiler)
|
||||
# ========================
|
||||
async def agent_identify_speaker(text_full, session_id, client: AsyncOpenAI):
|
||||
"""
|
||||
仅在 Agent A 判定结束时调用。根据全文和历史判断身份。
|
||||
"""
|
||||
history = await get_history(session_id)
|
||||
history_str = "\n".join([f"{h['role']}: {h['content']}" for h in history])
|
||||
|
||||
system_prompt = f"""
|
||||
#ROLE
|
||||
你是一个会议记录专家。请根据上下文和完整的段落内容,推断当前的发言人。
|
||||
#RULE
|
||||
1. **强制标准**:如果内容中包含 "控制要素席"、"空中侦察席"、"海上观察席"、"联合指挥中心"等明确发言人,必须直接返回名称。
|
||||
2. **语义标准**:如果内容完整且没有明确发言人,则根据语义进行判断,潜在的候选人有:{', '.join(CANDIDATE_SPEAKERS)}。
|
||||
3. 提供历史上下文:{history_str}
|
||||
4. 请将结果用 <result> </result>包裹着。
|
||||
|
||||
#EXAMPLES
|
||||
示例1:空中侦察席报告,今日预计飞行287架航班,空气湿度适宜,安全风险较小。 <result>空中侦察席</result>
|
||||
示例2:控制要素席报告,我这边已经完成了所有的准备工作,可以开始会议了。 <result>控制要素席<result>
|
||||
示例3:下面有请局长发言 <result>主持人</result>
|
||||
"""
|
||||
|
||||
result = await call_llm(client, system_prompt, f"完整发言内容:{text_full}")
|
||||
if result:
|
||||
match = re.search(r'<result>([^<]*)(?:</result>|<result>|$)', result, re.IGNORECASE)
|
||||
if match:
|
||||
content = match.group(1).strip()
|
||||
return content
|
||||
return "未知说话人"
|
||||
|
||||
# ========================
|
||||
# 核心编排逻辑 (多 Agent 协作)
|
||||
# ========================
|
||||
async def orchestrate_agents(new_fragment, websocket, client: AsyncOpenAI):
|
||||
session_id = DEFAULT_SESSION_ID
|
||||
|
||||
# 1. 存入 Buffer (累积)
|
||||
current_buffer = await append_to_buffer(session_id, new_fragment)
|
||||
print(f"📥 [Buffer] 长度: {len(current_buffer)} | 内容片段: {current_buffer[-20:]}")
|
||||
|
||||
# 2. 调用 Agent A 判断是否结束
|
||||
is_finished = await agent_check_completion(current_buffer, client)
|
||||
|
||||
if is_finished:
|
||||
print(f"🛑 [Agent A] 判定发言结束。触发身份识别...")
|
||||
|
||||
# 3. 调用 Agent B 识别身份
|
||||
speaker = await agent_identify_speaker(current_buffer, session_id, client)
|
||||
print(f"👤 [Agent B] 识别为: {speaker}")
|
||||
|
||||
# 4. 存入历史并清空 Buffer
|
||||
await save_history(session_id, speaker, current_buffer)
|
||||
await clear_buffer(session_id)
|
||||
|
||||
# 5. 推送最终结果给前端
|
||||
msg = {
|
||||
"type": "final_speech_record",
|
||||
"speaker": speaker,
|
||||
"content": current_buffer.strip(),
|
||||
"timestamp": "now"
|
||||
}
|
||||
await websocket.send(json.dumps(msg, ensure_ascii=False))
|
||||
|
||||
else:
|
||||
# 未结束,什么都不做,继续等待下一段文本
|
||||
print(f"⏳ [Agent A] 判定未结束...")
|
||||
|
||||
# ========================
|
||||
# WebSocket 主逻辑
|
||||
# ========================
|
||||
async def forward_audio(websocket):
|
||||
print("🟢 Client Connected.")
|
||||
|
||||
# 初始化 OpenAI 客户端
|
||||
# 注意:AsyncOpenAI 是线程/协程安全的,也可以放在全局初始化
|
||||
client = AsyncOpenAI(
|
||||
api_key=LLM_API_KEY,
|
||||
base_url=LLM_BASE_URL
|
||||
)
|
||||
|
||||
try:
|
||||
async with websockets.connect(TARGET_WS_URL) as target_ws:
|
||||
await target_ws.send(json.dumps(config))
|
||||
|
||||
async def frontend_to_target():
|
||||
async for message in websocket:
|
||||
await target_ws.send(message)
|
||||
|
||||
async def target_to_frontend():
|
||||
async for message in target_ws:
|
||||
# 1. 实时转发 (用于字幕上屏)
|
||||
await websocket.send(message)
|
||||
|
||||
if isinstance(message, str):
|
||||
try:
|
||||
data = json.loads(message)
|
||||
# 2. 触发 Agent 协作
|
||||
if data.get("mode") == "2pass-offline":
|
||||
asr_text = data.get("text", "").strip()
|
||||
if asr_text:
|
||||
# 异步触发,传入 client
|
||||
asyncio.create_task(
|
||||
orchestrate_agents(asr_text, websocket, client)
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
await asyncio.gather(frontend_to_target(), target_to_frontend())
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
finally:
|
||||
# 关闭客户端(虽然 AsyncOpenAI 通常会自动管理,但显式关闭是好习惯)
|
||||
await client.close()
|
||||
print("🔴 Client Disconnected.")
|
||||
|
||||
# ========================
|
||||
# 启动
|
||||
# ========================
|
||||
async def main():
|
||||
print(f"🚀 启动 WebSocket 智能语义分析服务: ws://{LOCAL_HOST}:{LOCAL_PORT}")
|
||||
print(f"🧠 Agent 模型: {LLM_MODEL}")
|
||||
|
||||
# 这里的 websockets.serve 会在 main 协程内部运行,此时已有 Loop
|
||||
async with websockets.serve(forward_audio, LOCAL_HOST, LOCAL_PORT):
|
||||
# 创建一个永远 pending 的 Future,让服务一直运行不退出
|
||||
await asyncio.Future()
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\n🛑 服务已停止")
|
||||
176
fastapi_wss/src/app.py
Normal file
@ -0,0 +1,176 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import wave
|
||||
import websockets
|
||||
import aiohttp
|
||||
|
||||
# ========================
|
||||
# 配置
|
||||
# ========================
|
||||
TARGET_WS_URL = os.getenv("TARGET_WS_URL", "ws://localhost:59805/ws/asr")
|
||||
LOCAL_HOST = "0.0.0.0"
|
||||
LOCAL_PORT = 10095
|
||||
|
||||
# ⚠️ 修改为你自己的 FastAPI ASR 服务地址
|
||||
ASR_LOCAL_URL = os.getenv("ASR_LOCAL_URL", "http://172.18.127.124:6688/asr")
|
||||
|
||||
SAMPLE_RATE = 16000 # ⬅️ 关键!
|
||||
SAMPLE_WIDTH = 2 # 16-bit
|
||||
CHANNELS = 1
|
||||
|
||||
config = {
|
||||
"chunk_size": [10, 10, 10],
|
||||
"wav_name": "h5",
|
||||
"is_speaking": True,
|
||||
"wav_format": "pcm",
|
||||
"chunk_interval": 10,
|
||||
"itn": True,
|
||||
"mode": "2pass",
|
||||
"hotwords": "",
|
||||
}
|
||||
|
||||
|
||||
# ========================
|
||||
# 调用本地 ASR 接口(带说话人识别)
|
||||
# ========================
|
||||
async def call_local_asr_with_audio(wav_path: str, session: aiohttp.ClientSession):
|
||||
"""调用你自己的 /asr 接口,返回结构化结果"""
|
||||
try:
|
||||
with open(wav_path, "rb") as f:
|
||||
data = aiohttp.FormData()
|
||||
data.add_field(
|
||||
"audio_file",
|
||||
f,
|
||||
filename=os.path.basename(wav_path),
|
||||
content_type="audio/wav"
|
||||
)
|
||||
# 可选:传递参数(如 spk_diarization=True 已默认开启)
|
||||
params = {
|
||||
"spk_diarization": "true",
|
||||
"spk_threshold": "0.6",
|
||||
"output": "json"
|
||||
}
|
||||
async with session.post(ASR_LOCAL_URL, data=data, params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.json()
|
||||
else:
|
||||
text = await resp.text()
|
||||
print(f"❌ 本地 ASR 接口错误: {resp.status} - {text}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"❌ 调用本地 ASR 异常: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ========================
|
||||
# 后台处理:音频 → 本地 ASR → 推送结果
|
||||
# ========================
|
||||
async def process_audio_with_local_asr(audio_chunks, asr_text, websocket, session):
|
||||
if not audio_chunks:
|
||||
return
|
||||
|
||||
wav_path = None
|
||||
try:
|
||||
# 1. 保存为 WAV
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
|
||||
wav_path = tmp.name
|
||||
with wave.open(wav_path, 'wb') as wf:
|
||||
wf.setnchannels(CHANNELS)
|
||||
wf.setsampwidth(SAMPLE_WIDTH)
|
||||
wf.setframerate(SAMPLE_RATE)
|
||||
wf.writeframes(b''.join(audio_chunks))
|
||||
|
||||
# 2. 调用本地 ASR 接口
|
||||
asr_result = await call_local_asr_with_audio(wav_path, session)
|
||||
if not asr_result or not asr_result.get("success"):
|
||||
print("❌ 本地 ASR 处理失败")
|
||||
return
|
||||
|
||||
# 3. 构建前端消息
|
||||
segments = asr_result.get("segments", [])
|
||||
full_text = asr_result.get("text", "")
|
||||
|
||||
# 推送完整结果(含说话人)
|
||||
msg = {
|
||||
"type": "asr_with_speaker",
|
||||
"full_text": full_text,
|
||||
"segments": segments, # 每段含 speaker, text, start, end 等
|
||||
"original_asr_text": asr_text # 来自远程 ASR 的原始文本(可选)
|
||||
}
|
||||
|
||||
await websocket.send(json.dumps(msg, ensure_ascii=False))
|
||||
print(f"✅ 本地 ASR + 说话人识别完成,共 {len(segments)} 段")
|
||||
|
||||
finally:
|
||||
if wav_path and os.path.exists(wav_path):
|
||||
os.unlink(wav_path)
|
||||
|
||||
|
||||
# ========================
|
||||
# 主 WebSocket 转发逻辑
|
||||
# ========================
|
||||
async def forward_audio(websocket):
|
||||
print("🟢 前端已连接。")
|
||||
current_audio_chunks = []
|
||||
lock = asyncio.Lock()
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
try:
|
||||
async with websockets.connect(TARGET_WS_URL) as target_ws:
|
||||
print(f"🔗 连接到远程 ASR: {TARGET_WS_URL}")
|
||||
await target_ws.send(json.dumps(config))
|
||||
|
||||
async def frontend_to_target():
|
||||
async for message in websocket:
|
||||
if isinstance(message, bytes):
|
||||
async with lock:
|
||||
current_audio_chunks.append(message)
|
||||
await target_ws.send(message)
|
||||
else:
|
||||
await target_ws.send(message)
|
||||
|
||||
async def target_to_frontend():
|
||||
async for message in target_ws:
|
||||
# 立即转发原始 ASR 消息(保持低延迟)
|
||||
await websocket.send(message)
|
||||
|
||||
if isinstance(message, str):
|
||||
try:
|
||||
data = json.loads(message)
|
||||
if data.get("mode") == "2pass-offline":
|
||||
asr_text = data.get("text", "").strip()
|
||||
print(f"🔊 触发本地 ASR 处理: '{asr_text}'")
|
||||
|
||||
async with lock:
|
||||
audio_copy = current_audio_chunks.copy()
|
||||
current_audio_chunks.clear()
|
||||
|
||||
# 启动后台任务:调用你自己的 /asr 接口
|
||||
asyncio.create_task(
|
||||
process_audio_with_local_asr(audio_copy, asr_text, websocket, session)
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
await asyncio.gather(frontend_to_target(), target_to_frontend())
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 连接异常: {e}")
|
||||
finally:
|
||||
print("🔴 前端断开连接。")
|
||||
|
||||
|
||||
# ========================
|
||||
# 启动服务
|
||||
# ========================
|
||||
async def main():
|
||||
print(f"🚀 启动 WebSocket 中转服务: ws://{LOCAL_HOST}:{LOCAL_PORT}")
|
||||
print(f"📝 本地 ASR 接口: {ASR_LOCAL_URL}")
|
||||
server = await websockets.serve(forward_audio, LOCAL_HOST, LOCAL_PORT)
|
||||
await server.wait_closed()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
317
fastapi_wss/src/speaker_det.py
Normal file
@ -0,0 +1,317 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import wave
|
||||
import websockets
|
||||
import aiohttp
|
||||
import logging
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
force=True
|
||||
)
|
||||
logger = logging.getLogger("app")
|
||||
|
||||
|
||||
# ========================
|
||||
# 配置
|
||||
# ========================
|
||||
load_dotenv()
|
||||
logger.info("正在加载环境变量 ")
|
||||
TARGET_WS_URL = os.getenv("TARGET_WS_URL", "ws://localhost:59805/ws/asr")
|
||||
LOCAL_HOST = "0.0.0.0"
|
||||
LOCAL_PORT = 10095
|
||||
ASR_LOCAL_URL = os.getenv("ASR_LOCAL_URL", "http://172.18.127.124:6688/asr")
|
||||
|
||||
SAMPLE_RATE = 16000
|
||||
SAMPLE_WIDTH = 2 # 16-bit = 2 bytes
|
||||
CHANNELS = 1
|
||||
|
||||
# 计算每秒的字节数
|
||||
BYTES_PER_SECOND = SAMPLE_RATE * SAMPLE_WIDTH * CHANNELS
|
||||
# 设定最小触发时长(秒)
|
||||
MIN_DURATION_THRESHOLD = 3.0
|
||||
|
||||
config = {
|
||||
"chunk_size": [10, 10, 10],
|
||||
"wav_name": "h5",
|
||||
"is_speaking": True,
|
||||
"wav_format": "pcm",
|
||||
"chunk_interval": 10,
|
||||
"itn": True,
|
||||
"mode": "2pass",
|
||||
"hotwords": "",
|
||||
}
|
||||
|
||||
|
||||
# ========================
|
||||
# 新增:声纹会话缓存管理类 (带超时机制 + 日志)
|
||||
# ========================
|
||||
class SpeakerSession:
|
||||
def __init__(self):
|
||||
self.last_speaker = None
|
||||
self.buffer = []
|
||||
self.accumulated_duration = 0.0
|
||||
self.lock = asyncio.Lock()
|
||||
self.MAX_BUFFER_DURATION = 3.0
|
||||
|
||||
async def process_and_send(self, new_msg, websocket, duration=0.0, force_flush=False):
|
||||
"""
|
||||
duration: 当前这段消息对应的音频时长(秒)
|
||||
"""
|
||||
async with self.lock:
|
||||
# === 1. 强制清空 (连接断开时) ===
|
||||
if force_flush:
|
||||
if self.buffer:
|
||||
logger.info(f"🧹 [最终清空] 准备推送 {len(self.buffer)} 条缓存...")
|
||||
for msg in self.buffer:
|
||||
text_content = msg.get("full_text", "")
|
||||
logger.info(f" └── 🚀 发送: {text_content}") # 打印发送内容
|
||||
await websocket.send(json.dumps(msg, ensure_ascii=False))
|
||||
self.buffer = []
|
||||
self.accumulated_duration = 0.0
|
||||
return
|
||||
|
||||
# 获取当前消息的说话人
|
||||
segments = new_msg.get("segments", [])
|
||||
current_speaker = segments[0].get("spk") if segments else "unknown"
|
||||
|
||||
# === 2. 如果是同一个人(或者刚开始) ===
|
||||
if self.last_speaker is None or current_speaker == self.last_speaker:
|
||||
self.buffer.append(new_msg)
|
||||
self.accumulated_duration += duration
|
||||
self.last_speaker = current_speaker
|
||||
|
||||
# 🌟 检查是否超时 20s
|
||||
if self.accumulated_duration >= self.MAX_BUFFER_DURATION:
|
||||
logger.info(f"⏰ [超时推送] 同人 ({current_speaker}) 积攒 {self.accumulated_duration:.2f}s >= 8s")
|
||||
|
||||
for msg in self.buffer:
|
||||
text_content = msg.get("full_text", "")
|
||||
logger.info(f" └── 🚀 发送: {text_content}") # 打印发送内容
|
||||
await websocket.send(json.dumps(msg, ensure_ascii=False))
|
||||
|
||||
# 清空缓存,重置时长
|
||||
self.buffer = []
|
||||
self.accumulated_duration = 0.0
|
||||
else:
|
||||
# 仅缓存,不发送
|
||||
text_preview = new_msg.get("full_text", "")
|
||||
logger.info(f"📥 [缓存中] Spk:{current_speaker} | 文本:{text_preview} | 累计:{self.accumulated_duration:.2f}s")
|
||||
|
||||
# === 3. 如果换人了 ===
|
||||
else:
|
||||
logger.info(f"🔄 [换人推送] {self.last_speaker} -> {current_speaker}")
|
||||
|
||||
# A. 推送上一位说话人的所有积攒消息
|
||||
for msg in self.buffer:
|
||||
text_content = msg.get("full_text", "")
|
||||
logger.info(f" └── 🚀 发送: {text_content}") # 打印发送内容
|
||||
await websocket.send(json.dumps(msg, ensure_ascii=False))
|
||||
|
||||
# B. 清空并开始缓存新人的消息
|
||||
self.buffer = []
|
||||
self.buffer.append(new_msg)
|
||||
self.accumulated_duration = duration
|
||||
self.last_speaker = current_speaker
|
||||
|
||||
# 打印新人入队日志
|
||||
new_text = new_msg.get("full_text", "")
|
||||
logger.info(f"📥 [新缓存] Spk:{current_speaker} | 文本:{new_text}")
|
||||
|
||||
# ========================
|
||||
# 调用本地 ASR 接口(带说话人识别)
|
||||
# ========================
|
||||
async def call_local_asr_with_audio(wav_path: str, session: aiohttp.ClientSession):
|
||||
try:
|
||||
with open(wav_path, "rb") as f:
|
||||
data = aiohttp.FormData()
|
||||
data.add_field(
|
||||
"audio_file",
|
||||
f,
|
||||
filename=os.path.basename(wav_path),
|
||||
content_type="audio/wav"
|
||||
)
|
||||
params = {
|
||||
"spk_diarization": "true",
|
||||
"spk_threshold": "0.6",
|
||||
"output": "json"
|
||||
}
|
||||
async with session.post(ASR_LOCAL_URL, data=data, params=params, timeout=aiohttp.ClientTimeout(total=60)) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.json()
|
||||
else:
|
||||
text = await resp.text()
|
||||
logger.error(f"❌ 本地 ASR 接口错误: {resp.status} - {text}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 调用本地 ASR 异常: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ========================
|
||||
# 后台处理:音频 → 本地 ASR → Session 缓存判断
|
||||
# ========================
|
||||
async def process_audio_with_local_asr(audio_bytes, asr_text, websocket, session, speaker_session):
|
||||
if not audio_bytes:
|
||||
return
|
||||
|
||||
wav_path = None
|
||||
try:
|
||||
# 1. 计算这段音频的时长,用于 Session 判断超时
|
||||
audio_duration = len(audio_bytes) / BYTES_PER_SECOND
|
||||
|
||||
# 2. 保存为 WAV
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp:
|
||||
wav_path = tmp.name
|
||||
|
||||
with wave.open(wav_path, 'wb') as wf:
|
||||
wf.setnchannels(CHANNELS)
|
||||
wf.setsampwidth(SAMPLE_WIDTH)
|
||||
wf.setframerate(SAMPLE_RATE)
|
||||
wf.writeframes(audio_bytes)
|
||||
|
||||
# 3. 调用本地 ASR 接口
|
||||
asr_result = await call_local_asr_with_audio(wav_path, session)
|
||||
if not asr_result or not asr_result.get("success"):
|
||||
logger.error("❌ 本地 ASR (带声纹) 处理失败")
|
||||
return
|
||||
|
||||
# 4. 构建消息
|
||||
segments = asr_result.get("segments", [])
|
||||
full_text = asr_result.get("text", "")
|
||||
|
||||
msg = {
|
||||
"mode": "offline-speaker",
|
||||
"full_text": full_text,
|
||||
"segments": segments,
|
||||
"original_asr_text": asr_text,
|
||||
"type": "asr_with_speaker",
|
||||
}
|
||||
|
||||
# 5. 🌟 传入 duration 参数
|
||||
await speaker_session.process_and_send(msg, websocket, duration=audio_duration)
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.warning("⚠️ WebSocket 连接已关闭")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 后台处理异常: {e}")
|
||||
finally:
|
||||
if wav_path and os.path.exists(wav_path):
|
||||
os.unlink(wav_path)
|
||||
|
||||
|
||||
# ========================
|
||||
# 主 WebSocket 转发逻辑
|
||||
# ========================
|
||||
async def forward_audio(websocket):
|
||||
logger.info("🟢 前端已连接。")
|
||||
|
||||
current_audio_chunks = []
|
||||
local_accumulated_audio = bytearray()
|
||||
|
||||
speaker_session = SpeakerSession()
|
||||
|
||||
lock = asyncio.Lock()
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
try:
|
||||
async with websockets.connect(TARGET_WS_URL) as target_ws:
|
||||
logger.info(f"🔗 连接到远程 ASR: {TARGET_WS_URL}")
|
||||
await target_ws.send(json.dumps(config))
|
||||
|
||||
# ---------------------------
|
||||
# 任务1: 前端 -> 远程 ASR
|
||||
# ---------------------------
|
||||
async def frontend_to_target():
|
||||
async for message in websocket:
|
||||
if isinstance(message, bytes):
|
||||
async with lock:
|
||||
current_audio_chunks.append(message)
|
||||
await target_ws.send(message)
|
||||
else:
|
||||
await target_ws.send(message)
|
||||
|
||||
# ---------------------------
|
||||
# 任务2: 远程 ASR -> 前端
|
||||
# ---------------------------
|
||||
async def target_to_frontend():
|
||||
async for message in target_ws:
|
||||
# 实时推送
|
||||
await websocket.send(message)
|
||||
|
||||
if isinstance(message, str):
|
||||
try:
|
||||
data = json.loads(message)
|
||||
mode = data.get("mode")
|
||||
|
||||
if mode == "2pass-offline":
|
||||
asr_text = data.get("text", "").strip()
|
||||
|
||||
async with lock:
|
||||
chunk_bytes = b"".join(current_audio_chunks)
|
||||
current_audio_chunks.clear()
|
||||
|
||||
local_accumulated_audio.extend(chunk_bytes)
|
||||
current_duration = len(local_accumulated_audio) / BYTES_PER_SECOND
|
||||
|
||||
if current_duration < MIN_DURATION_THRESHOLD:
|
||||
logger.info(f"⏳ 音频拼接中: {current_duration:.2f}s")
|
||||
else:
|
||||
logger.info(f"⚡ 触发本地声纹识别: {current_duration:.2f}s")
|
||||
|
||||
audio_to_send = bytes(local_accumulated_audio)
|
||||
local_accumulated_audio.clear()
|
||||
|
||||
asyncio.create_task(
|
||||
process_audio_with_local_asr(audio_to_send, asr_text, websocket, session, speaker_session)
|
||||
)
|
||||
|
||||
elif mode == "2pass-online":
|
||||
pass
|
||||
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
await asyncio.gather(frontend_to_target(), target_to_frontend())
|
||||
|
||||
except websockets.exceptions.ConnectionClosedOK:
|
||||
logger.info("🔗 远程 ASR 连接正常关闭。")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 连接异常: {e}")
|
||||
finally:
|
||||
logger.info("🔴 前端断开连接,清理资源...")
|
||||
|
||||
if len(local_accumulated_audio) > 0:
|
||||
final_duration = len(local_accumulated_audio) / BYTES_PER_SECOND
|
||||
logger.info(f"⚠️ 处理断开前的剩余音频: {final_duration:.2f}s")
|
||||
try:
|
||||
audio_to_send = bytes(local_accumulated_audio)
|
||||
local_accumulated_audio.clear()
|
||||
await process_audio_with_local_asr(audio_to_send, "Final-Flush", websocket, session, speaker_session)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 剩余音频处理异常: {e}")
|
||||
|
||||
# 强制推送最后残留缓存
|
||||
try:
|
||||
await speaker_session.process_and_send(None, websocket, force_flush=True)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 最终缓存推送失败: {e}")
|
||||
|
||||
|
||||
# ========================
|
||||
# 启动服务
|
||||
# ========================
|
||||
async def main():
|
||||
logger.info(f"🚀 启动 WebSocket 中转服务: ws://{LOCAL_HOST}:{LOCAL_PORT}")
|
||||
logger.info("📝 逻辑: 声纹识别缓存,同人累积满8s或换人时推送")
|
||||
server = await websockets.serve(forward_audio, LOCAL_HOST, LOCAL_PORT)
|
||||
await server.wait_closed()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
199
fastapi_wss/src/test.py
Normal file
@ -0,0 +1,199 @@
|
||||
import asyncio
|
||||
import audioop
|
||||
from array import array
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import wave
|
||||
import websockets
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
|
||||
WS_URL = "ws://localhost:10095"
|
||||
TARGET_SAMPLE_RATE = 16000
|
||||
TARGET_SAMPLE_WIDTH = 2
|
||||
CHUNK_SECONDS = 0.06
|
||||
FINAL_RESULT_TIMEOUT = 60
|
||||
WAV_PATH = r"E:\ZKYNLP\gonwgendoc\fastapi_wss\fastapi_wss\src\多人会议90分钟.wav"
|
||||
|
||||
|
||||
def downmix_to_mono_pcm16(chunk: bytes, channels: int) -> bytes:
|
||||
if channels == 1:
|
||||
return chunk
|
||||
|
||||
samples = array("h")
|
||||
samples.frombytes(chunk)
|
||||
if sys.byteorder != "little":
|
||||
samples.byteswap()
|
||||
|
||||
mono = array("h")
|
||||
for index in range(0, len(samples), channels):
|
||||
frame = samples[index:index + channels]
|
||||
if len(frame) == channels:
|
||||
mono.append(int(sum(frame) / channels))
|
||||
|
||||
if sys.byteorder != "little":
|
||||
mono.byteswap()
|
||||
return mono.tobytes()
|
||||
|
||||
|
||||
def validate_wav_for_streaming(wf: wave.Wave_read):
|
||||
sample_rate = wf.getframerate()
|
||||
sample_width = wf.getsampwidth()
|
||||
channels = wf.getnchannels()
|
||||
|
||||
if sample_width != TARGET_SAMPLE_WIDTH:
|
||||
raise ValueError(f"WAV sample width must be {TARGET_SAMPLE_WIDTH} bytes, got {sample_width} bytes")
|
||||
if channels < 1:
|
||||
raise ValueError(f"WAV channels must be >= 1, got {channels}")
|
||||
|
||||
if sample_rate != TARGET_SAMPLE_RATE:
|
||||
print(f"Input WAV is {sample_rate}Hz; resampling to {TARGET_SAMPLE_RATE}Hz before sending.")
|
||||
if channels > 1:
|
||||
print(f"Input WAV has {channels} channels; downmixing to mono before sending.")
|
||||
source_frames_per_chunk = max(1, int(sample_rate * CHUNK_SECONDS))
|
||||
return sample_rate, channels, source_frames_per_chunk
|
||||
|
||||
|
||||
class Pcm16Mono16kConverter:
|
||||
def __init__(self, source_rate: int, channels: int):
|
||||
self.source_rate = source_rate
|
||||
self.channels = channels
|
||||
self.rate_state = None
|
||||
|
||||
def convert(self, chunk: bytes) -> bytes:
|
||||
chunk = downmix_to_mono_pcm16(chunk, self.channels)
|
||||
if self.source_rate != TARGET_SAMPLE_RATE:
|
||||
chunk, self.rate_state = audioop.ratecv(
|
||||
chunk,
|
||||
TARGET_SAMPLE_WIDTH,
|
||||
1,
|
||||
self.source_rate,
|
||||
TARGET_SAMPLE_RATE,
|
||||
self.rate_state,
|
||||
)
|
||||
return chunk
|
||||
|
||||
def is_final_asr_message(data: dict) -> bool:
|
||||
mode = data.get("mode")
|
||||
if mode not in {"2pass-offline", "offline-speaker"}:
|
||||
return False
|
||||
return bool(data.get("is_final", True))
|
||||
|
||||
|
||||
async def recv_loop(ws, audio_sent_event: asyncio.Event, final_result_event: asyncio.Event, stats: dict):
|
||||
async for msg in ws:
|
||||
print("RECV:", msg)
|
||||
if not isinstance(msg, str):
|
||||
continue
|
||||
try:
|
||||
data = json.loads(msg)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if not is_final_asr_message(data):
|
||||
continue
|
||||
|
||||
now = time.perf_counter()
|
||||
stats["last_final_time"] = now
|
||||
if audio_sent_event.is_set() and "first_final_after_send_time" not in stats:
|
||||
stats["first_final_after_send_time"] = now
|
||||
final_result_event.set()
|
||||
|
||||
|
||||
def print_timing_stats(stats: dict):
|
||||
end_time = stats.get("first_final_after_send_time") or stats.get("end_time") or time.perf_counter()
|
||||
total_elapsed = end_time - stats["start_time"]
|
||||
send_elapsed = stats.get("send_end_time", end_time) - stats.get("send_start_time", stats["start_time"])
|
||||
tail_elapsed = None
|
||||
if "send_end_time" in stats and "first_final_after_send_time" in stats:
|
||||
tail_elapsed = stats["first_final_after_send_time"] - stats["send_end_time"]
|
||||
|
||||
print("\n===== ASR Timing =====")
|
||||
print(f"Audio duration: {stats.get('audio_duration_sec', 0.0):.2f}s")
|
||||
print(f"Audio send time: {send_elapsed:.2f}s")
|
||||
if tail_elapsed is not None:
|
||||
print(f"Post-send ASR wait: {tail_elapsed:.2f}s")
|
||||
else:
|
||||
print("Post-send ASR wait: timeout/no final result")
|
||||
print(f"Total elapsed: {total_elapsed:.2f}s")
|
||||
if stats.get("audio_duration_sec"):
|
||||
rtf = total_elapsed / stats["audio_duration_sec"]
|
||||
print(f"RTF: {rtf:.3f}x")
|
||||
print("======================\n")
|
||||
|
||||
async def main():
|
||||
stats = {"start_time": time.perf_counter()}
|
||||
audio_sent_event = asyncio.Event()
|
||||
final_result_event = asyncio.Event()
|
||||
|
||||
async with websockets.connect(
|
||||
WS_URL,
|
||||
max_size=None,
|
||||
ping_interval=None,
|
||||
ping_timeout=None,
|
||||
) as ws:
|
||||
recv_task = asyncio.create_task(recv_loop(ws, audio_sent_event, final_result_event, stats))
|
||||
|
||||
init = {
|
||||
"mode": "2pass",
|
||||
"chunk_size": [10, 10, 10],
|
||||
"chunk_interval": 10,
|
||||
"wav_name": "test",
|
||||
"is_speaking": True,
|
||||
"wav_format": "pcm",
|
||||
"itn": True
|
||||
}
|
||||
await ws.send(json.dumps(init, ensure_ascii=False))
|
||||
|
||||
connection_closed = False
|
||||
|
||||
with wave.open(WAV_PATH, "rb") as wf:
|
||||
source_rate, channels, source_frames_per_chunk = validate_wav_for_streaming(wf)
|
||||
stats["audio_duration_sec"] = wf.getnframes() / source_rate
|
||||
converter = Pcm16Mono16kConverter(source_rate, channels)
|
||||
|
||||
stats["send_start_time"] = time.perf_counter()
|
||||
while True:
|
||||
chunk = wf.readframes(source_frames_per_chunk)
|
||||
if not chunk:
|
||||
break
|
||||
chunk = converter.convert(chunk)
|
||||
try:
|
||||
await ws.send(chunk)
|
||||
except websockets.exceptions.ConnectionClosed as e:
|
||||
connection_closed = True
|
||||
print(f"WebSocket closed while sending audio: {type(e).__name__}: {e}")
|
||||
break
|
||||
await asyncio.sleep(CHUNK_SECONDS)
|
||||
|
||||
if not connection_closed:
|
||||
try:
|
||||
await ws.send(json.dumps({"is_speaking": False}, ensure_ascii=False))
|
||||
except websockets.exceptions.ConnectionClosed as e:
|
||||
connection_closed = True
|
||||
print(f"WebSocket closed before sending end marker: {type(e).__name__}: {e}")
|
||||
|
||||
stats["send_end_time"] = time.perf_counter()
|
||||
audio_sent_event.set()
|
||||
|
||||
if connection_closed and not final_result_event.is_set():
|
||||
print("Connection closed before final ASR result was received.")
|
||||
stats["end_time"] = time.perf_counter()
|
||||
print_timing_stats(stats)
|
||||
else:
|
||||
try:
|
||||
await asyncio.wait_for(final_result_event.wait(), timeout=FINAL_RESULT_TIMEOUT)
|
||||
except asyncio.TimeoutError:
|
||||
print(f"Timed out waiting for final ASR result after {FINAL_RESULT_TIMEOUT}s.")
|
||||
finally:
|
||||
stats["end_time"] = time.perf_counter()
|
||||
print_timing_stats(stats)
|
||||
|
||||
recv_task.cancel()
|
||||
await asyncio.gather(recv_task, return_exceptions=True)
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
1
fastapi_wss/test.json
Normal file
24
speakr/.dockerignore
Normal file
@ -0,0 +1,24 @@
|
||||
.git
|
||||
.codex/
|
||||
.agents/
|
||||
.env
|
||||
.env.*
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.swp
|
||||
*.swo
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
logs/
|
||||
node_modules/
|
||||
templates/index/node_modules/
|
||||
static/vite/
|
||||
instance/
|
||||
uploads/
|
||||
*.db
|
||||
*.log
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
3
speakr/.gitattributes
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
# 保证 shell 脚本在任何平台检出时都是 LF,否则 Docker 镜像内脚本无法执行
|
||||
*.sh text eol=lf
|
||||
docker-entrypoint.sh text eol=lf
|
||||
65
speakr/.gitignore
vendored
Normal file
@ -0,0 +1,65 @@
|
||||
# Python 字节码缓存
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.pyo
|
||||
|
||||
# 虚拟环境
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# 环境变量(含敏感信息,禁止提交)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# 日志文件
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# 编辑器 / IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
.trae/
|
||||
.codex/
|
||||
|
||||
# 测试 / 覆盖率
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
templates/index/node_modules/
|
||||
|
||||
# 构建产物
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
static/vite/
|
||||
skills/
|
||||
## mypy
|
||||
.mypy_cache/
|
||||
|
||||
# data
|
||||
|
||||
data/
|
||||
static/vite/
|
||||
instance/transcriptions.db
|
||||
ai-guide/
|
||||
音频web转文字前端示例/
|
||||
|
||||
# 本地测试脚本
|
||||
tests/
|
||||
|
||||
#本地调试文件
|
||||
*.backup
|
||||
|
||||
# 本地数据库文件
|
||||
*.db
|
||||
instance/transcriptions.db
|
||||
node_modules/
|
||||
instance/transcriptions.db
|
||||
instance/transcriptions.db
|
||||
137
speakr/.gitlab-ci.yml
Normal file
@ -0,0 +1,137 @@
|
||||
# GitLab CI/CD 配置 for speakr 项目
|
||||
# 自动构建并部署到 Kubernetes
|
||||
|
||||
stages:
|
||||
- build
|
||||
- deploy
|
||||
|
||||
variables:
|
||||
# 使用阿里云镜像仓库
|
||||
IMAGE_NAME: crpi-99azmmphmxwdoi76.cn-guangzhou.personal.cr.aliyuncs.com/sea_lee/speakr
|
||||
IMAGE_TAG: $CI_COMMIT_SHORT_SHA
|
||||
|
||||
# ============================================
|
||||
# 阶段 1: 构建 Docker 镜像并推送
|
||||
# ============================================
|
||||
build:
|
||||
stage: build
|
||||
image: docker:latest
|
||||
services:
|
||||
- docker:dind
|
||||
before_script:
|
||||
# 登录阿里云镜像仓库
|
||||
- echo "$ALIYUN_REGISTRY_PASSWORD" | docker login crpi-99azmmphmxwdoi76.cn-guangzhou.personal.cr.aliyuncs.com -u "$ALIYUN_REGISTRY_USER" --password-stdin
|
||||
script:
|
||||
- echo "=========================================="
|
||||
- echo "🚀 开始构建 speakr 镜像"
|
||||
- echo "=========================================="
|
||||
- echo "Git 提交: $CI_COMMIT_SHORT_SHA"
|
||||
- echo "分支: $CI_COMMIT_BRANCH"
|
||||
- echo "提交信息: $CI_COMMIT_TITLE"
|
||||
- echo "提交者: $GITLAB_USER_NAME"
|
||||
- echo ""
|
||||
|
||||
# 显示 Dockerfile 内容
|
||||
- echo "Dockerfile:"
|
||||
- cat Dockerfile
|
||||
- echo ""
|
||||
|
||||
# 构建 Docker 镜像 (使用现有的 Dockerfile)
|
||||
- echo "📦 构建 Docker 镜像..."
|
||||
- docker build -t $IMAGE_NAME:$IMAGE_TAG .
|
||||
- echo "✓ 镜像构建完成"
|
||||
- docker images | grep speakr
|
||||
|
||||
# 打标签为 latest
|
||||
- docker tag $IMAGE_NAME:$IMAGE_TAG $IMAGE_NAME:latest
|
||||
|
||||
# 推送到阿里云镜像仓库
|
||||
- echo ""
|
||||
- echo "📤 推送镜像到阿里云..."
|
||||
- docker push $IMAGE_NAME:$IMAGE_TAG
|
||||
- docker push $IMAGE_NAME:latest
|
||||
- echo "✓ 镜像推送完成"
|
||||
|
||||
- echo ""
|
||||
- echo "=========================================="
|
||||
- echo "✅ 构建阶段完成!"
|
||||
- echo "镜像: $IMAGE_NAME:$IMAGE_TAG"
|
||||
- echo "=========================================="
|
||||
only:
|
||||
- main
|
||||
- master
|
||||
- develop
|
||||
|
||||
# ============================================
|
||||
# 阶段 2: 部署到 Kubernetes
|
||||
# ============================================
|
||||
deploy:
|
||||
stage: deploy
|
||||
image: bitnami/kubectl:latest
|
||||
environment:
|
||||
name: production
|
||||
url: http://172.18.127.124
|
||||
before_script:
|
||||
# 配置 kubectl
|
||||
- echo "🔧 配置 kubectl..."
|
||||
- echo "$KUBECONFIG_CONTENT" | base64 -d > /tmp/kubeconfig
|
||||
- export KUBECONFIG=/tmp/kubeconfig
|
||||
- chmod 600 /tmp/kubeconfig
|
||||
|
||||
# 验证 Kubernetes 连接
|
||||
- echo ""
|
||||
- echo "验证 Kubernetes 连接..."
|
||||
- kubectl config current-context
|
||||
- kubectl version --client
|
||||
- kubectl get nodes
|
||||
script:
|
||||
- echo "=========================================="
|
||||
- echo "🚀 部署 speakr 到 Kubernetes"
|
||||
- echo "=========================================="
|
||||
- echo "命名空间: default"
|
||||
- echo "Deployment: speakr"
|
||||
- echo "镜像: $IMAGE_NAME:$IMAGE_TAG"
|
||||
- echo ""
|
||||
|
||||
# 检查 deployment 是否存在
|
||||
- |
|
||||
if kubectl get deployment speakr &>/dev/null; then
|
||||
echo "✓ Deployment 已存在,更新镜像..."
|
||||
kubectl set image deployment/speakr speakr=$IMAGE_NAME:$IMAGE_TAG
|
||||
else
|
||||
echo "✗ Deployment 不存在,创建新的..."
|
||||
kubectl create deployment speakr --image=$IMAGE_NAME:$IMAGE_TAG --replicas=2
|
||||
fi
|
||||
|
||||
# 等待 Deployment rollout 完成
|
||||
- echo ""
|
||||
- echo "⏳ 等待部署完成..."
|
||||
- kubectl rollout status deployment/speakr --timeout=10m
|
||||
|
||||
# 确保服务存在
|
||||
- |
|
||||
if ! kubectl get service speakr &>/dev/null; then
|
||||
echo "创建 Service..."
|
||||
kubectl expose deployment speakr --port=8899 --target-port=8899 --type=NodePort
|
||||
fi
|
||||
|
||||
# 显示最终状态
|
||||
- echo ""
|
||||
- echo "=========================================="
|
||||
- echo "✅ 部署完成!"
|
||||
- echo "=========================================="
|
||||
- kubectl get pods -l app=speakr
|
||||
- echo ""
|
||||
- kubectl get service speakr
|
||||
- echo ""
|
||||
|
||||
# 获取访问地址
|
||||
- |
|
||||
NODEPORT=$(kubectl get service speakr -o jsonpath='{.spec.ports[0].nodePort}')
|
||||
echo "🌐 访问地址:"
|
||||
echo " NodePort: http://172.18.127.124:$NODEPORT"
|
||||
echo " 直接访问: http://172.18.127.124:8899"
|
||||
echo ""
|
||||
only:
|
||||
- main
|
||||
- master
|
||||
62
speakr/Dockerfile
Normal file
@ -0,0 +1,62 @@
|
||||
FROM node:20-slim AS frontend-builder
|
||||
|
||||
WORKDIR /build/templates/index
|
||||
|
||||
# 安装前端依赖,并在镜像构建阶段生成 Vite 产物。
|
||||
COPY templates/index/package.json templates/index/pnpm-lock.yaml ./
|
||||
RUN corepack enable && \
|
||||
corepack prepare pnpm@9.15.9 --activate && \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY templates/index/ ./
|
||||
|
||||
RUN rm -f pnpm-workspace.yaml && pnpm build
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装运行时系统依赖。
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 先安装 Python 依赖,提高 Docker 层缓存命中率。
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 先准备 vendor 目录,便于离线依赖下载脚本写入文件。
|
||||
RUN mkdir -p /app/static/vendor
|
||||
|
||||
# 只复制下载脚本,避免业务代码变化导致 vendor 依赖重复下载。
|
||||
COPY scripts/download_offline_deps.py scripts/
|
||||
RUN pip install --no-cache-dir requests && \
|
||||
python scripts/download_offline_deps.py
|
||||
|
||||
# 复制应用代码。
|
||||
COPY . .
|
||||
|
||||
# 使用前端构建阶段生成的产物覆盖源码中的旧产物。
|
||||
COPY --from=frontend-builder /build/static/vite/index /app/static/vite/index
|
||||
|
||||
# 创建容器运行时数据目录。
|
||||
RUN mkdir -p /data/uploads /data/instance && \
|
||||
chmod 755 /data/uploads /data/instance
|
||||
|
||||
# 设置默认运行环境变量。
|
||||
ENV FLASK_APP=src/app.py
|
||||
ENV SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db
|
||||
ENV UPLOAD_FOLDER=/data/uploads
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
# 安装容器入口脚本。
|
||||
COPY scripts/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# 暴露 Flask/Gunicorn 服务端口。
|
||||
EXPOSE 8899
|
||||
|
||||
# 设置入口脚本和默认启动命令。
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["gunicorn", "--workers", "3", "--threads", "8", "--bind", "0.0.0.0:8899", "--timeout", "600", "src.app:app"]
|
||||
61
speakr/Dockerfile20260710
Normal file
@ -0,0 +1,61 @@
|
||||
FROM node:20-slim AS frontend-builder
|
||||
|
||||
WORKDIR /build/templates/index
|
||||
|
||||
# 安装前端依赖,并在镜像构建阶段生成 Vite 产物。
|
||||
COPY templates/index/package.json templates/index/pnpm-lock.yaml templates/index/pnpm-workspace.yaml ./
|
||||
RUN corepack enable && \
|
||||
corepack prepare pnpm@9.15.9 --activate && \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY templates/index/ ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装运行时系统依赖。
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 先安装 Python 依赖,提高 Docker 层缓存命中率。
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 先准备 vendor 目录,便于离线依赖下载脚本写入文件。
|
||||
RUN mkdir -p /app/static/vendor
|
||||
|
||||
# 只复制下载脚本,避免业务代码变化导致 vendor 依赖重复下载。
|
||||
COPY scripts/download_offline_deps.py scripts/
|
||||
RUN pip install --no-cache-dir requests && \
|
||||
python scripts/download_offline_deps.py
|
||||
|
||||
# 复制应用代码。
|
||||
COPY . .
|
||||
|
||||
# 使用前端构建阶段生成的产物覆盖源码中的旧产物。
|
||||
COPY --from=frontend-builder /build/static/vite/index /app/static/vite/index
|
||||
|
||||
# 创建容器运行时数据目录。
|
||||
RUN mkdir -p /data/uploads /data/instance && \
|
||||
chmod 755 /data/uploads /data/instance
|
||||
|
||||
# 设置默认运行环境变量。
|
||||
ENV FLASK_APP=src/app.py
|
||||
ENV SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db
|
||||
ENV UPLOAD_FOLDER=/data/uploads
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
# 安装容器入口脚本。
|
||||
COPY scripts/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# 暴露 Flask/Gunicorn 服务端口。
|
||||
EXPOSE 8899
|
||||
|
||||
# 设置入口脚本和默认启动命令。
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["gunicorn", "--workers", "3", "--threads", "8", "--bind", "0.0.0.0:8899", "--timeout", "600", "src.app:app"]
|
||||
661
speakr/LICENSE
Normal file
@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
513
speakr/README.md
Normal file
@ -0,0 +1,513 @@
|
||||
# Speakr - 定制版
|
||||
|
||||
> 基于 [murtaza-nasir/speakr](https://github.com/murtaza-nasir/speakr) 二次开发的语音转录与智能笔记平台。
|
||||
|
||||
## 项目简介
|
||||
|
||||
本项目是一个自托管的 AI 语音转录和智能笔记平台,支持:
|
||||
|
||||
- **语音录制与上传** - 浏览器直接录音或上传已有音频文件
|
||||
- **AI 转录** - 高准确率语音转文字,支持说话人识别
|
||||
- **交互式对话** - 对录音提问,获取 AI 回答
|
||||
- **智能标签** - 支持带自定义 AI 提示词的标签
|
||||
- **安全分享** - 生成安全链接分享录音
|
||||
- **草稿功能** - 本地草稿保存与上传
|
||||
- **声纹管理** - 自定义说话人声纹识别
|
||||
- **标准词映射** - 自动纠正专业术语和人名
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
speakr/
|
||||
├── app.py # 入口文件(启动脚本)
|
||||
├── src/
|
||||
│ ├── app.py # Flask 应用主逻辑(create_app 工厂模式)
|
||||
│ ├── config.py # 统一配置文件(所有环境变量集中管理)
|
||||
│ ├── logging_config.py # 日志配置模块(格式化、输出目标、日志级别)
|
||||
│ ├── flask_ext.py # Flask 扩展实例集合(LoginManager、Bcrypt、Limiter、CSRFProtect)
|
||||
│ ├── clients/ # 外部服务客户端层
|
||||
│ │ ├── __init__.py # 统一导出所有客户端和配置
|
||||
│ │ ├── database.py # 数据库客户端:db 实例 + 配置 + 初始化
|
||||
│ │ ├── llm_client.py # LLM 客户端:OpenAI 客户端初始化
|
||||
│ │ └── voiceprint_client.py # 声纹客户端:VoiceprintClient
|
||||
│ ├── api/ # API 路由层(Flask 蓝图)
|
||||
│ │ ├── __init__.py # 统一蓝图注册函数 register_blueprints()
|
||||
│ │ ├── template_filters.py # 模板过滤器注册(now、localdatetime)
|
||||
│ │ ├── auth.py # 认证路由(登录、注册、账户管理)
|
||||
│ │ ├── recording.py # 录音管理(CRUD、上传、转录、下载)
|
||||
│ │ ├── share.py # 分享功能
|
||||
│ │ ├── tag.py # 标签管理
|
||||
│ │ ├── speaker.py # 说话人管理
|
||||
│ │ ├── admin.py # 管理后台
|
||||
│ │ ├── search.py # 搜索和 Inquire
|
||||
│ │ ├── voiceprint.py # 声纹管理
|
||||
│ │ ├── config.py # 系统配置管理
|
||||
│ │ ├── hotword.py # 热词管理
|
||||
│ │ ├── template.py # 转录模板
|
||||
│ │ ├── event.py # 事件管理
|
||||
│ │ └── main.py # 主页和杂项
|
||||
│ ├── models/ # 数据模型层(每个模型独立文件)
|
||||
│ │ ├── __init__.py # 统一导出所有模型
|
||||
│ │ ├── user.py # 用户模型
|
||||
│ │ ├── speaker.py # 说话人模型
|
||||
│ │ ├── recording.py # 录音模型
|
||||
│ │ ├── tag.py # 标签模型
|
||||
│ │ ├── recording_tag.py # 录音-标签关联模型
|
||||
│ │ ├── event.py # 事件模型
|
||||
│ │ ├── share.py # 分享模型
|
||||
│ │ ├── transcript_chunk.py # 转录块模型
|
||||
│ │ ├── transcript_template.py # 转录模板模型
|
||||
│ │ ├── inquire_session.py # 询问会话模型
|
||||
│ │ ├── voiceprint.py # 声纹模型
|
||||
│ │ ├── system_setting.py # 系统设置模型
|
||||
│ │ ├── system_config.py # 系统配置模型
|
||||
│ │ ├── hotword.py # 热词模型
|
||||
│ │ ├── draft_recording.py # 草稿录音模型
|
||||
│ │ ├── draft_segment.py # 草稿片段模型
|
||||
│ │ └── forms.py # 表单类(注册、登录)
|
||||
│ ├── services/ # 业务逻辑层
|
||||
│ │ ├── __init__.py # 统一导出所有服务函数
|
||||
│ │ ├── llm_service.py # LLM 服务
|
||||
│ │ ├── transcription_service.py # 转录服务
|
||||
│ │ ├── summary_service.py # 摘要服务
|
||||
│ │ ├── embedding_service.py # 嵌入向量服务
|
||||
│ │ ├── event_service.py # 事件服务
|
||||
│ │ ├── document_service.py # 文档服务
|
||||
│ │ ├── speaker_service.py # 说话人服务
|
||||
│ │ ├── voiceprint_service.py # 声纹服务(业务逻辑)
|
||||
│ │ ├── auto_process_service.py # 自动处理服务
|
||||
│ │ ├── auth_service.py # 认证服务
|
||||
│ │ ├── sync_service.py # 远程数据同步服务
|
||||
│ │ ├── audio_chunking.py # 音频分块服务
|
||||
│ │ └── file_monitor.py # 文件监控服务
|
||||
│ ├── utils/ # 工具函数层
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── markdown.py # Markdown 转 HTML 工具
|
||||
│ │ ├── datetime.py # 时区转换工具
|
||||
│ │ ├── text_utils.py # 文本处理工具
|
||||
│ │ ├── json_utils.py # JSON 处理工具
|
||||
│ │ └── standard_word_mappings.py # 标准词映射工具
|
||||
├── templates/ # Jinja2 模板
|
||||
├── static/ # 静态资源(CSS/JS/图片)
|
||||
├── instance/ # SQLite 数据库文件
|
||||
├── uploads/ # 上传的音频文件
|
||||
└── .env # 环境变量配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 启动方式
|
||||
|
||||
### 前置条件
|
||||
|
||||
- Python 环境(已安装 conda 环境 `speakr`)
|
||||
- Nginx 反向代理(必须启动)
|
||||
|
||||
### 启动步骤
|
||||
|
||||
```bash
|
||||
# 1. 进入项目根目录(speakr 2 目录)
|
||||
cd "c:\Work\zdht\python-projects\speakr 2"
|
||||
|
||||
# 2. 启动 Nginx 反向代理(必须先启动)
|
||||
# Nginx 负责将 https://localhost:8083/tool/speakr/ 请求转发到 Flask 后端
|
||||
# ⚠️ 注意:不启动 Nginx 将无法访问应用
|
||||
nginx # 或根据你的安装方式启动 nginx
|
||||
|
||||
# 3. 激活 conda 环境并启动应用
|
||||
conda activate speakr
|
||||
python .\speakr\app.py
|
||||
```
|
||||
|
||||
Flask 后端默认监听 **5000 端口**,Nginx 负责将 `https://localhost:8083/tool/speakr/` 请求转发到 `http://localhost:5000`。
|
||||
|
||||
### 访问地址
|
||||
|
||||
- 主页面:`https://localhost:8083/tool/speakr/`
|
||||
- 管理后台:`https://localhost:8083/tool/speakr/admin`
|
||||
- 账户设置:`https://localhost:8083/tool/speakr/account`
|
||||
|
||||
### 常见错误
|
||||
|
||||
| 问题 | 原因 | 解决 |
|
||||
|------|------|------|
|
||||
| 页面无法访问 | Nginx 未启动 | 启动 Nginx 反向代理 |
|
||||
| `ModuleNotFoundError: No module named 'xxx'` | conda 环境未激活 | 执行 `conda activate speakr` |
|
||||
| 数据库文件位置错误 | 从错误目录启动 | 确保从 `speakr 2/` 目录执行 `python .\speakr\app.py` |
|
||||
|
||||
---
|
||||
|
||||
## 环境配置
|
||||
|
||||
编辑 `.env` 文件,主要配置项:
|
||||
|
||||
```ini
|
||||
# ASR 服务配置
|
||||
USE_ASR_ENDPOINT=true
|
||||
ASR_BASE_URL=http://10.100.3.22:6688
|
||||
ASR_DIARIZE=true
|
||||
|
||||
# 文本生成模型配置
|
||||
TEXT_MODEL_BASE_URL=http://10.100.3.22:9800/v1
|
||||
TEXT_MODEL_API_KEY=EMPTY
|
||||
TEXT_MODEL_NAME=minimax-m2.5
|
||||
|
||||
# 数据库配置
|
||||
SQLALCHEMY_DATABASE_URI=sqlite:///transcriptions.db
|
||||
# PostgreSQL 配置示例(切换时只需修改 URI 即可)
|
||||
# SQLALCHEMY_DATABASE_URI=postgresql://user:password@localhost/speakr
|
||||
# DB_POOL_SIZE=10
|
||||
# DB_MAX_OVERFLOW=20
|
||||
# DB_POOL_TIMEOUT=30
|
||||
# DB_POOL_RECYCLE=3600
|
||||
|
||||
# 外部访问端口(Nginx 监听端口)
|
||||
EXTERNAL_PORT=8083
|
||||
|
||||
# 时区配置
|
||||
TIMEZONE=Asia/Shanghai
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 重构计划与当前进度
|
||||
|
||||
### 拆分原则
|
||||
|
||||
1. ⭐ 标记的模块是业务特色,拆分时只改代码组织结构,不改业务逻辑
|
||||
2. 拆分顺序:
|
||||
- **第一步**:抽出 Models(最安全,纯数据定义)
|
||||
- **第二步**:抽出 Services(把 app.py 中的业务逻辑函数提取到独立文件)
|
||||
- **第三步**:抽出 API 蓝图(把路由按功能域分组注册)
|
||||
- **第四步**:抽出 Utils(工具函数独立化)
|
||||
- **第五步**:模板组件化(可选,优先级较低)
|
||||
3. `app.py` 最终只保留:Flask 初始化、数据库初始化、蓝图注册、全局错误处理
|
||||
4. 保持向后兼容:拆分过程中所有 API 路径不变,前端无需改动
|
||||
|
||||
### 需要特别注意的业务逻辑
|
||||
|
||||
| 功能 | 说明 | 拆分注意事项 |
|
||||
|------|------|-------------|
|
||||
| ⭐ 声纹管理 | VoiceprintClient 类、声纹注册/比对/同步 | 保持 VoiceprintClient 与外部平台的交互逻辑不变 |
|
||||
| ⭐ 系统配置 | SystemConfig 动态配置读写 | 保持配置缓存和热加载机制 |
|
||||
| ⭐ 草稿 API | 已有独立蓝图 draft_bp | 直接迁移,几乎不需改动 |
|
||||
| ⭐ 标准词映射 | 转写后的文本纠正 | 保持映射规则和应用逻辑 |
|
||||
| ⭐ 自动处理 | 定时/触发式自动转写 | 保持调度逻辑和状态管理 |
|
||||
| ⭐ 收件箱 | 录音的收件箱/高亮状态 | 保持查询和状态切换逻辑 |
|
||||
| ⭐ 热词 | 热词管理和应用 | 保持与转写服务的集成 |
|
||||
|
||||
### 当前进度
|
||||
|
||||
#### ✅ 已完成:Phase 1 - Models 层抽取
|
||||
|
||||
已将 `src/app.py` 中的 **16 个数据模型** 和 **2 个表单类** 从主文件中抽取到独立模块:
|
||||
|
||||
| 状态 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| ✅ | `src/database.py`(已移至 clients) | 创建独立数据库实例文件,集中管理 SQLAlchemy db 对象(Phase 2.5 移至 clients/database.py) |
|
||||
| ✅ | `src/models/` | 每个模型独立文件(user.py, speaker.py, recording.py 等 16 个模型) |
|
||||
| ✅ | `src/models/forms.py` | 表单类独立(RegistrationForm, LoginForm, password_check) |
|
||||
| ✅ | `src/utils/markdown.py` | Markdown 转 HTML 工具函数 |
|
||||
| ✅ | `src/utils/datetime.py` | 时区转换工具函数 |
|
||||
| ✅ | `src/app.py` | 删除模型定义,改为从 `src.models` 导入 |
|
||||
|
||||
**验证结果**:应用可正常启动,数据库文件路径正确(`speakr/instance/transcriptions.db`)
|
||||
|
||||
#### ✅ 已完成:Phase 2 - Services 层抽取
|
||||
|
||||
**服务文件已全部创建**(`src/services/` 目录下 11 个服务文件),app.py 中的旧函数定义已全部删除。
|
||||
|
||||
| 状态 | 服务文件 | 包含函数 |
|
||||
|------|---------|---------|
|
||||
| ✅ | `llm_service.py` | `call_llm_completion`, `clean_llm_response`, `format_transcription_for_llm`, `process_streaming_with_thinking`, `extract_thinking_content`, `preprocess_long_transcription`, `extract_corrected_text`, `format_api_error_message` |
|
||||
| ✅ | `transcription_service.py` | `transcribe_audio_asr`, `transcribe_audio_task`, `transcribe_single_file`, `transcribe_with_chunking`, `extract_audio_from_video`, `convert_to_wav` |
|
||||
| ✅ | `summary_service.py` | `generate_title_task`, `generate_summary_only_task` |
|
||||
| ✅ | `embedding_service.py` | `get_embedding_model`, `chunk_transcription`, `generate_embeddings`, `serialize_embedding`, `deserialize_embedding`, `process_recording_chunks`, `basic_text_search_chunks`, `semantic_search_chunks` |
|
||||
| ✅ | `event_service.py` | `extract_events_from_transcript`, `generate_ics_content`, `escape_ical_text` |
|
||||
| ✅ | `document_service.py` | `process_markdown_to_docx`, `get_recording_display_title`, `sanitize_download_filename_component`, `build_docx_download_filename`, `set_download_filename_header` |
|
||||
| ✅ | `speaker_service.py` | `update_speaker_usage`, `identify_speakers_from_text`, `identify_unidentified_speakers_from_text` |
|
||||
| ✅ | `voiceprint_service.py` | `sync_voiceprint_to_platform`(VoiceprintClient 已移至 clients 层) |
|
||||
| ✅ | `auto_process_service.py` | `initialize_file_monitor`, `get_file_monitor_functions` |
|
||||
| ✅ | `auth_service.py` | `is_safe_url`, `auto_login` |
|
||||
| ✅ | `__init__.py` | 统一导出所有服务函数 |
|
||||
|
||||
**已完成的工作:**
|
||||
|
||||
1. ✅ 删除了 app.py 中的 22 个重复函数定义,改为从服务层导入
|
||||
2. ✅ 创建了 `src/utils/text_utils.py` 和 `src/utils/json_utils.py`,将工具函数移出 app.py
|
||||
3. ✅ 添加了服务层和工具层的导入
|
||||
4. ✅ 核心功能测试通过,应用运行正常
|
||||
|
||||
#### ✅ 已完成:Phase 2.5 - Clients 层创建
|
||||
|
||||
按照行业规范创建了 `src/clients/` 层,统一管理与外部服务的连接客户端:
|
||||
|
||||
| 状态 | 客户端 | 说明 |
|
||||
|------|--------|------|
|
||||
| ✅ | `database.py` | 数据库客户端:`db` 实例 + 配置 + `init_database()` 初始化逻辑 |
|
||||
| ✅ | `llm_client.py` | LLM 客户端:OpenAI 客户端初始化(client, TEXT_MODEL_NAME, TEXT_MODEL_API_KEY 等) |
|
||||
| ✅ | `voiceprint_client.py` | 声纹客户端:VoiceprintClient 类 + voiceprint_client() 延迟初始化 |
|
||||
| ✅ | `__init__.py` | 统一导出所有客户端 |
|
||||
|
||||
**重构成果:**
|
||||
|
||||
- 删除了旧 `src/database.py`(空实例文件),合并到 `clients/database.py`
|
||||
- 删除了 `services/voiceprint_service.py` 中的 `VoiceprintClient` 类,移到 `clients/voiceprint_client.py`
|
||||
- 17 个模型文件 + 6 个服务文件的 `db` 导入统一改为 `from src.clients import db`
|
||||
- 消除了层层传递 `client` 参数的问题
|
||||
- 外部服务客户端与业务逻辑分离,架构更清晰
|
||||
|
||||
#### ✅ 已完成:Phase 2.6 - 服务层注释中文化
|
||||
|
||||
将所有服务层文件中的英文注释和日志消息翻译为中文,提升代码可读性:
|
||||
|
||||
| 文件 | 翻译内容 |
|
||||
|------|---------|
|
||||
| `clients/llm_client.py` | URL 注释清理逻辑、客户端初始化逻辑 |
|
||||
| `clients/voiceprint_client.py` | 声纹客户端注释和日志(新建文件,全中文) |
|
||||
| `clients/database.py` | 数据库配置和初始化注释(新建文件,全中文) |
|
||||
| `services/llm_service.py` | 思考内容提取、流式处理、API 错误格式化、分段总结等函数的注释和日志 |
|
||||
| `services/summary_service.py` | 摘要生成全流程的注释和日志(标签提示词、语言要求、系统/用户消息构建等) |
|
||||
| `services/transcription_service.py` | 音频提取、ASR 转录、分块转录、Whisper 调用等全流程注释和日志 |
|
||||
| `services/embedding_service.py` | 文本分块、嵌入向量生成、语义搜索等全流程注释和日志 |
|
||||
| `services/voiceprint_service.py` | 声纹同步业务逻辑注释 |
|
||||
|
||||
#### ✅ 已完成:Phase 3 - API 蓝图抽取
|
||||
|
||||
将 `src/app.py` 中的约 100 个路由定义按功能域分组抽取到独立的 Flask 蓝图中,app.py 从约 10000 行精简到约 700 行:
|
||||
|
||||
| 状态 | 蓝图文件 | 路由数量 | 说明 |
|
||||
|------|---------|---------|------|
|
||||
| ✅ | `api/auth.py` | 5 | 注册、登录、登出、账户管理、修改密码 |
|
||||
| ✅ | `api/recording.py` | 24 | 录音 CRUD、上传、转录、下载、状态管理、ASR 校正 |
|
||||
| ✅ | `api/share.py` | 7 | 分享创建、查看、访问 |
|
||||
| ✅ | `api/tag.py` | 6 | 标签 CRUD、应用到录音 |
|
||||
| ✅ | `api/speaker.py` | 5 | 说话人管理、搜索 |
|
||||
| ✅ | `api/admin.py` | 18 | 管理后台、统计分析、录音管理、配置管理 |
|
||||
| ✅ | `api/search.py` | 7 | 搜索、Inquire 对话、收件箱 |
|
||||
| ✅ | `api/voiceprint.py` | 6 | 声纹注册、管理、同步 |
|
||||
| ✅ | `api/config.py` | 6 | 系统配置读写、分段服务管理 |
|
||||
| ✅ | `api/hotword.py` | 2 | 热词管理 |
|
||||
| ✅ | `api/template.py` | 5 | 转录模板 CRUD |
|
||||
| ✅ | `api/event.py` | 3 | 事件提取、ICS 导出 |
|
||||
| ✅ | `api/main.py` | 5 | 主页、摘要生成、对话 |
|
||||
|
||||
**架构改进:**
|
||||
- 创建 `api/__init__.py` 中的 `register_blueprints()` 函数,统一管理所有蓝图注册和初始化
|
||||
- 使用 `init_xxx_bp()` 延迟注入模式解决循环导入问题(limiter、csrf、bcrypt 等全局对象)
|
||||
- 所有 API 路径保持不变,前端无需任何修改
|
||||
- `app.py` 仅保留:Flask 初始化、配置加载、蓝图注册、全局错误处理、启动逻辑
|
||||
|
||||
**重构后修复的问题:**
|
||||
|
||||
| 问题 | 原因 | 解决方案 |
|
||||
|------|------|---------|
|
||||
| 重定向循环 | `main.py` 中的 `index()` 路由误加了 `@login_required` 装饰器,丢失了自动登录逻辑 | 恢复备份中的 token 验证和自动登录逻辑,移除装饰器 |
|
||||
| WebSocket 连接失败 | `getUserConfig` 路由缺少 `FUNASR_WEBSOCKET_IP` 和 `SCREEN_PUSH_IP` 配置 | 在 `main.py` 的 `get_user_config()` 中补充 WebSocket 配置字段 |
|
||||
| 循环导入 bcrypt | `main.py` 在模块级别导入 `bcrypt` 导致循环依赖 | 改为在 `init_main_bp()` 中延迟注入 `bcrypt_instance` |
|
||||
|
||||
#### ✅ 已完成:统一配置管理
|
||||
|
||||
创建 `src/config.py` 统一配置文件,将所有散落的环境变量读取集中管理:
|
||||
|
||||
| 配置域 | 包含变量 |
|
||||
|--------|---------|
|
||||
| 基础配置 | `SECRET_KEY`, `UPLOAD_FOLDER`, `LOG_LEVEL`, `MAX_FILE_SIZE_MB` |
|
||||
| ASR 配置 | `USE_ASR_ENDPOINT`, `ASR_BASE_URL`, `ASR_DIARIZE`, `ASR_MIN_SPEAKERS`, `ASR_MAX_SPEAKERS` |
|
||||
| LLM 配置 | `TEXT_MODEL_API_KEY`, `TEXT_MODEL_BASE_URL`, `TEXT_MODEL_NAME` |
|
||||
| 分段配置 | `ENABLE_CHUNKING` |
|
||||
| Inquire 配置 | `ENABLE_INQUIRE_MODE` |
|
||||
| 数据库配置 | `SQLALCHEMY_DATABASE_URI`, `DB_POOL_SIZE`, `DB_MAX_OVERFLOW`, `DB_POOL_TIMEOUT`, `DB_POOL_RECYCLE` |
|
||||
| 外部 API | `TRANSCRIPTION_BASE_URL`, `VALID_TOKEN`, `EMBEDDINGS_AVAILABLE` |
|
||||
| 代理配置 | `HTTP_PROXY`, `HTTPS_PROXY` |
|
||||
|
||||
**重构成果:**
|
||||
- `clients/llm_client.py`、`clients/voiceprint_client.py` 从 `src.config` 导入配置
|
||||
- `clients/__init__.py` 统一导出所有配置项
|
||||
- `app.py` 通过 `from src.clients import ...` 统一导入配置,消除散落的环境变量读取
|
||||
- 提供 `validate_config()` 函数在启动时验证关键配置
|
||||
|
||||
#### ✅ 已完成:Phase 3.5 - app.py 清理与同步服务抽取
|
||||
|
||||
将 `src/app.py` 中残留的约 400 行业务逻辑彻底清理,app.py 从约 700 行精简到约 234 行:
|
||||
|
||||
| 状态 | 任务 | 说明 |
|
||||
|------|------|------|
|
||||
| ✅ | 删除重复的 preprocess 函数 | 删除了 `preprocess_long_transcription` 等 6 个重复定义的函数(已在 llm_service.py 中) |
|
||||
| ✅ | 抽取远程同步服务 | 创建了 `services/sync_service.py`,将约 180 行同步逻辑从 app.py 移出 |
|
||||
| ✅ | 清理无用 import | 删除了 30+ 个未使用的导入(render_template、OpenAI、httpx、subprocess、markdown 等) |
|
||||
| ✅ | 注释中文化 | 将所有英文注释翻译为中文,删除无意义注释 |
|
||||
| ✅ | 修复函数签名 | `start_sync_thread()` 增加 `app` 参数,调用处同步更新 |
|
||||
| ✅ | 优化配置读取 | `preprocess_long_transcription` 等函数直接从 `src.config` 读取配置,不再通过参数传递 |
|
||||
|
||||
#### ✅ 已完成:Phase 4 - app.py 工厂模式重构
|
||||
|
||||
采用 Flask `create_app()` 工厂模式,将 `src/app.py` 从 234 行进一步精简到 60 行,各层职责清晰分离:
|
||||
|
||||
**新建模块:**
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/logging_config.py` | 日志配置模块 |
|
||||
| `src/flask_ext.py` | Flask 扩展实例集合(login_manager、bcrypt、limiter、csrf) |
|
||||
| `src/services/register.py` | 服务层统一初始化(音频分段、同步线程、文件监控、表结构迁移) |
|
||||
| `src/api/template_filters.py` | 模板过滤器注册 |
|
||||
|
||||
**修改模块:**
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|---------|
|
||||
| `src/config.py` | 新增 `get_app_config()` 函数,一次性返回所有需写入 `app.config` 的配置项。新增配置只需改此函数,无需修改 `app.py` |
|
||||
| `src/clients/__init__.py` | 集中管理 `chunking_service` 和 `EMBEDDINGS_AVAILABLE` 的初始化与导出 |
|
||||
| `src/api/__init__.py` | 简化 `register_blueprints()` 函数,直接从 `src.flask_ext` 导入扩展实例 |
|
||||
| `src/app.py` | 采用 `create_app()` 工厂模式,仅负责编排各层初始化顺序 |
|
||||
|
||||
**`app.py` 最终结构(60 行):**
|
||||
|
||||
```
|
||||
create_app()
|
||||
├── 1. configure_logging() ← src/logging_config.py
|
||||
├── 2. 创建 Flask 应用实例
|
||||
├── 3. 加载应用配置 ← src/config.py 的 get_app_config()
|
||||
├── 4. 配置反向代理支持
|
||||
├── 5. 初始化 Flask 扩展 ← src/flask_ext.py
|
||||
├── 6. 初始化数据库 ← src/clients/database.py
|
||||
├── 7. 注册蓝图 ← src/api/__init__.py
|
||||
├── 8. 注册模板过滤器 ← src/api/template_filters.py
|
||||
└── 9. 初始化服务层 ← src/services/register.py
|
||||
```
|
||||
|
||||
**关键设计决策:**
|
||||
|
||||
| 决策 | 理由 |
|
||||
|------|------|
|
||||
| `flask_ext.py` 独立 | Flask 扩展是全局单例,不应放在 clients 或 services 中 |
|
||||
| `get_app_config()` 集中管理 | 新增配置只需改 config.py,app.py 永远不需要改动 |
|
||||
| `register_blueprints` 自行读扩展 | 蓝图注册最清楚自己需要什么扩展,无需 app.py 中转 |
|
||||
| `init_services` 放在最后 | 服务依赖数据库和蓝图,必须在它们之后初始化 |
|
||||
|
||||
---
|
||||
|
||||
## 开发规范
|
||||
|
||||
### API 接口返回格式规范
|
||||
|
||||
> **重要:所有新建/修改的 API 接口必须使用 `ApiResponse` 工具类统一返回格式。旧接口暂不强制迁移。**
|
||||
|
||||
#### 工具位置
|
||||
|
||||
`src/api/api_response.py`
|
||||
|
||||
#### 使用方式
|
||||
|
||||
```python
|
||||
from src.api.api_response import ApiResponse
|
||||
|
||||
# 成功响应(返回数据)
|
||||
@blueprint.route('/xxx', methods=['POST'])
|
||||
def create_xxx():
|
||||
result = do_something()
|
||||
return ApiResponse.success({'xxx': result.to_dict()})
|
||||
|
||||
# 成功响应(带提示消息)
|
||||
return ApiResponse.success({'xxx': result.to_dict()}, '创建成功')
|
||||
|
||||
# 成功响应(自定义状态码,如 201 Created)
|
||||
return ApiResponse.success({'xxx': result.to_dict()}, '创建成功', status_code=201)
|
||||
|
||||
# 错误响应
|
||||
if not xxx:
|
||||
return ApiResponse.error('资源未找到', 404)
|
||||
|
||||
# 错误响应(带详情)
|
||||
if not valid:
|
||||
return ApiResponse.error('参数校验失败', 400, detail='email 格式不正确')
|
||||
|
||||
# 分页响应
|
||||
return ApiResponse.paginated(
|
||||
items=[item.to_dict() for item in items],
|
||||
pagination={
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'total': total,
|
||||
'total_pages': total_pages,
|
||||
'has_next': has_next,
|
||||
'has_prev': has_prev
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
#### 响应格式约定
|
||||
|
||||
**成功响应结构:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "操作成功(可选)",
|
||||
"xxx": {...} // 业务数据直接合并到顶层
|
||||
}
|
||||
```
|
||||
|
||||
**分页响应结构:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"items": [...],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"total": 100,
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应结构:**
|
||||
```json
|
||||
{
|
||||
"error": "用户友好的错误提示"
|
||||
}
|
||||
```
|
||||
|
||||
#### 注意事项
|
||||
|
||||
1. `data` 参数必须是字典类型,列表数据请使用 `paginated()` 或直接合并到字典中
|
||||
2. 前端通过 `response.ok`(HTTP 状态码)判断成功/失败,通过 `data.error` 获取错误信息
|
||||
3. 旧接口暂不迁移,新接口和修改的接口必须使用 `ApiResponse`
|
||||
4. 不要在 `ApiResponse` 中直接传递原始异常信息给用户(`str(e)`),应转为友好提示
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**: Python/Flask + SQLAlchemy
|
||||
- **前端**: Vue.js 3 + Tailwind CSS
|
||||
- **AI**: OpenAI Whisper (ASR) + Ollama/OpenRouter (LLM)
|
||||
- **数据库**: SQLite(默认)/ PostgreSQL(支持环境变量切换)
|
||||
- **部署**: Nginx 反向代理 + Flask 开发服务器
|
||||
|
||||
---
|
||||
|
||||
## 依赖安装
|
||||
|
||||
```bash
|
||||
conda activate speakr
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
主要依赖:
|
||||
- `flask==2.3.3`
|
||||
- `flask-sqlalchemy==3.1.1`
|
||||
- `flask-login==0.6.3`
|
||||
- `flask-wtf==1.2.2`
|
||||
- `flask-bcrypt==1.0.1`
|
||||
- `openai==1.3.0`
|
||||
- `markdown==3.5.1`
|
||||
- `python-docx==1.1.0`
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目基于 AGPL v3.0 开源协议。
|
||||
44
speakr/Readme_deploy.md
Normal file
@ -0,0 +1,44 @@
|
||||
# 离线部署
|
||||
|
||||
cd /opt/speakr
|
||||
|
||||
# 1. 导入镜像(无需网络)
|
||||
docker load -i speakr.tar
|
||||
|
||||
# 2. 一次性:从镜像里取出前端构建产物和完整 vendor 离线依赖,补进宿主机代码目录
|
||||
docker create --name speakr-tmp speakr:latest
|
||||
docker cp speakr-tmp:/app/static/vite ./static/
|
||||
docker cp speakr-tmp:/app/static/vendor ./static/
|
||||
docker rm speakr-tmp
|
||||
|
||||
# 3. 准备 .env(按转写方式二选一,然后编辑填入密钥等配置)
|
||||
cp config/env.asr.example .env # 自建 ASR 服务用这个
|
||||
# cp config/env.whisper.example .env # Whisper API 用这个
|
||||
|
||||
|
||||
cd /home/wjs/speakr/speakr
|
||||
|
||||
# 1. 把服务器上这份脚本的 CRLF 转成 LF,并确保可执行
|
||||
sed -i 's/\r$//' scripts/docker-entrypoint.sh
|
||||
chmod +x scripts/docker-entrypoint.sh
|
||||
|
||||
# 2. 删掉一直重启的旧容器
|
||||
docker rm -f speakr
|
||||
|
||||
# 3. 重新启动:多加一条单文件挂载,用修好的脚本覆盖镜像里那份坏的
|
||||
docker run -d \
|
||||
--name speakr \
|
||||
--restart unless-stopped \
|
||||
-p 5000:8899 \
|
||||
-v /home/wjs/speakr/speakr:/app \
|
||||
-v /home/wjs/speakr/speakr/scripts/docker-entrypoint.sh:/usr/local/bin/docker-entrypoint.sh \
|
||||
-v /home/wjs/speakr/uploads:/data/uploads \
|
||||
-v /home/wjs/speakr/instance:/data/instance \
|
||||
speakr:v2
|
||||
|
||||
# 4. 确认启动正常(应看到"数据库不存在,正在创建..."之类的日志)
|
||||
docker logs -f speakr
|
||||
|
||||
# 合并压缩
|
||||
|
||||
cat speakr.tar.part* > speakr.tar
|
||||
1
speakr/VERSION
Normal file
@ -0,0 +1 @@
|
||||
v0.5.6
|
||||
4
speakr/app.py
Normal file
@ -0,0 +1,4 @@
|
||||
from src.app import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True, host="0.0.0.0", port=5000)
|
||||
43
speakr/config/docker-compose.example.yml
Normal file
@ -0,0 +1,43 @@
|
||||
services:
|
||||
app:
|
||||
image: learnedmachine/speakr:latest
|
||||
container_name: speakr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8899:8899"
|
||||
|
||||
# --- Configuration ---
|
||||
# Environment variables are loaded from the .env file.
|
||||
#
|
||||
# To get started:
|
||||
# 1. Choose your desired transcription method.
|
||||
# 2. Copy the corresponding example file to .env:
|
||||
#
|
||||
# For standard Whisper API:
|
||||
# cp config/env.whisper.example .env
|
||||
#
|
||||
# For a custom ASR endpoint:
|
||||
# cp config/env.asr.example .env
|
||||
#
|
||||
# 3. Edit the .env file to add your API keys and settings.
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
environment:
|
||||
# Set log level for troubleshooting
|
||||
# Use ERROR for production (minimal logs)
|
||||
# Use INFO for debugging issues (recommended when troubleshooting)
|
||||
# Use DEBUG for detailed development logging
|
||||
- LOG_LEVEL=ERROR
|
||||
|
||||
# --- Volume Configuration ---
|
||||
# Choose ONE of the following volume configurations.
|
||||
# Option 1 (Recommended): Bind mounts to local folders.
|
||||
volumes:
|
||||
- ./uploads:/data/uploads
|
||||
- ./instance:/data/instance
|
||||
|
||||
# Option 2: Docker-managed volumes.
|
||||
# volumes:
|
||||
# - speakr-uploads:/data/uploads
|
||||
# - speakr-instance:/data/instance
|
||||
74
speakr/config/env.asr.example
Normal file
@ -0,0 +1,74 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Speakr Configuration: ASR Endpoint
|
||||
#
|
||||
# Instructions:
|
||||
# 1. Copy this file to a new file named .env
|
||||
# cp env.asr.example .env
|
||||
# 2. Fill in the required URLs, API keys, and settings below.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# --- Text Generation Model (for summaries, titles, etc.) ---
|
||||
TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1
|
||||
TEXT_MODEL_API_KEY=your_openrouter_api_key
|
||||
TEXT_MODEL_NAME=openai/gpt-4o-mini
|
||||
|
||||
# --- Transcription Service (ASR Endpoint) ---
|
||||
# Setting this to true enables all ASR features including speaker diarization
|
||||
# Note: File chunking is NOT supported with ASR endpoints
|
||||
USE_ASR_ENDPOINT=true
|
||||
|
||||
# ASR Endpoint URL
|
||||
# For containers in same docker-compose: Use container name and internal port
|
||||
# Example: http://whisper-asr:9000 (NOT the host port 6002 or external IP)
|
||||
# For external ASR: Use http://192.168.1.100:9000 or http://asr.example.com:9000
|
||||
ASR_BASE_URL=http://whisper-asr:9000
|
||||
|
||||
# Optional: Override default speaker settings (defaults shown)
|
||||
# These are automatically set when USE_ASR_ENDPOINT=true
|
||||
# ASR_DIARIZE=true # Auto-enabled with ASR (set to false to disable)
|
||||
# ASR_MIN_SPEAKERS=1 # Default minimum speakers
|
||||
# ASR_MAX_SPEAKERS=5 # Default maximum speakers
|
||||
|
||||
# --- Application Settings ---
|
||||
# Set to "true" to allow user registration, "false" to disable
|
||||
ALLOW_REGISTRATION=false
|
||||
SUMMARY_MAX_TOKENS=8000
|
||||
CHAT_MAX_TOKENS=5000
|
||||
|
||||
# Timezone for displaying dates and times in the UI
|
||||
# Use a valid TZ database name (e.g., "America/New_York", "Europe/London", "UTC")
|
||||
TIMEZONE="UTC"
|
||||
|
||||
# Set the logging level for the application.
|
||||
# Options: DEBUG, INFO, WARNING, ERROR
|
||||
LOG_LEVEL="INFO"
|
||||
|
||||
# --- Admin User (created on first run) ---
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=changeme
|
||||
|
||||
# --- Inquire Mode (AI search across all recordings) ---
|
||||
# Set to "true" to enable semantic search and chat across all recordings
|
||||
# Requires additional dependencies (already included in Docker image)
|
||||
ENABLE_INQUIRE_MODE=false
|
||||
|
||||
# --- Automated File Processing (Black Hole Directory) ---
|
||||
# Set to "true" to enable automated file processing
|
||||
ENABLE_AUTO_PROCESSING=false
|
||||
|
||||
# Processing mode: admin_only, user_directories, or single_user
|
||||
AUTO_PROCESS_MODE=admin_only
|
||||
|
||||
# Directory to watch for new audio files
|
||||
AUTO_PROCESS_WATCH_DIR=/data/auto-process
|
||||
|
||||
# How often to check for new files (seconds)
|
||||
AUTO_PROCESS_CHECK_INTERVAL=30
|
||||
|
||||
# Default username for single_user mode (only used if AUTO_PROCESS_MODE=single_user)
|
||||
# AUTO_PROCESS_DEFAULT_USERNAME=admin
|
||||
|
||||
# --- Docker Settings (rarely need to be changed) ---
|
||||
SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db
|
||||
UPLOAD_FOLDER=/data/uploads
|
||||
77
speakr/config/env.whisper.example
Normal file
@ -0,0 +1,77 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Speakr Configuration: Standard Whisper API
|
||||
#
|
||||
# Instructions:
|
||||
# 1. Copy this file to a new file named .env
|
||||
# cp env.whisper.example .env
|
||||
# 2. Fill in the required API keys and settings below.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
# --- Text Generation Model (for summaries, titles, etc.) ---
|
||||
TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1
|
||||
TEXT_MODEL_API_KEY=your_openrouter_api_key
|
||||
TEXT_MODEL_NAME=openai/gpt-4o-mini
|
||||
|
||||
# --- Transcription Service (OpenAI Whisper API) ---
|
||||
TRANSCRIPTION_BASE_URL=https://api.openai.com/v1
|
||||
TRANSCRIPTION_API_KEY=your_openai_api_key
|
||||
WHISPER_MODEL=whisper-1
|
||||
|
||||
# --- Application Settings ---
|
||||
# Set to "true" to allow user registration, "false" to disable
|
||||
ALLOW_REGISTRATION=false
|
||||
SUMMARY_MAX_TOKENS=8000
|
||||
CHAT_MAX_TOKENS=5000
|
||||
|
||||
# Timezone for displaying dates and times in the UI
|
||||
# Use a valid TZ database name (e.g., "America/New_York", "Europe/London", "UTC")
|
||||
TIMEZONE="UTC"
|
||||
|
||||
# Set the logging level for the application.
|
||||
# Options: DEBUG, INFO, WARNING, ERROR
|
||||
LOG_LEVEL="INFO"
|
||||
|
||||
# --- Large File Chunking ---
|
||||
# Automatically splits large files to work with API limits
|
||||
ENABLE_CHUNKING=true
|
||||
|
||||
# Choose chunking method based on your provider's limits:
|
||||
# For size-based limits (e.g., OpenAI's 25MB limit):
|
||||
CHUNK_LIMIT=20MB
|
||||
|
||||
# For duration-based limits (uncomment and use instead):
|
||||
# CHUNK_LIMIT=1200s # 20 minutes
|
||||
# CHUNK_LIMIT=20m # Alternative format
|
||||
|
||||
# Overlap between chunks (seconds)
|
||||
CHUNK_OVERLAP_SECONDS=3
|
||||
|
||||
# --- Admin User (created on first run) ---
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=changeme
|
||||
|
||||
# --- Inquire Mode (AI search across all recordings) ---
|
||||
# Set to "true" to enable semantic search and chat across all recordings
|
||||
# Requires additional dependencies (already included in Docker image)
|
||||
ENABLE_INQUIRE_MODE=false
|
||||
|
||||
# --- Automated File Processing (Black Hole Directory) ---
|
||||
# Set to "true" to enable automated file processing
|
||||
ENABLE_AUTO_PROCESSING=false
|
||||
|
||||
# Processing mode: admin_only, user_directories, or single_user
|
||||
AUTO_PROCESS_MODE=admin_only
|
||||
|
||||
# Directory to watch for new audio files
|
||||
AUTO_PROCESS_WATCH_DIR=/data/auto-process
|
||||
|
||||
# How often to check for new files (seconds)
|
||||
AUTO_PROCESS_CHECK_INTERVAL=30
|
||||
|
||||
# Default username for single_user mode (only used if AUTO_PROCESS_MODE=single_user)
|
||||
# AUTO_PROCESS_DEFAULT_USERNAME=admin
|
||||
|
||||
# --- Docker Settings (rarely need to be changed) ---
|
||||
SQLALCHEMY_DATABASE_URI=sqlite:////data/instance/transcriptions.db
|
||||
UPLOAD_FOLDER=/data/uploads
|
||||
92
speakr/deployment/setup.sh
Normal file
@ -0,0 +1,92 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 停止转录服务
|
||||
sudo systemctl stop transcription
|
||||
|
||||
# 创建应用目录并设置所有者
|
||||
sudo mkdir -p /opt/transcription-app
|
||||
sudo chown $USER:$USER /opt/transcription-app
|
||||
|
||||
# 复制应用文件
|
||||
cp app.py /opt/transcription-app/
|
||||
cp -r templates /opt/transcription-app/
|
||||
cp requirements.txt /opt/transcription-app/
|
||||
cp scripts/reset_db.py /opt/transcription-app/
|
||||
cp scripts/create_admin.py /opt/transcription-app/
|
||||
cp .env /opt/transcription-app/ # 复制包含 API 密钥的 .env 文件
|
||||
|
||||
# 如果 .env 文件中不存在 SECRET_KEY,则添加
|
||||
if ! grep -q "SECRET_KEY" /opt/transcription-app/.env; then
|
||||
echo "SECRET_KEY=$(openssl rand -hex 32)" >> /opt/transcription-app/.env
|
||||
echo "已添加 SECRET_KEY 到 .env 文件"
|
||||
fi
|
||||
|
||||
# 创建并激活虚拟环境
|
||||
python3 -m venv /opt/transcription-app/venv
|
||||
source /opt/transcription-app/venv/bin/activate
|
||||
|
||||
# 安装依赖
|
||||
cd /opt/transcription-app
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 创建上传和数据库目录并设置权限
|
||||
mkdir -p /opt/transcription-app/uploads
|
||||
mkdir -p /opt/transcription-app/instance
|
||||
chmod 755 /opt/transcription-app/uploads
|
||||
chmod 755 /opt/transcription-app/instance
|
||||
|
||||
# 初始化或迁移数据库
|
||||
if [ ! -f /opt/transcription-app/instance/transcriptions.db ]; then
|
||||
# 如果数据库不存在,从头创建
|
||||
echo "数据库不存在,正在创建新数据库..."
|
||||
python reset_db.py
|
||||
else
|
||||
# 如果数据库存在,迁移模式以保留数据
|
||||
echo "数据库已存在,正在迁移模式以保留数据..."
|
||||
python migrate_db.py
|
||||
fi
|
||||
|
||||
# 设置所有文件的正确所有者
|
||||
sudo chown -R $USER:$USER /opt/transcription-app
|
||||
|
||||
# 创建 systemd 服务文件
|
||||
sudo tee /etc/systemd/system/transcription.service << EOF
|
||||
[Unit]
|
||||
Description=Transcription Web Application
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=$USER
|
||||
EnvironmentFile=/opt/transcription-app/.env
|
||||
WorkingDirectory=/opt/transcription-app
|
||||
Environment="PATH=/opt/transcription-app/venv/bin"
|
||||
Environment="PYTHONPATH=/opt/transcription-app"
|
||||
ExecStart=/opt/transcription-app/venv/bin/gunicorn --workers 3 --threads 8 --bind 0.0.0.0:8899 --timeout 600 app:app
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
# 重新加载 systemd 并启动服务
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart transcription
|
||||
sudo systemctl disable transcription
|
||||
sudo systemctl enable transcription
|
||||
|
||||
# 检查服务状态
|
||||
echo "正在检查服务状态..."
|
||||
sleep 3
|
||||
sudo systemctl status transcription
|
||||
|
||||
echo "安装完成!应用应在 8899 端口上运行。"
|
||||
|
||||
# 询问是否创建管理员账户
|
||||
read -p "是否现在创建管理员账户?(y/n): " create_admin
|
||||
if [[ $create_admin == "y" || $create_admin == "Y" ]]; then
|
||||
echo "正在创建管理员账户..."
|
||||
python create_admin.py
|
||||
else
|
||||
echo "你可以稍后运行以下命令来创建管理员账户:python create_admin.py"
|
||||
fi
|
||||
31
speakr/docs/.gitignore
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
# Jekyll build output
|
||||
_site/
|
||||
.sass-cache/
|
||||
.jekyll-cache/
|
||||
.jekyll-metadata
|
||||
|
||||
# Bundle directory
|
||||
.bundle/
|
||||
vendor/
|
||||
|
||||
# Local development files
|
||||
_config_local.yml
|
||||
local-serve.sh
|
||||
docker-serve.sh
|
||||
simple-serve.sh
|
||||
serve-local.sh
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Ruby version files
|
||||
.ruby-version
|
||||
.ruby-gemset
|
||||
|
||||
# Documentation verification (internal use)
|
||||
DOCUMENTATION_VERIFICATION_CHECKLIST.md
|
||||
28
speakr/docs/Dockerfile
Normal file
@ -0,0 +1,28 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install git (required for git-revision-date plugin)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y git && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install requirements
|
||||
COPY requirements-docs.txt .
|
||||
RUN pip install --no-cache-dir -r requirements-docs.txt
|
||||
|
||||
# Create docs directory structure
|
||||
RUN mkdir -p docs
|
||||
|
||||
# Copy mkdocs config to root
|
||||
COPY mkdocs.yml .
|
||||
|
||||
# Copy all documentation files to docs subdirectory
|
||||
COPY . ./docs/
|
||||
|
||||
# Expose MkDocs development server port
|
||||
EXPOSE 8000
|
||||
|
||||
# Run MkDocs server
|
||||
CMD ["mkdocs", "serve", "--dev-addr=0.0.0.0:8000"]
|
||||
8
speakr/docs/Gemfile.simple
Normal file
@ -0,0 +1,8 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "jekyll", "~> 4.3"
|
||||
gem "jekyll-seo-tag"
|
||||
gem "jekyll-sitemap"
|
||||
gem "jekyll-feed"
|
||||
gem "webrick", "~> 1.8"
|
||||
gem "kramdown-parser-gfm"
|
||||
33
speakr/docs/_includes/sidebar.html
Normal file
@ -0,0 +1,33 @@
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<img src="{{ '/assets/images/logo.png' | relative_url }}" alt="Speakr Logo" class="sidebar-logo">
|
||||
<h2 class="sidebar-title">Speakr Docs</h2>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
{% for section in site.navigation %}
|
||||
<div class="sidebar-section">
|
||||
<h3 class="sidebar-section-title">{{ section.title }}</h3>
|
||||
<ul class="sidebar-list">
|
||||
{% for item in section.items %}
|
||||
<li class="sidebar-item">
|
||||
<a href="{{ item.url | relative_url }}"
|
||||
class="sidebar-link {% if page.url == item.url %}active{% endif %}">
|
||||
{{ item.title }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<a href="https://github.com/murtaza-nasir/speakr" class="github-link">
|
||||
<svg class="github-icon" viewBox="0 0 16 16" width="20" height="20">
|
||||
<path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
403
speakr/docs/_layouts/default.html
Normal file
@ -0,0 +1,403 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ site.lang | default: 'en-US' }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
{% seo %}
|
||||
<link rel="stylesheet" href="{{ '/assets/css/style.css?v=' | append: site.github.build_revision | relative_url }}">
|
||||
<link rel="icon" type="image/png" href="{{ '/assets/images/logo.png' | relative_url }}">
|
||||
|
||||
<!-- Custom fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1f2937;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.docs-wrapper {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Sidebar Styles */
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
background: #f9fafb;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.5rem;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: white;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.sidebar-section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #6b7280;
|
||||
padding: 0 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
display: block;
|
||||
padding: 0.5rem 1.5rem;
|
||||
color: #4b5563;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.sidebar-link:hover {
|
||||
background: #e5e7eb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.sidebar-link.active {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.github-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.github-link:hover {
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.github-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Main Content Styles */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 280px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 3rem;
|
||||
}
|
||||
|
||||
/* Content Typography */
|
||||
.content-wrapper h1 {
|
||||
color: #1f2937;
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid #6366f1;
|
||||
}
|
||||
|
||||
.content-wrapper h2 {
|
||||
color: #1f2937;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.content-wrapper h3 {
|
||||
color: #374151;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.content-wrapper h4 {
|
||||
color: #4b5563;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.content-wrapper p {
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.6;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.content-wrapper img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.content-wrapper code {
|
||||
background: #f3f4f6;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
color: #dc2626;
|
||||
font-size: 0.85em;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.content-wrapper pre {
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
border: 1px solid #4a5568;
|
||||
}
|
||||
|
||||
.content-wrapper pre code {
|
||||
background: none;
|
||||
color: #e2e8f0;
|
||||
padding: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* Override syntax highlighting for better contrast */
|
||||
.content-wrapper .highlight pre {
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
.content-wrapper .highlight code {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.content-wrapper ul, .content-wrapper ol {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.content-wrapper li {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.content-wrapper blockquote {
|
||||
border-left: 4px solid #6366f1;
|
||||
padding-left: 1rem;
|
||||
margin: 1rem 0;
|
||||
font-style: italic;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.content-wrapper table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.content-wrapper th, .content-wrapper td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.content-wrapper th {
|
||||
background: #f9fafb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content-wrapper a {
|
||||
color: #4f46e5;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.content-wrapper a:hover {
|
||||
text-decoration: underline;
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
/* Ensure inline code in links is visible */
|
||||
.content-wrapper a code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Override Jekyll/Rouge syntax highlighting for better contrast */
|
||||
.highlighter-rouge .highlight {
|
||||
background: #2d3748 !important;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background: #2d3748 !important;
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
.highlight pre {
|
||||
background: #2d3748 !important;
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
/* Make all text light by default */
|
||||
.highlight * {
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
/* Then override specific elements */
|
||||
.highlight .c { color: #718096 !important; } /* Comment */
|
||||
.highlight .s, .highlight .s1, .highlight .s2 { color: #68d391 !important; } /* String */
|
||||
.highlight .k { color: #b794f4 !important; } /* Keyword */
|
||||
.highlight .o { color: #f6ad55 !important; } /* Operator */
|
||||
.highlight .m { color: #f6ad55 !important; } /* Number */
|
||||
.highlight .nb { color: #63b3ed !important; } /* Builtin */
|
||||
.highlight .nv { color: #fc8181 !important; } /* Variable */
|
||||
|
||||
/* Ensure operators like -O are visible */
|
||||
.highlight .p { color: #cbd5e0 !important; } /* Punctuation */
|
||||
.highlight .nt { color: #63b3ed !important; } /* Tag */
|
||||
.highlight .na { color: #f6ad55 !important; } /* Attribute */
|
||||
|
||||
/* Mobile Responsiveness */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar.active {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Home page special styles */
|
||||
.home-header {
|
||||
text-align: center;
|
||||
padding: 3rem 0;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
color: white;
|
||||
margin: -2rem -3rem 2rem -3rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.project-logo {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin: 0 auto 1rem;
|
||||
display: block;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.2);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="docs-wrapper">
|
||||
{% include sidebar.html %}
|
||||
|
||||
<main class="main-content">
|
||||
<div class="content-wrapper">
|
||||
{{ content }}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Mobile sidebar toggle
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.innerHTML = '☰';
|
||||
toggleBtn.style.cssText = 'position:fixed;top:1rem;left:1rem;z-index:200;padding:0.5rem 0.75rem;background:white;border:1px solid #e5e7eb;border-radius:8px;cursor:pointer;display:none;';
|
||||
|
||||
if (window.innerWidth <= 768) {
|
||||
toggleBtn.style.display = 'block';
|
||||
}
|
||||
|
||||
toggleBtn.onclick = function() {
|
||||
sidebar.classList.toggle('active');
|
||||
};
|
||||
|
||||
document.body.appendChild(toggleBtn);
|
||||
|
||||
window.addEventListener('resize', function() {
|
||||
if (window.innerWidth <= 768) {
|
||||
toggleBtn.style.display = 'block';
|
||||
} else {
|
||||
toggleBtn.style.display = 'none';
|
||||
sidebar.classList.remove('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
22
speakr/docs/_layouts/docs.html
Normal file
@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% if page.title %}{{ page.title }} - {% endif %}{{ site.title }}</title>
|
||||
<meta name="description" content="{% if page.description %}{{ page.description }}{% else %}{{ site.description }}{% endif %}">
|
||||
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
|
||||
<link rel="icon" type="image/png" href="{{ '/assets/images/logo.png' | relative_url }}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="docs-wrapper">
|
||||
{% include sidebar.html %}
|
||||
|
||||
<main class="main-content">
|
||||
<div class="content-wrapper">
|
||||
{{ content }}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
99
speakr/docs/admin-guide/index.md
Normal file
@ -0,0 +1,99 @@
|
||||
# 管理员指南
|
||||
|
||||
欢迎使用 Speakr 管理员指南!作为管理员,你可以控制 Speakr 实例的核心,管理用户、监控系统健康状况以及配置 AI 行为。
|
||||
|
||||
## 管理控制面板
|
||||
|
||||
<div class="guide-cards">
|
||||
<div class="guide-card">
|
||||
<div class="card-icon">👥</div>
|
||||
<h3>用户管理</h3>
|
||||
<p>创建账户、管理权限、监控使用情况以及控制对 Speakr 实例的访问。</p>
|
||||
<a href="user-management" class="card-link">管理用户 →</a>
|
||||
</div>
|
||||
|
||||
<div class="guide-card">
|
||||
<div class="card-icon">📊</div>
|
||||
<h3>系统统计</h3>
|
||||
<p>监控系统健康状况、跟踪使用模式,并在问题影响用户之前发现潜在问题。</p>
|
||||
<a href="statistics" class="card-link">查看统计 →</a>
|
||||
</div>
|
||||
|
||||
<div class="guide-card">
|
||||
<div class="card-icon">🔧</div>
|
||||
<h3>系统设置</h3>
|
||||
<p>配置全局限制、超时、文件大小以及影响所有用户的系统级行为。</p>
|
||||
<a href="system-settings" class="card-link">配置系统 →</a>
|
||||
</div>
|
||||
|
||||
<div class="guide-card">
|
||||
<div class="card-icon">✨</div>
|
||||
<h3>默认提示词</h3>
|
||||
<p>通过默认摘要提示词自定义 AI 行为,塑造内容处理方式。</p>
|
||||
<a href="prompts" class="card-link">设置提示词 →</a>
|
||||
</div>
|
||||
|
||||
<div class="guide-card">
|
||||
<div class="card-icon">🔍</div>
|
||||
<h3>向量存储</h3>
|
||||
<p>管理语义搜索功能、监控嵌入状态以及控制 Inquire 模式。</p>
|
||||
<a href="vector-store" class="card-link">管理搜索 →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 快速操作
|
||||
|
||||
<div class="action-cards">
|
||||
<div class="action-card">
|
||||
<span class="action-icon">➕</span>
|
||||
<div>
|
||||
<strong>添加新用户</strong>
|
||||
<p>用户管理 → 添加用户按钮 → 输入详细信息 → 设置权限</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-card">
|
||||
<span class="action-icon">📈</span>
|
||||
<div>
|
||||
<strong>检查系统健康状况</strong>
|
||||
<p>系统统计 → 查看指标 → 检查处理状态 → 监控存储</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-card">
|
||||
<span class="action-icon">⚙️</span>
|
||||
<div>
|
||||
<strong>更新设置</strong>
|
||||
<p>系统设置 → 调整限制 → 配置超时 → 保存更改</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-card">
|
||||
<span class="action-icon">🔄</span>
|
||||
<div>
|
||||
<strong>处理嵌入</strong>
|
||||
<p>向量存储 → 检查状态 → 处理待处理项 → 监控进度</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 需要管理员帮助?
|
||||
|
||||
<div class="help-section">
|
||||
<div class="help-item">
|
||||
<span class="help-icon">📖</span>
|
||||
<span>查看详细的<a href="../troubleshooting.md">故障排除指南</a></span>
|
||||
</div>
|
||||
<div class="help-item">
|
||||
<span class="help-icon">🐛</span>
|
||||
<span>检查 Docker 日志:<code>docker-compose logs -f app</code></span>
|
||||
</div>
|
||||
<div class="help-item">
|
||||
<span class="help-icon">💾</span>
|
||||
<span>定期备份你的数据目录</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
准备好管理你的 Speakr 实例了吗?从[用户管理](user-management.md)开始 →
|
||||
91
speakr/docs/admin-guide/prompts.md
Normal file
@ -0,0 +1,91 @@
|
||||
# 默认提示词
|
||||
|
||||
默认提示词选项卡允许你塑造 AI 如何在你的整个 Speakr 实例中[解释和总结录音](../features.md#automatic-summarization)。在这里,你可以建立用户在没有自定义[个人提示词](../user-guide/settings.md#custom-prompts-tab)时体验的基线智能。
|
||||
|
||||

|
||||
|
||||
## 理解提示词层级
|
||||
|
||||
Speakr 使用复杂的层级来确定在任何给定录音中使用哪个提示词。该系统在保持控制的同时提供灵活性,确保用户获得适当的摘要,同时在需要时允许自定义。
|
||||
|
||||
层级的最顶层是标签特定的提示词。当录音具有[标签](../user-guide/settings.md#tag-management-tab)且关联了提示词时,这些具有绝对优先级。在功能指南中了解[标签管理](../features.md#tagging-system)。多个标签提示词会智能地拼接,允许为专业内容类型进行复杂的提示词堆叠。
|
||||
|
||||
接下来是用户的个人自定义提示词,在其[账户设置](../user-guide/settings.md#custom-prompts-tab)中设置。用户还可以为摘要配置[语言偏好](../user-guide/settings.md#language-preferences)。这允许个人在不影响其他人的情况下根据自己的需求定制摘要。许多用户从不设置这个,因此你的管理员默认值更加重要。
|
||||
|
||||
你在此页面上配置的管理员默认提示词是大多数摘要的基础。这是新用户体验的内容,也是未自定义设置的长期用户所依赖的内容。它决定了你的 Speakr 实例的整体智能和实用性。
|
||||
|
||||
最后,如果所有其他方法都失败,硬编码的系统回退确保始终生成摘要。你在实践中很少看到这个,但它提供了一个安全网,确保系统永远不会无法生成输出。
|
||||
|
||||
## 设计有效的默认提示词
|
||||
|
||||
你的默认提示词不仅仅是技术指令——它是理解内容的模板。界面中显示的提示词演示了一种平衡的方法,要求关键问题、决策和待办事项。这种结构适用于商务会议,但可能不适合所有场景。
|
||||
|
||||
在设计提示词时考虑你的用户群体。研究机构可能会强调方法和发现。律师事务所可能会关注案件细节和判例。对于多语言支持,请参阅[语言配置](../features.md#language-support)和[语言问题故障排除](../troubleshooting.md#summary-language-doesnt-match-preference)。创意机构可能会突出概念和客户反馈。提示词应反映对用户最重要的内容。
|
||||
|
||||
AI 对清晰、结构化的请求响应最佳。使用项目符号或编号部分来组织输出。指定你想要的详细程度——"简要概述"与"全面分析"会产生截然不同的结果。如果某些格式至关重要,请包含示例。
|
||||
|
||||
请记住,此提示词适用于从五分钟检查到两小时研讨会的所有内容。设计时要考虑多样性。避免对可能不适用于所有内容的过于具体的要求。专注于提取具有普遍价值的信息,同时允许 AI 灵活地适应不同的录音类型。
|
||||
|
||||
## 默认提示词编辑器
|
||||
|
||||
大型文本区域显示你的当前默认提示词,完全支持 markdown 格式。你可以使用粗体进行强调、列表用于结构化,甚至可以使用代码块(如果需要显示示例格式)。编辑器会扩展以适应更长的提示词,尽管简洁通常会产生更好的结果。
|
||||
|
||||
当你点击"保存更改"按钮时,更改会立即保存。没有草稿或暂存——修改会立即影响所有新摘要。用户可以[重新生成摘要](../user-guide/transcripts.md)以将更新的提示词应用于现有录音。"重置为默认"按钮提供了安全网,如果你的自定义效果不如预期,可以恢复到原始提示词。
|
||||
|
||||
时间戳显示提示词的最后修改时间,有助于跟踪随时间的更改。如果多个管理员管理你的实例,这有助于协调谁更改了什么以及何时更改的。
|
||||
|
||||
## 理解 LLM 提示词结构
|
||||
|
||||
可展开的"查看完整 LLM 提示词结构"部分揭示了你的提示词如何融入发送给 AI 的完整指令。这个技术视图显示了系统提示词、你的自定义提示词以及转录文本的集成。
|
||||
|
||||
理解这个结构有助于你编写更好的提示词。你会看到某些指令已经由系统提示词处理,因此你不需要重复它们。你会理解你的提示词如何与转录文本交互,以及为什么某些措辞比其他措辞更有效。
|
||||
|
||||
这种透明性也有助于故障排除。如果摘要不符合预期,查看完整提示词结构通常会揭示原因。也许你的指令与系统指令冲突,或者你可能在请求转录文本中通常不存在的信息。
|
||||
|
||||
## 实用提示词策略
|
||||
|
||||
从经过验证的结构开始,并根据结果进行迭代。默认提示词之所以有效,是因为它请求具体、可操作的信息。关键问题提供上下文,决策记录结果,待办事项推动跟进。
|
||||
|
||||
在广泛部署之前,使用各种录音类型测试你的提示词。适用于正式演示的提示词可能在随意对话中失效。上传具有不同特征的测试录音并评估生成的摘要。
|
||||
|
||||
考虑季节性或基于项目的调整。在规划季节,你可能会强调目标和策略。在执行阶段,专注于进展和阻碍因素。随着组织需求的发展,你可以更新默认提示词。
|
||||
|
||||
监控用户关于摘要质量的反馈。如果用户经常编辑摘要或抱怨缺少信息,你的提示词可能需要调整。最好的提示词是用户很少需要修改的提示词。
|
||||
|
||||
## 高级提示词技巧
|
||||
|
||||
分层指令以获得细致的输出。不要只请求"待办事项",而是指定"带责任方和提及的截止日期的待办事项"。这种精度有助于 AI 在可用时提取更有价值的信息。
|
||||
|
||||
使用条件语言增加灵活性。诸如"如果适用"或"当讨论时"之类的短语允许 AI 跳过不适用于每个录音的部分。这可以防止摘要中出现无关的内容。
|
||||
|
||||
考虑 AI 模型的优势和局限性。当前模型擅长识别主题、提取具体信息和组织内容。它们在复杂推理、数学计算和未明确陈述的信息方面存在困难。设计发挥这些优势的提示词。
|
||||
|
||||
在细节与可读性之间取得平衡。极其详细的提示词可能会生成用户不阅读的综合性摘要。有时简洁、聚焦的摘要比详尽的文档更好地为用户服务。
|
||||
|
||||
## 与用户提示词协调
|
||||
|
||||
你的默认提示词应补充而非与用户自定义竞争。将其设计为适用于大多数情况的坚实基础,同时鼓励高级用户根据自己的特定需求进行自定义。
|
||||
|
||||
向用户传达你的提示词策略。让他们知道默认提示词强调什么,以便他们可以决定自定义是否对他们有利。分享基于你默认值构建的有效用户提示词示例。
|
||||
|
||||
考虑为用户记录提示词最佳实践。如果某些部门需要专业摘要,提供他们可以使用的推荐提示词。这赋能了用户,同时在重要的地方保持一致性。
|
||||
|
||||
## 衡量提示词有效性
|
||||
|
||||
跟踪用户修改 AI 生成摘要的频率。频繁编辑表明你的提示词没有捕获用户需要的内容。最少的编辑表明你的提示词有效地提取了有价值的信息。
|
||||
|
||||
定期审查摘要样本。它们是否始终包含请求的部分?信息是否准确且相关?用户是否添加了提示词应该请求的类似信息?
|
||||
|
||||
在用户审查或支持交互期间收集反馈。具体询问摘要质量以及默认格式是否满足他们的需求。不自定义提示词的用户完全依赖你的默认值,因此他们的反馈至关重要。
|
||||
|
||||
## 常见提示词陷阱
|
||||
|
||||
避免过于死板的提示词,将结构强加于不兼容的内容上。并非每个录音都有"决策"或"待办事项"。强制 AI 在不存在时找到这些内容会产生无意义的填充。
|
||||
|
||||
不要请求 AI 无法提供的信息。要求"未说出的担忧"或"未讨论的内容"超出了转录分析的范围。AI 只能处理实际说出和记录的内容。
|
||||
|
||||
抵制使提示词过长的诱惑。每个指令都会增加复杂性和潜在的混淆。专注于最重要的内容,而不是试图捕获每个可能的细节。
|
||||
|
||||
---
|
||||
|
||||
下一步:[向量存储](vector-store.md) →
|
||||
39
speakr/docs/admin-guide/statistics.md
Normal file
@ -0,0 +1,39 @@
|
||||
# 系统统计
|
||||
|
||||
系统统计选项卡将原始数据转化为关于你的 Speakr 实例的可操作洞察。一目了然,你可以看到你正在服务多少用户、他们创建了多少录音、他们消耗了多少存储,以及一切是否处理顺畅。
|
||||
|
||||

|
||||
|
||||
## 关键指标概览
|
||||
|
||||
统计页面顶部的四个醒目卡片让你立即了解系统的规模。总用户数显示你当前的用户基础规模,帮助你了解实例的覆盖范围。总录音数揭示了系统中的累积内容,而总存储显示了实际消耗的磁盘空间。当启用 Inquire 模式时,总查询数表明用户搜索录音的活跃程度。
|
||||
|
||||
这些数字讲述了你的实例的健康状况和增长的故事。不断增长的用户数与按比例增长的录音数表明健康的采用率。存储增长速度超过录音数增长可能表明用户正在上传更长的文件。查询数揭示了用户是否从语义搜索功能中获得价值。
|
||||
|
||||
## 录音状态分布
|
||||
|
||||
状态分布部分将你的录音分解为四种关键状态。已完成的录音已完全处理并准备好使用——这应该是你内容的绝大多数。处理中的录音当前正在进行转录或分析。待处理的录音已排队等待处理。失败的录音遇到错误需要关注。
|
||||
|
||||
在健康的系统中,你会看到大部分是已完成的录音,可能随时只有少数在处理中。大量待处理的录音可能表明你的系统不堪重负或后台处理已停止。失败的录音始终值得调查——它们可能会揭示配置问题、API 问题或用户尝试上传的损坏文件。
|
||||
|
||||
## 存储分析
|
||||
|
||||
"按存储排名的顶级用户"部分揭示了谁在你的系统中消耗了最多的资源。每个用户都列出了他们的总存储消耗和录音数量,让你了解他们是拥有许多小文件还是较少的大文件。
|
||||
|
||||
此信息对于容量规划和用户教育非常有价值。如果一个用户消耗了不成比例的存储,你可能需要更好地了解他们的使用场景。他们是在录制数小时的会议吗?永远保留所有内容?理解数字背后的原因有助于你做出更好的策略决策。
|
||||
|
||||
## 理解使用模式
|
||||
|
||||
统计不仅仅是数字——它们是等待发现的洞察。录音的突然激增可能与项目启动、学术学期或公司计划相吻合。超过录音增长的存储增长可能表明用户正在上传更长的内容或更高质量的音频文件。
|
||||
|
||||
定期监控有助于你在趋势成为问题之前发现它们。如果存储每月增长 10%,你可以预测何时需要扩展容量。如果失败的录音突然激增,你可以调查是否是 API 密钥过期或服务宕机。
|
||||
|
||||
## 容量规划
|
||||
|
||||
系统统计是你的基础设施需求的水晶球。存储增长趋势告诉你何时需要更多磁盘空间。用户增长模式表明何时可能需要扩展服务器资源。处理队列揭示了当前设置是否能够处理工作负载。
|
||||
|
||||
主动使用这些洞察。如果你看到存储以每月 50GB 的速度增长且你有 200GB 可用空间,你知道你在需要干预之前还有大约四个月的时间。这个提前期让你可以预算升级、规划迁移或在达到临界限制之前实施保留策略。
|
||||
|
||||
---
|
||||
|
||||
下一步:[系统设置](system-settings.md) →
|
||||
57
speakr/docs/admin-guide/system-settings.md
Normal file
@ -0,0 +1,57 @@
|
||||
# 系统设置
|
||||
|
||||
系统设置是你配置影响 Speakr 实例中每个用户和录音的基本行为的地方。这些全局参数塑造系统的运行方式,从技术限制到面向用户的功能。
|
||||
|
||||

|
||||
|
||||
## 转录文本长度限制
|
||||
|
||||
转录文本长度限制决定了在生成摘要或响应聊天时有多少文本会被发送给 AI。这个看似简单的数字对质量和成本都有很大影响。
|
||||
|
||||
当设置为"无限制"时,无论长度如何,整个转录文本都会发送给 AI。这确保了 AI 拥有完整的上下文,但对于长录音来说可能会变得昂贵。两小时的会议可能会生成 20,000 字的转录文本,消耗大量 API 令牌并可能超出 AI 模型的上下文窗口。此限制也将应用于说话人识别模态中的说话人自动检测功能。
|
||||
|
||||
设置字符限制(如 50,000 个字符)会为 API 消耗设置上限。系统将截断非常长的转录文本,仅将开头部分发送给 AI。这使成本可预测,但可能意味着 AI 会错过录音后面部分的重要内容。
|
||||
|
||||
最佳点取决于你的使用场景。对于典型的一小时以内的会议,50,000 个字符通常可以捕获所有内容。对于更长的会话,你可以增加此限制或培训用户拆分录音。监控你的 API 成本和用户反馈以找到正确的平衡。
|
||||
|
||||
## 最大文件大小
|
||||
|
||||
文件大小限制保护你的系统免受大量上传的压倒性影响,同时确保用户可以使用合理的录音。默认的 300MB 可容纳数小时的压缩音频,这涵盖了大多数使用场景。
|
||||
|
||||
提高此限制允许更长的录音,但需要仔细考虑。较大的文件需要更长的上传时间、消耗更多存储,并且可能在处理期间超时。你的服务器需要足够的内存来处理这些文件,并且你的存储必须能够容纳它们。网络超时、浏览器限制和用户耐心都影响着什么是实际的。
|
||||
|
||||
如果用户经常触及限制,请考虑他们是否真的需要这么长的单个录音。通常,将长会话拆分为逻辑片段会产生更好的结果——更容易查看、处理更快、摘要更聚焦。
|
||||
|
||||
## ASR 超时设置
|
||||
|
||||
ASR 超时决定了 Speakr 将等待高级转录服务完成工作的时间。默认的 1,800 秒(30 分钟)可处理大多数录音,但你可能需要根据你的转录服务和典型文件大小进行调整。
|
||||
|
||||
设置过低会导致较长的录音即使转录服务正常工作也会失败。录音似乎卡在处理中,然后最终失败,令必须重试或放弃的用户感到沮丧。设置过高会使系统资源等待可能已经失败的服务。
|
||||
|
||||
最佳超时取决于你的转录服务的性能和用户的录音长度。监控成功转录的处理时间,并将超时设置为舒适地高于你最长的正常处理时间。如果你经常处理数小时的录音,你可能需要 3,600 秒或更多。
|
||||
|
||||
## 录音免责声明
|
||||
|
||||
录音免责声明会在用户开始任何录音会话之前出现,非常适合法律通知、策略提醒或使用指南。这个 markdown 格式的消息确保用户在创建内容之前了解他们的责任。
|
||||
|
||||
组织通常将其用于合规要求——提醒用户关于同意要求、数据处理策略或适当使用指南。教育机构可能会指出录音仅用于学术目的。医疗机构可能会参考 HIPAA 合规要求。
|
||||
|
||||
保持免责声明简洁且相关。用户会频繁看到此消息,因此冗长的法律文本会变成被忽视的点击-through。专注于最重要的要点,并在需要时链接到详细策略。markdown 支持让你可以使用粗体文本进行强调或链接到其他资源来清晰地格式化消息。
|
||||
|
||||
## 系统级影响
|
||||
|
||||
此页面上的每个设置都会立即影响所有用户。更改在你保存后立即生效,无需系统重启或用户注销。这种即时应用意味着你应该仔细测试更改并将重大修改传达给你的用户。
|
||||
|
||||
刷新按钮从数据库重新加载设置,如果多个管理员可能在进行更改或你想确保你看到的是最新值,这将非常有用。界面显示每个设置的最后更新时间,帮助你跟踪随时间的更改。
|
||||
|
||||
## 常见问题故障排除
|
||||
|
||||
当录音持续失败时,请检查它们是否触及了你配置的限制。错误日志会指示文件是否过大或处理是否超时。用户可能没有意识到他们的录音超出了限制,尤其是如果他们上传现有内容而不是直接录音。
|
||||
|
||||
如果 API 成本意外飙升,请检查你的转录文本长度限制。如果未设置限制,单个用户上传许多长录音可能会大幅增加消耗。用户活动与系统设置的组合决定了你的实际成本。
|
||||
|
||||
处理积压可能表明你的超时设置过高。如果系统每次失败的转录尝试等待 30 分钟,一系列有问题的文件可能会阻塞队列数小时。在对慢速处理的耐心与服务实际宕机时快速失败之间取得平衡。
|
||||
|
||||
---
|
||||
|
||||
下一步:[默认提示词](prompts.md) →
|
||||
47
speakr/docs/admin-guide/user-management.md
Normal file
@ -0,0 +1,47 @@
|
||||
# 用户管理
|
||||
|
||||
用户管理选项卡是控制谁可以访问你的 Speakr 实例以及他们可以用它做什么的指挥中心。每个用户账户都从这里流转,从初始创建到最终删除,以及沿途所需的所有监控和调整。有关系统级使用模式,请参阅[系统统计](statistics.md)。
|
||||
|
||||

|
||||
|
||||
## 理解用户表格
|
||||
|
||||
当你打开用户管理时,你会看到一个显示系统中每个用户的综合表格。每一行都讲述了一个故事——电子邮件地址标识用户,管理员徽章显示他们的权限级别,录音数量揭示他们的活跃程度,存储度量展示他们的资源消耗。
|
||||
|
||||
顶部的搜索栏在你输入时会立即响应,过滤表格以帮助你快速找到特定用户。随着你的用户基础增长到超出单个屏幕可容纳的范围,这变得非常宝贵。当你在进行更改时,表格会实时更新,因此你始终看到系统的当前状态。
|
||||
|
||||
## 添加新用户
|
||||
|
||||
创建用户账户从点击右上角的"添加用户"按钮开始。出现的模态框会询问基本信息——用户名、电子邮件地址和密码。你还需要立即决定此人是否需要管理员权限,尽管你以后可以随时更改。
|
||||
|
||||
用户名成为他们在 Speakr 中的身份,出现在界面中并组织他们的录音。电子邮件地址具有双重用途——它是他们的登录凭证以及任何系统通信的联系方式。你设置的密码是临时的;用户应在首次登录后立即通过其[账户设置](../user-guide/settings.md)更改它。配置初始[语言偏好](../user-guide/settings.md#language-preferences)和[自定义提示词](../user-guide/settings.md#custom-prompts-tab)。
|
||||
|
||||
管理员权限很强大,应谨慎授予。管理员用户可以看到和修改所有[系统设置](system-settings.md)、管理其他用户(包括其他管理员)、配置[默认提示词](prompts.md)以及监控[向量存储](vector-store.md)。大多数用户永远不需要这些功能。
|
||||
|
||||
## 管理现有用户
|
||||
|
||||
每个用户行都包含操作按钮,让你对该账户拥有完全控制权。编辑按钮会打开一个模态框,你可以在其中更新他们的用户名或电子邮件地址。当人们更改姓名、切换电子邮件提供商或需要纠正初始输入错误时,这非常有用。
|
||||
|
||||
管理员切换可能是界面中最强大的单次点击。将用户提升为管理员授予他们可以访问和执行的所有权限。将管理员降级为普通用户会立即撤销他们的所有管理功能。系统会阻止你从自己的账户中删除管理员权限,确保你不会意外将自己锁定。
|
||||
|
||||
删除按钮需要仔细考虑。删除用户是永久性的,无法通过界面撤销。他们的所有录音、笔记和设置将与其账户一起删除。系统会请求确认,但一旦确认,删除立即且彻底。
|
||||
|
||||
## 监控使用模式
|
||||
|
||||
录音数量和存储列揭示了用户如何与你的 Speakr 实例交互。高录音数量可能表明严重依赖系统的重度用户,而低数量可能表明需要培训或可能根本不需要账户的用户。
|
||||
|
||||
存储消耗讲述了另一个重要故事。存储消耗过高的用户可能正在上传非常长的录音、无限期保留所有内容,或者可能滥用系统。你可以根据需要调整[文件大小限制](system-settings.md#maximum-file-size)并审查[分块设置](../troubleshooting.md#files-over-25mb-fail-with-openai)。此信息帮助你进行有关资源使用的知情对话并制定适当的策略。
|
||||
|
||||
当你定期审查这些数据时,通常会出现模式。你可能会注意到学术环境中的季节性变化、企业环境中基于项目的激增,或表明需要基础设施扩展的逐渐增长。
|
||||
|
||||
## 安全影响
|
||||
|
||||
每个用户账户都是潜在的安全向量。强密码是你的第一道防线,但仅靠它们是不够的。鼓励用户使用唯一密码、定期更改密码,并且永远不要与他人共享密码。
|
||||
|
||||
管理员账户需要额外的警惕。每个管理员都可以执行你可以做的所有操作,包括创建更多管理员或删除所有用户。将管理员访问限制在操作所需的绝对最小值。当某人不再需要管理员权限时,立即撤销它们。
|
||||
|
||||
不活动的账户构成特殊风险。它们可能拥有无人监控的弱密码或已泄露的密码。定期审核有助于你在这些漏洞成为问题之前识别并消除它们。
|
||||
|
||||
---
|
||||
|
||||
下一步:[系统统计](statistics.md) →
|
||||
71
speakr/docs/admin-guide/vector-store.md
Normal file
@ -0,0 +1,71 @@
|
||||
# 向量存储管理
|
||||
|
||||
向量存储选项卡控制 Inquire 模式背后的智能,这是 Speakr 的语义搜索功能,允许用户使用自然语言问题在其所有录音中查找信息。在这里,你可以监控和管理将转录文本转换为可搜索知识的嵌入系统。
|
||||
|
||||

|
||||
|
||||
## 理解 Inquire 模式
|
||||
|
||||
在深入管理之前,了解你在管理什么是有价值的。Inquire 模式将每个转录文本分解为重叠的文本块,将这些块转换为称为嵌入的数学表示,并以可搜索的格式存储它们。当用户提问时,他们的查询会被转换为相同的数学格式,并与所有存储的块进行比较,以找到最相关的信息。
|
||||
|
||||
这种方法超越了简单的关键字匹配。系统理解"预算担忧"与"财务约束"和"成本超支"相关,即使确切的词语不同。这种语义理解使 Inquire 模式在发现用户可能无法准确记住的信息时非常强大。
|
||||
|
||||
## 嵌入模型
|
||||
|
||||
你的 Speakr 实例使用 all-MiniLM-L6-v2 模型,在界面中显著显示。该模型生成 384 维向量——想象每个文本块映射到 384 维空间中的一个点,相似含义聚集在一起。
|
||||
|
||||
这个特定模型是经过精心选择的。存在具有更好准确性的更大模型,但它们需要 GPU 和大量计算资源。MiniLM 模型在仅 CPU 的系统上高效运行,使高级搜索无需昂贵的基础设施即可实现。它处理文本速度快、上下文理解好,并生成不会压倒你存储的紧凑嵌入。
|
||||
|
||||
该模型最适合英文文本,因为这是其训练数据的主要部分。其他语言可能会以不同的成功率运行,但英文内容始终会产生最可靠的结果。这个限制是模型效率和可访问性的权衡。
|
||||
|
||||
## 处理状态概览
|
||||
|
||||
状态卡片让你立即了解向量存储的健康状况。总录音数显示你的系统中有多少音频文件,而为 Inquire 处理的数量显示有多少已转换为可搜索的嵌入。这些数字最终应该匹配,尽管随着后台处理赶上通常会存在滞后。
|
||||
|
||||
需要处理显示等待嵌入生成的录音。当用户上传新内容时这个数字会增加,随着后台处理器处理队列而减少。持续高数字可能表明处理已停滞或你的系统不堪重负。
|
||||
|
||||
总块数显示你的录音已被分解为的细粒度片段。根据转录密度,典型的一小时录音可能会生成 50-60 个块。这种分块确保即使在非常长的录音中也能找到相关片段。
|
||||
|
||||
嵌入状态指示器提供快速健康检查。绿色的"可用"表示一切正常运行。其他状态可能表明模型正在加载、处理正在运行或需要注意。
|
||||
|
||||
## 处理进度
|
||||
|
||||
处理进度条显示通过嵌入队列的实时进展。当达到 100% 时,所有录音都已处理并可搜索。较低的百分比表示正在进行的工作,随着录音完成条形图会填充。
|
||||
|
||||
这种视觉反馈帮助你一目了然地了解系统状态。卡住的进度条表明处理已停止。缓慢的进度可能表明系统资源受限。快速的进度显示一切高效运行。
|
||||
|
||||
## 管理处理队列
|
||||
|
||||
刷新状态按钮更新所有统计信息和进度指示器,用于监控活动处理或验证最近上传的内容已排队。界面不会自动刷新,因此手动刷新确保你看到的是当前信息。
|
||||
|
||||
当系统显示录音需要处理但进度没有推进时,可能有几个因素在起作用。后台处理器可能已停止、嵌入模型可能加载失败,或者系统资源可能已耗尽。检查你的日志以获取具体的错误消息。
|
||||
|
||||
处理系统被设计为具有弹性。如果特定录音的处理失败,系统会标记它并继续而不是卡住。这些失败会出现在你的日志中,可能需要手动干预来解决。
|
||||
|
||||
## 优化性能
|
||||
|
||||
处理性能在很大程度上取决于你的系统资源。嵌入模型在加载时需要大约 500MB 的 RAM,再加上处理文本的额外内存。CPU 速度直接影响嵌入生成的速度——现代多核处理器可以同时处理多个录音。
|
||||
|
||||
磁盘 I/O 也很重要。系统读取转录文本、处理它们并将嵌入写回数据库。快速存储,特别是 SSD,可以显著提高处理吞吐量。如果你的向量存储与转录文本在不同的磁盘上,请确保两者都有足够的性能。
|
||||
|
||||
网络延迟不应该影响处理,因为一切都在本地发生,但数据库性能很重要。定期数据库维护,包括索引优化和清理操作,即使你的向量存储增长也能保持查询快速。
|
||||
|
||||
## 常见问题故障排除
|
||||
|
||||
当 Inquire 模式在处理过的录音下返回较差的结果时,问题可能是查询表达而非向量存储。鼓励用户问完整的问题而不是输入关键字。"John 关于预算说了什么?"比仅仅"John 预算"效果更好。
|
||||
|
||||
如果处理似乎冻结了,请检查 sentence-transformers 库是否正确安装。没有它系统会优雅降级,禁用 Inquire 模式而不是崩溃,但处理不会推进。你的日志会显示嵌入模型是否成功加载。
|
||||
|
||||
处理期间的内存错误通常表明你的系统试图同时处理太多内容。分块系统防止单个录音压倒内存,但并行处理多个大录音可能会超出可用 RAM。
|
||||
|
||||
## 扩展考虑
|
||||
|
||||
向量存储随着你的内容可预测地增长。每个块需要大约 2KB 的存储用于其嵌入和元数据。生成 50 个块的典型一小时录音需要大约 100KB 的嵌入存储。一万小时的录音可能需要 100MB 的嵌入存储——即使在适度的系统上也是可管理的。
|
||||
|
||||
得益于高效索引,即使使用大型向量存储,搜索性能仍然保持快速。然而,极其大型的实例(数十万条录音)可能受益于专用向量数据库解决方案,而不是内置的 SQLite 存储。
|
||||
|
||||
如果你的实例增长到超出舒适的限制,请考虑归档旧录音。向量存储仅包含活动录音,因此删除过时内容可以同时改善存储和搜索性能。
|
||||
|
||||
---
|
||||
|
||||
返回[管理员指南概览](index.md) →
|
||||
347
speakr/docs/assets/css/style.scss
Normal file
@ -0,0 +1,347 @@
|
||||
---
|
||||
---
|
||||
|
||||
@import "{{ site.theme }}";
|
||||
|
||||
// Custom colors
|
||||
:root {
|
||||
--primary-color: #6366f1;
|
||||
--secondary-color: #8b5cf6;
|
||||
--accent-color: #ec4899;
|
||||
--success-color: #10b981;
|
||||
--warning-color: #f59e0b;
|
||||
--danger-color: #ef4444;
|
||||
--dark-bg: #1f2937;
|
||||
--light-bg: #f9fafb;
|
||||
}
|
||||
|
||||
// Override theme colors
|
||||
.page-header {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
background: var(--dark-bg);
|
||||
color: #e5e7eb;
|
||||
padding: 2rem 1rem;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
// Logo in header
|
||||
.project-logo {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.project-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
// Navigation cards
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
color: var(--primary-color);
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: #6b7280;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.card .btn {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
|
||||
color: white;
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.card .btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
// Feature badges
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.badge-new {
|
||||
background: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-beta {
|
||||
background: var(--warning-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
// Screenshots
|
||||
img[src*="screenshots"] {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
margin: 1.5rem 0;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
// Code blocks
|
||||
pre {
|
||||
background: #1f2937;
|
||||
color: #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
border: 1px solid #374151;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #f3f4f6;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875em;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
// Tables
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--light-bg);
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
// Alerts and callouts
|
||||
.note, .warning, .tip {
|
||||
padding: 1rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
margin: 1.5rem 0;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
|
||||
.note {
|
||||
background: #eff6ff;
|
||||
border-color: var(--primary-color);
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background: #fef3c7;
|
||||
border-color: var(--warning-color);
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.tip {
|
||||
background: #d1fae5;
|
||||
border-color: var(--success-color);
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
// Sidebar navigation
|
||||
.sidebar {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.sidebar h3 {
|
||||
color: var(--primary-color);
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.sidebar ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar a {
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar a:hover {
|
||||
background: var(--light-bg);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.sidebar a.active {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
|
||||
color: white;
|
||||
}
|
||||
|
||||
// Feature list
|
||||
.feature-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.feature-list li {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.feature-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.feature-list li:before {
|
||||
content: "✓";
|
||||
color: var(--success-color);
|
||||
font-weight: bold;
|
||||
margin-right: 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
// Responsive design
|
||||
@media (max-width: 768px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Smooth scrolling
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
// Better typography
|
||||
.main-content {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.7;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
color: #1f2937;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
// Animations
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.main-content > * {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
BIN
speakr/docs/assets/images/logo.png
Normal file
|
After Width: | Height: | Size: 229 KiB |
BIN
speakr/docs/assets/images/main2.png
Normal file
|
After Width: | Height: | Size: 380 KiB |
BIN
speakr/docs/assets/images/screenshots/Admin dashboard.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
speakr/docs/assets/images/screenshots/Admin default prompts.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
speakr/docs/assets/images/screenshots/Admin stats.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
speakr/docs/assets/images/screenshots/Admin system settings.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
speakr/docs/assets/images/screenshots/Admin vector store.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
speakr/docs/assets/images/screenshots/Filters.png
Normal file
|
After Width: | Height: | Size: 304 KiB |
BIN
speakr/docs/assets/images/screenshots/Multilingual.png
Normal file
|
After Width: | Height: | Size: 410 KiB |
|
After Width: | Height: | Size: 213 KiB |
BIN
speakr/docs/assets/images/screenshots/Summary View.png
Normal file
|
After Width: | Height: | Size: 327 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 331 KiB |
BIN
speakr/docs/assets/images/screenshots/edit recording modal.png
Normal file
|
After Width: | Height: | Size: 461 KiB |
|
After Width: | Height: | Size: 460 KiB |
|
After Width: | Height: | Size: 156 KiB |
BIN
speakr/docs/assets/images/screenshots/event extraction.png
Normal file
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 523 KiB |
|
After Width: | Height: | Size: 211 KiB |
BIN
speakr/docs/assets/images/screenshots/inquire mode 2.png
Normal file
|
After Width: | Height: | Size: 254 KiB |
BIN
speakr/docs/assets/images/screenshots/inquire mode.png
Normal file
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 338 KiB |
BIN
speakr/docs/assets/images/screenshots/new recording view.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
speakr/docs/assets/images/screenshots/notes tab.png
Normal file
|
After Width: | Height: | Size: 357 KiB |
BIN
speakr/docs/assets/images/screenshots/record from screen.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
speakr/docs/assets/images/screenshots/record from tab.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 82 KiB |
BIN
speakr/docs/assets/images/screenshots/settings about page.png
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
speakr/docs/assets/images/screenshots/settings account info.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 154 KiB |
BIN
speakr/docs/assets/images/screenshots/share recording modal.png
Normal file
|
After Width: | Height: | Size: 464 KiB |
BIN
speakr/docs/assets/images/screenshots/share recording.png
Normal file
|
After Width: | Height: | Size: 219 KiB |
BIN
speakr/docs/assets/images/screenshots/shared recording.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 23 KiB |
BIN
speakr/docs/assets/images/screenshots/speaker id modal.png
Normal file
|
After Width: | Height: | Size: 457 KiB |
|
After Width: | Height: | Size: 529 KiB |
|
After Width: | Height: | Size: 20 KiB |
BIN
speakr/docs/assets/images/speakr-logo.png
Normal file
|
After Width: | Height: | Size: 229 KiB |
89
speakr/docs/ci-cd-setup.md
Normal file
@ -0,0 +1,89 @@
|
||||
# Speakr CI/CD 设置
|
||||
|
||||
本文档解释了 Speakr 项目的持续集成和持续部署(CI/CD)流水线是如何设置的。
|
||||
|
||||
## 自动 Docker 镜像构建与发布
|
||||
|
||||
Speakr 项目使用 GitHub Actions 在代码推送到仓库时自动构建 Docker 镜像并发布到 Docker Hub。
|
||||
|
||||
### 工作原理
|
||||
|
||||
1. 当代码推送到 `master` 分支或创建新标签(格式为 `v*.*.*`)时,GitHub Actions 工作流将被触发。
|
||||
2. 工作流使用项目的 Dockerfile 构建 Docker 镜像。
|
||||
3. 如果触发原因是推送到 `master` 分支,镜像将被标记为:
|
||||
- `latest`
|
||||
- 提交的短 SHA 值
|
||||
- 分支名称(`master`)
|
||||
4. 如果触发原因是新标签(例如 `v1.2.3`),镜像将被标记为:
|
||||
- 完整版本号(`1.2.3`)
|
||||
- 主.次版本号(`1.2`)
|
||||
- 标签名称(`v1.2.3`)
|
||||
5. 然后镜像会被推送到你账户下的 Docker Hub。
|
||||
|
||||
### 设置步骤
|
||||
|
||||
要设置自动 Docker 镜像发布,请按照以下步骤操作:
|
||||
|
||||
1. **创建 Docker Hub 账户**(如果你还没有的话):
|
||||
- 访问 [Docker Hub](https://hub.docker.com/) 并注册账户。
|
||||
- 创建一个名为 `speakr` 的仓库。
|
||||
|
||||
2. **创建 Docker Hub 访问令牌**:
|
||||
- 登录 Docker Hub。
|
||||
- 点击右上角的用户名,选择"Account Settings"进入账户设置。
|
||||
- 导航到"Security"选项卡。
|
||||
- 点击"New Access Token"。
|
||||
- 给令牌取个名字(例如"GitHub Actions")并设置适当的权限(至少需要"Read & Write")。
|
||||
- 复制生成的令牌(你将无法再次看到它)。
|
||||
|
||||
3. **将 Docker Hub 凭据添加到 GitHub Secrets**:
|
||||
- 进入你的 GitHub 仓库。
|
||||
- 点击"Settings" > "Secrets and variables" > "Actions"。
|
||||
- 点击"New repository secret"。
|
||||
- 添加以下密钥:
|
||||
- 名称:`DOCKERHUB_USERNAME`,值:你的 Docker Hub 用户名
|
||||
- 名称:`DOCKERHUB_TOKEN`,值:你在上一步生成的访问令牌
|
||||
|
||||
4. **验证工作流**:
|
||||
- 工作流文件位于 `.github/workflows/docker-publish.yml`。
|
||||
- 它已经配置好可以构建并推送 Docker 镜像到 Docker Hub。
|
||||
- 如果需要,你可以进一步自定义它。
|
||||
|
||||
5. **触发工作流**:
|
||||
- 推送更改到 `master` 分支或创建新标签来触发工作流。
|
||||
- 进入 GitHub 仓库的"Actions"选项卡来监控工作流进度。
|
||||
|
||||
### 使用已发布的 Docker 镜像
|
||||
|
||||
镜像发布到 Docker Hub 后,你可以在 docker-compose.yml 文件中使用它们:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
image: learnedmachine/speakr:latest
|
||||
container_name: speakr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8899:8899"
|
||||
volumes:
|
||||
- ./uploads:/data/uploads
|
||||
- ./instance:/data/instance
|
||||
env_file:
|
||||
- .env
|
||||
```
|
||||
|
||||
### 版本控制策略
|
||||
|
||||
要创建新版本的应用程序:
|
||||
|
||||
1. 进行更改并提交到仓库。
|
||||
2. 按照语义化版本控制创建新标签:
|
||||
```bash
|
||||
git tag v1.0.0 # 替换为适当的版本号
|
||||
git push origin v1.0.0
|
||||
```
|
||||
3. GitHub Actions 工作流将自动构建并发布带有相应版本标签的新 Docker 镜像。
|
||||
|
||||
这样用户可以选择使用最新版本(`learnedmachine/speakr:latest`)或特定版本(`learnedmachine/speakr:1.0.0`)。
|
||||
67
speakr/docs/create_docs.py
Normal file
@ -0,0 +1,67 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def create_markdown_doc(base_dir):
|
||||
output = []
|
||||
|
||||
# Add header
|
||||
output.append("# Project Files\n")
|
||||
output.append("Generated documentation of all project files.\n")
|
||||
|
||||
# Function to read and format file content
|
||||
def add_file_content(filepath, relative_path):
|
||||
output.append(f"\n## {relative_path}\n")
|
||||
output.append("```" + get_file_extension(filepath) + "\n")
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
output.append(f.read())
|
||||
except Exception as e:
|
||||
output.append(f"Error reading file: {e}")
|
||||
output.append("```\n")
|
||||
|
||||
def get_file_extension(filepath):
|
||||
ext = os.path.splitext(filepath)[1][1:].lower()
|
||||
# Map file extensions to markdown code block languages
|
||||
extension_map = {
|
||||
'py': 'python',
|
||||
'html': 'html',
|
||||
'js': 'javascript',
|
||||
'css': 'css',
|
||||
'sh': 'bash',
|
||||
'md': 'markdown',
|
||||
'txt': 'text'
|
||||
}
|
||||
return extension_map.get(ext, '')
|
||||
|
||||
# List of important file patterns to include
|
||||
patterns = [
|
||||
'*.py',
|
||||
'*.html',
|
||||
'*.js',
|
||||
'*.css',
|
||||
'*.sh',
|
||||
'requirements.txt'
|
||||
]
|
||||
|
||||
# Walk through directory and add files
|
||||
for root, _, _ in os.walk(base_dir):
|
||||
for pattern in patterns:
|
||||
for filepath in Path(root).glob(pattern):
|
||||
if 'venv' not in str(filepath) and '__pycache__' not in str(filepath):
|
||||
relative_path = os.path.relpath(filepath, base_dir)
|
||||
add_file_content(filepath, relative_path)
|
||||
|
||||
# Write to output file
|
||||
output_path = os.path.join(base_dir, 'project_files.md')
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(output))
|
||||
|
||||
return output_path
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Get the current directory
|
||||
current_dir = os.getcwd()
|
||||
|
||||
# Create the markdown file
|
||||
output_file = create_markdown_doc(current_dir)
|
||||
print(f"Created markdown documentation at: {output_file}")
|
||||
189
speakr/docs/faq.md
Normal file
@ -0,0 +1,189 @@
|
||||
# 常见问题
|
||||
|
||||
这些是人们开始使用 Speakr 时最常遇到的问题。这里的答案将为你节省时间,并帮助你了解如何充分利用该平台。
|
||||
|
||||
## 一般问题
|
||||
|
||||
### Speakr 究竟是什么?
|
||||
|
||||
Speakr 是一个自托管的 Web 应用程序,可将你的音频录音转化为有组织、可搜索且智能的笔记。它将[转录](features.md#core-transcription-features)、[AI 摘要](features.md#automatic-summarization)、[说话人识别](features.md#speaker-diarization)和[语义搜索](user-guide/inquire-mode.md)整合到一个你完全掌控的单一平台中。如果你自托管了像 Whisper 端点或推荐的 ASR 服务这样的 ASR 模型,以及用于 LLM 的 OpenAI 兼容 API,你的数据永远不会离开你的基础设施,从而为你提供完整的隐私和控制权。
|
||||
|
||||
### Speakr 与其他转录服务有何不同?
|
||||
|
||||
关键区别在于自托管——你在自己的服务器上运行 Speakr,完全掌控自己的数据。除了隐私保护之外,Speakr 还将转录与 AI 驱动的功能(如[智能摘要](features.md#automatic-summarization)、与录音的[交互式聊天](user-guide/transcripts.md)以及跨所有内容的[语义搜索](user-guide/inquire-mode.md))进行了集成。它不仅仅将语音转换为文本,还让这些文本变得有用且易于访问。
|
||||
|
||||
### Speakr 支持哪些音频格式?
|
||||
|
||||
Speakr 支持大多数常见音频格式,包括 MP3、WAV、M4A、OGG、FLAC 等。系统在内部使用 FFmpeg 来处理音频,因此基本上任何 FFmpeg 支持的格式都能正常工作。包含音轨的视频文件也受到支持——Speakr 会提取并处理其音频部分。
|
||||
|
||||
### 多人可以使用同一个 Speakr 实例吗?
|
||||
|
||||
可以,Speakr 被设计为一个多用户系统。每个用户都有自己的账户,拥有独立的录音、设置和说话人库。管理员可以[创建和管理用户账户](admin-guide/user-management.md)。请参阅[系统统计](admin-guide/statistics.md)以监控使用情况、监控使用情况以及配置系统范围的设置。除非通过[分享链接](user-guide/sharing.md)明确共享,否则用户无法看到彼此的录音。了解[分享安全性](user-guide/sharing.md#security-and-privacy-considerations)。
|
||||
|
||||
## 安装与设置
|
||||
|
||||
### 最低系统要求是什么?
|
||||
|
||||
Speakr 可以在适度的硬件上舒适运行。你至少需要 2GB RAM,但推荐使用 4GB 以获得更好的性能。CPU 需求取决于你的使用情况——双核处理器可以应对单用户实例,而繁忙的多用户安装则受益于更多核心。存储需求取决于你的录音量,但请至少预留 20GB 可用空间用于应用程序和初始录音。
|
||||
|
||||
### 安装 Speakr 需要了解 Docker 吗?
|
||||
|
||||
基本的 Docker 知识会有帮助,但并非必需。[快速入门指南](getting-started.md)提供了可以复制和运行的确切命令。对于生产部署,请参阅[安装指南](getting-started/installation.md)。你需要在服务器上安装 Docker 和 Docker Compose,创建包含 API 密钥的配置文件,然后运行一条命令启动所有内容。通常最难的部分是从 OpenAI 或 OpenRouter 获取 API 密钥。
|
||||
|
||||
### 我可以在树莓派上运行 Speakr 吗?
|
||||
|
||||
可以,Speakr 可以在配备至少 4GB RAM 的树莓派 4 或更新版本上运行。性能无法与完整服务器相比,尤其是在转录处理方面,但对于个人使用来说完全足够。ARM 兼容的 Docker 镜像开箱即用。只需对大录音的较长处理时间保持耐心即可。
|
||||
|
||||
### 我可以在 Mac 上使用 ASR webservice 进行说话人分离吗?
|
||||
|
||||
提供[说话人分离](features.md#speaker-diarization)功能的可选 ASR webservice(`onerahmet/openai-whisper-asr-webservice`)在 macOS 上有特定要求:
|
||||
|
||||
**GPU 限制**:GPU 直通在 macOS 上无法工作,因为 Docker 在 Linux 虚拟机内运行容器。这是 Mac 上 Docker 的根本限制。
|
||||
|
||||
**解决方案**:使用标准 CPU 镜像而非 GPU 版本:
|
||||
- 使用 `onerahmet/openai-whisper-asr-webservice:latest`(而非 `:latest-gpu`)
|
||||
- `:latest` 标签同时提供 amd64(Intel)和 arm64(Apple Silicon)架构
|
||||
- 没有 GPU 加速时处理速度会变慢,但功能完全正常
|
||||
|
||||
Mac 的配置示例:
|
||||
```bash
|
||||
docker run -d -p 9000:9000 \
|
||||
-e ASR_MODEL=base \
|
||||
-e ASR_ENGINE=whisperx \
|
||||
-e HF_TOKEN=your_huggingface_token \
|
||||
onerahmet/openai-whisper-asr-webservice:latest
|
||||
```
|
||||
|
||||
注意:如果你不需要在转录中进行说话人识别,可以直接使用 Speakr 配合标准 Whisper API,这不需要额外的容器。
|
||||
|
||||
### 如何备份我的 Speakr 数据?
|
||||
|
||||
你的 Speakr 数据由三个基本组件组成:`instance/` 目录中的 SQLite 数据库、`uploads/` 目录中的音频文件和转录内容,以及 `.env` 文件中的配置。要创建完整备份,请先停止容器以确保数据库一致性,然后备份所有三个目录:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
tar czf speakr_backup_$(date +%Y%m%d).tar.gz uploads/ instance/ .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
强烈建议在生产环境中定期进行自动化备份。
|
||||
|
||||
## 转录与 AI 功能
|
||||
|
||||
### 转录的准确度如何?
|
||||
|
||||
转录准确度取决于多个因素——音频质量、说话人清晰度、背景噪音和专业词汇。请参阅[故障排除指南](troubleshooting.md#poor-transcription-quality)获取建议。为专业词汇配置[自定义提示词](admin-guide/prompts.md)。在音频质量良好的情况下,清晰的英语语音预计可达到 90-95% 的准确度。在口音较重、多人重叠说话或录音质量差的情况下,准确度会下降。配备说话人分离功能的 ASR 端点通常能提供更好的实际可用性,即使原始准确度相近。
|
||||
|
||||
### Whisper API 和 ASR 端点有什么区别?
|
||||
|
||||
Whisper API 提供基本转录功能——将语音转换为文本,但不包含说话人识别。[推荐的 ASR 容器](getting-started.md#option-b-custom-asr-endpoint-configuration)(`onerahmet/openai-whisper-asr-webservice`)提供高级功能,如[说话人分离](features.md#speaker-diarization),可以识别并标记对话中的不同说话人。了解转录后如何[管理说话人](user-guide/transcripts.md#speaker-identification)。对于有多位参与者的会议,说话人分离是必不可少的,而对于单人录音(如口述或播客),Whisper API 就足够了。
|
||||
|
||||
**关于 ASR 引擎的说明**:为了让说话人分离在 ASR webservice 中正常工作,你必须使用 `ASR_ENGINE=whisperx`,而不是 `faster_whisper`。虽然 faster_whisper 提供转录功能,但它不支持说话人识别。
|
||||
|
||||
### Speakr 可以转录英语以外的语言吗?
|
||||
|
||||
可以,Speakr 通过其转录服务支持多种语言。Whisper 模型可以处理数十种语言,准确度各不相同——西班牙语、法语、德语和中文等主要语言效果良好,而不太常见的语言准确度可能会降低。你可以在[账户设置](user-guide/settings.md#language-preferences)中设置首选语言,或将其留空以自动检测。请参阅[语言支持详情](features.md#language-support)。
|
||||
|
||||
**中文转录的重要提示**:使用 ASR 端点进行中文音频转录时,请避免使用 distil 模型(如 distil-large-v3),因为它们可能会将中文错误识别为英文。请使用完整 large-v3 模型或类似的非蒸馏模型以获得准确的中文转录。
|
||||
|
||||
### 我的录音可以有多长?
|
||||
|
||||
录音长度没有硬性限制,但需要考虑实际因素。非常长的录音(超过 2-3 小时)处理时间更长,消耗更多 API 额度,并且可能导致界面响应变慢。文件上传限制默认为 300MB,足以容纳数小时的压缩音频。对于全天候研讨会等超长内容,建议将其拆分为逻辑段落。
|
||||
|
||||
### 什么 AI 模型生成摘要?
|
||||
|
||||
摘要生成使用在[环境配置文件](getting-started.md#step-3-configure-your-transcription-service)中配置的语言模型。通过 [AI 提示词](admin-guide/prompts.md)自定义摘要——可以通过本地 LLM 端点或 OpenAI、OpenRouter 等云服务提供商。模型选择会影响[摘要质量](features.md#automatic-summarization)、成本和处理速度。在[系统统计](admin-guide/statistics.md)中监控性能。
|
||||
|
||||
## 隐私与安全
|
||||
|
||||
### 我的数据真的私密吗?
|
||||
|
||||
正确自托管时,你的音频和转录内容永远不会离开你的服务器。但是,转录和摘要 API(OpenAI、OpenRouter)确实会在他们的服务器上处理你的内容。要实现完全隐私,你需要对转录和摘要都使用本地模型,这需要大量的计算资源。
|
||||
|
||||
### 我可以将 Speakr 用于机密商务会议吗?
|
||||
|
||||
可以,但需要采取适当的预防措施。自托管让数据处于你的控制之下,但请考虑你的 API 提供商的数据政策。OpenAI 和 OpenRouter 有不同的数据保留和使用政策。为了获得最高安全性,请使用本地转录和摘要模型,但这需要强大的硬件和技术专长。
|
||||
|
||||
### 分享链接安全吗?
|
||||
|
||||
[分享链接](user-guide/sharing.md)使用密码学安全的随机令牌,无法被猜测。你可以从[分享仪表板](user-guide/sharing.md#managing-your-shared-recordings)管理已分享的录音。但是,任何拥有链接的人都可以在未经身份验证的情况下访问共享内容。请将分享链接视为密码——仅通过安全渠道发送它们,并在不再需要时撤销访问权限。对于敏感内容,请考虑需要身份验证的替代分享方式。
|
||||
|
||||
### 谁可以看到我的录音?
|
||||
|
||||
默认情况下,只有你可以看到自己的录音。[管理员用户](admin-guide/user-management.md)无法通过界面直接查看其他用户的录音,尽管他们可以监控[使用模式](admin-guide/statistics.md),但理论上他们可以访问数据库。已分享的录音对拥有分享链接的任何人可访问。同一 Speakr 实例上的其他用户无法看到你的录音,除非你明确分享。
|
||||
|
||||
## 功能与特性
|
||||
|
||||
### 什么是 Inquire Mode?
|
||||
|
||||
[Inquire Mode](user-guide/inquire-mode.md) 是 Speakr 的语义搜索功能,让你可以使用自然语言问题在所有录音中查找信息。必须配置[向量存储](admin-guide/vector-store.md)才能使其正常工作。与其搜索精确的关键词,你可以提出诸如"我们关于营销预算做了什么决定?"这样的问题,并获得讨论过该主题的任何录音的相关摘录。它使用 AI 嵌入来理解含义和上下文。
|
||||
|
||||
### 说话人档案如何工作?
|
||||
|
||||
当你在转录中[识别说话人](user-guide/transcripts.md#speaker-identification)时,通过点击通用标签(SPEAKER_01 等)并分配名称,Speakr 会将这些保存为说话人档案。你可以在[账户设置](user-guide/settings.md#speakers-management-tab)中管理它们。在未来的更新中,我们打算添加功能,允许录音根据声音特征自动建议说话人身份。随着时间的推移,你会建立一个已识别说话人的库,使多人转录变得更加有用。
|
||||
|
||||
### 转录生成后可以编辑吗?
|
||||
|
||||
可以,转录内容完全可编辑。点击任何转录上方的"编辑"按钮进行修改。请参阅[转录指南](user-guide/transcripts.md#editing-transcriptions)了解编辑选项。这对于修正识别错误的专业术语、专有名词或更正说话人分配特别有用。你的编辑会被保留——如果你重新生成摘要或使用[聊天功能](user-guide/transcripts.md),它们不会丢失。可以使用[各种格式](user-guide/transcripts.md)导出已编辑的转录。
|
||||
|
||||
### 有哪些导出格式可用?
|
||||
|
||||
Speakr 可以以多种格式导出录音。你可以直接将转录内容复制到剪贴板,以便粘贴到其他应用程序中。了解[导出选项](features.md#export-options)和[分享](user-guide/sharing.md)。可以下载完整的录音为 Word 文档(.docx),包括转录、摘要和笔记。[分享链接](user-guide/sharing.md)提供只读的 Web 访问。你可以在[分享设置](user-guide/sharing.md#creating-a-share-link)中配置可见内容。聊天历史记录也可以导出用于文档目的。
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 为什么转录需要这么长时间?
|
||||
|
||||
多个因素会影响转录速度——文件大小、API 服务负载、网络速度和模型选择。大文件自然需要更长时间。API 服务在高峰使用时段可能会变慢。慢速互联网连接会在上传音频时造成瓶颈。使用更大、更准确的模型(如 Whisper Large)比使用较小模型耗时更长。
|
||||
|
||||
### 我的录音卡在"待处理"状态
|
||||
|
||||
这通常意味着后台处理器已停止或遇到错误。请检查 Docker 日志中的错误消息。请参阅[故障排除指南](troubleshooting.md#transcription-never-starts)了解详情。在[向量存储](admin-guide/vector-store.md)中监控处理进度。常见原因包括 API 密钥无效、API 配额超限或网络连接问题。重启容器通常可以解决临时问题。请检查你的 API 提供商仪表板,了解使用限制或账单问题。
|
||||
|
||||
### 为什么所有说话人都显示为"UNKNOWN_SPEAKER"?
|
||||
|
||||
这是说话人分离配置不正确时的常见问题。以下是修复方法:
|
||||
|
||||
1. **检查 ASR_ENGINE**:确保你在 ASR 容器中使用的是 `ASR_ENGINE=whisperx`,而不是 `faster_whisper`
|
||||
2. **验证 ASR_DIARIZE**:虽然在 `USE_ASR_ENDPOINT=true` 时默认设置为 `true`,但请在 .env 文件中显式设置 `ASR_DIARIZE=true`
|
||||
3. **HuggingFace Token**:ASR 容器需要有效的 `HF_TOKEN` 环境变量来下载说话人分离模型
|
||||
4. **Docker 网络**:如果容器在同一个 docker-compose 中,请使用容器名称(例如 `http://whisper-asr:9000`),而不是 localhost 或外部 IP
|
||||
5. **检查日志**:在 ASR 容器日志中查找 pyannote/VAD 消息以确认说话人分离已激活
|
||||
|
||||
ASR 服务应在转录中返回类似 "SPEAKER_00"、"SPEAKER_01" 的说话人标签。然后你可以[识别这些说话人](user-guide/transcripts.md#speaker-identification)并赋予真实姓名。
|
||||
|
||||
### 为什么我无法分享录音?
|
||||
|
||||
[分享](user-guide/sharing.md)需要你的 Speakr 实例可以从互联网通过 HTTPS/SSL 加密访问。请检查[分享要求](user-guide/sharing.md#requirements-for-sharing)和[故障排除](troubleshooting.md#sharing-links-dont-work)。本地安装或未配置 HTTPS 的环境无法生成可用的分享链接。当不满足这些要求时,系统会禁用分享功能。要启用分享功能,请在具有域名和 SSL 证书的公共服务器上部署 Speakr。
|
||||
|
||||
### 大转录时界面响应缓慢
|
||||
|
||||
浏览器在显示大量文本时会遇到困难,尤其是在带有说话人分离的气泡视图中。对于超过 2 小时的录音,请考虑使用简单视图而非气泡视图。如果性能随时间下降,请清除浏览器缓存。将超长录音拆分为多个段落可以提高性能和可用性。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 我应该如何组织我的录音?
|
||||
|
||||
尽早开发一个一致的[标签系统](user-guide/settings.md#tag-management-tab)。为不同的项目、会议类型或客户创建标签。标签可以包含用于专门处理的[自定义提示词](admin-guide/prompts.md)。使用描述性标题,帮助你在数月后找到录音。在录音后立即添加笔记,此时上下文仍然清晰。定期维护——归档旧录音和清理测试文件——让你的录音库保持易于管理的状态。
|
||||
|
||||
### 我需要告知他人他们正在被录音吗?
|
||||
|
||||
法律要求因司法管辖区而异。许多地区要求所有被录制方的明确同意。Speakr 包含可配置的[录音免责声明](admin-guide/system-settings.md#recording-disclaimer)功能。请参阅[合规性考虑](troubleshooting.md#recording-disclaimer-for-legal-compliance)。设置适当的法律文本,在录音开始前显示。请咨询当地法律以确保合规——这在欧盟、加州或澳大利亚等有严格录音法律的地区尤为重要。
|
||||
|
||||
### 什么音频质量最适合转录?
|
||||
|
||||
尽可能在安静的环境中录音。使用靠近说话人的优质麦克风。对于会议,请将录音设备放置在所有参与者都能清晰听到的中心位置。避免背景音乐或电视噪音。高质量的音频不仅能提高转录准确度,还能减少处理时间和 API 成本。
|
||||
|
||||
### 如何最大化转录准确度?
|
||||
|
||||
说话清晰,避免互相打断。尽量减少背景噪音和回声。对于技术内容,请考虑在你的[提示词](admin-guide/prompts.md)中添加自定义词汇表或术语表。用户可以为其录音设置[个人提示词](user-guide/settings.md#custom-prompts-tab)。请使用适当的[语言设置](user-guide/settings.md#language-preferences),而不是依赖自动检测。请参阅[语言支持](features.md#language-support)以获得最佳效果。对于多人录音,请使用 [ASR 端点](getting-started.md#option-b-custom-asr-endpoint-configuration)并设置适当的说话人数量。转录后[识别说话人](user-guide/transcripts.md#speaker-identification)以获得最佳效果。
|
||||
|
||||
对于中文转录,请使用 large-v3 模型,因为较小的模型可能无法正确输出中文字符。对于其他语言,请测试不同模型,为你的特定语言和口音找到最佳准确度。
|
||||
|
||||
### 按大小分块和按持续时间分块有什么区别?
|
||||
|
||||
按文件大小分块(如 CHUNK_LIMIT=20MB)适用于比特率一致的音频。当转录服务有时间限制时(如 Azure 的 1500 秒上限),按持续时间分块(如 CHUNK_LIMIT=1400s)更为合适。基于持续时间的分块确保无论文件压缩或质量如何,都不会有任何分块超过时间限制。
|
||||
|
||||
---
|
||||
|
||||
返回 [首页](index.md) →
|
||||
BIN
speakr/docs/favicon.ico
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
109
speakr/docs/features.md
Normal file
@ -0,0 +1,109 @@
|
||||
# 功能特性
|
||||
|
||||
Speakr 将强大的转录能力与智能 AI 功能相结合,将您的音频录音转化为有价值的、可操作的知识。每个功能的设计都旨在节省时间并从您的语音内容中提取最大价值。
|
||||
|
||||
## 核心转录功能
|
||||
|
||||
### 多引擎支持
|
||||
|
||||
Speakr 支持多种转录引擎,以满足您的需求和预算。使用 [OpenAI 的 Whisper API](getting-started.md#option-a-openai-whisper-configuration) 进行快速、基于云的转录,并具有出色的准确率。部署 [推荐的 ASR 容器](getting-started.md#option-b-custom-asr-endpoint-configuration) 以实现说话人分离和本地处理等高级功能。有关详细设置说明,请参阅[安装指南](getting-started/installation.md)。系统会自动处理不同的音频格式,并根据需要进行转换以获得最佳转录质量。
|
||||
|
||||
### 说话人分离
|
||||
|
||||
使用 [ASR 端点](getting-started.md#option-b-custom-asr-endpoint-configuration) 时,Speakr 会自动识别录音中的不同说话人。如果遇到问题,请查看[故障排除指南](troubleshooting.md#speaker-identification-not-working)。每个说话人都会获得一个唯一标签,您可以稍后使用真实姓名进行自定义。系统会记住这些说话人档案,构建一个随时间推移不断提高识别准确率的库。在[账户设置](user-guide/settings.md)中管理您的说话人库。此功能将多人会议从大段文本转换为有组织的对话。
|
||||
|
||||
### 语言支持
|
||||
|
||||
使用自动检测或手动选择,转录数十种语言的内容。英语、西班牙语、法语、德语和中文等主要语言获得出色支持,准确率高。系统能优雅地处理多语言内容,在同一次录音中根据需要切换语言。
|
||||
|
||||
## AI 驱动的智能功能
|
||||
|
||||
### 自动摘要
|
||||
|
||||
每条录音都会收到一个 AI 生成的摘要,捕捉关键点、决策和行动项。通过[自定义提示词](admin-guide/prompts.md)进行配置。用户还可以为他们的录音设置[个人提示词](user-guide/settings.md#custom-prompts-tab)。摘要会根据您的内容类型进行调整——技术会议获得详细的技术摘要,而非正式对话则获得更轻松的概述。自定义提示词让您可以根据特定需求调整摘要。
|
||||
|
||||
### 事件提取
|
||||
|
||||
在摘要处理过程中,Speakr 可以自动从录音中提取值得添加到日历的事件。在账户设置中启用后,系统会识别会议、截止日期、预约和其他时间敏感项目的提及。每个检测到的事件都可以导出为与任何日历应用程序兼容的 ICS 文件。该功能能智能解析相对日期引用,并在未提及具体时间时提供合理的默认值。在工作流程中了解更多关于[使用事件提取](user-guide/transcripts.md#event-extraction)的信息。
|
||||
|
||||
### 交互式对话
|
||||
|
||||
通过集成的对话功能,将静态转录稿转换为动态对话。了解如何在[转录稿指南](user-guide/transcripts.md)中有效使用 AI 对话。针对您的录音提问,并基于实际内容获得智能回答。请求自定义摘要、提取特定信息,或生成衍生内容如电子邮件或报告。AI 在整个对话过程中保持上下文,支持复杂的多轮交互。
|
||||
|
||||
### 语义搜索(Inquire 模式)
|
||||
|
||||
通过 [Inquire 模式](user-guide/inquire-mode.md) 使用自然语言问题而非关键词搜索您的所有录音。必须配置 [向量存储](admin-guide/vector-store.md) 此功能才能工作。语义搜索理解含义和上下文,即使确切的词语不匹配也能找到相关内容。提出诸如"我们什么时候讨论过预算增加?"这样的问题,即可从任何涉及该主题的录音中获取结果,无论使用了什么特定术语。
|
||||
|
||||
## 组织与管理
|
||||
|
||||
### 标签系统
|
||||
|
||||
使用灵活的[标签系统](user-guide/settings.md#tag-management-tab)组织录音,超越简单的标签。标签可以包含用于专门处理的[自定义 AI 提示词](admin-guide/prompts.md)。每个标签可以携带[自定义 AI 提示词](admin-guide/prompts.md)和转录设置,根据内容类型启用自动的专业处理。标签使用颜色进行视觉组织,当同一录音应用多个标签时智能堆叠。
|
||||
|
||||
### 说话人管理
|
||||
|
||||
构建和维护跨录音持久的说话人档案库。每次转录后[识别说话人](user-guide/transcripts.md#speaker-identification)以构建您的库。识别后,系统会在未来的录音中记住并建议这些说话人。系统跟踪使用统计信息,显示每个说话人出现的频率以及最后一次被识别的时间。批量管理工具有助于维护干净、有序的说话人库。
|
||||
|
||||
### 自定义提示词
|
||||
|
||||
通过多个层级的[自定义提示词](admin-guide/prompts.md)塑造 AI 行为。了解[提示词层级](admin-guide/prompts.md#understanding-prompt-hierarchy)以进行有效配置。个人提示词适用于您的所有录音,而特定标签的提示词会为特定内容类型激活。层级系统确保正确的提示词应用于每条录音,当多个提示词相关时智能堆叠。
|
||||
|
||||
## 分享与协作
|
||||
|
||||
### 安全分享链接
|
||||
|
||||
生成加密安全链接以[分享录音](user-guide/sharing.md)给您 Speakr 实例之外的用户。注意[分享要求](user-guide/sharing.md#requirements-for-sharing)。精确控制接收者看到的内容——根据需要包含或排除摘要和笔记。分享链接可在任何设备上使用,无需账户或身份验证。
|
||||
|
||||
### 导出选项
|
||||
|
||||
以多种格式导出录音以满足不同用途。生成包含完整转录稿、摘要和笔记的 Word 文档用于正式文档。复制格式化文本到剪贴板以便快速分享。导出单个组件如摘要或笔记用于定向分发。使用[可自定义模板](user-guide/transcript-templates.md)下载转录稿,为您的内容格式化为不同用例,从字幕到采访格式。
|
||||
|
||||
### 实时监控
|
||||
|
||||
从中央仪表板跟踪所有已分享的录音。查看已分享的内容、时间以及权限。修改分享设置而无需生成新链接,或在分享不再合适时立即撤销访问权限。
|
||||
|
||||
## 高级功能
|
||||
|
||||
### 音频分块
|
||||
|
||||
通过智能分块处理超出 API 限制的大型音频文件。查看[故障排除指南](troubleshooting.md#files-over-25mb-fail-with-openai)获取配置详情。在 FAQ 中了解[分块策略](faq.md#whats-the-difference-between-chunking-by-size-vs-duration)。系统自动将长录音分割为可管理的片段,分别处理,然后无缝组装结果。按持续时间或文件大小配置分块大小,以匹配您的 API 提供商的要求。
|
||||
|
||||
### 黑洞处理
|
||||
|
||||
设置一个监视目录,放入其中的音频文件会被自动处理。在[系统设置](admin-guide/system-settings.md)中配置此功能以实现自动化工作流。非常适合自动化工作流或批量处理,黑洞目录会监视新文件并将其排队进行转录,无需手动干预。
|
||||
|
||||
### 自定义 ASR 配置
|
||||
|
||||
使用自定义 ASR 设置针对特定场景微调转录。查看 [ASR 配置](troubleshooting.md#asr-endpoint-returns-405-or-404-errors) 获取常见设置问题。为不同会议类型设置预期说话人数量。为技术词汇或特定口音配置专用模型。通过标签系统自动应用这些设置。
|
||||
|
||||
## 用户体验
|
||||
|
||||
### 渐进式 Web 应用
|
||||
|
||||
将 Speakr 安装为渐进式 Web 应用,在任何设备上获得原生般的体验。PWA 在离线状态下可查看现有录音,并在恢复连接时同步。移动优化的界面确保在手机和平板电脑上流畅运行。
|
||||
|
||||
### 深色模式
|
||||
|
||||
通过影响每个界面元素的完整深色模式实现,减少眼睛疲劳。主题偏好在会话和设备之间持久保存,每次登录时自动应用您的选择。
|
||||
|
||||
### 响应式设计
|
||||
|
||||
从任何设备访问 Speakr,界面会自动适应屏幕尺寸。桌面用户获得完整的三面板布局,可同时访问录音、转录稿和对话。移动用户获得为触摸交互和小屏幕优化的简化界面。
|
||||
|
||||
## 管理控制
|
||||
|
||||
### 多用户支持
|
||||
|
||||
为整个团队运行单个 Speakr 实例,具有隔离的用户空间。查看[用户管理](admin-guide/user-management.md)了解详情。[FAQ](faq.md#can-multiple-people-use-the-same-speakr-instance) 解释了多用户架构。每个用户维护自己的录音、设置和说话人库。管理员管理用户、监控使用情况并配置系统级设置,而无需访问个人录音。
|
||||
|
||||
### 系统监控
|
||||
|
||||
通过全面的统计信息和指标跟踪系统健康状况。监控转录队列、存储使用情况和处理性能。识别瓶颈并根据实际使用模式优化配置。
|
||||
|
||||
### 灵活配置
|
||||
|
||||
通过[环境变量](getting-started.md#step-3-configure-your-transcription-service)和[管理员设置](admin-guide/index.md)配置 Speakr 的各个方面。检查[系统设置](admin-guide/system-settings.md)获取全局配置选项。设置 API 端点、调整处理限制、启用或禁用功能,以及自定义用户体验。系统会适应您的基础设施和需求。
|
||||
|
||||
---
|
||||
|
||||
返回 [首页](index.md) →
|
||||
188
speakr/docs/getting-started.md
Normal file
@ -0,0 +1,188 @@
|
||||
# 快速入门指南
|
||||
|
||||
只需几分钟即可使用预构建的 Docker 镜像运行 Speakr!本指南将带你了解部署 Speakr 的最快方法,支持使用 OpenAI Whisper API 或[自定义 ASR 端点](features.md#speaker-diarization)。
|
||||
|
||||
> **注意:** 如果你想使用 ASR 端点选项来实现说话人分离功能,你需要运行一个额外的 Docker 容器(`onerahmet/openai-whisper-asr-webservice`)。请参阅[运行 ASR 服务以支持说话人分离](getting-started/installation.md#running-asr-service-for-speaker-diarization)获取详细的设置说明。
|
||||
|
||||
## 前置条件
|
||||
|
||||
开始之前,请确保你的系统上已安装 Docker 和 Docker Compose。你还需要一个 API 密钥(OpenAI 或 OpenRouter,或兼容服务),至少 2GB 可用内存,以及约 10GB 可用磁盘空间用于存储录音和转录内容。
|
||||
|
||||
## 步骤 1:创建项目目录
|
||||
|
||||
首先,为 Speakr 安装创建一个目录并进入:
|
||||
|
||||
```bash
|
||||
mkdir speakr
|
||||
cd speakr
|
||||
```
|
||||
|
||||
## 步骤 2:下载配置文件
|
||||
|
||||
下载 Docker Compose 配置,并根据你选择的转录服务选择合适的环境模板:
|
||||
|
||||
```bash
|
||||
# Download docker compose example
|
||||
wget https://raw.githubusercontent.com/murtaza-nasir/speakr/master/config/docker-compose.example.yml -O docker-compose.yml
|
||||
```
|
||||
|
||||
现在下载环境配置模板。根据你要使用的转录服务,你有两种选择。
|
||||
|
||||
使用标准 OpenAI Whisper API(推荐大多数用户使用):
|
||||
```bash
|
||||
wget https://raw.githubusercontent.com/murtaza-nasir/speakr/master/config/env.whisper.example -O .env
|
||||
```
|
||||
|
||||
或使用带说话人分离功能的自定义 ASR 端点(需要额外的 ASR 容器——见下方说明):
|
||||
```bash
|
||||
wget https://raw.githubusercontent.com/murtaza-nasir/speakr/master/config/env.asr.example -O .env
|
||||
```
|
||||
|
||||
> **重要:** ASR 端点选项需要与 Speakr 一起运行一个额外的 Docker 容器(`onerahmet/openai-whisper-asr-webservice`)。有关完整的设置说明,包括两个容器的 docker-compose 配置,请参阅[运行 ASR 服务以支持说话人分离](getting-started/installation.md#running-asr-service-for-speaker-diarization)。
|
||||
|
||||
## 步骤 3:配置你的转录服务
|
||||
|
||||
在你喜欢的文本编辑器中打开 `.env` 文件,并根据你选择的服务进行配置。
|
||||
|
||||
### 选项 A:OpenAI Whisper 配置
|
||||
|
||||
如果你使用 OpenAI Whisper,你需要同时设置转录服务和文本生成模型。文本生成模型用于创建摘要、生成标题和驱动聊天功能。
|
||||
|
||||
编辑你的 `.env` 文件并更新以下关键变量:
|
||||
|
||||
```bash
|
||||
# For text generation (summaries, chat, titles)
|
||||
TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1
|
||||
TEXT_MODEL_API_KEY=your_openrouter_api_key_here
|
||||
TEXT_MODEL_NAME=openai/gpt-4o-mini
|
||||
|
||||
# For transcription
|
||||
TRANSCRIPTION_BASE_URL=https://api.openai.com/v1
|
||||
TRANSCRIPTION_API_KEY=your_openai_api_key_here
|
||||
WHISPER_MODEL=whisper-1
|
||||
```
|
||||
|
||||
文本模型可以使用 OpenRouter 来访问各种 AI 模型,或者你可以将其直接指向 OpenAI,使用与转录服务相同的基础 URL 和 API 密钥。OpenRouter 提供对多种模型的访问,包括 GPT-4、Claude 等,对于文本生成任务来说可能更具成本效益。
|
||||
|
||||
### 选项 B:自定义 ASR 端点配置
|
||||
|
||||
> **前置条件:** 此选项需要运行一个额外的 ASR 服务容器(`onerahmet/openai-whisper-asr-webservice`)。你可以:
|
||||
>
|
||||
> - 在同一个 Docker Compose 栈中运行两个容器(推荐)——请参阅[完整设置指南](getting-started/installation.md#running-asr-service-for-speaker-diarization)
|
||||
> - 在同一台或不同的机器上单独运行 ASR 服务
|
||||
> - 如果你已经部署了现有的 ASR 服务,可以直接使用
|
||||
|
||||
如果你使用的是自定义 ASR 服务(如 WhisperX 或自托管的 Whisper 服务器),请配置以下变量:
|
||||
|
||||
```bash
|
||||
# For text generation (summaries, chat, titles)
|
||||
TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1
|
||||
TEXT_MODEL_API_KEY=your_openrouter_api_key_here
|
||||
TEXT_MODEL_NAME=openai/gpt-4o-mini
|
||||
|
||||
# Enable ASR endpoint
|
||||
USE_ASR_ENDPOINT=true
|
||||
|
||||
# ASR service URL (use container name if in same docker compose)
|
||||
ASR_BASE_URL=http://whisper-asr:9000
|
||||
```
|
||||
|
||||
当使用 ASR 端点时,[说话人分离](features.md#speaker-diarization)功能会自动启用,使 Speakr 能够识别录音中的不同说话人。转录完成后,你需要[识别说话人](user-guide/transcripts.md#speaker-identification)来构建你的说话人库。ASR_BASE_URL 应指向你的 ASR 服务。如果你在同一个 Docker Compose 栈中运行 ASR 服务,请使用容器名称和内部端口(如 `http://whisper-asr:9000`)。对于外部服务,请使用带有适当 IP 地址或域名的完整 URL。
|
||||
|
||||
## 步骤 4:配置管理员账户
|
||||
|
||||
Speakr 会在首次启动时自动创建一个管理员用户。在启动之前,请在 `.env` 文件中配置这些凭据:
|
||||
|
||||
```bash
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=changeme
|
||||
```
|
||||
|
||||
确保将这些值更改为更安全的值,尤其是密码。当你首次启动 Speakr 时,该管理员账户将自动创建,你将使用这些凭据进行登录。通过此方法创建的第一个用户将成为系统管理员,拥有对所有功能的完全访问权限,包括用户管理和系统设置。
|
||||
|
||||
## 步骤 5:启动 Speakr
|
||||
|
||||
配置完成后,使用 Docker Compose 启动 Speakr:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
首次启动需要几分钟时间,因为 Docker 需要下载预构建镜像(约 3GB)并初始化数据库。你可以通过查看日志来监控启动过程:
|
||||
|
||||
```bash
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
查找表示 Flask 应用程序正在运行并已准备好接受连接的消息。按 Ctrl+C 退出日志视图(这不会停止容器)。
|
||||
|
||||
## 步骤 6:访问 Speakr
|
||||
|
||||
容器运行后,打开你的 Web 浏览器并访问:
|
||||
|
||||
```
|
||||
http://localhost:8899
|
||||
```
|
||||
|
||||
使用你在步骤 4 中配置的管理员凭据登录。现在你应该能看到 Speakr 仪表盘,已准备好进行你的第一次录音。
|
||||
|
||||
## 你的第一次录音
|
||||
|
||||
登录后,你可以立即开始使用 Speakr。点击顶部导航栏中的"New Recording"按钮,上传现有音频文件或开始[现场录音](user-guide/recording.md)。有关详细说明,请参阅[录音指南](user-guide/recording.md)。对于上传文件,Speakr 支持[常见音频格式](faq.md#what-audio-formats-does-speakr-support),如 MP3、M4A、WAV 等,默认文件大小限制为 500MB。你可以在[系统设置](admin-guide/system-settings.md)中调整此限制。对于现场录音,你可以从麦克风、系统音频或同时进行录制。
|
||||
|
||||
## 可选功能
|
||||
|
||||
### 启用 Inquire 模式
|
||||
|
||||
[Inquire 模式](user-guide/inquire-mode.md)允许你使用自然语言问题在所有录音中进行搜索。在功能指南中了解更多关于[语义搜索功能](features.md#semantic-search-inquire-mode)的信息。要启用它,请在 `.env` 文件中设置:
|
||||
|
||||
```bash
|
||||
ENABLE_INQUIRE_MODE=true
|
||||
```
|
||||
|
||||
然后使用 `docker compose restart` 重启容器以使更改生效。
|
||||
|
||||
### 启用用户注册
|
||||
|
||||
默认情况下,只有管理员可以创建新用户。在管理员指南中了解更多关于[用户管理](admin-guide/user-management.md)的信息。要允许自行注册,请设置:
|
||||
|
||||
```bash
|
||||
ALLOW_REGISTRATION=true
|
||||
```
|
||||
|
||||
### 配置你的时区
|
||||
|
||||
设置本地时区以显示准确的时间戳:
|
||||
|
||||
```bash
|
||||
TIMEZONE="America/New_York"
|
||||
```
|
||||
|
||||
使用 TZ 数据库中的任何有效时区,如"Europe/London"、"Asia/Tokyo"或"UTC"。
|
||||
|
||||
## 停止和启动 Speakr
|
||||
|
||||
要停止 Speakr 同时保留所有数据:
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
要再次启动:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
你的录音、转录内容和设置将保存在主机系统上的 `./uploads` 和 `./instance` 目录中。
|
||||
|
||||
## 故障排除
|
||||
|
||||
如果 Speakr 无法正常启动,请使用 `docker compose logs app` 检查日志中的错误信息。有关更详细的帮助,请参阅[故障排除指南](troubleshooting.md),特别是[安装问题](troubleshooting.md#installation-and-setup-issues)部分。常见问题包括 API 密钥不正确(日志中会显示身份验证错误)或端口冲突(如果其他服务正在使用 8899 端口)。你可以通过编辑 `docker-compose.yml` 文件并修改 ports 部分来更改端口。
|
||||
|
||||
如果转录失败,请验证你的 API 密钥是否正确,并且你选择的服务有足够的额度。日志将显示详细的错误信息,有助于识别问题。
|
||||
|
||||
---
|
||||
|
||||
下一步:[安装指南](getting-started/installation.md)(用于生产部署和高级配置)
|
||||
709
speakr/docs/getting-started/installation.md
Normal file
@ -0,0 +1,709 @@
|
||||
# 安装指南
|
||||
|
||||
本综合指南涵盖了 Speakr 的生产环境部署,包括详细的配置选项、性能调优和部署最佳实践。虽然快速入门指南能让你快速运行,但本指南提供了稳健生产部署所需的一切。
|
||||
|
||||
## 了解 Speakr 的架构
|
||||
|
||||
在开始安装之前,了解 Speakr 的工作原理会很有帮助。该应用集成了外部 API,主要用于两个目的:将音频转换为文本的转录服务,以及为摘要、标题和交互式聊天等功能提供支持的文本生成服务。Speakr 设计灵活,既支持 OpenAI 等云端服务,也支持运行在你自有基础设施上的自托管方案。
|
||||
|
||||
Speakr 使用特定的 API 端点格式进行这些集成。对于转录,它支持标准的 OpenAI Whisper API 格式,使用 `/audio/transcriptions` 端点,该格式被 OpenAI、OpenRouter 和许多自托管方案所实现。另外,对于说话人分离等高级功能,Speakr 可以连接提供 `/asr` 端点的 ASR Web 服务。**注意:使用 ASR 端点选项需要与 Speakr 一起运行额外的 Docker 容器**(`onerahmet/openai-whisper-asr-webservice`)——完整的设置说明请参考下方的[运行 ASR 服务用于说话人分离](#running-asr-service-for-speaker-diarization)部分。对于文本生成,Speakr 使用 OpenAI Chat Completions API 格式的 `/chat/completions` 端点,该格式被不同的 AI 提供商广泛支持。
|
||||
|
||||
## 前置要求
|
||||
|
||||
对于生产环境部署,请确保你的系统满足以下要求。你需要 Docker Engine 20.10 或更高版本以及 Docker Compose 2.0 或更高版本。系统应至少有 4GB 内存,推荐 8GB 以获得最佳性能,尤其是处理较长录音时。预留至少 20GB 可用磁盘空间来存放录音和转录文件,实际需求将取决于你的使用模式。除非你在本地运行所有服务,否则服务器应有稳定的互联网连接以进行转录和 AI 服务的 API 调用。
|
||||
|
||||
## 选择部署方式
|
||||
|
||||
你有两种主要的部署方式。第一种(推荐)是使用 Docker Hub 上的预构建 Docker 镜像,无需源代码即可快速运行。第二种是从源码构建,适用于需要修改代码或偏好自行构建镜像的场景。两种方法都使用 Docker Compose 进行编排和管理。
|
||||
|
||||
## 使用预构建镜像的标准安装
|
||||
|
||||
### 步骤 1:创建安装目录
|
||||
|
||||
为你的 Speakr 安装选择合适的位置。该目录将包含你的配置文件和数据卷。对于生产环境部署,建议使用专用目录如 `/opt/speakr` 或 `/srv/speakr`,这样可以与用户主目录清晰分离,并遵循 Linux 文件系统层次标准。
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/speakr
|
||||
cd /opt/speakr
|
||||
```
|
||||
|
||||
如果只是测试或个人使用,也可以在主文件夹中创建目录。位置不是关键,但将所有内容组织在一个地方更便于管理。
|
||||
|
||||
### 步骤 2:创建 Docker Compose 配置
|
||||
|
||||
创建 `docker-compose.yml` 文件,内容如下:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: learnedmachine/speakr:latest
|
||||
container_name: speakr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8899:8899"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./uploads:/data/uploads
|
||||
- ./instance:/data/instance
|
||||
```
|
||||
|
||||
或下载示例配置:
|
||||
|
||||
```bash
|
||||
wget https://raw.githubusercontent.com/murtaza-nasir/speakr/master/config/docker-compose.example.yml -O docker-compose.yml
|
||||
```
|
||||
|
||||
重启策略 `unless-stopped` 确保 Speakr 在系统重启后自动启动,除非你显式停止了它。数据卷挂载本地目录用于持久化存储上传文件和数据库文件。
|
||||
|
||||
### 步骤 3:环境配置
|
||||
|
||||
环境配置用于告诉 Speakr 使用哪些 AI 服务以及如何连接它们。根据你的转录服务选择下载相应的环境模板。该模板包含所有配置变量,并附有 helpful 注释解释每个设置。
|
||||
|
||||
#### 使用 OpenAI Whisper API
|
||||
|
||||
如果你使用 OpenAI 的 Whisper API 或任何兼容服务,下载 Whisper 环境模板:
|
||||
|
||||
```bash
|
||||
wget https://raw.githubusercontent.com/murtaza-nasir/speakr/master/config/env.whisper.example -O .env
|
||||
```
|
||||
|
||||
然后编辑 `.env` 文件添加你的 API 密钥并自定义设置。配置按逻辑部分组织。首先,配置用于摘要、标题和聊天功能的文本生成模型。这里推荐使用 OpenRouter,因为它能以有竞争力的价格访问多个 AI 模型,但你也可以使用任何 OpenAI 兼容服务:
|
||||
|
||||
```bash
|
||||
TEXT_MODEL_BASE_URL=https://openrouter.ai/api/v1
|
||||
TEXT_MODEL_API_KEY=sk-or-v1-your-key-here
|
||||
TEXT_MODEL_NAME=openai/gpt-4o-mini
|
||||
```
|
||||
|
||||
如果你更喜欢直接使用 OpenAI 进行文本生成,只需将 base URL 改为 `https://api.openai.com/v1` 并使用你的 OpenAI API 密钥。你也可以通过 Ollama 或 LM Studio 使用本地模型,指向 `http://localhost:11434/v1` 或类似地址。
|
||||
|
||||
接下来,配置转录服务。这是将音频文件转换为文本的服务:
|
||||
|
||||
```bash
|
||||
TRANSCRIPTION_BASE_URL=https://api.openai.com/v1
|
||||
TRANSCRIPTION_API_KEY=sk-your-openai-key-here
|
||||
WHISPER_MODEL=whisper-1
|
||||
```
|
||||
|
||||
#### 使用自定义 ASR 端点配合说话人分离
|
||||
|
||||
如果你想要说话人分离功能来识别录音中的不同说话者,你需要使用 ASR Web 服务端点。**这需要与 Speakr 一起运行额外的 Docker 容器**(`onerahmet/openai-whisper-asr-webservice`),但为会议转录和多说话者录音提供了强大的功能。
|
||||
|
||||
> **重要提示:** 在进行此配置之前,你需要先设置 ASR 服务容器。请参考[运行 ASR 服务用于说话人分离](#running-asr-service-for-speaker-diarization)获取完整的说明,了解如何一起或分别部署两个容器。
|
||||
|
||||
下载 ASR 配置模板:
|
||||
|
||||
```bash
|
||||
wget https://raw.githubusercontent.com/murtaza-nasir/speakr/master/config/env.asr.example -O .env
|
||||
```
|
||||
|
||||
ASR 配置启用自定义端点并告诉 Speakr 在哪里找到它:
|
||||
|
||||
```bash
|
||||
USE_ASR_ENDPOINT=true
|
||||
ASR_BASE_URL=http://your-asr-service:9000
|
||||
```
|
||||
|
||||
ASR_BASE_URL 取决于你的部署架构。如果你在与 Speakr 相同的 Docker Compose 堆栈中运行 ASR 服务,请使用 docker-compose.yml 中的服务名,如 `http://whisper-asr:9000`。这使用 Docker 的内部网络进行通信。如果 ASR 服务运行在其他地方,请使用其完整 URL 和适当的 IP 地址或域名。
|
||||
|
||||
使用 ASR 端点时会自动启用说话人分离。系统将识别录音中的不同说话者并标记为 Speaker 1、Speaker 2 等。你可以选择取消注释并调整环境文件中的 ASR_MIN_SPEAKERS 和 ASR_MAX_SPEAKERS 来覆盖默认的说话者检测设置。
|
||||
|
||||
### 步骤 4:配置系统设置
|
||||
|
||||
Speakr 的一个便利功能是自动创建管理员账户。无需经过注册流程,你只需在环境文件中定义管理员凭据,Speakr 会在首次启动时自动创建账户。这确保你可以立即登录,无需额外的设置步骤:
|
||||
|
||||
```bash
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_EMAIL=admin@your-domain.com
|
||||
ADMIN_PASSWORD=your-secure-password-here
|
||||
```
|
||||
|
||||
为管理员账户选择强密码,因为它拥有完整的系统访问权限,包括管理用户和查看所有录音的能力。管理员账户是特殊的,无法通过常规注册流程创建,只能通过环境变量创建。
|
||||
|
||||
接下来,配置应用程序的行为方式。这些设置控制用户访问和系统操作:
|
||||
|
||||
```bash
|
||||
ALLOW_REGISTRATION=false
|
||||
TIMEZONE="America/New_York"
|
||||
LOG_LEVEL="INFO"
|
||||
```
|
||||
|
||||
设置 `ALLOW_REGISTRATION=false` 意味着只有管理员可以创建新用户账户,这对于需要控制访问权限的私有安装是推荐的。如果你为团队或家庭运行 Speakr,这可以防止无关人员创建账户。时区设置影响整个界面中日期和时间的显示方式,因此请将其设置为你的本地时区以方便使用。日志级别控制 Speakr 写入日志的信息量。在初始设置和测试期间使用 `INFO` 以了解发生了什么,然后在生产环境中切换到 `ERROR` 以减少日志量并提高性能。
|
||||
|
||||
### 步骤 5:配置高级功能
|
||||
|
||||
#### 大文件处理
|
||||
|
||||
Speakr 最有用的功能之一是自动处理大音频文件。许多转录 API 有文件大小限制,OpenAI 的 25MB 限制就是一个常见的约束。Speakr 通过智能分段自动处理这个问题,而不是强制你手动分割文件:
|
||||
|
||||
```bash
|
||||
ENABLE_CHUNKING=true
|
||||
CHUNK_LIMIT=20MB
|
||||
CHUNK_OVERLAP_SECONDS=3
|
||||
```
|
||||
|
||||
启用分段后,Speakr 会自动检测文件是否超过配置的限制并将其分割为较小的部分。每个部分单独处理,转录结果无缝合并回一起。重叠设置确保在分段边界处不会丢失单词,这对于连续语音尤为重要。分段限制可以指定为文件大小如 `20MB` 或持续时间如 `20m`(20 分钟),具体取决于你的 API 限制。
|
||||
|
||||
此功能仅适用于标准 Whisper API 方法。如果你使用 ASR 端点,则不需要分段,因为这些服务通常原生支持大文件。
|
||||
|
||||
#### Inquire 模式用于语义搜索
|
||||
|
||||
Inquire 模式将 Speakr 从简单的转录工具转变为你所有录音的知识库。启用后,你可以使用自然语言问题在所有转录中进行搜索:
|
||||
|
||||
```bash
|
||||
ENABLE_INQUIRE_MODE=true
|
||||
```
|
||||
|
||||
启用 Inquire 模式后,Speakr 会为你的转录创建嵌入向量,从而支持语义搜索。这意味着你可以问"我们什么时候讨论了营销预算?"这样的问题,即使没有使用确切的词语也能找到相关录音。该功能在转录期间需要额外的处理,但随着你的录音库增长,它将提供强大的搜索能力。
|
||||
|
||||
#### 自动化文件处理
|
||||
|
||||
自动化文件处理功能(有时称为"黑洞"目录)会监控指定文件夹中的新音频文件并自动处理它们,无需手动干预。这非常适合与录音设备、自动化工作流或批处理场景集成:
|
||||
|
||||
```bash
|
||||
ENABLE_AUTO_PROCESSING=true
|
||||
AUTO_PROCESS_MODE=admin_only
|
||||
AUTO_PROCESS_WATCH_DIR=/data/auto-process
|
||||
AUTO_PROCESS_CHECK_INTERVAL=30
|
||||
```
|
||||
|
||||
启用后,Speakr 每 30 秒检查一次监控目录是否有新音频文件。发现的任何文件都会自动移动到上传目录并使用你配置的转录设置进行处理。`admin_only` 模式将所有处理后的文件分配给管理员用户,但你也可以为多用户场景配置每个用户的独立目录。
|
||||
|
||||
要使用此功能,你需要在 Docker Compose 配置中挂载额外的数据卷,我们将在接下来的步骤中介绍。
|
||||
|
||||
### 步骤 6:设置数据目录
|
||||
|
||||
Speakr 需要本地目录来持久化存储你的数据。这些目录作为 Docker 数据卷挂载,确保你的录音和数据库在容器更新和重启后仍然存在:
|
||||
|
||||
```bash
|
||||
mkdir -p uploads instance
|
||||
chmod 755 uploads instance
|
||||
```
|
||||
|
||||
`uploads` 目录存储所有音频文件及其转录内容,按用户组织。`instance` 目录包含跟踪所有录音、用户和设置的 SQLite 数据库。将权限设置为 755 确保 Docker 容器可以读写这些目录,同时保持合理的安全性。
|
||||
|
||||
如果你使用自动化文件处理功能,也需要创建该目录:
|
||||
|
||||
```bash
|
||||
mkdir -p auto-process
|
||||
chmod 755 auto-process
|
||||
```
|
||||
|
||||
### 步骤 7:启动 Speakr
|
||||
|
||||
所有配置完成后,你就可以启动 Speakr 了。`-d` 标志以分离模式运行容器,意味着它将在后台持续运行:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
首次运行此命令时,Docker 将从 Docker Hub 下载 Speakr 镜像。该镜像约 3GB,包含运行 Speakr 所需的所有依赖项,包括用于音频处理的 FFmpeg 和各种 Python 库。下载时间取决于你的互联网连接速度。
|
||||
|
||||
监控启动过程以确保一切正常运行:
|
||||
|
||||
```bash
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
观察日志是否有任何错误消息。你应该会看到关于数据库初始化、管理员用户创建的消息,最后会显示 Flask 应用程序在 8899 端口运行的消息。按 Ctrl+C 停止查看日志(这不会停止容器,只是停止日志查看)。
|
||||
|
||||
### 步骤 8:验证安装
|
||||
|
||||
打开浏览器并访问 `http://your-server:8899`,将 `your-server` 替换为你的实际服务器地址,如果是本地运行则使用 `localhost`。你应该会看到 Speakr 登录页面,带有其独特的渐变设计。
|
||||
|
||||
使用你在环境文件中配置的管理员凭据登录。如果登录失败,请检查 Docker 日志以确保管理员账户创建成功。有时环境文件中的拼写错误会导致问题。
|
||||
|
||||
登录成功后,通过创建测试录音或上传示例音频文件来测试安装。录音界面应显示麦克风、系统音频或两者的选项。首先尝试上传一个小型音频文件以验证你的 API 密钥是否正常工作。对于短文件,转录过程应在几秒钟内完成,你应该会看到转录文本出现以及 AI 生成的摘要。
|
||||
|
||||
如果转录失败,请检查 Docker 日志中的 API 认证错误或连接问题。常见问题包括 API 密钥不正确、API 额度不足或网络连接问题。
|
||||
|
||||
## 高级部署场景
|
||||
|
||||
### 运行 ASR 服务用于说话人分离
|
||||
|
||||
如果你需要说话人分离功能来识别录音中的不同说话者,你需要与 Speakr 一起运行 ASR 服务。这涉及部署额外的 Docker 容器(`onerahmet/openai-whisper-asr-webservice`)来提供 ASR 端点。推荐的方法是在同一个 Docker Compose 堆栈中运行两个容器,以简化网络和管理。
|
||||
|
||||
首先,你需要一个 Hugging Face 令牌来访问说话人分离模型。在 Hugging Face 创建账户,生成访问令牌,并且重要的是,访问 pyannote/segmentation-3.0 和 pyannote/speaker-diarization-3.1 模型页面以接受它们的条款。这些是需要明确授权的受限模型。
|
||||
|
||||
以下是包含两个服务的完整 Docker Compose 配置:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
whisper-asr:
|
||||
image: onerahmet/openai-whisper-asr-webservice:latest-gpu
|
||||
container_name: whisper-asr-webservice
|
||||
ports:
|
||||
- "9000:9000"
|
||||
environment:
|
||||
- ASR_MODEL=distil-large-v3
|
||||
- ASR_COMPUTE_TYPE=int8
|
||||
- ASR_ENGINE=whisperx
|
||||
- HF_TOKEN=your_huggingface_token_here
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
capabilities: [gpu]
|
||||
device_ids: ["0"]
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- speakr-network
|
||||
|
||||
speakr:
|
||||
image: learnedmachine/speakr:latest
|
||||
container_name: speakr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8899:8899"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./uploads:/data/uploads
|
||||
- ./instance:/data/instance
|
||||
depends_on:
|
||||
- whisper-asr
|
||||
networks:
|
||||
- speakr-network
|
||||
|
||||
networks:
|
||||
speakr-network:
|
||||
driver: bridge
|
||||
```
|
||||
|
||||
> **Mac 用户注意:** 由于 Docker 的架构,GPU 直通在 macOS 上不起作用。请使用 `onerahmet/openai-whisper-asr-webservice:latest`(CPU 版本)而不是 `:latest-gpu`,并删除 `deploy` 部分。ASR 服务将使用 CPU 处理,速度较慢但功能完整。请参考[常见问题](../faq.md#can-i-use-the-asr-webservice-for-speaker-diarization-on-mac)获取 Mac 特定的配置示例。
|
||||
|
||||
当在同一个 Docker Compose 文件中运行两个服务时,容器使用服务名进行通信。在 `.env` 文件中,设置 `ASR_BASE_URL=http://whisper-asr:9000`,使用服务名而不是 localhost 或 IP 地址。这是一个常见的混淆点,但这就是 Docker 网络的工作方式。
|
||||
|
||||
#### 在独立的 Docker Compose 文件中运行服务
|
||||
|
||||
如果你偏好独立管理服务或将 ASR 服务添加到现有 Speakr 安装中,你可以在单独的 Docker Compose 文件中运行它们。这种方法提供了更大的灵活性,无论服务是在同一台机器还是不同的机器上都可以工作。
|
||||
|
||||
##### 选项 1:同一台机器共享网络
|
||||
|
||||
如果两个服务运行在同一台机器上,你可以使用 Docker 的内部网络进行通信:
|
||||
|
||||
首先,创建共享的 Docker 网络:
|
||||
|
||||
```bash
|
||||
docker network create speakr-network
|
||||
```
|
||||
|
||||
为 ASR 服务创建 `docker-compose.asr.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
whisper-asr:
|
||||
image: onerahmet/openai-whisper-asr-webservice:latest-gpu
|
||||
container_name: whisper-asr-webservice
|
||||
ports:
|
||||
- "9000:9000"
|
||||
environment:
|
||||
- ASR_MODEL=distil-large-v3
|
||||
- ASR_COMPUTE_TYPE=int8
|
||||
- ASR_ENGINE=whisperx
|
||||
- HF_TOKEN=your_huggingface_token_here
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
capabilities: [gpu]
|
||||
device_ids: ["0"]
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- speakr-network
|
||||
|
||||
networks:
|
||||
speakr-network:
|
||||
external: true
|
||||
```
|
||||
|
||||
更新你的 Speakr `docker-compose.yml` 以使用共享网络:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: learnedmachine/speakr:latest
|
||||
container_name: speakr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8899:8899"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./uploads:/data/uploads
|
||||
- ./instance:/data/instance
|
||||
networks:
|
||||
- speakr-network
|
||||
|
||||
networks:
|
||||
speakr-network:
|
||||
external: true
|
||||
```
|
||||
|
||||
在你的 `.env` 文件中,使用容器名:
|
||||
```bash
|
||||
ASR_BASE_URL=http://whisper-asr-webservice:9000
|
||||
```
|
||||
|
||||
##### 选项 2:不同机器
|
||||
|
||||
当在不同机器上运行时,你不需要共享网络。每个服务独立运行,并使用 IP 地址或主机名通过网络进行通信。
|
||||
|
||||
在 ASR 服务器上,创建 `docker-compose.asr.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
whisper-asr:
|
||||
image: onerahmet/openai-whisper-asr-webservice:latest-gpu
|
||||
container_name: whisper-asr-webservice
|
||||
ports:
|
||||
- "9000:9000" # 暴露到网络
|
||||
environment:
|
||||
- ASR_MODEL=distil-large-v3
|
||||
- ASR_COMPUTE_TYPE=int8
|
||||
- ASR_ENGINE=whisperx
|
||||
- HF_TOKEN=your_huggingface_token_here
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
capabilities: [gpu]
|
||||
device_ids: ["0"]
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
在 Speakr 服务器上,使用标准的 `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: learnedmachine/speakr:latest
|
||||
container_name: speakr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8899:8899"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./uploads:/data/uploads
|
||||
- ./instance:/data/instance
|
||||
```
|
||||
|
||||
在你的 Speakr `.env` 文件中,使用 ASR 服务器的 IP 地址或主机名:
|
||||
```bash
|
||||
# 使用 IP 地址
|
||||
ASR_BASE_URL=http://192.168.1.100:9000
|
||||
|
||||
# 或使用主机名
|
||||
ASR_BASE_URL=http://asr-server.local:9000
|
||||
```
|
||||
|
||||
在各自的机器上启动两个服务:
|
||||
|
||||
```bash
|
||||
# 在 ASR 服务器上
|
||||
docker compose -f docker-compose.asr.yml up -d
|
||||
|
||||
# 在 Speakr 服务器上
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
确保机器之间可以访问端口 9000(如有需要请检查防火墙规则)。
|
||||
|
||||
## 生产环境注意事项
|
||||
|
||||
### 使用反向代理配置 SSL
|
||||
|
||||
对于生产环境部署,将 Speakr 运行在反向代理后面对于安全性和启用所有功能至关重要。浏览器录音功能,特别是系统音频捕获,由于浏览器安全限制需要 HTTPS 才能工作。反向代理处理 SSL 终止,意味着它管理 HTTPS 证书,同时在内部通过 HTTP 与 Speakr 通信。
|
||||
|
||||
以下是 nginx 的完整配置示例:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name speakr.yourdomain.com;
|
||||
|
||||
ssl_certificate /path/to/certificate.crt;
|
||||
ssl_certificate_key /path/to/private.key;
|
||||
|
||||
# 安全头部
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8899;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 实时功能的 WebSocket 支持
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# 实时 ASR WebSocket 代理。如果存在更广泛的 /tool/speakr/ 路由,请将其放在前面。
|
||||
# location /tool/speakr/ws/ {
|
||||
# proxy_pass http://speakr:8899/ws/;
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Upgrade $http_upgrade;
|
||||
# proxy_set_header Connection "upgrade";
|
||||
# proxy_read_timeout 3600s;
|
||||
# }
|
||||
|
||||
# 大文件上传的超时设置
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
}
|
||||
|
||||
# 将 HTTP 重定向到 HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
server_name speakr.yourdomain.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
WebSocket 配置对 Speakr 的实时功能很重要。超时设置确保大文件上传不会被中断。你可以使用 Certbot 从 Let's Encrypt 获取免费的 SSL 证书,让每个人都能使用 HTTPS。
|
||||
|
||||
### 备份策略
|
||||
|
||||
定期备份对于生产环境部署至关重要。你的 Speakr 数据包含三个需要备份的关键组件:`instance` 目录中的 SQLite 数据库、`uploads` 目录中的音频文件和转录内容,以及 `.env` 文件中的配置。
|
||||
|
||||
创建一个备份脚本来捕获所有三个组件:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
BACKUP_DIR="/backup/speakr"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
# 如果备份目录不存在则创建
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# 创建备份
|
||||
tar czf "$BACKUP_DIR/speakr_backup_$DATE.tar.gz" \
|
||||
/opt/speakr/instance \
|
||||
/opt/speakr/uploads \
|
||||
/opt/speakr/.env
|
||||
|
||||
# 可选:仅保留最近 30 天的备份
|
||||
find "$BACKUP_DIR" -name "speakr_backup_*.tar.gz" -mtime +30 -delete
|
||||
|
||||
echo "备份完成:speakr_backup_$DATE.tar.gz"
|
||||
```
|
||||
|
||||
使脚本可执行并使用 cron 安排自动每日备份:
|
||||
|
||||
```bash
|
||||
chmod +x /opt/speakr/backup.sh
|
||||
crontab -e
|
||||
# 添加此行以在每天凌晨 2 点进行备份:
|
||||
0 2 * * * /opt/speakr/backup.sh
|
||||
```
|
||||
|
||||
对于关键部署,考虑将备份复制到远程存储或云服务以获得额外的冗余。压缩后的备份大小通常远小于原始数据,因为音频文件压缩效果很好。
|
||||
|
||||
### 监控和维护
|
||||
|
||||
主动监控有助于在问题影响用户之前预防问题。音频文件会随着时间的推移占用大量存储空间,尤其是如果你经常录制较长的会议。设置磁盘空间监控,当使用率超过 80% 时发出警报。一种简单的监控方法是使用 cron 和 df:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
USAGE=$(df /opt/speakr | tail -1 | awk '{print $5}' | sed 's/%//')
|
||||
if [ $USAGE -gt 80 ]; then
|
||||
echo "警告:Speakr 磁盘使用率已达 ${USAGE}%" | mail -s "Speakr 磁盘警报" admin@example.com
|
||||
fi
|
||||
```
|
||||
|
||||
定期监控 Docker 容器的健康和日志。你可以使用 Docker 内置的健康检查功能或外部监控工具。检查是否存在重复的 API 失败、认证错误或处理超时等模式。同时跟踪你的 API 使用情况和成本,因为大量使用会产生可观的费用。
|
||||
|
||||
### 安全加固
|
||||
|
||||
生产环境部署需要比默认配置更多的安全措施。首先确保所有账户使用强密码,尤其是管理员账户。切勿在生产环境中使用默认或简单密码。
|
||||
|
||||
使用防火墙规则限制网络访问。如果 Speakr 仅在内部使用,请将访问限制为你组织的 IP 范围:
|
||||
|
||||
```bash
|
||||
# 使用 ufw 的示例
|
||||
ufw allow from 192.168.1.0/24 to any port 8899
|
||||
ufw deny 8899
|
||||
```
|
||||
|
||||
在反向代理级别实施速率限制以防止滥用和 API 耗尽。在 nginx 中,你可以添加:
|
||||
|
||||
```nginx
|
||||
limit_req_zone $binary_remote_addr zone=speakr:10m rate=10r/s;
|
||||
limit_req zone=speakr burst=20;
|
||||
```
|
||||
|
||||
保持 Docker 镜像更新以获得最新的安全补丁。定期检查更新并规划维护窗口进行更新。更新前务必先备份,如果可能的话先在暂存环境中测试更新。
|
||||
|
||||
## 更新 Speakr
|
||||
|
||||
保持 Speakr 更新可确保你拥有最新的功能和安全补丁。更新过程很简单,但应谨慎操作以避免数据丢失。
|
||||
|
||||
首先,更新前务必创建备份:
|
||||
|
||||
```bash
|
||||
# 创建备份
|
||||
tar czf speakr_backup_before_update.tar.gz uploads/ instance/ .env
|
||||
|
||||
# 拉取最新镜像
|
||||
docker compose pull
|
||||
|
||||
# 停止当前容器
|
||||
docker compose down
|
||||
|
||||
# 使用新镜像启动
|
||||
docker compose up -d
|
||||
|
||||
# 检查日志以确保成功启动
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
更新过程会保留你所有的数据,因为数据存储在容器外的挂载数据卷中。但是,查看发布说明很重要,因为某些更新可能需要配置更改或有需要注意的破坏性变更。
|
||||
|
||||
如果更新导致问题,你可以通过在 docker-compose.yml 中指定先前版本来回滚:
|
||||
|
||||
```yaml
|
||||
image: learnedmachine/speakr:v1.2.3 # 替换为你之前的版本
|
||||
```
|
||||
|
||||
## 常见问题排查
|
||||
|
||||
### 容器无法启动
|
||||
|
||||
当容器无法启动时,日志通常会准确告诉你问题所在。首先检查日志:
|
||||
|
||||
```bash
|
||||
docker compose logs app
|
||||
```
|
||||
|
||||
常见的启动问题包括缺失或格式不正确的 `.env` 文件。确保你的 `.env` 文件存在且具有正确的语法。每一行应该是 `KEY=value` 格式,等号周围不要有空格。注释以 `#` 开头。
|
||||
|
||||
端口冲突是另一个常见问题。检查端口 8899 是否已被占用:
|
||||
|
||||
```bash
|
||||
netstat -tulpn | grep 8899
|
||||
# 或在 macOS 上:
|
||||
lsof -i :8899
|
||||
```
|
||||
|
||||
如果端口被占用,请停止冲突的服务或更改 docker-compose.yml 中 Speakr 的端口。
|
||||
|
||||
### 转录失败
|
||||
|
||||
转录失败通常源于 API 配置问题。检查 Docker 日志中的具体错误消息:
|
||||
|
||||
```bash
|
||||
docker compose logs app | grep -i error
|
||||
```
|
||||
|
||||
常见的转录问题包括不正确的 API 密钥,这在日志中会显示为认证错误。仔细检查 `.env` 文件中的密钥并确保它们是正确的服务。API 额度不足会显示为配额或支付错误。检查你的 API 提供商账户余额。网络连接问题会显示为连接超时或 DNS 解析失败。
|
||||
|
||||
对于 ASR 端点,验证服务是否正在运行并可访问:
|
||||
|
||||
```bash
|
||||
# 测试 ASR 端点连接
|
||||
curl http://your-asr-service:9000/docs
|
||||
```
|
||||
|
||||
如果使用 Docker 网络和服务名,请记住容器必须在同一网络上才能通信。
|
||||
|
||||
### 性能问题
|
||||
|
||||
性能缓慢可能有多种原因。首先检查系统资源:
|
||||
|
||||
```bash
|
||||
# 检查内存使用情况
|
||||
free -h
|
||||
|
||||
# 检查磁盘 I/O
|
||||
iotop
|
||||
|
||||
# 检查 Docker 资源使用情况
|
||||
docker stats speakr
|
||||
```
|
||||
|
||||
如果内存紧张,考虑添加 swap 空间或升级你的服务器。对于磁盘 I/O 问题,确保 uploads 和 instance 目录使用 SSD 存储。传统硬盘会显著降低操作速度,尤其是多用户并发使用时。
|
||||
|
||||
对于大文件处理,确保正确配置了分段。没有分段的话,大文件可能会超时或完全失败。分段大小应略低于你的 API 限制,以考虑编码开销。
|
||||
|
||||
如果你在许多并发用户情况下看到转录缓慢,你可能触及了 API 速率限制。查看你的 API 提供商文档中的速率限制,如果需要请考虑升级你的计划。
|
||||
|
||||
### 浏览器录音问题
|
||||
|
||||
如果浏览器录音不起作用,尤其是系统音频,最常见的原因是你使用的是 HTTP 而不是 HTTPS。由于隐私问题,浏览器需要安全连接才能进行音频捕获。要么使用反向代理设置 SSL,要么(仅用于本地开发)修改浏览器的安全设置,将你的本地 URL 视为安全。
|
||||
|
||||
在 Chrome 中,导航到 `chrome://flags`,搜索"insecure origins",并将你的 URL 添加到列表中。请记住这会降低安全性,仅应用于开发环境。
|
||||
|
||||
## 从源码构建
|
||||
|
||||
如果你需要修改 Speakr 的代码或偏好自行构建镜像,你可以从源码构建。这需要克隆仓库并使用 Docker 的构建功能。
|
||||
|
||||
首先,克隆仓库并进入目录:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/murtaza-nasir/speakr.git
|
||||
cd speakr
|
||||
```
|
||||
|
||||
修改 docker-compose.yml 以本地构建而不是使用预构建镜像:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
build: . # 从当前目录构建
|
||||
image: speakr:custom # 自定义构建的标签
|
||||
container_name: speakr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8899:8899"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./uploads:/data/uploads
|
||||
- ./instance:/data/instance
|
||||
```
|
||||
|
||||
构建并启动自定义版本:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
`--build` 标志强制 Docker 即使已有镜像也重新构建。当你进行了代码更改并想测试时很有用。
|
||||
|
||||
## 性能优化
|
||||
|
||||
对于高负载部署或处理大量大文件时,优化变得很重要。如果使用 ASR,首先从模型选择开始。distil-large-v3 模型在速度和准确性之间提供了极佳的平衡。对于纯英语内容,使用 `.en` 变体,它们在英语上更快更准确。
|
||||
|
||||
为你的工作负载优化 Docker 资源分配:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: learnedmachine/speakr:latest
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 8G
|
||||
cpus: '4'
|
||||
reservations:
|
||||
memory: 4G
|
||||
cpus: '2'
|
||||
```
|
||||
|
||||
这确保 Speakr 有足够的资源,同时防止它在共享服务器上消耗所有资源。
|
||||
|
||||
对于存储性能,为 Docker 数据卷使用 SSD 驱动器。数据库从快速随机 I/O 中获益良多,大音频文件处理在 SSD 上也会快得多。如果使用网络存储,请确保低延迟连接。
|
||||
|
||||
---
|
||||
|
||||
下一步:[用户指南](../user-guide/index.md) 了解如何使用 Speakr 的所有功能
|
||||
156
speakr/docs/index.md
Normal file
@ -0,0 +1,156 @@
|
||||
# 欢迎使用 Speakr
|
||||
|
||||
Speakr 是一款功能强大的自托管转录平台,帮助您捕获、转录和理解音频内容。无论您是录制会议、采访、讲座还是个人笔记,Speakr 都能将语音转化为有价值的、可搜索的知识。
|
||||
|
||||
<div style="max-width: 80%; margin: 2em auto;">
|
||||
<img src="assets/images/screenshots/Filters.png" alt="Main Interface" style="border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);">
|
||||
</div>
|
||||
|
||||
## 快速导航
|
||||
|
||||
<div class="grid cards">
|
||||
<div class="card">
|
||||
<div class="card-icon">📚</div>
|
||||
<h3>快速开始</h3>
|
||||
<p>初次使用 Speakr?从这里开始快速了解并查看设置指南。</p>
|
||||
<a href="getting-started" class="card-link">开始使用 →</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-icon">🚀</div>
|
||||
<h3>安装指南</h3>
|
||||
<p>Docker 和手动安装的逐步说明。</p>
|
||||
<a href="getting-started/installation" class="card-link">立即安装 →</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-icon">👤</div>
|
||||
<h3>用户指南</h3>
|
||||
<p>学习如何<a href="user-guide/recording">录制</a>、<a href="user-guide/transcripts">转录</a>和管理您的音频内容。</p>
|
||||
<a href="user-guide/" class="card-link">了解更多 →</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-icon">⚙️</div>
|
||||
<h3>管理员指南</h3>
|
||||
<p>配置<a href="admin-guide/user-management">用户</a>、<a href="admin-guide/prompts">系统设置</a>并管理您的实例。</p>
|
||||
<a href="admin-guide/" class="card-link">进行配置 →</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-icon">❓</div>
|
||||
<h3>常见问题</h3>
|
||||
<p>查找关于 Speakr 的常见问题解答。</p>
|
||||
<a href="faq" class="card-link">查看常见问题 →</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-icon">🔧</div>
|
||||
<h3>故障排除</h3>
|
||||
<p>解决<a href="troubleshooting#transcription-problems">转录问题</a>和<a href="troubleshooting#performance-issues">性能问题</a>的方案。</p>
|
||||
<a href="troubleshooting" class="card-link">获取帮助 →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 核心功能
|
||||
|
||||
<div class="feature-grid">
|
||||
<div class="feature-card">
|
||||
<h4>🎙️ 智能录制</h4>
|
||||
<ul>
|
||||
<li>从麦克风或系统捕获音频</li>
|
||||
<li>录制时做笔记</li>
|
||||
<li>生成<a href="features#automatic-summarization">智能摘要</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<h4>🤖 AI 转录</h4>
|
||||
<ul>
|
||||
<li><a href="features#language-support">多语言支持</a></li>
|
||||
<li><a href="features#speaker-diarization">说话人识别</a></li>
|
||||
<li>自定义词汇表</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<h4>🔍 智能搜索</h4>
|
||||
<ul>
|
||||
<li><a href="user-guide/inquire-mode">语义搜索</a></li>
|
||||
<li>自然语言查询</li>
|
||||
<li>跨录音搜索</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<h4>📊 内容组织</h4>
|
||||
<ul>
|
||||
<li><a href="features#tagging-system">智能标签系统</a></li>
|
||||
<li><a href="admin-guide/prompts">自定义 AI 提示词</a></li>
|
||||
<li><a href="features#speaker-management">参与者追踪</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<h4>🌍 国际化</h4>
|
||||
<ul>
|
||||
<li>支持 5+ 种语言</li>
|
||||
<li>自动 UI 翻译</li>
|
||||
<li>本地化摘要</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<h4>🔒 隐私优先</h4>
|
||||
<ul>
|
||||
<li><a href="getting-started/installation">支持自托管</a></li>
|
||||
<li><a href="troubleshooting#offline-deployment">支持离线使用</a></li>
|
||||
<li><a href="user-guide/sharing">安全分享</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
## 最新更新
|
||||
|
||||
!!! info "Version 0.5.6 - 最新版本"
|
||||
- **事件提取** - 自动识别并从录音中导出日历事件
|
||||
- **转录模板** - 使用灵活的模板系统自定义下载格式
|
||||
- **增强的导出选项** - 以多种格式下载转录内容,适应不同使用场景
|
||||
- **改进的用户界面** - 所有页面提供更好的移动端布局和加载遮罩
|
||||
|
||||
上一个版本 (v0.5.5):
|
||||
|
||||
- 完整的国际化与多语言支持
|
||||
- 增强的 128kbps 音频处理以获得更高准确率
|
||||
- 支持日期预设和多标签选择的高级过滤功能
|
||||
|
||||
## 获取帮助
|
||||
|
||||
需要协助?我们随时为您提供帮助:
|
||||
|
||||
<div class="help-grid">
|
||||
<div class="help-card">
|
||||
<h4>📖 文档</h4>
|
||||
<p>您已经在这里!浏览我们的综合指南:</p>
|
||||
<ul>
|
||||
<li><a href="faq">常见问题解答</a></li>
|
||||
<li><a href="troubleshooting">故障排除指南</a></li>
|
||||
<li><a href="user-guide/">用户文档</a></li>
|
||||
<li><a href="admin-guide/">管理员文档</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h4>💬 社区</h4>
|
||||
<p>与其他用户交流并获取支持:</p>
|
||||
<ul>
|
||||
<li><a href="https://github.com/murtaza-nasir/speakr/issues">报告问题</a></li>
|
||||
<li><a href="https://github.com/murtaza-nasir/speakr/discussions">参与讨论</a></li>
|
||||
<li><a href="https://github.com/murtaza-nasir/speakr">在 GitHub 上 Star</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
准备好将您的音频转化为可操作的洞察了吗?[立即开始](getting-started.md) →
|
||||
125
speakr/docs/javascripts/extra.js
Normal file
@ -0,0 +1,125 @@
|
||||
// Custom JavaScript for Speakr documentation
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add smooth scrolling for anchor links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
const href = this.getAttribute('href');
|
||||
if (href === '#') return;
|
||||
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(href);
|
||||
if (target) {
|
||||
target.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
|
||||
// Update URL without jumping
|
||||
history.pushState(null, null, href);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add external link indicators
|
||||
document.querySelectorAll('a[href^="http"]').forEach(link => {
|
||||
if (!link.hostname.includes(window.location.hostname)) {
|
||||
link.setAttribute('target', '_blank');
|
||||
link.setAttribute('rel', 'noopener noreferrer');
|
||||
|
||||
// Add external icon if not already present
|
||||
if (!link.querySelector('.external-icon')) {
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'external-icon';
|
||||
icon.innerHTML = ' ↗';
|
||||
icon.style.fontSize = '0.75em';
|
||||
icon.style.verticalAlign = 'super';
|
||||
link.appendChild(icon);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Enhance search functionality
|
||||
const searchInput = document.querySelector('.md-search__input');
|
||||
if (searchInput) {
|
||||
// Add keyboard shortcut hint
|
||||
searchInput.setAttribute('placeholder', 'Search (Press "/" to focus)');
|
||||
|
||||
// Add "/" keyboard shortcut
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === '/' && !searchInput.matches(':focus')) {
|
||||
e.preventDefault();
|
||||
searchInput.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add responsive tables wrapper
|
||||
document.querySelectorAll('table').forEach(table => {
|
||||
if (!table.parentElement.classList.contains('table-wrapper')) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'table-wrapper';
|
||||
wrapper.style.overflowX = 'auto';
|
||||
table.parentElement.insertBefore(wrapper, table);
|
||||
wrapper.appendChild(table);
|
||||
}
|
||||
});
|
||||
|
||||
// Add image zoom functionality
|
||||
document.querySelectorAll('.md-content img').forEach(img => {
|
||||
img.style.cursor = 'zoom-in';
|
||||
img.addEventListener('click', function() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.top = '0';
|
||||
overlay.style.left = '0';
|
||||
overlay.style.width = '100%';
|
||||
overlay.style.height = '100%';
|
||||
overlay.style.background = 'rgba(0, 0, 0, 0.9)';
|
||||
overlay.style.zIndex = '9999';
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.alignItems = 'center';
|
||||
overlay.style.justifyContent = 'center';
|
||||
overlay.style.cursor = 'zoom-out';
|
||||
|
||||
const zoomedImg = document.createElement('img');
|
||||
zoomedImg.src = img.src;
|
||||
zoomedImg.style.maxWidth = '90%';
|
||||
zoomedImg.style.maxHeight = '90%';
|
||||
zoomedImg.style.objectFit = 'contain';
|
||||
|
||||
overlay.appendChild(zoomedImg);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
overlay.addEventListener('click', function() {
|
||||
document.body.removeChild(overlay);
|
||||
});
|
||||
|
||||
// Close on Escape key
|
||||
document.addEventListener('keydown', function closeOnEscape(e) {
|
||||
if (e.key === 'Escape' && document.body.contains(overlay)) {
|
||||
document.body.removeChild(overlay);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Add loading indicator for slow operations
|
||||
window.addEventListener('load', function() {
|
||||
// Remove any loading indicators
|
||||
const loader = document.querySelector('.page-loader');
|
||||
if (loader) {
|
||||
loader.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Print-friendly styling
|
||||
window.addEventListener('beforeprint', function() {
|
||||
document.body.classList.add('print-mode');
|
||||
});
|
||||
|
||||
window.addEventListener('afterprint', function() {
|
||||
document.body.classList.remove('print-mode');
|
||||
});
|
||||
16
speakr/docs/requirements-docs.txt
Normal file
@ -0,0 +1,16 @@
|
||||
# MkDocs and Material theme
|
||||
mkdocs>=1.5.3
|
||||
mkdocs-material>=9.5.0
|
||||
mkdocs-material-extensions>=1.3
|
||||
|
||||
# MkDocs plugins
|
||||
mkdocs-minify-plugin>=0.7.2
|
||||
mkdocs-git-revision-date-localized-plugin>=1.2.2
|
||||
|
||||
# Markdown extensions
|
||||
pymdown-extensions>=10.5
|
||||
pygments>=2.17.0
|
||||
|
||||
# Additional utilities
|
||||
Pillow>=10.1.0 # For image optimization
|
||||
cairosvg>=2.7.1 # For SVG support
|
||||
156
speakr/docs/rework-notes/backend-improvement-log.md
Normal file
@ -0,0 +1,156 @@
|
||||
# 后端代码改进工作记录
|
||||
|
||||
> 基于 2026-06-23 的代码审查,记录所有发现的问题及处理状态。
|
||||
> 注:问题编号与分析报告一致。
|
||||
|
||||
---
|
||||
|
||||
## 一、暂不处理(鉴权相关,后续配合主系统调整)
|
||||
|
||||
| 编号 | 问题 | 原因 |
|
||||
|------|------|------|
|
||||
| #1 | `auto_login()` 完全绕过认证 | 鉴权在主系统完成,我们是补充子系统 |
|
||||
| #2 | 未认证接口泄露敏感数据 | 同上 |
|
||||
| #4 | CSRF 防护全局失效 | 同上 |
|
||||
|
||||
---
|
||||
|
||||
## 二、已处理
|
||||
|
||||
### #8 recording.py 下载接口重复代码抽取
|
||||
- **状态**: ✅ 已完成
|
||||
- **文件**: `src/services/document_service.py`、`src/api/recording.py`
|
||||
- **修复**: 抽取 `add_unicode_paragraph()` / `create_docx_with_unicode_title()` / `save_and_send_docx()` 到 document_service.py,4 个下载接口共减少约 280 行重复代码
|
||||
|
||||
### #9 recording.py 大文件拆分
|
||||
- **状态**: ✅ 已完成
|
||||
- **文件**: `src/api/recording_download.py`(新建)
|
||||
- **修复**: 下载路由拆分到独立蓝图 `recording_download_bp`,recording.py 从 1610 行降至约 1330 行;在 `api/__init__.py` 统一注册
|
||||
|
||||
### #3 字段名不匹配 Bug:`error_msg` vs `error_message`
|
||||
- **状态**: ✅ 已完成
|
||||
- **文件**: `src/services/transcription_service.py:202, :581`
|
||||
- **问题**: 模型字段是 `error_message`,service 写入 `recording.error_msg`(不存在的属性),错误信息丢失
|
||||
- **修复**: 两处 `error_msg` 改为 `error_message`
|
||||
|
||||
### #10 错误处理不一致且泄露内部异常
|
||||
- **状态**: 🔴 已还原(撤销全局错误处理器和 `api_response.py`)
|
||||
- **文件**: `src/api/` 下所有蓝图文件
|
||||
- **问题**: 51 处 `return jsonify({'error': str(e)}), 500` 把内部异常暴露给客户端
|
||||
- **处理**: 曾创建 `api_response.py` 和全局错误处理器替换,但用户认为全局错误处理器不需要,已还原
|
||||
- **后续**: 等 #11 统一返回和日志方案重新设计后再处理
|
||||
|
||||
### #5 硬编码默认密钥(仅警告)
|
||||
- **状态**: ✅ 已完成(报警告,不阻止启动)
|
||||
- **文件**: `src/config.py:17, :73`
|
||||
- **处理**: `validate_config()` 已有警告逻辑,`get_app_config()` 中保留默认值但启动时打印警告
|
||||
|
||||
### #15 LLM/Voiceprint 客户端无超时和重试
|
||||
- **状态**: ✅ 已完成
|
||||
- **文件**: `src/clients/llm_client.py`、`src/clients/voiceprint_client.py`
|
||||
- **修复**:
|
||||
- llm_client: 添加 `httpx.Timeout(connect=10, read=300, write=60, pool=30)`,`print()` 改为 `logging`
|
||||
- voiceprint_client: 改用持久化 `httpx.Client` 复用连接池,添加指数退避重试(3次),细化异常类型
|
||||
|
||||
### #16 双入口文件端口不一致
|
||||
- **状态**: ✅ 已完成
|
||||
- **文件**: `src/app.py`
|
||||
- **修复**: 端口和 debug 模式从环境变量 `FLASK_PORT` / `FLASK_DEBUG` 读取,默认 5000 / false
|
||||
|
||||
### #20 config.py 循环依赖风险
|
||||
- **状态**: ✅ 已完成
|
||||
- **文件**: `src/config.py`、`src/app.py`、`src/services/register.py`
|
||||
- **修复**: `get_app_config()` 移除对 `src.clients` 的导入;新增 `init_early_services()` 在蓝图注册前初始化 `chunking_service` 和 `EMBEDDINGS_AVAILABLE`
|
||||
|
||||
### #21 admin.py 绕过 config 直接读 os.environ
|
||||
- **状态**: ✅ 已完成
|
||||
- **文件**: `src/api/admin.py:466-470`、`src/config.py`
|
||||
- **修复**: 自动处理配置项(`ENABLE_AUTO_PROCESSING` 等 5 项)移入 config.py;admin.py 从 `current_app.config` 读取
|
||||
|
||||
---
|
||||
|
||||
## 三、标记待做(高优先级)
|
||||
|
||||
### #11 统一错误返回格式
|
||||
- **状态**: 🔴 已还原(撤销全局错误处理器和 `api_response.py`)
|
||||
- **处理**: 曾创建 `api_response.py` 和全局错误处理器,但已还原;后续需重新设计统一返回和日志方案
|
||||
- **优先级**: 高(在 #6/#7/#12 之前)
|
||||
|
||||
### #19 统一日志格式
|
||||
- **状态**: ✅ 已完成
|
||||
- **修复**:
|
||||
- `src/config.py`: `print()` 改为 `logging.warning()`
|
||||
- `src/clients/llm_client.py`: `print()` 改为 `logging.info()` / `logging.error()`
|
||||
- `src/api/draft_api.py`: 约 20 条英文日志全部改为中文
|
||||
- `src/services/llm_service.py`: 约 15 条英文/混杂日志全部改为中文,语义更清晰
|
||||
- `src/services/audio_chunking.py`: 约 40 条英文日志全部改为中文,统计和建议部分语义优化
|
||||
- 所有日志语言统一为中文,语义表达便于调试
|
||||
|
||||
---
|
||||
|
||||
## 四、标记待做(性能优化,有空再处理)
|
||||
|
||||
### #6 N+1 查询优化
|
||||
- **文件**: `src/api/admin.py:81-93, :645-652`、`src/models/recording.py:38-64`
|
||||
- **问题**: admin 接口和 to_dict 列表场景存在 N+1 查询
|
||||
- **处理**: 标记,当前系统规模无性能问题,有空再优化
|
||||
- **优先级**: 中
|
||||
|
||||
### #7 后台线程管理优化
|
||||
- **文件**: `src/api/recording.py` 多处 `threading.Thread`
|
||||
- **问题**: 无线程池、无任务队列、无重试
|
||||
- **处理**: 标记,当前无并发压力,后续引入 ThreadPoolExecutor 或 Celery
|
||||
- **优先级**: 中(与 #6 一致)
|
||||
|
||||
### #12 缺少输入校验
|
||||
- **问题**: POST/PUT 接口缺少 email 格式、密码强度、字段长度等校验
|
||||
- **处理**: 标记,后续引入 marshmallow/pydantic
|
||||
- **优先级**: 中(与 #6/#7 一致)
|
||||
|
||||
### #13 sync_service.py 每轮循环重建 engine(PostgreSQL)
|
||||
- **文件**: `src/services/sync_service.py:78`
|
||||
- **问题**: `create_engine` 在循环内每次重建,连接泄漏
|
||||
- **优先级**: 高(与 #11 一致,数据库返工)
|
||||
|
||||
### #14 数据库迁移仅支持 SQLite(PostgreSQL)
|
||||
- **文件**: `src/services/register.py:130-161`
|
||||
- **问题**: `PRAGMA table_info` 是 SQLite 专有,PostgreSQL 上不执行
|
||||
- **优先级**: 高(与 #11 一致,数据库返工)
|
||||
|
||||
### #17 缺失数据库索引(PostgreSQL)
|
||||
- **文件**: `src/models/recording.py` 等
|
||||
- **问题**: 关键列无 `index=True`,PostgreSQL 不会自动建索引
|
||||
- **优先级**: 高(与 #11 一致,数据库返工)
|
||||
|
||||
### #18 datetime 时区不一致(PostgreSQL)
|
||||
- **文件**: 多模型和 API 混用 `datetime.now` / `datetime.utcnow`
|
||||
- **问题**: 跨时区部署时时间错乱
|
||||
- **优先级**: 高(与 #11 一致,数据库返工)
|
||||
|
||||
---
|
||||
|
||||
## 进度追踪
|
||||
|
||||
| 编号 | 问题 | 状态 | 备注 |
|
||||
|------|------|------|------|
|
||||
| #8 | 下载接口重复代码抽取 | ✅ 已完成 | 优先级最高,已处理 |
|
||||
| #9 | 大文件拆分 | ✅ 已完成 | 优先级最高,已处理 |
|
||||
| #3 | error_msg 字段名修复 | ✅ 已完成 | 可改,已处理 |
|
||||
| #10 | 错误处理统一 | 🔴 已还原 | 全局错误处理器不需要,已撤销 |
|
||||
| #5 | 硬编码密钥警告 | ✅ 已完成 | 仅警告,已处理 |
|
||||
| #15 | 客户端超时和重试 | ✅ 已完成 | 同 #3/#10 优先级 |
|
||||
| #16 | 双入口文件端口 | ✅ 已完成 | 同 #3/#10 优先级 |
|
||||
| #20 | config.py 循环依赖 | ✅ 已完成 | 同 #3/#10 优先级 |
|
||||
| #21 | admin.py 绕过 config | ✅ 已完成 | 同 #3/#10 优先级 |
|
||||
| #11 | 统一错误返回格式 | 🟡 部分完成 | 高优先级,需完善日志 |
|
||||
| #19 | 统一日志格式 | ✅ 已完成 | 高优先级,已处理 |
|
||||
| #13 | sync_service engine 复用 | 📋 待做 | 中优先级,数据库返工 |
|
||||
| #14 | 数据库迁移跨库兼容 | 📋 待做 | 中优先级,数据库返工 |
|
||||
| #17 | 数据库索引 | 📋 待做 | 中优先级,数据库返工 |
|
||||
| #18 | 时区统一 | 📋 待做 | 中优先级,数据库返工 |
|
||||
| #6 | N+1 查询优化 | 📋 待做 | 中优先级,优化 |
|
||||
| #7 | 后台线程管理 | 📋 待做 | 中优先级,优化 |
|
||||
| #12 | 输入校验 | 📋 待做 | 中优先级,优化 |
|
||||
| #1 | 认证绕过 | ⏸️ 搁置 | 鉴权相关,暂不处理 |
|
||||
| #2 | 未认证接口泄露数据 | ⏸️ 搁置 | 鉴权相关,暂不处理 |
|
||||
| #4 | CSRF 防护失效 | ⏸️ 搁置 | 鉴权相关,暂不处理 |
|
||||