chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
# Service with websocket-python
|
||||
|
||||
This is a demo using funasr pipeline with websocket python-api. It supports the offline, online, offline/online-2pass unifying speech recognition.
|
||||
|
||||
## For the Server
|
||||
|
||||
### Install the modelscope and funasr
|
||||
|
||||
```shell
|
||||
pip install -U modelscope funasr
|
||||
# For the users in China, you could install with the command:
|
||||
# pip install -U modelscope funasr -i https://mirror.sjtu.edu.cn/pypi/web/simple
|
||||
git clone https://github.com/alibaba/FunASR.git && cd FunASR
|
||||
```
|
||||
|
||||
### Install the requirements for server
|
||||
|
||||
```shell
|
||||
cd runtime/python/websocket
|
||||
pip install -r requirements_server.txt
|
||||
```
|
||||
|
||||
### Start server
|
||||
|
||||
##### API-reference
|
||||
```shell
|
||||
python funasr_wss_server.py \
|
||||
--port [port id] \
|
||||
--asr_model [asr model_name] \
|
||||
--asr_model_online [asr model_name] \
|
||||
--punc_model [punc model_name] \
|
||||
--ngpu [0 or 1] \
|
||||
--ncpu [1 or 4] \
|
||||
--certfile [path of certfile for ssl] \
|
||||
--keyfile [path of keyfile for ssl]
|
||||
```
|
||||
##### Usage examples
|
||||
```shell
|
||||
python funasr_wss_server.py --port 10095
|
||||
```
|
||||
|
||||
## For the client
|
||||
|
||||
Install the requirements for client
|
||||
```shell
|
||||
git clone https://github.com/alibaba/FunASR.git && cd FunASR
|
||||
cd funasr/runtime/python/websocket
|
||||
pip install -r requirements_client.txt
|
||||
```
|
||||
If you want infer from videos, you should install `ffmpeg`
|
||||
```shell
|
||||
apt-get install -y ffmpeg #ubuntu
|
||||
# yum install -y ffmpeg # centos
|
||||
# brew install ffmpeg # mac
|
||||
# winget install ffmpeg # wins
|
||||
pip3 install websockets ffmpeg-python
|
||||
```
|
||||
|
||||
### Start client
|
||||
#### API-reference
|
||||
```shell
|
||||
python funasr_wss_client.py \
|
||||
--host [ip_address] \
|
||||
--port [port id] \
|
||||
--chunk_size ["5,10,5"=600ms, "8,8,4"=480ms] \
|
||||
--chunk_interval [duration of send chunk_size/chunk_interval] \
|
||||
--words_max_print [max number of words to print] \
|
||||
--audio_in [if set, loadding from wav.scp, else recording from mircrophone] \
|
||||
--output_dir [if set, write the results to output_dir] \
|
||||
--mode [`online` for streaming asr, `offline` for non-streaming, `2pass` for unifying streaming and non-streaming asr] \
|
||||
--thread_num [thread_num for send data]
|
||||
```
|
||||
|
||||
#### Usage examples
|
||||
##### ASR offline client
|
||||
Recording from mircrophone
|
||||
```shell
|
||||
# --chunk_interval, "10": 600/10=60ms, "5"=600/5=120ms, "20": 600/12=30ms
|
||||
python funasr_wss_client.py --host "0.0.0.0" --port 10095 --mode offline
|
||||
```
|
||||
Loadding from wav.scp(kaldi style)
|
||||
```shell
|
||||
# --chunk_interval, "10": 600/10=60ms, "5"=600/5=120ms, "20": 600/12=30ms
|
||||
python funasr_wss_client.py --host "0.0.0.0" --port 10095 --mode offline --audio_in "./data/wav.scp" --output_dir "./results"
|
||||
```
|
||||
|
||||
##### ASR streaming client
|
||||
Recording from mircrophone
|
||||
```shell
|
||||
# --chunk_size, "5,10,5"=600ms, "8,8,4"=480ms
|
||||
python funasr_wss_client.py --host "0.0.0.0" --port 10095 --mode online --chunk_size "5,10,5"
|
||||
```
|
||||
Loadding from wav.scp(kaldi style)
|
||||
```shell
|
||||
# --chunk_size, "5,10,5"=600ms, "8,8,4"=480ms
|
||||
python funasr_wss_client.py --host "0.0.0.0" --port 10095 --mode online --chunk_size "5,10,5" --audio_in "./data/wav.scp" --output_dir "./results"
|
||||
```
|
||||
|
||||
##### ASR offline/online 2pass client
|
||||
Recording from mircrophone
|
||||
```shell
|
||||
# --chunk_size, "5,10,5"=600ms, "8,8,4"=480ms
|
||||
python funasr_wss_client.py --host "0.0.0.0" --port 10095 --mode 2pass --chunk_size "8,8,4"
|
||||
```
|
||||
Loadding from wav.scp(kaldi style)
|
||||
```shell
|
||||
# --chunk_size, "5,10,5"=600ms, "8,8,4"=480ms
|
||||
python funasr_wss_client.py --host "0.0.0.0" --port 10095 --mode 2pass --chunk_size "8,8,4" --audio_in "./data/wav.scp" --output_dir "./results"
|
||||
```
|
||||
|
||||
#### Websocket api
|
||||
```shell
|
||||
# class Funasr_websocket_recognizer example with 3 step
|
||||
# 1.create an recognizer
|
||||
rcg=Funasr_websocket_recognizer(host="127.0.0.1",port="30035",is_ssl=True,mode="2pass")
|
||||
# 2.send pcm data to asr engine and get asr result
|
||||
text=rcg.feed_chunk(data)
|
||||
print("text",text)
|
||||
# 3.get last result, set timeout=3
|
||||
text=rcg.close(timeout=3)
|
||||
print("text",text)
|
||||
```
|
||||
|
||||
## Acknowledge
|
||||
1. This project is maintained by [FunASR community](https://github.com/modelscope/FunASR).
|
||||
2. We acknowledge [zhaoming](https://github.com/zhaomingwork/FunASR/tree/fix_bug_for_python_websocket) for contributing the websocket service.
|
||||
3. We acknowledge [cgisky1980](https://github.com/cgisky1980/FunASR) for contributing the websocket service of offline model.
|
||||
@@ -0,0 +1,278 @@
|
||||
'''
|
||||
功能概述:音频分段摘要
|
||||
步骤:
|
||||
1、传入mp3,wav格式音频文件
|
||||
2、调用funasr生成分段文本内容
|
||||
3、调用大模型生成摘要
|
||||
4、输出结果
|
||||
|
||||
'''
|
||||
from flask import Flask, request, jsonify
|
||||
import re
|
||||
from modelscope.pipelines import pipeline
|
||||
from modelscope.utils.constant import Tasks
|
||||
from werkzeug.utils import secure_filename
|
||||
import torch
|
||||
import os
|
||||
from openai import OpenAI
|
||||
from funasr import AutoModel
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# 配置上传文件夹
|
||||
UPLOAD_FOLDER = 'uploads'
|
||||
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
|
||||
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
|
||||
|
||||
auto_model = AutoModel(model="damo/speech_paraformer-large-vad-punc_asr_nat-zh-cn-16k-common-vocab8404-pytorch",
|
||||
vad_model="damo/speech_fsmn_vad_zh-cn-16k-common-pytorch",
|
||||
punc_model="damo/punc_ct-transformer_zh-cn-common-vocab272727-pytorch",
|
||||
spk_model="damo/speech_campplus_sv_zh-cn_16k-common",
|
||||
device="cuda:0" if torch.cuda.is_available() else "cpu"
|
||||
)
|
||||
# 声纹对比
|
||||
sv_pipeline = pipeline(
|
||||
task='speaker-verification',
|
||||
model='damo/speech_campplus_sv_zh-cn_16k-common',
|
||||
model_revision='v1.0.0',
|
||||
device="cuda:0" if torch.cuda.is_available() else "cpu"
|
||||
)
|
||||
|
||||
|
||||
|
||||
# 注册声纹:传音频wav文件,实现注册音频返回音频的embedding数据
|
||||
@app.route('/Register_Speaker', methods=['POST'])
|
||||
def Register_Speaker():
|
||||
# 检查文件上传
|
||||
if 'file' not in request.files:
|
||||
return jsonify({"error": "No audio file provided"}), 400
|
||||
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
return jsonify({"error": "Empty filename"}), 400
|
||||
|
||||
# 保存上传文件
|
||||
filename = secure_filename(file.filename)
|
||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||
file.save(filepath)
|
||||
try:
|
||||
# 执行音频文件Embeding
|
||||
result = sv_pipeline([filepath], output_emb=True)
|
||||
embedding = result['embs'][0]
|
||||
# 删除临时文件
|
||||
os.remove(filepath)
|
||||
if len(embedding) == 0:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"result": "音频解析结果为空"
|
||||
})
|
||||
else:
|
||||
if isinstance(embedding, np.ndarray):
|
||||
embedding_list = embedding.tolist()
|
||||
else:
|
||||
embedding_list = list(embedding)
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"result": embedding_list
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
# 清理文件
|
||||
print(f"错误类型: {type(e).__name__}")
|
||||
print(f"错误信息: {str(e)}")
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
# 会议撰写
|
||||
@app.route('/AsrCamWithIdentify', methods=['POST'])
|
||||
def speech_recognition_Timestamp_cam_identify_speakers():
|
||||
# 检查文件上传
|
||||
if 'file' not in request.files:
|
||||
return jsonify({"error": "No audio file provided"}), 400
|
||||
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
return jsonify({"error": "Empty filename"}), 400
|
||||
|
||||
# 保存上传文件
|
||||
filename = secure_filename(file.filename)
|
||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||
file.save(filepath)
|
||||
# 2. 从FormData读取参数并解析JSON
|
||||
# 读取identify_speakers(默认False)
|
||||
identify_speakers_str = request.form.get('identify_speakers', 'false')
|
||||
identify_speakers = json.loads(identify_speakers_str.lower()) # 转为bool
|
||||
|
||||
# 读取speaker_db(默认空字典)
|
||||
speaker_db_str = request.form.get('speaker_db', '{}')
|
||||
speaker_db = json.loads(speaker_db_str)
|
||||
|
||||
# 3. 验证声纹库(如果需要对比)
|
||||
if identify_speakers:
|
||||
if not isinstance(speaker_db, dict) or len(speaker_db) == 0:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"result": "声纹库为空或格式错误,无法进行对比"
|
||||
}), 400
|
||||
try:
|
||||
# 执行语音识别
|
||||
result = auto_model.generate(input=filepath,
|
||||
batch_size_s=300,
|
||||
hotword='',
|
||||
)
|
||||
|
||||
# 处理结果
|
||||
processed_result = process_cam_result_with_identify_speakers(result,speaker_db,filepath,identify_speakers)
|
||||
|
||||
os.remove(filepath)
|
||||
if len(processed_result) == 0:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"result": "音频解析结果为空"
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"result": processed_result
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
# 清理文件
|
||||
print(f"错误类型: {type(e).__name__}")
|
||||
print(f"错误信息: {str(e)}")
|
||||
if os.path.exists(filepath):
|
||||
os.remove(filepath)
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
import torchaudio
|
||||
def _extract_audio_segment(audio_path, start_sec, end_sec):
|
||||
"""根据时间戳提取音频片段"""
|
||||
waveform, sample_rate = torchaudio.load(audio_path)
|
||||
|
||||
# 计算起止采样点
|
||||
start_sample = int(start_sec * sample_rate)
|
||||
end_sample = int(end_sec * sample_rate)
|
||||
|
||||
# 提取片段
|
||||
segment = waveform[:, start_sample:end_sample]
|
||||
|
||||
# 保存为临时文件(pipeline需要文件路径)
|
||||
temp_path = f"/tmp/temp_segment_{start_sec}_{end_sec}.wav"
|
||||
torchaudio.save(temp_path, segment, sample_rate)
|
||||
|
||||
return temp_path
|
||||
# 是否使用声纹转化的结果处理
|
||||
import json
|
||||
def process_cam_result_with_identify_speakers(result,speaker_db,filepath,identify_speakers=False,threshold=0.45):
|
||||
"""处理ASR结果,返回包含时间和内容的JSON对象列表"""
|
||||
if not isinstance(result, list) or len(result) == 0:
|
||||
return []
|
||||
|
||||
data = result[0]
|
||||
best_match = "unknown"
|
||||
best_score = 0.0
|
||||
# 创建JSON格式的输出
|
||||
output = []
|
||||
sentence_infos = data.get('sentence_info',[])
|
||||
|
||||
current_sentence = {
|
||||
"spk": sentence_infos[0]["spk"],
|
||||
"spk_name": best_match,
|
||||
"confidence":best_score,
|
||||
"start": sentence_infos[0]["start"],
|
||||
"end": sentence_infos[0]["end"],
|
||||
"text": sentence_infos[0]["text"]
|
||||
}
|
||||
|
||||
|
||||
for i in range(1, len(sentence_infos)):
|
||||
sentence_info = sentence_infos[i]
|
||||
# 检查合并条件:相同说话人且时间间隔≤1000ms
|
||||
if (current_sentence["spk"] == sentence_info["spk"] and
|
||||
sentence_info["start"] - current_sentence["end"] <= 1000):
|
||||
|
||||
# 合并文本内容(中文无需加空格)
|
||||
current_sentence["text"] += sentence_info["text"]
|
||||
|
||||
# 更新整句结束时间
|
||||
current_sentence["end"] = sentence_info["end"]
|
||||
|
||||
else:
|
||||
# 保存合并完成的句子
|
||||
if identify_speakers: # 提取说话人
|
||||
segment_audio = _extract_audio_segment( #提取对应的音频
|
||||
filepath, current_sentence['start']/1000, current_sentence['end']/1000
|
||||
)
|
||||
result_b = sv_pipeline([segment_audio], output_emb=True)['embs'][0] #获取音频向量
|
||||
os.remove(segment_audio)
|
||||
# 遍历声纹库
|
||||
|
||||
for name, db_emb in speaker_db.items():
|
||||
# 计算余弦相似度
|
||||
data_list = json.loads(db_emb)
|
||||
arr = np.array(data_list, dtype=np.float32)
|
||||
similarity = 1 - cosine(result_b, arr)
|
||||
similarity = float(similarity)
|
||||
if similarity > best_score and similarity > threshold:
|
||||
best_score = similarity
|
||||
best_match = name
|
||||
output.append({
|
||||
"spk": current_sentence["spk"],
|
||||
"spk_name": best_match,
|
||||
"confidence":best_score,
|
||||
"text": current_sentence["text"],
|
||||
"start": current_sentence["start"],
|
||||
"end": current_sentence["end"]
|
||||
})
|
||||
# 重新开始新句子
|
||||
current_sentence = sentence_info.copy()
|
||||
best_match = "unknown"
|
||||
best_score = 0.0
|
||||
|
||||
# 保存合并完成的句子
|
||||
if identify_speakers: # 提取说话人
|
||||
segment_audio = _extract_audio_segment( # 提取对应的音频
|
||||
filepath, current_sentence['start']/1000, current_sentence['end']/1000
|
||||
)
|
||||
result_b = sv_pipeline([segment_audio], output_emb=True)['embs'][0] # 获取音频向量
|
||||
os.remove(segment_audio)
|
||||
# 遍历声纹库
|
||||
|
||||
for name, db_emb in speaker_db.items():
|
||||
# 计算余弦相似度
|
||||
data_list = json.loads(db_emb)
|
||||
arr = np.array(data_list, dtype=np.float32)
|
||||
similarity = 1 - cosine(result_b, arr)
|
||||
similarity = float(similarity)
|
||||
if similarity > best_score and similarity > threshold:
|
||||
best_score = similarity
|
||||
best_match = name
|
||||
output.append({
|
||||
"spk": current_sentence["spk"],
|
||||
"spk_name": best_match,
|
||||
"confidence":best_score,
|
||||
"text": current_sentence["text"],
|
||||
"start": current_sentence["start"],
|
||||
"end": current_sentence["end"]
|
||||
})
|
||||
|
||||
return output # 返回JSON对象列表
|
||||
|
||||
|
||||
from scipy.spatial.distance import cosine
|
||||
import numpy as np
|
||||
@app.route('/calculate_similarity', methods=['POST'])
|
||||
def calculate_similarity():
|
||||
"""计算两个特征向量的余弦相似度"""
|
||||
data = request.get_json(force=True)
|
||||
emb1 = np.array(data['emb1'], dtype=np.float32)
|
||||
emb2 = np.array(data['emb2'], dtype=np.float32)
|
||||
print(1-cosine(emb1,emb2))
|
||||
return jsonify({
|
||||
"status": "success",
|
||||
"result": float(1 - cosine(emb1, emb2))
|
||||
})
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=10099, debug=True)
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Copyright FunASR (https://github.com/alibaba-damo-academy/FunASR). All Rights
|
||||
Reserved. MIT License (https://opensource.org/licenses/MIT)
|
||||
|
||||
2022-2023 by zhaomingwork@qq.com
|
||||
"""
|
||||
|
||||
# pip install websocket-client
|
||||
import ssl
|
||||
from websocket import ABNF
|
||||
from websocket import create_connection
|
||||
from queue import Queue
|
||||
import threading
|
||||
import traceback
|
||||
import json
|
||||
import time
|
||||
import numpy as np
|
||||
|
||||
|
||||
# class for recognizer in websocket
|
||||
class Funasr_websocket_recognizer:
|
||||
"""
|
||||
python asr recognizer lib
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host="127.0.0.1",
|
||||
port="30035",
|
||||
is_ssl=True,
|
||||
chunk_size="0, 10, 5",
|
||||
chunk_interval=10,
|
||||
mode="offline",
|
||||
wav_name="default",
|
||||
):
|
||||
"""
|
||||
host: server host ip
|
||||
port: server port
|
||||
is_ssl: True for wss protocal, False for ws
|
||||
"""
|
||||
try:
|
||||
if is_ssl == True:
|
||||
ssl_context = ssl.SSLContext()
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
uri = "wss://{}:{}".format(host, port)
|
||||
ssl_opt = {"cert_reqs": ssl.CERT_NONE}
|
||||
else:
|
||||
uri = "ws://{}:{}".format(host, port)
|
||||
ssl_context = None
|
||||
ssl_opt = None
|
||||
self.host = host
|
||||
self.port = port
|
||||
|
||||
self.msg_queue = Queue() # used for recognized result text
|
||||
|
||||
print("connect to url", uri)
|
||||
self.websocket = create_connection(uri, ssl=ssl_context, sslopt=ssl_opt)
|
||||
|
||||
self.thread_msg = threading.Thread(
|
||||
target=Funasr_websocket_recognizer.thread_rec_msg, args=(self,)
|
||||
)
|
||||
self.thread_msg.start()
|
||||
chunk_size = [int(x) for x in chunk_size.split(",")]
|
||||
stride = int(60 * chunk_size[1] / chunk_interval / 1000 * 16000 * 2)
|
||||
chunk_num = (len(audio_bytes) - 1) // stride + 1
|
||||
|
||||
message = json.dumps(
|
||||
{
|
||||
"mode": mode,
|
||||
"chunk_size": chunk_size,
|
||||
"encoder_chunk_look_back": 4,
|
||||
"decoder_chunk_look_back": 1,
|
||||
"chunk_interval": chunk_interval,
|
||||
"wav_name": wav_name,
|
||||
"is_speaking": True,
|
||||
}
|
||||
)
|
||||
|
||||
self.websocket.send(message)
|
||||
|
||||
print("send json", message)
|
||||
|
||||
except Exception as e:
|
||||
print("Exception:", e)
|
||||
traceback.print_exc()
|
||||
|
||||
# threads for rev msg
|
||||
def thread_rec_msg(self):
|
||||
try:
|
||||
while True:
|
||||
msg = self.websocket.recv()
|
||||
if msg is None or len(msg) == 0:
|
||||
continue
|
||||
msg = json.loads(msg)
|
||||
|
||||
self.msg_queue.put(msg)
|
||||
except Exception as e:
|
||||
print("client closed")
|
||||
|
||||
# feed data to asr engine, wait_time means waiting for result until time out
|
||||
def feed_chunk(self, chunk, wait_time=0.01):
|
||||
try:
|
||||
self.websocket.send(chunk, ABNF.OPCODE_BINARY)
|
||||
# loop to check if there is a message, timeout in 0.01s
|
||||
while True:
|
||||
msg = self.msg_queue.get(timeout=wait_time)
|
||||
if self.msg_queue.empty():
|
||||
break
|
||||
|
||||
return msg
|
||||
except:
|
||||
return ""
|
||||
|
||||
def close(self, timeout=1):
|
||||
message = json.dumps({"is_speaking": False})
|
||||
self.websocket.send(message)
|
||||
# sleep for timeout seconds to wait for result
|
||||
time.sleep(timeout)
|
||||
msg = ""
|
||||
while not self.msg_queue.empty():
|
||||
msg = self.msg_queue.get()
|
||||
|
||||
self.websocket.close()
|
||||
# only resturn the last msg
|
||||
return msg
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
print("example for Funasr_websocket_recognizer")
|
||||
import wave
|
||||
|
||||
wav_path = "/Users/zhifu/Downloads/modelscope_models/speech_seaco_paraformer_large_asr_nat-zh-cn-16k-common-vocab8404-pytorch/example/asr_example.wav"
|
||||
with wave.open(wav_path, "rb") as wav_file:
|
||||
params = wav_file.getparams()
|
||||
frames = wav_file.readframes(wav_file.getnframes())
|
||||
audio_bytes = bytes(frames)
|
||||
|
||||
stride = int(60 * 10 / 10 / 1000 * 16000 * 2)
|
||||
chunk_num = (len(audio_bytes) - 1) // stride + 1
|
||||
# create an recognizer
|
||||
rcg = Funasr_websocket_recognizer(
|
||||
host="127.0.0.1", port="10095", is_ssl=True, mode="2pass", chunk_size="0,10,5"
|
||||
)
|
||||
# loop to send chunk
|
||||
for i in range(chunk_num):
|
||||
|
||||
beg = i * stride
|
||||
data = audio_bytes[beg : beg + stride]
|
||||
|
||||
text = rcg.feed_chunk(data, wait_time=0.02)
|
||||
if len(text) > 0:
|
||||
print("text", text)
|
||||
time.sleep(0.05)
|
||||
|
||||
# get last message
|
||||
text = rcg.close(timeout=3)
|
||||
print("text", text)
|
||||
@@ -0,0 +1,557 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
import os
|
||||
import time
|
||||
import websockets, ssl
|
||||
import asyncio
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import traceback
|
||||
from multiprocessing import Process
|
||||
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.ERROR)
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--host", type=str, default="localhost", required=False, help="host ip, localhost, 0.0.0.0"
|
||||
)
|
||||
parser.add_argument("--port", type=int, default=10095, required=False, help="grpc server port")
|
||||
parser.add_argument("--chunk_size", type=str, default="5, 10, 5", help="chunk")
|
||||
parser.add_argument("--encoder_chunk_look_back", type=int, default=4, help="chunk")
|
||||
parser.add_argument("--decoder_chunk_look_back", type=int, default=0, help="chunk")
|
||||
parser.add_argument("--chunk_interval", type=int, default=10, help="chunk")
|
||||
parser.add_argument(
|
||||
"--hotword",
|
||||
type=str,
|
||||
default="",
|
||||
help="hotword file path, one hotword perline (e.g.:阿里巴巴 20)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--audio_in",
|
||||
type=str,
|
||||
default=None,
|
||||
help="音频输入路径;不传则使用麦克风(需安装 PyAudio)",
|
||||
)
|
||||
parser.add_argument("--audio_fs", type=int, default=16000, help="audio_fs")
|
||||
|
||||
# ✅ 修复语义:默认 False;传入参数则不 sleep(用于压测)
|
||||
parser.add_argument(
|
||||
"--send_without_sleep",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="若设置:发送音频不按实时节奏 sleep(用于压测)",
|
||||
)
|
||||
|
||||
parser.add_argument("--thread_num", type=int, default=1, help="thread_num")
|
||||
parser.add_argument("--words_max_print", type=int, default=10000, help="chunk")
|
||||
parser.add_argument("--output_dir", type=str, default=None, help="output_dir")
|
||||
parser.add_argument("--ssl", type=int, default=1, help="1 for ssl connect, 0 for no ssl")
|
||||
parser.add_argument("--use_itn", type=int, default=1, help="1 for using itn, 0 for not itn")
|
||||
parser.add_argument("--mode", type=str, default="2pass", help="offline, online, 2pass")
|
||||
|
||||
# ✅ 验收日志输出目录(每个 meeting 单独写,避免多进程抢文件)
|
||||
parser.add_argument("--log_dir", type=str, default="./asr_logs", help="验收日志输出目录")
|
||||
parser.add_argument("--log_flush_every", type=int, default=1, help="events.jsonl 每写N行flush一次(默认1更安全)")
|
||||
|
||||
args = parser.parse_args()
|
||||
args.chunk_size = [int(x) for x in args.chunk_size.split(",")]
|
||||
print(args)
|
||||
|
||||
from queue import Queue
|
||||
from datetime import datetime
|
||||
|
||||
voices = Queue()
|
||||
offline_msg_done = False
|
||||
|
||||
# === 延迟统计相关:对每个 wav_name 记录首包/末包发送时间 & 是否已经打印过延迟 ===
|
||||
latency_first_audio_time = {} # {wav_name: t_first_chunk_send}
|
||||
latency_last_audio_time = {} # {wav_name: t_last_chunk_send}
|
||||
latency_first_text_printed = {} # {wav_name: bool}
|
||||
|
||||
|
||||
def _iso(ts: float) -> str:
|
||||
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
||||
|
||||
|
||||
class MeetingWriter:
|
||||
"""
|
||||
每个进程/meeting 单独写:
|
||||
- events.jsonl:收到的每条服务端消息(在线/离线/2pass)
|
||||
- meta.json:本次运行参数(方便复现)
|
||||
"""
|
||||
def __init__(self, log_dir: str, meeting_id: str, flush_every: int = 1):
|
||||
self.meeting_id = str(meeting_id)
|
||||
self.base = os.path.join(log_dir, f"meeting_{self.meeting_id}")
|
||||
os.makedirs(self.base, exist_ok=True)
|
||||
|
||||
self.fp_events = open(os.path.join(self.base, "events.jsonl"), "a", encoding="utf-8")
|
||||
self.flush_every = max(1, int(flush_every))
|
||||
self._cnt = 0
|
||||
|
||||
meta_path = os.path.join(self.base, "meta.json")
|
||||
if not os.path.exists(meta_path):
|
||||
with open(meta_path, "w", encoding="utf-8") as f:
|
||||
meta = {
|
||||
"created_at": _iso(time.time()),
|
||||
"meeting_id": self.meeting_id,
|
||||
"args": vars(args),
|
||||
}
|
||||
f.write(json.dumps(meta, ensure_ascii=False, indent=2))
|
||||
|
||||
def write_event(self, obj: dict):
|
||||
self.fp_events.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
||||
self._cnt += 1
|
||||
if self._cnt % self.flush_every == 0:
|
||||
self.fp_events.flush()
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.fp_events.flush()
|
||||
self.fp_events.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if args.output_dir is not None:
|
||||
if not os.path.exists(args.output_dir):
|
||||
os.makedirs(args.output_dir)
|
||||
|
||||
|
||||
async def record_microphone():
|
||||
"""从麦克风实时录音发送到服务端(一般单路测试使用)"""
|
||||
try:
|
||||
import pyaudio
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"缺少 PyAudio,麦克风推流前请先运行 `pip install pyaudio`"
|
||||
) from e
|
||||
|
||||
global voices
|
||||
FORMAT = pyaudio.paInt16
|
||||
CHANNELS = 1
|
||||
RATE = 16000
|
||||
chunk_size = 60 * args.chunk_size[1] / args.chunk_interval
|
||||
CHUNK = int(RATE / 1000 * chunk_size)
|
||||
|
||||
p = pyaudio.PyAudio()
|
||||
|
||||
stream = p.open(
|
||||
format=FORMAT, channels=CHANNELS, rate=RATE, input=True, frames_per_buffer=CHUNK
|
||||
)
|
||||
# hotwords
|
||||
fst_dict = {}
|
||||
hotword_msg = ""
|
||||
if args.hotword.strip() != "":
|
||||
if os.path.exists(args.hotword):
|
||||
f_scp = open(args.hotword, encoding="utf-8")
|
||||
hot_lines = f_scp.readlines()
|
||||
for line in hot_lines:
|
||||
words = line.strip().split(" ")
|
||||
if len(words) < 2:
|
||||
print("Please checkout format of hotwords")
|
||||
continue
|
||||
try:
|
||||
fst_dict[" ".join(words[:-1])] = int(words[-1])
|
||||
except ValueError:
|
||||
print("Please checkout format of hotwords")
|
||||
hotword_msg = json.dumps(fst_dict, ensure_ascii=False)
|
||||
else:
|
||||
hotword_msg = args.hotword
|
||||
|
||||
use_itn = True
|
||||
if args.use_itn == 0:
|
||||
use_itn = False
|
||||
|
||||
message = json.dumps(
|
||||
{
|
||||
"mode": args.mode,
|
||||
"chunk_size": args.chunk_size,
|
||||
"chunk_interval": args.chunk_interval,
|
||||
"encoder_chunk_look_back": args.encoder_chunk_look_back,
|
||||
"decoder_chunk_look_back": args.decoder_chunk_look_back,
|
||||
"wav_name": "microphone",
|
||||
"is_speaking": True,
|
||||
"hotwords": hotword_msg,
|
||||
"itn": use_itn,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
await websocket.send(message)
|
||||
while True:
|
||||
data = stream.read(CHUNK)
|
||||
await websocket.send(data)
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
|
||||
async def record_from_scp(chunk_begin, chunk_size):
|
||||
"""从 wav/scp 文件读取音频分片发送,用于压测和延迟测试"""
|
||||
global voices, latency_first_audio_time, latency_last_audio_time
|
||||
if args.audio_in.endswith(".scp"):
|
||||
f_scp = open(args.audio_in)
|
||||
wavs = f_scp.readlines()
|
||||
else:
|
||||
wavs = [args.audio_in]
|
||||
|
||||
# hotwords
|
||||
hotword_msg = ""
|
||||
if args.hotword.strip() != "":
|
||||
if os.path.exists(args.hotword):
|
||||
with open(args.hotword, encoding="utf-8") as f_scp:
|
||||
hot_lines = f_scp.readlines()
|
||||
|
||||
hot_list = []
|
||||
for line in hot_lines:
|
||||
words = line.strip().split()
|
||||
if not words:
|
||||
continue
|
||||
# Python AutoModel: 用逗号分隔多个热词
|
||||
hot_list.append(words[0])
|
||||
|
||||
hotword_msg = ",".join(hot_list)
|
||||
else:
|
||||
hotword_msg = args.hotword
|
||||
|
||||
print("hotword", hotword_msg)
|
||||
|
||||
sample_rate = args.audio_fs
|
||||
wav_format = "pcm"
|
||||
use_itn = True
|
||||
if args.use_itn == 0:
|
||||
use_itn = False
|
||||
|
||||
if chunk_size > 0:
|
||||
wavs = wavs[chunk_begin: chunk_begin + chunk_size]
|
||||
|
||||
for wav in wavs:
|
||||
wav_splits = wav.strip().split()
|
||||
|
||||
wav_name = wav_splits[0] if len(wav_splits) > 1 else "demo"
|
||||
wav_path = wav_splits[1] if len(wav_splits) > 1 else wav_splits[0]
|
||||
if not len(wav_path.strip()) > 0:
|
||||
continue
|
||||
|
||||
if wav_path.endswith(".pcm"):
|
||||
with open(wav_path, "rb") as f:
|
||||
audio_bytes = f.read()
|
||||
elif wav_path.endswith(".wav"):
|
||||
import wave
|
||||
with wave.open(wav_path, "rb") as wav_file:
|
||||
sample_rate = wav_file.getframerate()
|
||||
frames = wav_file.readframes(wav_file.getnframes())
|
||||
audio_bytes = bytes(frames)
|
||||
else:
|
||||
wav_format = "others"
|
||||
with open(wav_path, "rb") as f:
|
||||
audio_bytes = f.read()
|
||||
|
||||
stride = int(60 * args.chunk_size[1] / args.chunk_interval / 1000 * sample_rate * 2)
|
||||
chunk_num = (len(audio_bytes) - 1) // stride + 1
|
||||
|
||||
# send first control message
|
||||
message = json.dumps(
|
||||
{
|
||||
"mode": args.mode,
|
||||
"chunk_size": args.chunk_size,
|
||||
"chunk_interval": args.chunk_interval,
|
||||
"encoder_chunk_look_back": args.encoder_chunk_look_back,
|
||||
"decoder_chunk_look_back": args.decoder_chunk_look_back,
|
||||
"audio_fs": sample_rate,
|
||||
"wav_name": wav_name,
|
||||
"wav_format": wav_format,
|
||||
"is_speaking": True,
|
||||
"hotwords": hotword_msg,
|
||||
"itn": use_itn,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
await websocket.send(message)
|
||||
is_speaking = True
|
||||
|
||||
# 初始化该 wav 的统计状态
|
||||
latency_first_audio_time[wav_name] = None
|
||||
latency_last_audio_time[wav_name] = None
|
||||
latency_first_text_printed[wav_name] = False
|
||||
|
||||
for i in range(chunk_num):
|
||||
beg = i * stride
|
||||
data = audio_bytes[beg: beg + stride]
|
||||
|
||||
now_ts = time.time()
|
||||
if latency_first_audio_time[wav_name] is None:
|
||||
latency_first_audio_time[wav_name] = now_ts
|
||||
latency_last_audio_time[wav_name] = now_ts
|
||||
|
||||
await websocket.send(data)
|
||||
|
||||
if i == chunk_num - 1:
|
||||
is_speaking = False
|
||||
await websocket.send(json.dumps({"is_speaking": is_speaking}, ensure_ascii=False))
|
||||
|
||||
# ✅ sleep策略:默认按实时节奏;若开启 send_without_sleep 则几乎不 sleep(压测)
|
||||
if args.send_without_sleep:
|
||||
sleep_duration = 0.001
|
||||
else:
|
||||
sleep_duration = (
|
||||
0.001
|
||||
if args.mode == "offline"
|
||||
else 60 * args.chunk_size[1] / args.chunk_interval / 1000
|
||||
)
|
||||
await asyncio.sleep(sleep_duration)
|
||||
|
||||
if not args.mode == "offline":
|
||||
await asyncio.sleep(2)
|
||||
|
||||
if args.mode == "offline":
|
||||
global offline_msg_done
|
||||
while not offline_msg_done:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await asyncio.sleep(10)
|
||||
|
||||
await websocket.close()
|
||||
|
||||
|
||||
async def message(id, writer: MeetingWriter):
|
||||
"""接收服务端识别结果 + 打印实时文本 + 打印延迟 + 写验收日志(events.jsonl)"""
|
||||
import websockets
|
||||
global websocket, voices, offline_msg_done
|
||||
global latency_first_audio_time, latency_last_audio_time, latency_first_text_printed
|
||||
|
||||
multi_mode = args.thread_num > 1 # 多路并发时,打印风格更简洁
|
||||
text_print = ""
|
||||
text_print_2pass_online = ""
|
||||
text_print_2pass_offline = ""
|
||||
|
||||
if args.output_dir is not None:
|
||||
ibest_writer = open(
|
||||
os.path.join(args.output_dir, "text.{}".format(id)), "a", encoding="utf-8"
|
||||
)
|
||||
else:
|
||||
ibest_writer = None
|
||||
|
||||
try:
|
||||
while True:
|
||||
meg = await websocket.recv()
|
||||
meg = json.loads(meg)
|
||||
|
||||
wav_name = meg.get("wav_name", "demo")
|
||||
text = meg.get("text", "")
|
||||
mode = meg.get("mode", "")
|
||||
spk_name = meg.get("spk_name", "")
|
||||
spk_score = meg.get("spk_score", None)
|
||||
now_ts = time.time()
|
||||
|
||||
# === 延迟统计:仅在首条 online/2pass-online 文本时计算并打印一次 ===
|
||||
latency_last_ms = None
|
||||
latency_first_ms = None
|
||||
if text and mode in ("online", "2pass-online"):
|
||||
if not latency_first_text_printed.get(wav_name, False):
|
||||
t_last = latency_last_audio_time.get(wav_name, None)
|
||||
t_first = latency_first_audio_time.get(wav_name, None)
|
||||
latency_last_ms = (now_ts - t_last) * 1000.0 if t_last is not None else None
|
||||
latency_first_ms = (now_ts - t_first) * 1000.0 if t_first is not None else None
|
||||
|
||||
latency_first_text_printed[wav_name] = True
|
||||
|
||||
if multi_mode:
|
||||
parts = [f"[MEETING {id}][LATENCY] wav={wav_name}, mode={mode}"]
|
||||
if latency_last_ms is not None:
|
||||
parts.append(f"from_last_chunk={latency_last_ms:.1f} ms")
|
||||
if latency_first_ms is not None:
|
||||
parts.append(f"from_first_chunk={latency_first_ms:.1f} ms")
|
||||
print(" ".join(parts))
|
||||
else:
|
||||
print(
|
||||
f"[LATENCY] wav={wav_name}, mode={mode}, "
|
||||
f"from_last_chunk={(latency_last_ms or 0):.1f} ms, "
|
||||
f"from_first_chunk={(latency_first_ms or 0):.1f} ms"
|
||||
)
|
||||
|
||||
timestamp = meg.get("timestamp", "")
|
||||
offline_msg_done = meg.get("is_final", False)
|
||||
|
||||
# ✅ 验收友好:每条消息落 events.jsonl(便于后处理)
|
||||
event = {
|
||||
"ts": _iso(now_ts),
|
||||
"recv_ts": now_ts,
|
||||
"meeting_id": str(id),
|
||||
"wav_name": wav_name,
|
||||
"mode": mode,
|
||||
"is_final": bool(meg.get("is_final", False)),
|
||||
"text": text,
|
||||
"spk_name": spk_name,
|
||||
"spk_score": spk_score,
|
||||
"latency_first_ms": latency_first_ms,
|
||||
"latency_last_ms": latency_last_ms,
|
||||
"server_timestamp": meg.get("timestamp", None),
|
||||
"sentence_info": meg.get("sentence_info", None),
|
||||
"punc_array": meg.get("punc_array", None),
|
||||
}
|
||||
if writer is not None:
|
||||
writer.write_event(event)
|
||||
|
||||
# 保存到 output_dir(保留你原来的逻辑)
|
||||
if ibest_writer is not None and text:
|
||||
if timestamp != "":
|
||||
text_write_line = "{}\t{}\t{}\n".format(wav_name, text, timestamp)
|
||||
else:
|
||||
text_write_line = "{}\t{}\n".format(wav_name, text)
|
||||
ibest_writer.write(text_write_line)
|
||||
|
||||
if "mode" not in meg:
|
||||
continue
|
||||
|
||||
# ===== 多路并发输出风格:只打印精简行 =====
|
||||
if multi_mode:
|
||||
if mode in ("offline", "2pass-offline") and text:
|
||||
spk_name2 = meg.get("spk_name", "unknown")
|
||||
spk_score2 = meg.get("spk_score", 0.0)
|
||||
print(
|
||||
f"[MEETING {id}][FINAL][{wav_name}] "
|
||||
f"spk={spk_name2}({float(spk_score2):.3f}) text=\"{text}\""
|
||||
)
|
||||
if timestamp:
|
||||
print(f"[MEETING {id}][TIMESTAMP][{wav_name}] {timestamp}")
|
||||
continue
|
||||
|
||||
# ===== 单路模式输出:保留原滚动体验 =====
|
||||
if meg["mode"] == "online":
|
||||
text_print += "{}".format(text)
|
||||
text_print = text_print[-args.words_max_print:]
|
||||
print("pid" + str(id) + ": " + text_print)
|
||||
|
||||
elif meg["mode"] == "offline":
|
||||
if timestamp != "":
|
||||
text_print += "{} timestamp: {}".format(text, timestamp)
|
||||
else:
|
||||
text_print += "{}".format(text)
|
||||
|
||||
spk_info = ""
|
||||
if spk_name:
|
||||
if spk_score is not None:
|
||||
spk_info = f" [spk={spk_name} score={float(spk_score):.3f}]"
|
||||
else:
|
||||
spk_info = f" [spk={spk_name}]"
|
||||
|
||||
print("pid" + str(id) + ": " + wav_name + ": " + text_print + spk_info)
|
||||
offline_msg_done = True
|
||||
|
||||
else:
|
||||
# 2pass 模式
|
||||
if meg["mode"] == "2pass-online":
|
||||
text_print_2pass_online += "{}".format(text)
|
||||
text_print = text_print_2pass_offline + text_print_2pass_online
|
||||
else:
|
||||
text_print_2pass_online = ""
|
||||
text_print = text_print_2pass_offline + "{}".format(text)
|
||||
text_print_2pass_offline += "{}".format(text)
|
||||
|
||||
if spk_name:
|
||||
if spk_score is not None:
|
||||
text_print += f" [spk={spk_name} score={float(spk_score):.3f}]"
|
||||
else:
|
||||
text_print += f" [spk={spk_name}]"
|
||||
|
||||
text_print = text_print[-args.words_max_print:]
|
||||
print("pid" + str(id) + ": " + text_print)
|
||||
|
||||
except websockets.exceptions.ConnectionClosedOK:
|
||||
print(f"[MEETING {id}] connection closed normally")
|
||||
except Exception as e:
|
||||
print(f"[MEETING {id}] Exception:", e)
|
||||
finally:
|
||||
try:
|
||||
if ibest_writer is not None:
|
||||
ibest_writer.flush()
|
||||
ibest_writer.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def ws_client(id, chunk_begin, chunk_size):
|
||||
if args.audio_in is None:
|
||||
chunk_begin = 0
|
||||
chunk_size = 1
|
||||
global websocket, voices, offline_msg_done
|
||||
|
||||
for i in range(chunk_begin, chunk_begin + chunk_size):
|
||||
offline_msg_done = False
|
||||
voices = Queue()
|
||||
|
||||
if args.ssl == 1:
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
uri = "wss://{}:{}".format(args.host, args.port)
|
||||
else:
|
||||
uri = "ws://{}:{}".format(args.host, args.port)
|
||||
ssl_context = None
|
||||
|
||||
print("connect to", uri)
|
||||
|
||||
async with websockets.connect(
|
||||
uri, subprotocols=["binary"], ping_interval=None, ssl=ssl_context
|
||||
) as websocket:
|
||||
meeting_tag = f"{id}_{i}"
|
||||
writer = MeetingWriter(args.log_dir, meeting_id=meeting_tag, flush_every=args.log_flush_every)
|
||||
try:
|
||||
if args.audio_in is not None:
|
||||
task = asyncio.create_task(record_from_scp(i, 1))
|
||||
else:
|
||||
task = asyncio.create_task(record_microphone())
|
||||
task3 = asyncio.create_task(message(str(id) + "_" + str(i), writer)) # processid+fileid
|
||||
await asyncio.gather(task, task3)
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
return
|
||||
|
||||
|
||||
def one_thread(id, chunk_begin, chunk_size):
|
||||
# ✅ 子进程里用 asyncio.run 更稳
|
||||
asyncio.run(ws_client(id, chunk_begin, chunk_size))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# for microphone
|
||||
if args.audio_in is None:
|
||||
p = Process(target=one_thread, args=(0, 0, 0))
|
||||
p.start()
|
||||
p.join()
|
||||
print("end")
|
||||
else:
|
||||
# calculate the number of wavs for each process
|
||||
if args.audio_in.endswith(".scp"):
|
||||
f_scp = open(args.audio_in)
|
||||
wavs = f_scp.readlines()
|
||||
else:
|
||||
wavs = [args.audio_in]
|
||||
|
||||
total_len = len(wavs)
|
||||
if total_len >= args.thread_num:
|
||||
chunk_size = int(total_len / args.thread_num)
|
||||
remain_wavs = total_len - chunk_size * args.thread_num
|
||||
else:
|
||||
chunk_size = 1
|
||||
remain_wavs = 0
|
||||
|
||||
process_list = []
|
||||
chunk_begin = 0
|
||||
for i in range(args.thread_num):
|
||||
now_chunk_size = chunk_size
|
||||
if remain_wavs > 0:
|
||||
now_chunk_size = chunk_size + 1
|
||||
remain_wavs = remain_wavs - 1
|
||||
|
||||
p = Process(target=one_thread, args=(i, chunk_begin, now_chunk_size))
|
||||
chunk_begin = chunk_begin + now_chunk_size
|
||||
p.start()
|
||||
process_list.append(p)
|
||||
|
||||
for p in process_list:
|
||||
p.join()
|
||||
|
||||
print("end")
|
||||
@@ -0,0 +1,749 @@
|
||||
import asyncio
|
||||
import json
|
||||
import websockets
|
||||
import time
|
||||
import numpy as np
|
||||
import argparse
|
||||
import ssl
|
||||
import os
|
||||
import wave
|
||||
import functools
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from scipy.spatial.distance import cosine
|
||||
|
||||
import torch # 保留不影响
|
||||
|
||||
|
||||
def to_python(obj):
|
||||
"""递归地把 numpy / torch 等类型转成纯 Python,可 JSON 序列化。"""
|
||||
try:
|
||||
import numpy as np # noqa
|
||||
import torch # noqa
|
||||
except Exception:
|
||||
np = None
|
||||
torch = None
|
||||
|
||||
if np is not None and isinstance(obj, np.generic):
|
||||
return obj.item()
|
||||
if np is not None and isinstance(obj, np.ndarray):
|
||||
return obj.tolist()
|
||||
if torch is not None and isinstance(obj, torch.Tensor):
|
||||
return obj.cpu().tolist()
|
||||
|
||||
if isinstance(obj, dict):
|
||||
return {k: to_python(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (list, tuple)):
|
||||
return [to_python(v) for v in obj]
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--host", type=str, default="0.0.0.0", required=False, help="host ip")
|
||||
parser.add_argument("--port", type=int, default=10095, required=False, help="grpc server port")
|
||||
|
||||
parser.add_argument(
|
||||
"--asr_model",
|
||||
type=str,
|
||||
default="iic/speech_paraformer-large-contextual_asr_nat-zh-cn-16k-common-vocab8404",
|
||||
help="model from modelscope",
|
||||
)
|
||||
parser.add_argument("--asr_model_revision", type=str, default="v2.0.4", help="")
|
||||
|
||||
parser.add_argument(
|
||||
"--asr_model_online",
|
||||
type=str,
|
||||
default="iic/speech_paraformer-large_asr_nat-zh-cn-16k-common-vocab8404-online",
|
||||
help="model from modelscope",
|
||||
)
|
||||
parser.add_argument("--asr_model_online_revision", type=str, default="v2.0.4", help="")
|
||||
|
||||
parser.add_argument(
|
||||
"--vad_model",
|
||||
type=str,
|
||||
default="iic/speech_fsmn_vad_zh-cn-16k-common-pytorch",
|
||||
help="model from modelscope",
|
||||
)
|
||||
parser.add_argument("--vad_model_revision", type=str, default="v2.0.4", help="")
|
||||
|
||||
parser.add_argument(
|
||||
"--punc_model",
|
||||
type=str,
|
||||
default="iic/punc_ct-transformer_zh-cn-common-vad_realtime-vocab272727",
|
||||
help="model from modelscope",
|
||||
)
|
||||
parser.add_argument("--punc_model_revision", type=str, default="v2.0.4", help="")
|
||||
|
||||
parser.add_argument("--ngpu", type=int, default=1, help="0 for cpu, 1 for gpu")
|
||||
parser.add_argument("--device", type=str, default="cuda", help="cuda, cpu")
|
||||
parser.add_argument("--ncpu", type=int, default=4, help="cpu cores")
|
||||
|
||||
parser.add_argument(
|
||||
"--certfile",
|
||||
type=str,
|
||||
default="../../ssl_key/server.crt",
|
||||
required=False,
|
||||
help="certfile for ssl",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keyfile",
|
||||
type=str,
|
||||
default="../../ssl_key/server.key",
|
||||
required=False,
|
||||
help="keyfile for ssl",
|
||||
)
|
||||
|
||||
# ====== 保存 2pass 离线阶段送入 ASR 的音频片段(排查 VAD 切分)======
|
||||
parser.add_argument(
|
||||
"--save_offline_segments",
|
||||
action="store_true",
|
||||
help="Save each offline (2pass) audio segment sent to offline ASR as wav for debugging VAD split.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--save_offline_segments_dir",
|
||||
type=str,
|
||||
default="./offline_segments",
|
||||
help="Directory to save offline wav segments when --save_offline_segments is enabled.",
|
||||
)
|
||||
|
||||
# ====== 并发控制:核心新增 ======
|
||||
parser.add_argument(
|
||||
"--worker_threads",
|
||||
type=int,
|
||||
default=max(4, (os.cpu_count() or 4)),
|
||||
help="ThreadPoolExecutor max_workers. Used to offload blocking inference so event loop won't be blocked.",
|
||||
)
|
||||
parser.add_argument("--concurrent_vad", type=int, default=4, help="Max concurrent VAD generate() calls.")
|
||||
parser.add_argument("--concurrent_asr_online", type=int, default=4, help="Max concurrent streaming ASR generate() calls.")
|
||||
parser.add_argument("--concurrent_asr_offline", type=int, default=2, help="Max concurrent offline ASR generate() calls.")
|
||||
parser.add_argument("--concurrent_punc", type=int, default=1, help="Max concurrent punctuation generate() calls.")
|
||||
parser.add_argument("--concurrent_sv", type=int, default=1, help="Max concurrent speaker verification generate() calls.")
|
||||
parser.add_argument(
|
||||
"--speaker_db_reload_sec",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Reload speaker_db.json at most once every N seconds (avoid frequent disk IO).",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
websocket_users = set()
|
||||
SPEAKER_DB_PATH = os.path.join(os.path.dirname(__file__), "speaker_db.json")
|
||||
|
||||
|
||||
def _ensure_dir(p: str):
|
||||
try:
|
||||
os.makedirs(p, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _pcm_duration_ms(pcm_bytes: bytes, fs: int, ch: int = 1, sampwidth: int = 2) -> int:
|
||||
"""根据 fs/ch/sampwidth 计算 PCM 时长,避免写死 16k -> 32 bytes/ms。"""
|
||||
if not pcm_bytes:
|
||||
return 0
|
||||
bytes_per_ms = (fs * ch * sampwidth) / 1000.0
|
||||
if bytes_per_ms <= 0:
|
||||
return 0
|
||||
return int(len(pcm_bytes) / bytes_per_ms)
|
||||
|
||||
|
||||
def _safe_int(v, default):
|
||||
try:
|
||||
return int(v)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
# ========= speaker db:加缓存,避免每段都读盘 =========
|
||||
_SPEAKER_DB_CACHE = {}
|
||||
_SPEAKER_DB_CACHE_TS = 0.0
|
||||
|
||||
|
||||
def _load_speaker_db_sync():
|
||||
if not os.path.exists(SPEAKER_DB_PATH):
|
||||
return {}
|
||||
try:
|
||||
with open(SPEAKER_DB_PATH, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def get_speaker_db_cached(now_ts: float, reload_sec: int):
|
||||
global _SPEAKER_DB_CACHE, _SPEAKER_DB_CACHE_TS
|
||||
if (now_ts - _SPEAKER_DB_CACHE_TS) >= max(1, int(reload_sec)):
|
||||
_SPEAKER_DB_CACHE = _load_speaker_db_sync()
|
||||
_SPEAKER_DB_CACHE_TS = now_ts
|
||||
return _SPEAKER_DB_CACHE or {}
|
||||
|
||||
|
||||
def _save_wav_sync(out_path: str, audio_bytes: bytes, fs: int, ch: int, sampwidth: int):
|
||||
with wave.open(out_path, "wb") as wf:
|
||||
wf.setnchannels(ch)
|
||||
wf.setsampwidth(sampwidth)
|
||||
wf.setframerate(fs)
|
||||
wf.writeframes(audio_bytes)
|
||||
|
||||
|
||||
def save_offline_wav_segment_sync(websocket, audio_bytes: bytes, reason: str = "offline"):
|
||||
"""
|
||||
保存离线阶段送入 ASR 的音频片段,方便人工试听排查 VAD 切分是否正确。
|
||||
约定:audio_bytes 为 单声道 PCM16 little-endian(默认 16k)。
|
||||
(注意:这是同步函数,外层会放线程池执行)
|
||||
"""
|
||||
if not getattr(websocket, "save_offline_segments", False):
|
||||
return
|
||||
if "2pass" not in (getattr(websocket, "mode", "") or ""):
|
||||
return
|
||||
if not audio_bytes:
|
||||
return
|
||||
|
||||
fs = int(getattr(websocket, "audio_fs", 16000) or 16000)
|
||||
ch = 1
|
||||
sampwidth = 2 # int16
|
||||
|
||||
# int16 对齐
|
||||
if len(audio_bytes) % 2 == 1:
|
||||
audio_bytes = audio_bytes[:-1]
|
||||
if not audio_bytes:
|
||||
return
|
||||
|
||||
seg_idx = int(getattr(websocket, "offline_seg_idx", 0))
|
||||
websocket.offline_seg_idx = seg_idx + 1
|
||||
|
||||
duration_ms = _pcm_duration_ms(audio_bytes, fs=fs, ch=ch, sampwidth=sampwidth)
|
||||
|
||||
base_dir = getattr(websocket, "offline_save_dir", args.save_offline_segments_dir)
|
||||
_ensure_dir(base_dir)
|
||||
|
||||
wav_name = (getattr(websocket, "wav_name", "microphone") or "microphone").replace("/", "_")
|
||||
ts = int(time.time() * 1000)
|
||||
fname = f"{wav_name}_{ts}_seg{seg_idx:04d}_{reason}_{duration_ms}ms.wav"
|
||||
out_path = os.path.join(base_dir, fname)
|
||||
|
||||
try:
|
||||
_save_wav_sync(out_path, audio_bytes, fs=fs, ch=ch, sampwidth=sampwidth)
|
||||
print(f"[SAVE_OFFLINE_SEG] {out_path} ({duration_ms} ms, {len(audio_bytes)} bytes)")
|
||||
except Exception as e:
|
||||
print(f"[SAVE_OFFLINE_SEG] failed: {e}")
|
||||
|
||||
|
||||
print("model loading")
|
||||
from funasr import AutoModel # noqa
|
||||
|
||||
# ====== 离线 ASR ======
|
||||
model_asr = AutoModel(
|
||||
model="paraformer-zh",
|
||||
model_revision="v2.0.4",
|
||||
ngpu=args.ngpu,
|
||||
ncpu=args.ncpu,
|
||||
device=args.device,
|
||||
disable_pbar=True,
|
||||
disable_log=True,
|
||||
)
|
||||
|
||||
# streaming asr
|
||||
model_asr_streaming = AutoModel(
|
||||
model=args.asr_model_online,
|
||||
model_revision=args.asr_model_online_revision,
|
||||
ngpu=args.ngpu,
|
||||
ncpu=args.ncpu,
|
||||
device=args.device,
|
||||
disable_pbar=True,
|
||||
disable_log=True,
|
||||
)
|
||||
|
||||
# vad
|
||||
model_vad = AutoModel(
|
||||
model=args.vad_model,
|
||||
model_revision=args.vad_model_revision,
|
||||
ngpu=args.ngpu,
|
||||
ncpu=args.ncpu,
|
||||
device=args.device,
|
||||
disable_pbar=True,
|
||||
disable_log=True,
|
||||
)
|
||||
|
||||
# punc
|
||||
if args.punc_model != "":
|
||||
model_punc = AutoModel(
|
||||
model=args.punc_model,
|
||||
model_revision=args.punc_model_revision,
|
||||
ngpu=args.ngpu,
|
||||
ncpu=args.ncpu,
|
||||
device=args.device,
|
||||
disable_pbar=True,
|
||||
disable_log=True,
|
||||
)
|
||||
else:
|
||||
model_punc = None
|
||||
|
||||
# sv
|
||||
model_sv = AutoModel(
|
||||
model="iic/speech_campplus_sv_zh-cn_16k-common",
|
||||
ngpu=args.ngpu,
|
||||
device=args.device,
|
||||
disable_pbar=True,
|
||||
disable_log=True,
|
||||
)
|
||||
|
||||
print("model loaded! (now supports multi-client with non-blocking inference)")
|
||||
|
||||
|
||||
# ====== 线程池 + 并发阈值(核心)======
|
||||
EXECUTOR = ThreadPoolExecutor(max_workers=int(args.worker_threads))
|
||||
|
||||
SEM_VAD = asyncio.Semaphore(max(1, int(args.concurrent_vad)))
|
||||
SEM_ASR_ONLINE = asyncio.Semaphore(max(1, int(args.concurrent_asr_online)))
|
||||
SEM_ASR_OFFLINE = asyncio.Semaphore(max(1, int(args.concurrent_asr_offline)))
|
||||
SEM_PUNC = asyncio.Semaphore(max(1, int(args.concurrent_punc)))
|
||||
SEM_SV = asyncio.Semaphore(max(1, int(args.concurrent_sv)))
|
||||
SEM_WAV = asyncio.Semaphore(max(1, 4)) # 保存 wav 一般不需要太大
|
||||
|
||||
|
||||
async def run_blocking(fn, *a, sem: asyncio.Semaphore | None = None, **kw):
|
||||
"""
|
||||
把阻塞函数丢线程池执行,避免卡 event loop。
|
||||
sem 用于限流(避免 GPU / 模型被打爆)。
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
call = functools.partial(fn, *a, **kw)
|
||||
if sem is None:
|
||||
return await loop.run_in_executor(EXECUTOR, call)
|
||||
async with sem:
|
||||
return await loop.run_in_executor(EXECUTOR, call)
|
||||
|
||||
|
||||
def _generate_sync(model, audio_or_text, status_dict):
|
||||
# 注意:status_dict 里包含 cache,会被 generate 更新
|
||||
return model.generate(input=audio_or_text, **status_dict)
|
||||
|
||||
|
||||
async def ws_reset(websocket):
|
||||
print("ws reset now, total num is ", len(websocket_users))
|
||||
|
||||
websocket.status_dict_asr_online["cache"] = {}
|
||||
websocket.status_dict_asr_online["is_final"] = True
|
||||
websocket.status_dict_vad["cache"] = {}
|
||||
websocket.status_dict_vad["is_final"] = True
|
||||
websocket.status_dict_punc["cache"] = {}
|
||||
|
||||
await websocket.close()
|
||||
|
||||
|
||||
async def clear_websocket():
|
||||
for websocket in list(websocket_users):
|
||||
await ws_reset(websocket)
|
||||
websocket_users.clear()
|
||||
|
||||
|
||||
async def ws_serve(websocket, path=None):
|
||||
# websockets 新版本不会传 path,这里做兼容
|
||||
if path is None:
|
||||
path = getattr(websocket, "path", None)
|
||||
frames = []
|
||||
frames_asr = []
|
||||
frames_asr_online = []
|
||||
global websocket_users
|
||||
websocket_users.add(websocket)
|
||||
|
||||
websocket.status_dict_asr = {} # hotword 等
|
||||
websocket.status_dict_asr_online = {"cache": {}, "is_final": False}
|
||||
websocket.status_dict_vad = {"cache": {}, "is_final": False}
|
||||
websocket.status_dict_punc = {"cache": {}}
|
||||
|
||||
websocket.chunk_interval = 10
|
||||
websocket.vad_pre_idx = 0
|
||||
speech_start = False
|
||||
speech_end_i = -1
|
||||
|
||||
websocket.wav_name = "microphone"
|
||||
websocket.mode = "2pass"
|
||||
websocket.is_speaking = True # ✅ 默认初始化,避免 AttributeError
|
||||
|
||||
# 保存离线片段
|
||||
websocket.audio_fs = 16000
|
||||
websocket.offline_seg_idx = 0
|
||||
websocket.save_offline_segments = bool(args.save_offline_segments)
|
||||
websocket.offline_save_dir = args.save_offline_segments_dir
|
||||
if websocket.save_offline_segments:
|
||||
_ensure_dir(websocket.offline_save_dir)
|
||||
print(f"[SAVE_OFFLINE_SEG] enabled, dir={websocket.offline_save_dir}")
|
||||
|
||||
print("new user connected", flush=True)
|
||||
|
||||
try:
|
||||
async for message in websocket:
|
||||
# ========== 1) 先处理“文本配置消息” ==========
|
||||
if isinstance(message, str):
|
||||
try:
|
||||
messagejson = json.loads(message)
|
||||
except Exception as e:
|
||||
print("bad json message:", e, message[:200])
|
||||
continue
|
||||
|
||||
print("=============messagejson============", messagejson)
|
||||
|
||||
if "is_speaking" in messagejson:
|
||||
websocket.is_speaking = bool(messagejson["is_speaking"])
|
||||
websocket.status_dict_asr_online["is_final"] = (not websocket.is_speaking)
|
||||
|
||||
if "chunk_interval" in messagejson:
|
||||
websocket.chunk_interval = _safe_int(
|
||||
messagejson["chunk_interval"], websocket.chunk_interval
|
||||
)
|
||||
|
||||
if "wav_name" in messagejson:
|
||||
websocket.wav_name = messagejson.get("wav_name") or websocket.wav_name
|
||||
|
||||
if "chunk_size" in messagejson:
|
||||
chunk_size = messagejson["chunk_size"]
|
||||
if isinstance(chunk_size, str):
|
||||
chunk_size = [x.strip() for x in chunk_size.split(",") if x.strip()]
|
||||
websocket.status_dict_asr_online["chunk_size"] = [int(x) for x in chunk_size]
|
||||
|
||||
if "encoder_chunk_look_back" in messagejson:
|
||||
websocket.status_dict_asr_online["encoder_chunk_look_back"] = messagejson[
|
||||
"encoder_chunk_look_back"
|
||||
]
|
||||
|
||||
if "decoder_chunk_look_back" in messagejson:
|
||||
websocket.status_dict_asr_online["decoder_chunk_look_back"] = messagejson[
|
||||
"decoder_chunk_look_back"
|
||||
]
|
||||
|
||||
if "hotwords" in messagejson:
|
||||
hotword_data = messagejson["hotwords"]
|
||||
websocket.status_dict_asr["hotword"] = hotword_data
|
||||
websocket.status_dict_asr_online["hotword"] = hotword_data
|
||||
print(f"热词已更新: {hotword_data}")
|
||||
|
||||
if "mode" in messagejson:
|
||||
websocket.mode = messagejson["mode"] or websocket.mode
|
||||
|
||||
if "audio_fs" in messagejson:
|
||||
websocket.audio_fs = _safe_int(messagejson["audio_fs"], 16000)
|
||||
|
||||
continue
|
||||
|
||||
# ========== 2) 处理“二进制音频消息” ==========
|
||||
if "chunk_size" not in websocket.status_dict_asr_online:
|
||||
print("[WARN] chunk_size not set yet, skip audio frame (send config first).")
|
||||
continue
|
||||
|
||||
try:
|
||||
websocket.status_dict_vad["chunk_size"] = int(
|
||||
websocket.status_dict_asr_online["chunk_size"][1] * 60 / websocket.chunk_interval
|
||||
)
|
||||
except Exception as e:
|
||||
print("[WARN] set vad chunk_size failed:", e)
|
||||
continue
|
||||
|
||||
pcm = message
|
||||
frames.append(pcm)
|
||||
|
||||
duration_ms = _pcm_duration_ms(pcm, fs=websocket.audio_fs, ch=1, sampwidth=2)
|
||||
websocket.vad_pre_idx += duration_ms
|
||||
|
||||
# online asr
|
||||
frames_asr_online.append(pcm)
|
||||
websocket.status_dict_asr_online["is_final"] = (speech_end_i != -1)
|
||||
|
||||
if (len(frames_asr_online) % websocket.chunk_interval == 0) or websocket.status_dict_asr_online["is_final"]:
|
||||
if websocket.mode in ("2pass", "online"):
|
||||
audio_in = b"".join(frames_asr_online)
|
||||
try:
|
||||
await async_asr_online(websocket, audio_in)
|
||||
except Exception:
|
||||
print(f"error in asr streaming, {websocket.status_dict_asr_online}")
|
||||
frames_asr_online = []
|
||||
|
||||
if speech_start:
|
||||
frames_asr.append(pcm)
|
||||
|
||||
# vad online
|
||||
try:
|
||||
speech_start_i, speech_end_i = await async_vad(websocket, pcm)
|
||||
except Exception as e:
|
||||
print("error in vad:", e)
|
||||
speech_start_i, speech_end_i = -1, -1
|
||||
|
||||
if speech_start_i != -1:
|
||||
speech_start = True
|
||||
if duration_ms > 0:
|
||||
beg_bias = (websocket.vad_pre_idx - speech_start_i) // duration_ms
|
||||
else:
|
||||
beg_bias = 0
|
||||
frames_pre = frames[-beg_bias:] if beg_bias > 0 else []
|
||||
frames_asr = []
|
||||
frames_asr.extend(frames_pre)
|
||||
|
||||
# ========== 3) 2pass:离线阶段触发点 ==========
|
||||
if (speech_end_i != -1) or (not websocket.is_speaking):
|
||||
if websocket.mode in ("2pass", "offline"):
|
||||
audio_in = b"".join(frames_asr)
|
||||
reason = "vad_end" if speech_end_i != -1 else "not_speaking"
|
||||
|
||||
# 保存 wav:放线程池,避免磁盘 IO 卡 loop
|
||||
if websocket.save_offline_segments and audio_in:
|
||||
try:
|
||||
await run_blocking(
|
||||
save_offline_wav_segment_sync,
|
||||
websocket,
|
||||
audio_in,
|
||||
reason,
|
||||
sem=SEM_WAV,
|
||||
)
|
||||
except Exception as e:
|
||||
print("[SAVE_OFFLINE_SEG] async failed:", e)
|
||||
|
||||
try:
|
||||
await async_asr(websocket, audio_in)
|
||||
except Exception as e:
|
||||
print("error in asr offline:", e)
|
||||
|
||||
frames_asr = []
|
||||
speech_start = False
|
||||
frames_asr_online = []
|
||||
websocket.status_dict_asr_online["cache"] = {}
|
||||
|
||||
if not websocket.is_speaking:
|
||||
websocket.vad_pre_idx = 0
|
||||
frames = []
|
||||
websocket.status_dict_vad["cache"] = {}
|
||||
speech_end_i = -1
|
||||
else:
|
||||
frames = frames[-20:]
|
||||
|
||||
except websockets.ConnectionClosed:
|
||||
print("ConnectionClosed...", websocket_users, flush=True)
|
||||
await ws_reset(websocket)
|
||||
if websocket in websocket_users:
|
||||
websocket_users.remove(websocket)
|
||||
except websockets.InvalidState:
|
||||
print("InvalidState...")
|
||||
except Exception as e:
|
||||
print("Exception:", e)
|
||||
try:
|
||||
await ws_reset(websocket)
|
||||
except Exception:
|
||||
pass
|
||||
if websocket in websocket_users:
|
||||
websocket_users.remove(websocket)
|
||||
|
||||
|
||||
# ===================== 推理:全部改为“线程池 + 限流” =====================
|
||||
|
||||
async def async_vad(websocket, audio_in: bytes):
|
||||
# model_vad.generate 是阻塞的,必须 offload
|
||||
out = await run_blocking(_generate_sync, model_vad, audio_in, websocket.status_dict_vad, sem=SEM_VAD)
|
||||
segments_result = out[0].get("value", [])
|
||||
|
||||
speech_start = -1
|
||||
speech_end = -1
|
||||
|
||||
if len(segments_result) == 0 or len(segments_result) > 1:
|
||||
return speech_start, speech_end
|
||||
if segments_result[0][0] != -1:
|
||||
speech_start = segments_result[0][0]
|
||||
if segments_result[0][1] != -1:
|
||||
speech_end = segments_result[0][1]
|
||||
return speech_start, speech_end
|
||||
|
||||
|
||||
def _sv_and_match_sync(audio_in: bytes, reload_sec: int):
|
||||
"""
|
||||
同步执行:SV embedding + speaker_db 匹配
|
||||
返回 (spk_name, best_score)
|
||||
"""
|
||||
spk_name = "unknown"
|
||||
best_score = 0.0
|
||||
|
||||
sv_out = model_sv.generate(input=audio_in, embedding=True)[0]
|
||||
embedding = sv_out["spk_embedding"][0].cpu().numpy()
|
||||
|
||||
now_ts = time.time()
|
||||
local_speaker_db = get_speaker_db_cached(now_ts, reload_sec=reload_sec)
|
||||
if local_speaker_db:
|
||||
for name, ref_embedding in local_speaker_db.items():
|
||||
if ref_embedding is None:
|
||||
continue
|
||||
arr = np.array(ref_embedding, dtype=np.float32)
|
||||
similarity = 1.0 - cosine(embedding, arr)
|
||||
print("sv similarity with {}: {}".format(name, similarity))
|
||||
if similarity > best_score and similarity > 0.2:
|
||||
best_score = similarity
|
||||
spk_name = name
|
||||
|
||||
return spk_name, float(best_score)
|
||||
|
||||
|
||||
async def async_asr(websocket, audio_in: bytes):
|
||||
mode = "2pass-offline" if "2pass" in (websocket.mode or "") else websocket.mode
|
||||
|
||||
if len(audio_in) <= 0:
|
||||
message = {
|
||||
"mode": mode,
|
||||
"text": "",
|
||||
"wav_name": websocket.wav_name,
|
||||
"is_final": True,
|
||||
}
|
||||
await websocket.send(json.dumps(message, ensure_ascii=False))
|
||||
return
|
||||
|
||||
# 1) ASR(阻塞,线程池执行)
|
||||
rec_result_list = await run_blocking(
|
||||
_generate_sync,
|
||||
model_asr,
|
||||
audio_in,
|
||||
websocket.status_dict_asr,
|
||||
sem=SEM_ASR_OFFLINE,
|
||||
)
|
||||
rec_result = rec_result_list[0]
|
||||
|
||||
print("offline_asr, raw:", rec_result)
|
||||
print("offline_asr, keys:", rec_result.keys())
|
||||
|
||||
text = rec_result.get("text", "")
|
||||
timestamp = rec_result.get("timestamp", None)
|
||||
sentence_info = rec_result.get("sentence_info", None)
|
||||
|
||||
# 2) 声纹识别(阻塞,线程池执行)
|
||||
spk_name = "unknown"
|
||||
best_score = 0.0
|
||||
try:
|
||||
spk_name, best_score = await run_blocking(
|
||||
_sv_and_match_sync,
|
||||
audio_in,
|
||||
int(args.speaker_db_reload_sec),
|
||||
sem=SEM_SV,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"声纹识别失败: {e}")
|
||||
|
||||
# 3) 标点(阻塞,线程池执行)
|
||||
punc_array = None
|
||||
if model_punc is not None and len(text) > 0:
|
||||
try:
|
||||
# punc 只对文本处理
|
||||
punc_out = await run_blocking(
|
||||
_generate_sync,
|
||||
model_punc,
|
||||
text,
|
||||
websocket.status_dict_punc,
|
||||
sem=SEM_PUNC,
|
||||
)
|
||||
punc_result = punc_out[0]
|
||||
print("offline, after punc", punc_result)
|
||||
|
||||
if "text" in punc_result and punc_result["text"]:
|
||||
text = punc_result["text"]
|
||||
if "punc_array" in punc_result:
|
||||
punc_array = punc_result["punc_array"]
|
||||
except Exception as e:
|
||||
print("punc failed:", e)
|
||||
|
||||
# 4) 构造最终 message
|
||||
if len(text) > 0:
|
||||
print("======offline final text:", text)
|
||||
message = {
|
||||
"mode": mode,
|
||||
"spk_name": spk_name,
|
||||
"spk_score": float(best_score),
|
||||
"text": text,
|
||||
"wav_name": websocket.wav_name,
|
||||
"is_final": True,
|
||||
}
|
||||
if timestamp is not None:
|
||||
message["timestamp"] = to_python(timestamp)
|
||||
if sentence_info is not None:
|
||||
message["sentence_info"] = to_python(sentence_info)
|
||||
if punc_array is not None:
|
||||
message["punc_array"] = to_python(punc_array)
|
||||
|
||||
try:
|
||||
await websocket.send(json.dumps(message, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
print("send json failed:", e)
|
||||
print("message types:", {k: type(v) for k, v in message.items()})
|
||||
else:
|
||||
message = {
|
||||
"mode": mode,
|
||||
"spk_name": spk_name,
|
||||
"spk_score": float(best_score),
|
||||
"text": "",
|
||||
"wav_name": websocket.wav_name,
|
||||
"is_final": True,
|
||||
}
|
||||
await websocket.send(json.dumps(message, ensure_ascii=False))
|
||||
|
||||
|
||||
async def async_asr_online(websocket, audio_in: bytes):
|
||||
if len(audio_in) <= 0:
|
||||
return
|
||||
|
||||
# streaming generate 也是阻塞:线程池执行
|
||||
rec_out = await run_blocking(
|
||||
_generate_sync,
|
||||
model_asr_streaming,
|
||||
audio_in,
|
||||
websocket.status_dict_asr_online,
|
||||
sem=SEM_ASR_ONLINE,
|
||||
)
|
||||
rec_result = rec_out[0]
|
||||
print("online, ", rec_result)
|
||||
|
||||
# 2pass:online 只要 partial,不发 final(final 交给 offline)
|
||||
if websocket.mode == "2pass" and websocket.status_dict_asr_online.get("is_final", False):
|
||||
return
|
||||
|
||||
if rec_result.get("text"):
|
||||
mode = "2pass-online" if "2pass" in (websocket.mode or "") else websocket.mode
|
||||
message = {
|
||||
"mode": mode,
|
||||
"text": rec_result["text"],
|
||||
"wav_name": websocket.wav_name,
|
||||
"is_final": bool(
|
||||
websocket.status_dict_asr_online.get("is_final", False) or (not websocket.is_speaking)
|
||||
),
|
||||
}
|
||||
await websocket.send(json.dumps(message, ensure_ascii=False))
|
||||
|
||||
|
||||
# ===================== 启动服务 =====================
|
||||
|
||||
async def main():
|
||||
if len(args.certfile) > 0:
|
||||
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ssl_context.load_cert_chain(args.certfile, keyfile=args.keyfile)
|
||||
server = await websockets.serve(
|
||||
ws_serve,
|
||||
args.host,
|
||||
args.port,
|
||||
subprotocols=["binary"],
|
||||
ping_interval=None,
|
||||
ssl=ssl_context,
|
||||
)
|
||||
else:
|
||||
server = await websockets.serve(
|
||||
ws_serve,
|
||||
args.host,
|
||||
args.port,
|
||||
subprotocols=["binary"],
|
||||
ping_interval=None,
|
||||
)
|
||||
|
||||
print(f"WS server started at ws(s)://{args.host}:{args.port}")
|
||||
await server.wait_closed()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
finally:
|
||||
try:
|
||||
EXECUTOR.shutdown(wait=False, cancel_futures=True)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,2 @@
|
||||
websockets
|
||||
pyaudio
|
||||
@@ -0,0 +1 @@
|
||||
websockets
|
||||
Reference in New Issue
Block a user