support long audio
Browse files- .gitattributes +12 -0
- .pytest_cache/v/cache/nodeids +9 -0
- app/config.py +6 -3
- app/summarizer.py +50 -26
- ax_meeting.egg-info/PKG-INFO +46 -50
- ax_meeting/_upload_1566a625-52a6-4663-a62b-7570dac7b7dd_20200327_2P.wav +3 -0
- ax_meeting/_upload_1676128a-2d16-45b0-a3d5-faa94dea89bd_20200327_2P.wav +3 -0
- ax_meeting/_upload_176e329f-da53-4e64-85ca-4ba3f7ac76a3_20200327_2P.wav +3 -0
- ax_meeting/_upload_911af462-37e5-4314-911b-78fd3024cb7f_20200327_2P.wav +3 -0
- ax_meeting/_upload_cfac62fb-1bd0-425f-8e31-317aa922604e_20200327_2P.wav +3 -0
- ax_meeting/_upload_e4b352ae-a156-4e46-b41f-37313325fcfe_20200327_2P.wav +3 -0
- ax_meeting/_upload_eb77c179-4271-48e8-8fda-8e0dc528417c_20200327_2P.wav +3 -0
- ax_meeting/config.py +6 -3
- ax_meeting/diar_asr_cli.py +112 -27
- ax_meeting/engines.py +16 -57
- ax_meeting/pipeline.py +9 -0
- ax_meeting/server.py +95 -12
- ax_meeting/summarizer.py +50 -26
- ax_meeting/utils/ax_cam_bin.py +7 -7
- ax_meeting/utils/frontend.py +8 -4
- ax_meeting/utils/utils/frontend.py +8 -4
- build/lib/ax_meeting/config.py +6 -3
- build/lib/ax_meeting/diar_asr_cli.py +112 -27
- build/lib/ax_meeting/engines.py +16 -57
- build/lib/ax_meeting/pipeline.py +9 -0
- build/lib/ax_meeting/server.py +95 -12
- build/lib/ax_meeting/summarizer.py +50 -26
- build/lib/ax_meeting/utils/ax_cam_bin.py +7 -7
- build/lib/ax_meeting/utils/frontend.py +8 -4
- build/lib/ax_meeting/utils/utils/frontend.py +8 -4
- dist/ax_meeting-0.1.1-py3-none-any.whl +2 -2
- start_meeting_server.sh +6 -4
- tests/test_lightweight.py +178 -0
- wav/cache_test/clip_01.wav +3 -0
- wav/cache_test/clip_02.wav +3 -0
- wav/cache_test/clip_03.wav +3 -0
- wav/cache_test/clip_04.wav +3 -0
- wav/cache_test/long_5min.wav +3 -0
.gitattributes
CHANGED
|
@@ -69,3 +69,15 @@ ax_meeting/recordings/fb6451b9-4df9-48a7-9ca3-753155a5a2d8.webm filter=lfs diff=
|
|
| 69 |
ax_meeting/recordings/fde9916e-930d-49d3-9f23-9db3fef0a63e.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 70 |
ax_meeting/recordings/fde9916e-930d-49d3-9f23-9db3fef0a63e.webm filter=lfs diff=lfs merge=lfs -text
|
| 71 |
wav/fb6451b9-4df9-48a7-9ca3-753155a5a2d8.mp3 filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
ax_meeting/recordings/fde9916e-930d-49d3-9f23-9db3fef0a63e.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 70 |
ax_meeting/recordings/fde9916e-930d-49d3-9f23-9db3fef0a63e.webm filter=lfs diff=lfs merge=lfs -text
|
| 71 |
wav/fb6451b9-4df9-48a7-9ca3-753155a5a2d8.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 72 |
+
ax_meeting/_upload_1566a625-52a6-4663-a62b-7570dac7b7dd_20200327_2P.wav filter=lfs diff=lfs merge=lfs -text
|
| 73 |
+
ax_meeting/_upload_1676128a-2d16-45b0-a3d5-faa94dea89bd_20200327_2P.wav filter=lfs diff=lfs merge=lfs -text
|
| 74 |
+
ax_meeting/_upload_176e329f-da53-4e64-85ca-4ba3f7ac76a3_20200327_2P.wav filter=lfs diff=lfs merge=lfs -text
|
| 75 |
+
ax_meeting/_upload_911af462-37e5-4314-911b-78fd3024cb7f_20200327_2P.wav filter=lfs diff=lfs merge=lfs -text
|
| 76 |
+
ax_meeting/_upload_cfac62fb-1bd0-425f-8e31-317aa922604e_20200327_2P.wav filter=lfs diff=lfs merge=lfs -text
|
| 77 |
+
ax_meeting/_upload_e4b352ae-a156-4e46-b41f-37313325fcfe_20200327_2P.wav filter=lfs diff=lfs merge=lfs -text
|
| 78 |
+
ax_meeting/_upload_eb77c179-4271-48e8-8fda-8e0dc528417c_20200327_2P.wav filter=lfs diff=lfs merge=lfs -text
|
| 79 |
+
wav/cache_test/clip_01.wav filter=lfs diff=lfs merge=lfs -text
|
| 80 |
+
wav/cache_test/clip_02.wav filter=lfs diff=lfs merge=lfs -text
|
| 81 |
+
wav/cache_test/clip_03.wav filter=lfs diff=lfs merge=lfs -text
|
| 82 |
+
wav/cache_test/clip_04.wav filter=lfs diff=lfs merge=lfs -text
|
| 83 |
+
wav/cache_test/long_5min.wav filter=lfs diff=lfs merge=lfs -text
|
.pytest_cache/v/cache/nodeids
CHANGED
|
@@ -1,6 +1,15 @@
|
|
| 1 |
[
|
|
|
|
| 2 |
"tests/test_lightweight.py::TestPickSpeaker::test_pick_speaker_no_overlap",
|
| 3 |
"tests/test_lightweight.py::TestPickSpeaker::test_pick_speaker_overlap",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"tests/test_lightweight.py::TestTextCleaner::test_angle_tokens",
|
| 5 |
"tests/test_lightweight.py::TestTextCleaner::test_concat_tokens",
|
| 6 |
"tests/test_lightweight.py::TestTextCleaner::test_empty_after_clean",
|
|
|
|
| 1 |
[
|
| 2 |
+
"tests/test_lightweight.py::TestIncrementalSummarizer::test_summarize_incrementally_uses_previous_summary_and_returns_last_round",
|
| 3 |
"tests/test_lightweight.py::TestPickSpeaker::test_pick_speaker_no_overlap",
|
| 4 |
"tests/test_lightweight.py::TestPickSpeaker::test_pick_speaker_overlap",
|
| 5 |
+
"tests/test_lightweight.py::TestServerStartup::test_preload_models_calls_ensure_loaded",
|
| 6 |
+
"tests/test_lightweight.py::TestSummarizerHelpers::test_split_text_fixed_size_chunks_by_chars",
|
| 7 |
+
"tests/test_lightweight.py::TestSummarizerHelpers::test_split_text_fixed_size_empty",
|
| 8 |
+
"tests/test_lightweight.py::TestSummarizerHelpers::test_split_text_fixed_size_invalid_chunk_size",
|
| 9 |
+
"tests/test_lightweight.py::TestSummarizerHelpers::test_summary_chunk_chars_uses_environment_override",
|
| 10 |
+
"tests/test_lightweight.py::TestSummarizerHelpers::test_summary_target_range_from_scalar",
|
| 11 |
+
"tests/test_lightweight.py::TestSummarizerHelpers::test_summary_target_range_from_tuple",
|
| 12 |
+
"tests/test_lightweight.py::TestSummarizerHelpers::test_summary_target_range_normalizes_reverse_order",
|
| 13 |
"tests/test_lightweight.py::TestTextCleaner::test_angle_tokens",
|
| 14 |
"tests/test_lightweight.py::TestTextCleaner::test_concat_tokens",
|
| 15 |
"tests/test_lightweight.py::TestTextCleaner::test_empty_after_clean",
|
app/config.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
# -*- coding: utf-8 -*-
|
|
|
|
| 2 |
|
| 3 |
SAMPLE_RATE = 16000
|
| 4 |
|
|
@@ -14,6 +15,8 @@ MIN_SEGMENT_MS = 300
|
|
| 14 |
# Merge VAD segments shorter than this in offline diarization
|
| 15 |
MERGE_VAD_MAX_LEN_MS = 15 * 1000
|
| 16 |
|
| 17 |
-
# LLM summarization chunking
|
| 18 |
-
SUMMARY_CHUNK_CHARS = 1000
|
| 19 |
-
|
|
|
|
|
|
|
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
+
import os
|
| 3 |
|
| 4 |
SAMPLE_RATE = 16000
|
| 5 |
|
|
|
|
| 15 |
# Merge VAD segments shorter than this in offline diarization
|
| 16 |
MERGE_VAD_MAX_LEN_MS = 15 * 1000
|
| 17 |
|
| 18 |
+
# LLM summarization chunking, measured in transcript characters instead of tokens
|
| 19 |
+
SUMMARY_CHUNK_CHARS = int(os.getenv("SUMMARY_CHUNK_CHARS", 12 * 1000))
|
| 20 |
+
|
| 21 |
+
# Target summary length range in Chinese characters
|
| 22 |
+
SUMMARY_TARGET_CHARS = (100, 500)
|
app/summarizer.py
CHANGED
|
@@ -11,26 +11,24 @@ except Exception as e: # pragma: no cover
|
|
| 11 |
OpenAI = None
|
| 12 |
|
| 13 |
|
| 14 |
-
def
|
| 15 |
text = text.strip()
|
| 16 |
if not text:
|
| 17 |
return []
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
chunks = [chunks[0][i:i + max_chars] for i in range(0, len(chunks[0]), max_chars)]
|
| 33 |
-
return chunks
|
| 34 |
|
| 35 |
|
| 36 |
class IncrementalSummarizer:
|
|
@@ -46,24 +44,50 @@ class IncrementalSummarizer:
|
|
| 46 |
self.model = model if model is not None else os.getenv("OPENAI_MODEL", "AXERA-TECH/Qwen3-1.7B")
|
| 47 |
|
| 48 |
def summarize_incrementally(self, transcript: str) -> str:
|
| 49 |
-
|
|
|
|
| 50 |
if not chunks:
|
| 51 |
return ""
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
summary = ""
|
| 54 |
-
for idx, chunk in enumerate(chunks):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
prompt = (
|
| 56 |
-
"你是会议纪要助手。"
|
| 57 |
-
f"\n前
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
)
|
| 62 |
|
| 63 |
resp = self.client.chat.completions.create(
|
| 64 |
model=self.model,
|
| 65 |
messages=[
|
| 66 |
-
{"role": "system", "content": "你擅长
|
| 67 |
{"role": "user", "content": prompt},
|
| 68 |
],
|
| 69 |
temperature=0.2,
|
|
@@ -71,5 +95,5 @@ class IncrementalSummarizer:
|
|
| 71 |
|
| 72 |
summary = (resp.choices[0].message.content or "").strip()
|
| 73 |
summary = re.sub(r"<think>.*?</think>", "", summary, flags=re.DOTALL).strip()
|
| 74 |
-
print(f"Summary chunk {idx
|
| 75 |
return summary
|
|
|
|
| 11 |
OpenAI = None
|
| 12 |
|
| 13 |
|
| 14 |
+
def _split_text_fixed_size(text: str, chunk_chars: int) -> List[str]:
|
| 15 |
text = text.strip()
|
| 16 |
if not text:
|
| 17 |
return []
|
| 18 |
+
if chunk_chars <= 0:
|
| 19 |
+
raise ValueError("SUMMARY_CHUNK_CHARS must be > 0")
|
| 20 |
+
return [text[i:i + chunk_chars] for i in range(0, len(text), chunk_chars)]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _summary_target_range() -> tuple[int, int]:
|
| 24 |
+
if isinstance(SUMMARY_TARGET_CHARS, (tuple, list)) and len(SUMMARY_TARGET_CHARS) == 2:
|
| 25 |
+
min_chars = int(SUMMARY_TARGET_CHARS[0])
|
| 26 |
+
max_chars = int(SUMMARY_TARGET_CHARS[1])
|
| 27 |
+
else:
|
| 28 |
+
min_chars = max_chars = int(SUMMARY_TARGET_CHARS)
|
| 29 |
+
if min_chars > max_chars:
|
| 30 |
+
min_chars, max_chars = max_chars, min_chars
|
| 31 |
+
return min_chars, max_chars
|
|
|
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
class IncrementalSummarizer:
|
|
|
|
| 44 |
self.model = model if model is not None else os.getenv("OPENAI_MODEL", "AXERA-TECH/Qwen3-1.7B")
|
| 45 |
|
| 46 |
def summarize_incrementally(self, transcript: str) -> str:
|
| 47 |
+
transcript = transcript.strip()
|
| 48 |
+
chunks = _split_text_fixed_size(transcript, SUMMARY_CHUNK_CHARS)
|
| 49 |
if not chunks:
|
| 50 |
return ""
|
| 51 |
|
| 52 |
+
min_chars, max_chars = _summary_target_range()
|
| 53 |
+
total_chunks = len(chunks)
|
| 54 |
+
print(f"[summarizer] transcript_total_chars={len(transcript)}")
|
| 55 |
+
print(f"[summarizer] summary_chunk_chars={SUMMARY_CHUNK_CHARS}, total_chunks={total_chunks}")
|
| 56 |
+
|
| 57 |
summary = ""
|
| 58 |
+
for idx, chunk in enumerate(chunks, start=1):
|
| 59 |
+
print(f"[summarizer] chunk_{idx}_chars={len(chunk)}")
|
| 60 |
+
if idx == 1:
|
| 61 |
+
previous_summary_block = "无。这是第一轮请求。"
|
| 62 |
+
else:
|
| 63 |
+
previous_summary_block = (
|
| 64 |
+
"以下内容是上一轮请求返回的摘要,属于更早时序的内容,不是新的原始会议文本。\n"
|
| 65 |
+
f"{summary}"
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
prompt = (
|
| 69 |
+
"你是会议纪要助手。"
|
| 70 |
+
f"\n当前是第{idx}/{total_chunks}轮总结请求,请严格按照时间顺序理解内容。"
|
| 71 |
+
"\n请区分两类输入:"
|
| 72 |
+
"\n1. <previous_summary> 标签内是上一轮摘要,表示已处理过的、更早时序内容;"
|
| 73 |
+
"\n2. <current_transcript> 标签内是本轮新发送的原始 transcript,时序上晚于上一轮摘要。"
|
| 74 |
+
"\n<previous_summary>"
|
| 75 |
+
f"\n{previous_summary_block}"
|
| 76 |
+
"\n</previous_summary>"
|
| 77 |
+
"\n<current_transcript>"
|
| 78 |
+
"\n以下内容是本轮新发送的 transcript 原文,请与上一轮摘要衔接后理解:"
|
| 79 |
+
f"\n{chunk}"
|
| 80 |
+
"\n</current_transcript>"
|
| 81 |
+
"\n请基于 <previous_summary> 和 <current_transcript> 生成一个更新后的摘要,覆盖截至当前轮为止的全部会议信息。"
|
| 82 |
+
"\n请务必保留此前各轮与本轮中出现的关键决策、结论、待办事项、负责人、时间点、风险与分歧;若新内容与旧摘要有关,请在更新摘要时延续并整合,不要遗漏已有关键事实。"
|
| 83 |
+
f"\n请根据内容丰富度和文本长度,将摘要控制在约{min_chars}到{max_chars}字。"
|
| 84 |
+
"\n输出要求: 只输出摘要正文,不要标题,不要重复标签。/no_think"
|
| 85 |
)
|
| 86 |
|
| 87 |
resp = self.client.chat.completions.create(
|
| 88 |
model=self.model,
|
| 89 |
messages=[
|
| 90 |
+
{"role": "system", "content": "你擅长按时间顺序理解会议记录,并基于已有摘要与新增文本生成连贯的更新摘要。"},
|
| 91 |
{"role": "user", "content": prompt},
|
| 92 |
],
|
| 93 |
temperature=0.2,
|
|
|
|
| 95 |
|
| 96 |
summary = (resp.choices[0].message.content or "").strip()
|
| 97 |
summary = re.sub(r"<think>.*?</think>", "", summary, flags=re.DOTALL).strip()
|
| 98 |
+
print(f"Summary chunk {idx}: {summary}")
|
| 99 |
return summary
|
ax_meeting.egg-info/PKG-INFO
CHANGED
|
@@ -71,78 +71,77 @@ For those who are interested in model conversion, you can try to export axmodel
|
|
| 71 |
- 链接互联网,确保 AX650N 的设备能正常执行 apt install, pip install 等指令
|
| 72 |
- 已验证设备:AX650N DEMO Board
|
| 73 |
|
| 74 |
-
## Python API 运行
|
| 75 |
-
|
| 76 |
-
在python3.10(验证)
|
| 77 |
-
Requirements
|
| 78 |
-
|
| 79 |
-
```
|
| 80 |
-
pip3 install -r requirements.txt
|
| 81 |
-
```
|
| 82 |
-
|
| 83 |
## 流式会议纪要 Web Demo
|
| 84 |
|
| 85 |
-
支持浏览器麦克风实时分段转录,会议结束后自动做说话人聚类 + ASR,并调用 OpenAI 兼容接口生成会议纪要。
|
| 86 |
|
| 87 |
-
|
| 88 |
|
| 89 |
```bash
|
| 90 |
-
|
| 91 |
-
```
|
| 92 |
-
|
| 93 |
-
服务会在启动阶段预加载 VAD / 说话人 / ASR 相关 axmodel,首次会议转写不再叠加模型加载耗时。
|
| 94 |
-
|
| 95 |
-
浏览器访问:
|
| 96 |
|
| 97 |
-
|
| 98 |
-
http://127.0.0.1:8000
|
| 99 |
-
```
|
| 100 |
-
|
| 101 |
-
环境变量(可选,用作会议纪要生成):
|
| 102 |
|
|
|
|
| 103 |
```
|
| 104 |
-
OPENAI_API_KEY=xxx
|
| 105 |
-
OPENAI_BASE_URL=http://127.0.0.1:8001/v1 # 本地 OpenAI 协议服务时设置
|
| 106 |
-
OPENAI_MODEL=AXERA-TECH/Qwen3-1.7B
|
| 107 |
-
HOST=0.0.0.0
|
| 108 |
-
PORT=8000
|
| 109 |
-
SSL_CERT=cert.pem
|
| 110 |
-
SSL_KEY=key.pem
|
| 111 |
-
AX_MODEL_DIR=/path/to/ax_model
|
| 112 |
-
```
|
| 113 |
-
|
| 114 |
-
依赖提示(WebSocket):
|
| 115 |
-
- 请确保安装了 `websockets` 或 `uvicorn[standard]`,否则浏览器实时流式会失败
|
| 116 |
-
|
| 117 |
-
设备权限提示:
|
| 118 |
-
- 如果遇到 `/dev/axcl_host` 权限错误,请用有权限的账号或 `sudo` 运行
|
| 119 |
|
| 120 |
-
|
| 121 |
-
- 如果无法从 pip 获取 `pyaxengine`,请将本地 wheel 放到 `ax_meeting/vendor/`,或设置 `AXENGINE_WHEEL=/path/to/pyaxengine.whl`
|
| 122 |
-
|
| 123 |
-
HTTPS(推荐,便于浏览器麦克风权限):
|
| 124 |
|
| 125 |
```bash
|
| 126 |
openssl req -x509 -newkey rsa:2048 -nodes \\
|
| 127 |
-keyout key.pem -out cert.pem -days 365 \\
|
| 128 |
-
-subj "/CN=<你的IP>"
|
| 129 |
```
|
| 130 |
|
|
|
|
|
|
|
| 131 |
```bash
|
|
|
|
| 132 |
SSL_CERT=cert.pem SSL_KEY=key.pem python -m ax_meeting.server
|
| 133 |
-
```
|
| 134 |
|
| 135 |
-
使用包内自签证书(默认打包在
|
| 136 |
-
|
| 137 |
-
```bash
|
| 138 |
SSL_CERT=ax_meeting/certs/cert.pem SSL_KEY=ax_meeting/certs/key.pem python -m ax_meeting.server
|
| 139 |
-
```
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
网页参数说明(说话人聚类):
|
| 142 |
- `mer_cos` 越小越容易分开说话人(更敏感,可能误分)
|
| 143 |
- `min_cluster_size` 越小越容易分出更多说话人
|
| 144 |
- `AHC` 更稳定但可能偏保守,`spectral` 更灵敏
|
| 145 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
## 生成 wheel 包
|
| 147 |
|
| 148 |
```bash
|
|
@@ -151,9 +150,6 @@ SSL_CERT=ax_meeting/certs/cert.pem SSL_KEY=ax_meeting/certs/key.pem python -m ax
|
|
| 151 |
|
| 152 |
生成结果在 `dist/` 目录。
|
| 153 |
|
| 154 |
-

|
| 155 |
-
|
| 156 |
-
|
| 157 |
## 离线处理脚本
|
| 158 |
|
| 159 |
对单个会议音频文件执行说话人聚类 + ASR,并导出文本,可选会议总结(LLM 通过参数配置):
|
|
|
|
| 71 |
- 链接互联网,确保 AX650N 的设备能正常执行 apt install, pip install 等指令
|
| 72 |
- 已验证设备:AX650N DEMO Board
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
## 流式会议纪要 Web Demo
|
| 75 |
|
| 76 |
+
总体功能:支持浏览器麦克风实时分段转录,会议结束后自动做说话人聚类 + ASR,并调用 OpenAI 兼容接口生成会议纪要。
|
| 77 |
|
| 78 |
+
### 环境配置
|
| 79 |
|
| 80 |
```bash
|
| 81 |
+
cd 3D-Speaker-MT.axera
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
+
pip3 install -r requirements.txt
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
+
pip3 install ./dist/ax_meeting-0.1.1-py3-none-any.whl
|
| 86 |
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
|
| 88 |
+
HTTPS(推荐本地生成自签证书,便于浏览器麦克风权限):
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
```bash
|
| 91 |
openssl req -x509 -newkey rsa:2048 -nodes \\
|
| 92 |
-keyout key.pem -out cert.pem -days 365 \\
|
| 93 |
+
-subj "/CN=<你的IP>"
|
| 94 |
```
|
| 95 |
|
| 96 |
+
### 实时及离线会议转录服务
|
| 97 |
+
|
| 98 |
```bash
|
| 99 |
+
# 使用本地生成的证书
|
| 100 |
SSL_CERT=cert.pem SSL_KEY=key.pem python -m ax_meeting.server
|
|
|
|
| 101 |
|
| 102 |
+
# 或者使用包内自签证书(默认打包在 ax_meeting/certs/)
|
|
|
|
|
|
|
| 103 |
SSL_CERT=ax_meeting/certs/cert.pem SSL_KEY=ax_meeting/certs/key.pem python -m ax_meeting.server
|
|
|
|
| 104 |
|
| 105 |
+
Local URL: https://10.126.29.13:8000
|
| 106 |
+
AX_ASR_OUTPUT_TIMESTAMP=0
|
| 107 |
+
INFO: Started server process [3930453]
|
| 108 |
+
INFO: Waiting for application startup.
|
| 109 |
+
Preloading AX models...
|
| 110 |
+
[INFO] Using provider: AXCLRTExecutionProvider
|
| 111 |
+
[INFO] SOC Name: AX650N
|
| 112 |
+
[INFO] VNPU type: VNPUType.DISABLED
|
| 113 |
+
[INFO] Compiler version: 4.2 057581a9
|
| 114 |
+
[INFO] Using provider: AXCLRTExecutionProvider
|
| 115 |
+
[INFO] SOC Name: AX650N
|
| 116 |
+
[INFO] VNPU type: VNPUType.DISABLED
|
| 117 |
+
[INFO] Compiler version: 4.2 057581a9
|
| 118 |
+
[INFO] Using provider: AXCLRTExecutionProvider
|
| 119 |
+
[INFO] SOC Name: AX650N
|
| 120 |
+
[INFO] VNPU type: VNPUType.DISABLED
|
| 121 |
+
[INFO] Compiler version: 4.2 2cc4ac86
|
| 122 |
+
AX model preload complete.
|
| 123 |
+
INFO: Application startup complete.
|
| 124 |
+
INFO: Uvicorn running on https://0.0.0.0:8000 (Press CTRL+C to quit)
|
| 125 |
+
```
|
| 126 |
网页参数说明(说话人聚类):
|
| 127 |
- `mer_cos` 越小越容易分开说话人(更敏感,可能误分)
|
| 128 |
- `min_cluster_size` 越小越容易分出更多说话人
|
| 129 |
- `AHC` 更稳定但可能偏保守,`spectral` 更灵敏
|
| 130 |
|
| 131 |
+
|
| 132 |
+
### 会议转录+本地大模型纪要总结服务
|
| 133 |
+
|
| 134 |
+
默认已启动本地大模型服务端口8001,详细服务启动见:[AXERA-TECH/Qwen3-1.7B](https://huggingface.co/AXERA-TECH/Qwen3-1.7B)
|
| 135 |
+
|
| 136 |
+
```
|
| 137 |
+
chmod +x start_meeting_server.sh
|
| 138 |
+
./start_meeting_server.sh
|
| 139 |
+
```
|
| 140 |
+

|
| 141 |
+
|
| 142 |
+
设备权限提示:
|
| 143 |
+
- 如果遇到 `/dev/axcl_host` 权限错误,请用有权限的账号或 `sudo` 运行
|
| 144 |
+
|
| 145 |
## 生成 wheel 包
|
| 146 |
|
| 147 |
```bash
|
|
|
|
| 150 |
|
| 151 |
生成结果在 `dist/` 目录。
|
| 152 |
|
|
|
|
|
|
|
|
|
|
| 153 |
## 离线处理脚本
|
| 154 |
|
| 155 |
对单个会议音频文件执行说话人聚类 + ASR,并导出文本,可选会议总结(LLM 通过参数配置):
|
ax_meeting/_upload_1566a625-52a6-4663-a62b-7570dac7b7dd_20200327_2P.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3bd799992bb9327844b78547d3c91b444376cf359b5fdc03f26c14c1c921f0c0
|
| 3 |
+
size 31283820
|
ax_meeting/_upload_1676128a-2d16-45b0-a3d5-faa94dea89bd_20200327_2P.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3bd799992bb9327844b78547d3c91b444376cf359b5fdc03f26c14c1c921f0c0
|
| 3 |
+
size 31283820
|
ax_meeting/_upload_176e329f-da53-4e64-85ca-4ba3f7ac76a3_20200327_2P.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3bd799992bb9327844b78547d3c91b444376cf359b5fdc03f26c14c1c921f0c0
|
| 3 |
+
size 31283820
|
ax_meeting/_upload_911af462-37e5-4314-911b-78fd3024cb7f_20200327_2P.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3bd799992bb9327844b78547d3c91b444376cf359b5fdc03f26c14c1c921f0c0
|
| 3 |
+
size 31283820
|
ax_meeting/_upload_cfac62fb-1bd0-425f-8e31-317aa922604e_20200327_2P.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3bd799992bb9327844b78547d3c91b444376cf359b5fdc03f26c14c1c921f0c0
|
| 3 |
+
size 31283820
|
ax_meeting/_upload_e4b352ae-a156-4e46-b41f-37313325fcfe_20200327_2P.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3bd799992bb9327844b78547d3c91b444376cf359b5fdc03f26c14c1c921f0c0
|
| 3 |
+
size 31283820
|
ax_meeting/_upload_eb77c179-4271-48e8-8fda-8e0dc528417c_20200327_2P.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3bd799992bb9327844b78547d3c91b444376cf359b5fdc03f26c14c1c921f0c0
|
| 3 |
+
size 31283820
|
ax_meeting/config.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
# -*- coding: utf-8 -*-
|
|
|
|
| 2 |
|
| 3 |
SAMPLE_RATE = 16000
|
| 4 |
|
|
@@ -17,6 +18,8 @@ MERGE_VAD_MAX_LEN_MS = 15 * 1000
|
|
| 17 |
# For diarization output segments, keep shorter to avoid mixing speakers
|
| 18 |
DIAR_MERGE_VAD_MAX_LEN_MS = 8 * 1000
|
| 19 |
|
| 20 |
-
# LLM summarization chunking
|
| 21 |
-
SUMMARY_CHUNK_CHARS = 1000
|
| 22 |
-
|
|
|
|
|
|
|
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
+
import os
|
| 3 |
|
| 4 |
SAMPLE_RATE = 16000
|
| 5 |
|
|
|
|
| 18 |
# For diarization output segments, keep shorter to avoid mixing speakers
|
| 19 |
DIAR_MERGE_VAD_MAX_LEN_MS = 8 * 1000
|
| 20 |
|
| 21 |
+
# LLM summarization chunking, measured in transcript characters instead of tokens
|
| 22 |
+
SUMMARY_CHUNK_CHARS = int(os.getenv("SUMMARY_CHUNK_CHARS", 12 * 1000))
|
| 23 |
+
|
| 24 |
+
# Target summary length range in Chinese characters
|
| 25 |
+
SUMMARY_TARGET_CHARS = (100, 500)
|
ax_meeting/diar_asr_cli.py
CHANGED
|
@@ -1,21 +1,29 @@
|
|
| 1 |
-
# -*- coding: utf-8 -*-
|
| 2 |
-
import argparse
|
| 3 |
-
|
|
|
|
| 4 |
|
| 5 |
import numpy as np
|
| 6 |
import soundfile as sf
|
| 7 |
|
| 8 |
from ax_meeting.model_bundle import ModelBundle
|
| 9 |
from ax_meeting.utils.vad_utils import merge_vad
|
| 10 |
-
from ax_meeting.utils.ax_cam_bin import (
|
| 11 |
chunk,
|
| 12 |
distribute_spk,
|
| 13 |
do_clustering,
|
| 14 |
get_trans_sentence_sensevoice,
|
| 15 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
-
def load_audio(path: str, target_sr: int = 16000) -> np.ndarray:
|
| 19 |
audio, sr = sf.read(path, dtype="float32")
|
| 20 |
if audio.ndim > 1:
|
| 21 |
audio = audio.mean(axis=1)
|
|
@@ -27,33 +35,110 @@ def load_audio(path: str, target_sr: int = 16000) -> np.ndarray:
|
|
| 27 |
x_old = np.linspace(0, 1, num=len(audio), endpoint=False)
|
| 28 |
x_new = np.linspace(0, 1, num=new_len, endpoint=False)
|
| 29 |
audio = np.interp(x_new, x_old, audio).astype(np.float32)
|
| 30 |
-
return audio
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
vad_time = [[vad_t[0] / 1000, vad_t[1] / 1000] for vad_t in res_vad]
|
| 48 |
chunks = [c for (st, ed) in vad_time for c in chunk(st, ed)]
|
| 49 |
|
| 50 |
if not chunks:
|
| 51 |
return ""
|
| 52 |
|
| 53 |
-
embeddings = bundle.speaker_infer(speech, fs, chunks=chunks)
|
| 54 |
-
_, diar_results = do_clustering(chunks, embeddings, speaker_num=None, params=spk_params)
|
| 55 |
-
|
| 56 |
-
|
|
|
|
| 57 |
all_metadata = {"merged_words": [], "merged_timestamps": []}
|
| 58 |
audio_duration = speech.shape[0] / float(fs) if fs > 0 else 0.0
|
| 59 |
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
import argparse
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
|
| 6 |
import numpy as np
|
| 7 |
import soundfile as sf
|
| 8 |
|
| 9 |
from ax_meeting.model_bundle import ModelBundle
|
| 10 |
from ax_meeting.utils.vad_utils import merge_vad
|
| 11 |
+
from ax_meeting.utils.ax_cam_bin import (
|
| 12 |
chunk,
|
| 13 |
distribute_spk,
|
| 14 |
do_clustering,
|
| 15 |
get_trans_sentence_sensevoice,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _env_float(name: str, default: float) -> float:
|
| 20 |
+
raw = os.getenv(name)
|
| 21 |
+
if raw is None or raw == "":
|
| 22 |
+
return default
|
| 23 |
+
return float(raw)
|
| 24 |
|
| 25 |
|
| 26 |
+
def load_audio(path: str, target_sr: int = 16000) -> np.ndarray:
|
| 27 |
audio, sr = sf.read(path, dtype="float32")
|
| 28 |
if audio.ndim > 1:
|
| 29 |
audio = audio.mean(axis=1)
|
|
|
|
| 35 |
x_old = np.linspace(0, 1, num=len(audio), endpoint=False)
|
| 36 |
x_new = np.linspace(0, 1, num=new_len, endpoint=False)
|
| 37 |
audio = np.interp(x_new, x_old, audio).astype(np.float32)
|
| 38 |
+
return audio
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _merge_overlapping_vad(vad_segments: list[list[int]], max_gap_ms: int = 0) -> list[list[int]]:
|
| 42 |
+
if not vad_segments:
|
| 43 |
+
return []
|
| 44 |
+
ordered = sorted(vad_segments, key=lambda item: (item[0], item[1]))
|
| 45 |
+
merged = [ordered[0]]
|
| 46 |
+
for start_ms, end_ms in ordered[1:]:
|
| 47 |
+
last = merged[-1]
|
| 48 |
+
if start_ms <= last[1] + max_gap_ms:
|
| 49 |
+
last[1] = max(last[1], end_ms)
|
| 50 |
+
else:
|
| 51 |
+
merged.append([start_ms, end_ms])
|
| 52 |
+
return merged
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def detect_vad_segments_chunked(
|
| 56 |
+
bundle: ModelBundle,
|
| 57 |
+
speech: np.ndarray,
|
| 58 |
+
fs: int = 16000,
|
| 59 |
+
chunk_sec: float | None = None,
|
| 60 |
+
overlap_sec: float | None = None,
|
| 61 |
+
) -> list[list[int]]:
|
| 62 |
+
if speech.size == 0:
|
| 63 |
+
return []
|
| 64 |
+
|
| 65 |
+
chunk_sec = _env_float("DIAR_VAD_CHUNK_SEC", 300.0) if chunk_sec is None else chunk_sec
|
| 66 |
+
overlap_sec = _env_float("DIAR_VAD_OVERLAP_SEC", 2.0) if overlap_sec is None else overlap_sec
|
| 67 |
+
duration_sec = speech.shape[0] / float(fs)
|
| 68 |
+
if chunk_sec <= 0 or duration_sec <= chunk_sec:
|
| 69 |
+
return bundle.vad_infer(speech)
|
| 70 |
+
|
| 71 |
+
chunk_samples = max(int(chunk_sec * fs), 1)
|
| 72 |
+
overlap_samples = max(int(overlap_sec * fs), 0)
|
| 73 |
+
total_samples = speech.shape[0]
|
| 74 |
+
collected: list[list[int]] = []
|
| 75 |
+
|
| 76 |
+
for core_start in range(0, total_samples, chunk_samples):
|
| 77 |
+
core_end = min(core_start + chunk_samples, total_samples)
|
| 78 |
+
read_start = max(0, core_start - overlap_samples)
|
| 79 |
+
read_end = min(total_samples, core_end + overlap_samples)
|
| 80 |
+
chunk_audio = speech[read_start:read_end]
|
| 81 |
+
local_segments = bundle.vad_infer(chunk_audio)
|
| 82 |
+
core_start_ms = int(core_start / fs * 1000)
|
| 83 |
+
core_end_ms = int(core_end / fs * 1000)
|
| 84 |
+
read_start_ms = int(read_start / fs * 1000)
|
| 85 |
+
audio_end_ms = int(total_samples / fs * 1000)
|
| 86 |
+
|
| 87 |
+
for local_start_ms, local_end_ms in local_segments:
|
| 88 |
+
global_start_ms = max(0, int(local_start_ms) + read_start_ms)
|
| 89 |
+
global_end_ms = min(audio_end_ms, int(local_end_ms) + read_start_ms)
|
| 90 |
+
if global_end_ms <= global_start_ms:
|
| 91 |
+
continue
|
| 92 |
+
mid_ms = (global_start_ms + global_end_ms) // 2
|
| 93 |
+
if core_start_ms <= mid_ms < core_end_ms:
|
| 94 |
+
collected.append([global_start_ms, global_end_ms])
|
| 95 |
+
|
| 96 |
+
return _merge_overlapping_vad(collected, max_gap_ms=0)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _remap_speaker_labels_by_first_start(diar_results: list[list[float]]) -> list[list[float]]:
|
| 100 |
+
if not diar_results:
|
| 101 |
+
return []
|
| 102 |
+
first_start_by_spk: dict[int, float] = {}
|
| 103 |
+
for start_sec, _end_sec, spk in sorted(diar_results, key=lambda item: (item[0], item[1])):
|
| 104 |
+
spk_id = int(spk)
|
| 105 |
+
if spk_id not in first_start_by_spk:
|
| 106 |
+
first_start_by_spk[spk_id] = float(start_sec)
|
| 107 |
+
spk_map = {
|
| 108 |
+
spk_id: new_id
|
| 109 |
+
for new_id, spk_id in enumerate(
|
| 110 |
+
sorted(first_start_by_spk, key=lambda spk_id: (first_start_by_spk[spk_id], spk_id))
|
| 111 |
+
)
|
| 112 |
+
}
|
| 113 |
+
return [[start_sec, end_sec, spk_map[int(spk)]] for start_sec, end_sec, spk in diar_results]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def diar_asr(
|
| 117 |
+
bundle: ModelBundle,
|
| 118 |
+
speech: np.ndarray,
|
| 119 |
+
spk_params: dict | None = None,
|
| 120 |
+
fs: int = 16000,
|
| 121 |
+
vad_merge_max_len_ms: int = 15 * 1000,
|
| 122 |
+
) -> str:
|
| 123 |
+
if speech.size == 0:
|
| 124 |
+
return ""
|
| 125 |
+
|
| 126 |
+
res_vad = detect_vad_segments_chunked(bundle, speech, fs=fs)
|
| 127 |
+
if not res_vad:
|
| 128 |
+
return ""
|
| 129 |
+
|
| 130 |
+
vad_segments = merge_vad(res_vad, vad_merge_max_len_ms)
|
| 131 |
vad_time = [[vad_t[0] / 1000, vad_t[1] / 1000] for vad_t in res_vad]
|
| 132 |
chunks = [c for (st, ed) in vad_time for c in chunk(st, ed)]
|
| 133 |
|
| 134 |
if not chunks:
|
| 135 |
return ""
|
| 136 |
|
| 137 |
+
embeddings = bundle.speaker_infer(speech, fs, chunks=chunks)
|
| 138 |
+
_, diar_results = do_clustering(chunks, embeddings, speaker_num=None, params=spk_params)
|
| 139 |
+
diar_results = _remap_speaker_labels_by_first_start(diar_results)
|
| 140 |
+
|
| 141 |
+
lines = []
|
| 142 |
all_metadata = {"merged_words": [], "merged_timestamps": []}
|
| 143 |
audio_duration = speech.shape[0] / float(fs) if fs > 0 else 0.0
|
| 144 |
|
ax_meeting/engines.py
CHANGED
|
@@ -11,6 +11,7 @@ import soundfile as sf
|
|
| 11 |
from ax_meeting.model_bundle import ModelBundle
|
| 12 |
from ax_meeting.config import SAMPLE_RATE, PAUSE_MS, MIN_SEGMENT_MS, MERGE_VAD_MAX_LEN_MS, DIAR_MERGE_VAD_MAX_LEN_MS
|
| 13 |
from ax_meeting.diar_utils import pick_speaker
|
|
|
|
| 14 |
from ax_meeting.utils.vad_utils import merge_vad
|
| 15 |
from ax_meeting.utils.ax_cam_bin import chunk, distribute_spk, do_clustering, get_trans_sentence_sensevoice
|
| 16 |
|
|
@@ -173,6 +174,11 @@ class VadAsrEngine:
|
|
| 173 |
out.append(VadAsrSegment(start_ms, end_ms, text.strip(), seg_audio))
|
| 174 |
return out
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
class DiarAsrEngine:
|
| 178 |
def __init__(
|
|
@@ -205,60 +211,13 @@ class DiarAsrEngine:
|
|
| 205 |
if not chunks:
|
| 206 |
return ""
|
| 207 |
speech = np.concatenate(chunks, axis=0)
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
embeddings = self.bundle.speaker_infer(speech, self.sample_rate, chunks=chunks)
|
| 220 |
-
_, diar_results = do_clustering(chunks, embeddings, speaker_num=None, params=spk_params)
|
| 221 |
-
|
| 222 |
-
lines = []
|
| 223 |
-
all_metadata = {"merged_words": [], "merged_timestamps": []}
|
| 224 |
-
audio_duration = speech.shape[0] / float(self.sample_rate) if self.sample_rate > 0 else 0.0
|
| 225 |
-
|
| 226 |
-
for i, segment in enumerate(vad_segments):
|
| 227 |
-
segment_start, segment_end = segment
|
| 228 |
-
start_sample = int(segment_start / 1000 * self.sample_rate)
|
| 229 |
-
end_sample = min(int(segment_end / 1000 * self.sample_rate), speech.shape[0])
|
| 230 |
-
segment_speech = speech[start_sample:end_sample]
|
| 231 |
-
time_offset_sec = segment_start / 1000.0
|
| 232 |
-
|
| 233 |
-
_, meta = self.bundle.asr_infer(
|
| 234 |
-
segment_speech,
|
| 235 |
-
output_timestamp=True,
|
| 236 |
-
key=f"segment_{i}",
|
| 237 |
-
)
|
| 238 |
-
|
| 239 |
-
seg_words = meta.get("merged_words", [])
|
| 240 |
-
seg_timestamps = meta.get("merged_timestamps", [])
|
| 241 |
-
if seg_words and seg_timestamps and len(seg_words) == len(seg_timestamps):
|
| 242 |
-
adjusted_timestamps = [
|
| 243 |
-
[
|
| 244 |
-
min(ts[0] + time_offset_sec, audio_duration),
|
| 245 |
-
min(ts[1] + time_offset_sec, audio_duration),
|
| 246 |
-
]
|
| 247 |
-
for ts in seg_timestamps
|
| 248 |
-
]
|
| 249 |
-
all_metadata["merged_words"].extend(seg_words)
|
| 250 |
-
all_metadata["merged_timestamps"].extend(adjusted_timestamps)
|
| 251 |
-
|
| 252 |
-
if all_metadata["merged_words"]:
|
| 253 |
-
output_asr = {
|
| 254 |
-
"merged_words": all_metadata["merged_words"],
|
| 255 |
-
"merged_timestamps": all_metadata["merged_timestamps"],
|
| 256 |
-
}
|
| 257 |
-
asr_timestamps = get_trans_sentence_sensevoice(output_asr)
|
| 258 |
-
sentence_info_with_spk = distribute_spk(asr_timestamps, diar_results)
|
| 259 |
-
lines = [
|
| 260 |
-
f"Speaker_{spk}: [{timeinterval[0]:.3f} {timeinterval[1]:.3f}] {text_string}"
|
| 261 |
-
for text_string, timeinterval, spk in sentence_info_with_spk
|
| 262 |
-
]
|
| 263 |
-
|
| 264 |
-
return "\n".join(lines)
|
|
|
|
| 11 |
from ax_meeting.model_bundle import ModelBundle
|
| 12 |
from ax_meeting.config import SAMPLE_RATE, PAUSE_MS, MIN_SEGMENT_MS, MERGE_VAD_MAX_LEN_MS, DIAR_MERGE_VAD_MAX_LEN_MS
|
| 13 |
from ax_meeting.diar_utils import pick_speaker
|
| 14 |
+
from ax_meeting.diar_asr_cli import diar_asr
|
| 15 |
from ax_meeting.utils.vad_utils import merge_vad
|
| 16 |
from ax_meeting.utils.ax_cam_bin import chunk, distribute_spk, do_clustering, get_trans_sentence_sensevoice
|
| 17 |
|
|
|
|
| 174 |
out.append(VadAsrSegment(start_ms, end_ms, text.strip(), seg_audio))
|
| 175 |
return out
|
| 176 |
|
| 177 |
+
def reset(self) -> None:
|
| 178 |
+
self.audio_chunks = []
|
| 179 |
+
self.total_samples = 0
|
| 180 |
+
self.last_processed_ms = 0
|
| 181 |
+
|
| 182 |
|
| 183 |
class DiarAsrEngine:
|
| 184 |
def __init__(
|
|
|
|
| 211 |
if not chunks:
|
| 212 |
return ""
|
| 213 |
speech = np.concatenate(chunks, axis=0)
|
| 214 |
+
return diar_asr(
|
| 215 |
+
self.bundle,
|
| 216 |
+
speech,
|
| 217 |
+
fs=self.sample_rate,
|
| 218 |
+
spk_params=spk_params,
|
| 219 |
+
vad_merge_max_len_ms=DIAR_MERGE_VAD_MAX_LEN_MS,
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
def reset(self) -> None:
|
| 223 |
+
self.audio_chunks = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ax_meeting/pipeline.py
CHANGED
|
@@ -151,3 +151,12 @@ class StreamingMeetingSession:
|
|
| 151 |
fs=self.sample_rate,
|
| 152 |
spk_params=getattr(self, "spk_params", None),
|
| 153 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
fs=self.sample_rate,
|
| 152 |
spk_params=getattr(self, "spk_params", None),
|
| 153 |
)
|
| 154 |
+
|
| 155 |
+
def release(self) -> None:
|
| 156 |
+
self.audio_chunks = []
|
| 157 |
+
self.total_samples = 0
|
| 158 |
+
self.last_processed_ms = 0
|
| 159 |
+
self.last_vad_check_ts = 0.0
|
| 160 |
+
self.processing = False
|
| 161 |
+
if hasattr(self, "spk_params"):
|
| 162 |
+
delattr(self, "spk_params")
|
ax_meeting/server.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
import asyncio
|
| 3 |
from contextlib import asynccontextmanager
|
|
|
|
|
|
|
| 4 |
import json
|
| 5 |
import os
|
| 6 |
import uuid
|
|
@@ -19,6 +21,7 @@ from ax_meeting.summarizer import IncrementalSummarizer
|
|
| 19 |
|
| 20 |
APP_DIR = Path(__file__).parent
|
| 21 |
STATIC_DIR = APP_DIR / "static"
|
|
|
|
| 22 |
|
| 23 |
def preload_models() -> None:
|
| 24 |
print("Preloading AX models...")
|
|
@@ -43,6 +46,44 @@ TRANSCRIPT_STORE = {}
|
|
| 43 |
RECORDING_STORE = {}
|
| 44 |
RECORDING_DIR = APP_DIR / "recordings"
|
| 45 |
RECORDING_DIR.mkdir(exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
|
| 48 |
async def summarize_transcript_text(
|
|
@@ -51,6 +92,8 @@ async def summarize_transcript_text(
|
|
| 51 |
openai_api_key: str | None = None,
|
| 52 |
openai_model: str | None = None,
|
| 53 |
) -> str:
|
|
|
|
|
|
|
| 54 |
# 每次请求都新建一次总结器,避免复用上一次会话上下文。
|
| 55 |
summarizer = IncrementalSummarizer(
|
| 56 |
api_key=openai_api_key or None,
|
|
@@ -76,6 +119,7 @@ def index():
|
|
| 76 |
async def ws_endpoint(ws: WebSocket):
|
| 77 |
await ws.accept()
|
| 78 |
session_id = str(uuid.uuid4())
|
|
|
|
| 79 |
try:
|
| 80 |
await asyncio.to_thread(models.ensure_loaded)
|
| 81 |
except Exception as e:
|
|
@@ -106,20 +150,33 @@ async def ws_endpoint(ws: WebSocket):
|
|
| 106 |
session.spk_params = data.get("spk_params")
|
| 107 |
result = await session.finalize()
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
| 117 |
|
| 118 |
final_text = result.get("transcript", "")
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
await ws.send_json({"type": "final_transcript", "text": final_text})
|
| 121 |
await ws.send_json({"type": "summary", "text": summary})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
await ws.send_json({"type": "end_ack"})
|
|
|
|
| 123 |
|
| 124 |
elif msg_type == "ping":
|
| 125 |
await ws.send_json({"type": "pong"})
|
|
@@ -129,6 +186,10 @@ async def ws_endpoint(ws: WebSocket):
|
|
| 129 |
|
| 130 |
except WebSocketDisconnect:
|
| 131 |
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
|
| 134 |
@app.post("/diar_asr")
|
|
@@ -154,14 +215,18 @@ async def diar_asr_api(
|
|
| 154 |
temp_path = APP_DIR / f"_upload_{session_id}_{file.filename}"
|
| 155 |
content = await file.read()
|
| 156 |
temp_path.write_bytes(content)
|
|
|
|
| 157 |
try:
|
| 158 |
engine = DiarAsrEngine(models)
|
| 159 |
text = engine.transcribe(str(temp_path), spk_params=spk_params or None)
|
| 160 |
finally:
|
|
|
|
|
|
|
| 161 |
try:
|
| 162 |
temp_path.unlink(missing_ok=True)
|
| 163 |
except Exception:
|
| 164 |
pass
|
|
|
|
| 165 |
TRANSCRIPT_STORE[session_id] = text
|
| 166 |
return {"session_id": session_id, "text": text}
|
| 167 |
|
|
@@ -173,7 +238,12 @@ async def summary_api(
|
|
| 173 |
openai_api_key: str = Form(default=""),
|
| 174 |
openai_model: str = Form(default=""),
|
| 175 |
):
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
if not transcript:
|
| 178 |
raise HTTPException(status_code=400, detail="当前会话没有可总结的转录文本,请先完成导入识别或会议转写。")
|
| 179 |
|
|
@@ -187,12 +257,25 @@ async def summary_api(
|
|
| 187 |
except Exception as e:
|
| 188 |
raise HTTPException(status_code=500, detail=f"会议纪要生成失败: {e}") from e
|
| 189 |
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
|
| 193 |
@app.get("/export/{session_id}")
|
| 194 |
async def export_transcript(session_id: str):
|
| 195 |
-
text = TRANSCRIPT_STORE.get(session_id
|
|
|
|
|
|
|
| 196 |
return PlainTextResponse(text, media_type="text/plain; charset=utf-8")
|
| 197 |
|
| 198 |
|
|
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
import asyncio
|
| 3 |
from contextlib import asynccontextmanager
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
import gc
|
| 6 |
import json
|
| 7 |
import os
|
| 8 |
import uuid
|
|
|
|
| 21 |
|
| 22 |
APP_DIR = Path(__file__).parent
|
| 23 |
STATIC_DIR = APP_DIR / "static"
|
| 24 |
+
RESULT_DIR = Path(os.getenv("RESULT_TXT_DIR", "result_txt")).resolve()
|
| 25 |
|
| 26 |
def preload_models() -> None:
|
| 27 |
print("Preloading AX models...")
|
|
|
|
| 46 |
RECORDING_STORE = {}
|
| 47 |
RECORDING_DIR = APP_DIR / "recordings"
|
| 48 |
RECORDING_DIR.mkdir(exist_ok=True)
|
| 49 |
+
DISABLE_SUMMARY = os.getenv("DISABLE_SUMMARY", "0").lower() in {"1", "true", "yes", "on"}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _malloc_trim() -> None:
|
| 53 |
+
try:
|
| 54 |
+
import ctypes
|
| 55 |
+
|
| 56 |
+
ctypes.CDLL("libc.so.6").malloc_trim(0)
|
| 57 |
+
except Exception:
|
| 58 |
+
pass
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def release_process_memory() -> None:
|
| 62 |
+
gc.collect()
|
| 63 |
+
_malloc_trim()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def persist_result_texts(transcript: str, summary: str) -> tuple[Path, Path]:
|
| 67 |
+
RESULT_DIR.mkdir(parents=True, exist_ok=True)
|
| 68 |
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
| 69 |
+
transcript_path = RESULT_DIR / f"{stamp}_transcript.txt"
|
| 70 |
+
summary_path = RESULT_DIR / f"{stamp}_summary.txt"
|
| 71 |
+
transcript_path.write_text(transcript, encoding="utf-8")
|
| 72 |
+
summary_path.write_text(summary, encoding="utf-8")
|
| 73 |
+
return transcript_path, summary_path
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def clear_session_cache(session_id: str, session: StreamingMeetingSession | None = None) -> None:
|
| 77 |
+
TRANSCRIPT_STORE.pop(session_id, None)
|
| 78 |
+
recording_path = RECORDING_STORE.pop(session_id, None)
|
| 79 |
+
if recording_path:
|
| 80 |
+
try:
|
| 81 |
+
recording_path.unlink(missing_ok=True)
|
| 82 |
+
except Exception:
|
| 83 |
+
pass
|
| 84 |
+
if session is not None:
|
| 85 |
+
session.release()
|
| 86 |
+
release_process_memory()
|
| 87 |
|
| 88 |
|
| 89 |
async def summarize_transcript_text(
|
|
|
|
| 92 |
openai_api_key: str | None = None,
|
| 93 |
openai_model: str | None = None,
|
| 94 |
) -> str:
|
| 95 |
+
if DISABLE_SUMMARY:
|
| 96 |
+
return ""
|
| 97 |
# 每次请求都新建一次总结器,避免复用上一次会话上下文。
|
| 98 |
summarizer = IncrementalSummarizer(
|
| 99 |
api_key=openai_api_key or None,
|
|
|
|
| 119 |
async def ws_endpoint(ws: WebSocket):
|
| 120 |
await ws.accept()
|
| 121 |
session_id = str(uuid.uuid4())
|
| 122 |
+
session = None
|
| 123 |
try:
|
| 124 |
await asyncio.to_thread(models.ensure_loaded)
|
| 125 |
except Exception as e:
|
|
|
|
| 150 |
session.spk_params = data.get("spk_params")
|
| 151 |
result = await session.finalize()
|
| 152 |
|
| 153 |
+
summary = ""
|
| 154 |
+
if not DISABLE_SUMMARY:
|
| 155 |
+
try:
|
| 156 |
+
summary = await summarize_transcript_text(
|
| 157 |
+
result.get("transcript", ""),
|
| 158 |
+
openai_base_url=data.get("openai_base_url") or None,
|
| 159 |
+
)
|
| 160 |
+
except Exception as e:
|
| 161 |
+
summary = f"LLM 总结失败: {e}"
|
| 162 |
|
| 163 |
final_text = result.get("transcript", "")
|
| 164 |
+
try:
|
| 165 |
+
transcript_path, summary_path = persist_result_texts(final_text, summary)
|
| 166 |
+
except Exception as e:
|
| 167 |
+
await ws.send_json({"type": "error", "message": f"结果文件保存失败: {e}"})
|
| 168 |
+
continue
|
| 169 |
await ws.send_json({"type": "final_transcript", "text": final_text})
|
| 170 |
await ws.send_json({"type": "summary", "text": summary})
|
| 171 |
+
await ws.send_json(
|
| 172 |
+
{
|
| 173 |
+
"type": "saved_files",
|
| 174 |
+
"transcript_path": str(transcript_path),
|
| 175 |
+
"summary_path": str(summary_path),
|
| 176 |
+
}
|
| 177 |
+
)
|
| 178 |
await ws.send_json({"type": "end_ack"})
|
| 179 |
+
clear_session_cache(session_id, session=session)
|
| 180 |
|
| 181 |
elif msg_type == "ping":
|
| 182 |
await ws.send_json({"type": "pong"})
|
|
|
|
| 186 |
|
| 187 |
except WebSocketDisconnect:
|
| 188 |
return
|
| 189 |
+
finally:
|
| 190 |
+
if session is not None:
|
| 191 |
+
session.release()
|
| 192 |
+
release_process_memory()
|
| 193 |
|
| 194 |
|
| 195 |
@app.post("/diar_asr")
|
|
|
|
| 215 |
temp_path = APP_DIR / f"_upload_{session_id}_{file.filename}"
|
| 216 |
content = await file.read()
|
| 217 |
temp_path.write_bytes(content)
|
| 218 |
+
engine = None
|
| 219 |
try:
|
| 220 |
engine = DiarAsrEngine(models)
|
| 221 |
text = engine.transcribe(str(temp_path), spk_params=spk_params or None)
|
| 222 |
finally:
|
| 223 |
+
if engine is not None:
|
| 224 |
+
engine.reset()
|
| 225 |
try:
|
| 226 |
temp_path.unlink(missing_ok=True)
|
| 227 |
except Exception:
|
| 228 |
pass
|
| 229 |
+
release_process_memory()
|
| 230 |
TRANSCRIPT_STORE[session_id] = text
|
| 231 |
return {"session_id": session_id, "text": text}
|
| 232 |
|
|
|
|
| 238 |
openai_api_key: str = Form(default=""),
|
| 239 |
openai_model: str = Form(default=""),
|
| 240 |
):
|
| 241 |
+
if DISABLE_SUMMARY:
|
| 242 |
+
raise HTTPException(status_code=503, detail="summary disabled")
|
| 243 |
+
transcript = TRANSCRIPT_STORE.get(session_id)
|
| 244 |
+
if transcript is None:
|
| 245 |
+
raise HTTPException(status_code=404, detail="session not found")
|
| 246 |
+
transcript = transcript.strip()
|
| 247 |
if not transcript:
|
| 248 |
raise HTTPException(status_code=400, detail="当前会话没有可总结的转录文本,请先完成导入识别或会议转写。")
|
| 249 |
|
|
|
|
| 257 |
except Exception as e:
|
| 258 |
raise HTTPException(status_code=500, detail=f"会议纪要生成失败: {e}") from e
|
| 259 |
|
| 260 |
+
try:
|
| 261 |
+
transcript_path, summary_path = persist_result_texts(transcript, summary)
|
| 262 |
+
except Exception as e:
|
| 263 |
+
raise HTTPException(status_code=500, detail=f"结果文件保存失败: {e}") from e
|
| 264 |
+
|
| 265 |
+
clear_session_cache(session_id)
|
| 266 |
+
return {
|
| 267 |
+
"session_id": session_id,
|
| 268 |
+
"text": summary,
|
| 269 |
+
"transcript_path": str(transcript_path),
|
| 270 |
+
"summary_path": str(summary_path),
|
| 271 |
+
}
|
| 272 |
|
| 273 |
|
| 274 |
@app.get("/export/{session_id}")
|
| 275 |
async def export_transcript(session_id: str):
|
| 276 |
+
text = TRANSCRIPT_STORE.get(session_id)
|
| 277 |
+
if text is None:
|
| 278 |
+
return PlainTextResponse("not found", status_code=404)
|
| 279 |
return PlainTextResponse(text, media_type="text/plain; charset=utf-8")
|
| 280 |
|
| 281 |
|
ax_meeting/summarizer.py
CHANGED
|
@@ -11,26 +11,24 @@ except Exception as e: # pragma: no cover
|
|
| 11 |
OpenAI = None
|
| 12 |
|
| 13 |
|
| 14 |
-
def
|
| 15 |
text = text.strip()
|
| 16 |
if not text:
|
| 17 |
return []
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
chunks = [chunks[0][i:i + max_chars] for i in range(0, len(chunks[0]), max_chars)]
|
| 33 |
-
return chunks
|
| 34 |
|
| 35 |
|
| 36 |
class IncrementalSummarizer:
|
|
@@ -46,24 +44,50 @@ class IncrementalSummarizer:
|
|
| 46 |
self.model = model if model is not None else os.getenv("OPENAI_MODEL", "AXERA-TECH/Qwen3-1.7B")
|
| 47 |
|
| 48 |
def summarize_incrementally(self, transcript: str) -> str:
|
| 49 |
-
|
|
|
|
| 50 |
if not chunks:
|
| 51 |
return ""
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
summary = ""
|
| 54 |
-
for idx, chunk in enumerate(chunks):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
prompt = (
|
| 56 |
-
"你是会议纪要助手。"
|
| 57 |
-
f"\n前
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
)
|
| 62 |
|
| 63 |
resp = self.client.chat.completions.create(
|
| 64 |
model=self.model,
|
| 65 |
messages=[
|
| 66 |
-
{"role": "system", "content": "你擅长
|
| 67 |
{"role": "user", "content": prompt},
|
| 68 |
],
|
| 69 |
temperature=0.2,
|
|
@@ -71,5 +95,5 @@ class IncrementalSummarizer:
|
|
| 71 |
|
| 72 |
summary = (resp.choices[0].message.content or "").strip()
|
| 73 |
summary = re.sub(r"<think>.*?</think>", "", summary, flags=re.DOTALL).strip()
|
| 74 |
-
print(f"Summary chunk {idx
|
| 75 |
return summary
|
|
|
|
| 11 |
OpenAI = None
|
| 12 |
|
| 13 |
|
| 14 |
+
def _split_text_fixed_size(text: str, chunk_chars: int) -> List[str]:
|
| 15 |
text = text.strip()
|
| 16 |
if not text:
|
| 17 |
return []
|
| 18 |
+
if chunk_chars <= 0:
|
| 19 |
+
raise ValueError("SUMMARY_CHUNK_CHARS must be > 0")
|
| 20 |
+
return [text[i:i + chunk_chars] for i in range(0, len(text), chunk_chars)]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _summary_target_range() -> tuple[int, int]:
|
| 24 |
+
if isinstance(SUMMARY_TARGET_CHARS, (tuple, list)) and len(SUMMARY_TARGET_CHARS) == 2:
|
| 25 |
+
min_chars = int(SUMMARY_TARGET_CHARS[0])
|
| 26 |
+
max_chars = int(SUMMARY_TARGET_CHARS[1])
|
| 27 |
+
else:
|
| 28 |
+
min_chars = max_chars = int(SUMMARY_TARGET_CHARS)
|
| 29 |
+
if min_chars > max_chars:
|
| 30 |
+
min_chars, max_chars = max_chars, min_chars
|
| 31 |
+
return min_chars, max_chars
|
|
|
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
class IncrementalSummarizer:
|
|
|
|
| 44 |
self.model = model if model is not None else os.getenv("OPENAI_MODEL", "AXERA-TECH/Qwen3-1.7B")
|
| 45 |
|
| 46 |
def summarize_incrementally(self, transcript: str) -> str:
|
| 47 |
+
transcript = transcript.strip()
|
| 48 |
+
chunks = _split_text_fixed_size(transcript, SUMMARY_CHUNK_CHARS)
|
| 49 |
if not chunks:
|
| 50 |
return ""
|
| 51 |
|
| 52 |
+
min_chars, max_chars = _summary_target_range()
|
| 53 |
+
total_chunks = len(chunks)
|
| 54 |
+
print(f"[summarizer] transcript_total_chars={len(transcript)}")
|
| 55 |
+
print(f"[summarizer] summary_chunk_chars={SUMMARY_CHUNK_CHARS}, total_chunks={total_chunks}")
|
| 56 |
+
|
| 57 |
summary = ""
|
| 58 |
+
for idx, chunk in enumerate(chunks, start=1):
|
| 59 |
+
print(f"[summarizer] chunk_{idx}_chars={len(chunk)}")
|
| 60 |
+
if idx == 1:
|
| 61 |
+
previous_summary_block = "无。这是第一轮请求。"
|
| 62 |
+
else:
|
| 63 |
+
previous_summary_block = (
|
| 64 |
+
"以下内容是上一轮请求返回的摘要,属于更早时序的内容,不是新的原始会议文本。\n"
|
| 65 |
+
f"{summary}"
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
prompt = (
|
| 69 |
+
"你是会议纪要助手。"
|
| 70 |
+
f"\n当前是第{idx}/{total_chunks}轮总结请求,请严格按照时间顺序理解内容。"
|
| 71 |
+
"\n请区分两类输入:"
|
| 72 |
+
"\n1. <previous_summary> 标签内是上一轮摘要,表示已处理过的、更早时序内容;"
|
| 73 |
+
"\n2. <current_transcript> 标签内是本轮新发送的原始 transcript,时序上晚于上一轮摘要。"
|
| 74 |
+
"\n<previous_summary>"
|
| 75 |
+
f"\n{previous_summary_block}"
|
| 76 |
+
"\n</previous_summary>"
|
| 77 |
+
"\n<current_transcript>"
|
| 78 |
+
"\n以下内容是本轮新发送的 transcript 原文,请与上一轮摘要衔接后理解:"
|
| 79 |
+
f"\n{chunk}"
|
| 80 |
+
"\n</current_transcript>"
|
| 81 |
+
"\n请基于 <previous_summary> 和 <current_transcript> 生成一个更新后的摘要,覆盖截至当前轮为止的全部会议信息。"
|
| 82 |
+
"\n请务必保留此前各轮与本轮中出现的关键决策、结论、待办事项、负责人、时间点、风险与分歧;若新内容与旧摘要有关,请在更新摘要时延续并整合,不要遗漏已有关键事实。"
|
| 83 |
+
f"\n请根据内容丰富度和文本长度,将摘要控制在约{min_chars}到{max_chars}字。"
|
| 84 |
+
"\n输出要求: 只输出摘要正文,不要标题,不要重复标签。/no_think"
|
| 85 |
)
|
| 86 |
|
| 87 |
resp = self.client.chat.completions.create(
|
| 88 |
model=self.model,
|
| 89 |
messages=[
|
| 90 |
+
{"role": "system", "content": "你擅长按时间顺序理解会议记录,并基于已有摘要与新增文本生成连贯的更新摘要。"},
|
| 91 |
{"role": "user", "content": prompt},
|
| 92 |
],
|
| 93 |
temperature=0.2,
|
|
|
|
| 95 |
|
| 96 |
summary = (resp.choices[0].message.content or "").strip()
|
| 97 |
summary = re.sub(r"<think>.*?</think>", "", summary, flags=re.DOTALL).strip()
|
| 98 |
+
print(f"Summary chunk {idx}: {summary}")
|
| 99 |
return summary
|
ax_meeting/utils/ax_cam_bin.py
CHANGED
|
@@ -209,8 +209,8 @@ class AX_SpeakerEmbeddingInference:
|
|
| 209 |
if wav.ndim > 1:
|
| 210 |
wav = wav.reshape(-1)
|
| 211 |
|
| 212 |
-
|
| 213 |
-
max_len = max(
|
| 214 |
max_len = max(max_len, 57900)
|
| 215 |
|
| 216 |
def circle_pad_np(x: np.ndarray, target_len: int) -> np.ndarray:
|
|
@@ -220,14 +220,14 @@ class AX_SpeakerEmbeddingInference:
|
|
| 220 |
xcat = np.tile(x, n)
|
| 221 |
return xcat[:target_len]
|
| 222 |
|
| 223 |
-
wavs = [circle_pad_np(x, max_len) for x in wavs]
|
| 224 |
-
|
| 225 |
batch_size = 1
|
| 226 |
embeddings = []
|
| 227 |
-
for i in range(0, len(
|
| 228 |
-
|
| 229 |
feats_list = []
|
| 230 |
-
for
|
|
|
|
|
|
|
| 231 |
feat = compute_fbank(w, fs, n_mels=80, mean_nor=True)
|
| 232 |
if feat.shape[0] >= 360:
|
| 233 |
feat = feat[:360]
|
|
|
|
| 209 |
if wav.ndim > 1:
|
| 210 |
wav = wav.reshape(-1)
|
| 211 |
|
| 212 |
+
chunk_ranges = [(int(st * fs), int(ed * fs)) for st, ed in chunks]
|
| 213 |
+
max_len = max((max(ed - st, 0) for st, ed in chunk_ranges), default=0)
|
| 214 |
max_len = max(max_len, 57900)
|
| 215 |
|
| 216 |
def circle_pad_np(x: np.ndarray, target_len: int) -> np.ndarray:
|
|
|
|
| 220 |
xcat = np.tile(x, n)
|
| 221 |
return xcat[:target_len]
|
| 222 |
|
|
|
|
|
|
|
| 223 |
batch_size = 1
|
| 224 |
embeddings = []
|
| 225 |
+
for i in range(0, len(chunk_ranges), batch_size):
|
| 226 |
+
batch_ranges = chunk_ranges[i:i+batch_size]
|
| 227 |
feats_list = []
|
| 228 |
+
for st, ed in batch_ranges:
|
| 229 |
+
w = wav[st:ed]
|
| 230 |
+
w = circle_pad_np(w, max_len)
|
| 231 |
feat = compute_fbank(w, fs, n_mels=80, mean_nor=True)
|
| 232 |
if feat.shape[0] >= 360:
|
| 233 |
feat = feat[:360]
|
ax_meeting/utils/frontend.py
CHANGED
|
@@ -11,6 +11,10 @@ root_dir = Path(__file__).resolve().parent
|
|
| 11 |
logger_initialized = {}
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
class WavFrontend:
|
| 15 |
"""Conventional frontend structure for ASR."""
|
| 16 |
|
|
@@ -51,9 +55,9 @@ class WavFrontend:
|
|
| 51 |
self.reset_status()
|
| 52 |
|
| 53 |
def fbank(self, waveform: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
| 54 |
-
waveform =
|
| 55 |
self.fbank_fn = knf.OnlineFbank(self.opts)
|
| 56 |
-
self.fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform
|
| 57 |
frames = self.fbank_fn.num_frames_ready
|
| 58 |
mat = np.empty([frames, self.opts.mel_opts.num_bins])
|
| 59 |
for i in range(frames):
|
|
@@ -63,9 +67,9 @@ class WavFrontend:
|
|
| 63 |
return feat, feat_len
|
| 64 |
|
| 65 |
def fbank_online(self, waveform: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
| 66 |
-
waveform =
|
| 67 |
# self.fbank_fn = knf.OnlineFbank(self.opts)
|
| 68 |
-
self.fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform
|
| 69 |
frames = self.fbank_fn.num_frames_ready
|
| 70 |
mat = np.empty([frames, self.opts.mel_opts.num_bins])
|
| 71 |
for i in range(self.fbank_beg_idx, frames):
|
|
|
|
| 11 |
logger_initialized = {}
|
| 12 |
|
| 13 |
|
| 14 |
+
def _prepare_waveform_buffer(waveform: np.ndarray) -> np.ndarray:
|
| 15 |
+
return np.ascontiguousarray(waveform * (1 << 15), dtype=np.float32)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
class WavFrontend:
|
| 19 |
"""Conventional frontend structure for ASR."""
|
| 20 |
|
|
|
|
| 55 |
self.reset_status()
|
| 56 |
|
| 57 |
def fbank(self, waveform: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
| 58 |
+
waveform = _prepare_waveform_buffer(waveform)
|
| 59 |
self.fbank_fn = knf.OnlineFbank(self.opts)
|
| 60 |
+
self.fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform)
|
| 61 |
frames = self.fbank_fn.num_frames_ready
|
| 62 |
mat = np.empty([frames, self.opts.mel_opts.num_bins])
|
| 63 |
for i in range(frames):
|
|
|
|
| 67 |
return feat, feat_len
|
| 68 |
|
| 69 |
def fbank_online(self, waveform: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
| 70 |
+
waveform = _prepare_waveform_buffer(waveform)
|
| 71 |
# self.fbank_fn = knf.OnlineFbank(self.opts)
|
| 72 |
+
self.fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform)
|
| 73 |
frames = self.fbank_fn.num_frames_ready
|
| 74 |
mat = np.empty([frames, self.opts.mel_opts.num_bins])
|
| 75 |
for i in range(self.fbank_beg_idx, frames):
|
ax_meeting/utils/utils/frontend.py
CHANGED
|
@@ -12,6 +12,10 @@ root_dir = Path(__file__).resolve().parent
|
|
| 12 |
logger_initialized = {}
|
| 13 |
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
class WavFrontend:
|
| 16 |
"""Conventional frontend structure for ASR."""
|
| 17 |
|
|
@@ -52,9 +56,9 @@ class WavFrontend:
|
|
| 52 |
self.reset_status()
|
| 53 |
|
| 54 |
def fbank(self, waveform: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
| 55 |
-
waveform =
|
| 56 |
fbank_fn = knf.OnlineFbank(self.opts)
|
| 57 |
-
fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform
|
| 58 |
frames = fbank_fn.num_frames_ready
|
| 59 |
mat = np.empty([frames, self.opts.mel_opts.num_bins])
|
| 60 |
for i in range(frames):
|
|
@@ -64,9 +68,9 @@ class WavFrontend:
|
|
| 64 |
return feat, feat_len
|
| 65 |
|
| 66 |
def fbank_online(self, waveform: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
| 67 |
-
waveform =
|
| 68 |
# self.fbank_fn = knf.OnlineFbank(self.opts)
|
| 69 |
-
self.fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform
|
| 70 |
frames = self.fbank_fn.num_frames_ready
|
| 71 |
mat = np.empty([frames, self.opts.mel_opts.num_bins])
|
| 72 |
for i in range(self.fbank_beg_idx, frames):
|
|
|
|
| 12 |
logger_initialized = {}
|
| 13 |
|
| 14 |
|
| 15 |
+
def _prepare_waveform_buffer(waveform: np.ndarray) -> np.ndarray:
|
| 16 |
+
return np.ascontiguousarray(waveform * (1 << 15), dtype=np.float32)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
class WavFrontend:
|
| 20 |
"""Conventional frontend structure for ASR."""
|
| 21 |
|
|
|
|
| 56 |
self.reset_status()
|
| 57 |
|
| 58 |
def fbank(self, waveform: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
| 59 |
+
waveform = _prepare_waveform_buffer(waveform)
|
| 60 |
fbank_fn = knf.OnlineFbank(self.opts)
|
| 61 |
+
fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform)
|
| 62 |
frames = fbank_fn.num_frames_ready
|
| 63 |
mat = np.empty([frames, self.opts.mel_opts.num_bins])
|
| 64 |
for i in range(frames):
|
|
|
|
| 68 |
return feat, feat_len
|
| 69 |
|
| 70 |
def fbank_online(self, waveform: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
| 71 |
+
waveform = _prepare_waveform_buffer(waveform)
|
| 72 |
# self.fbank_fn = knf.OnlineFbank(self.opts)
|
| 73 |
+
self.fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform)
|
| 74 |
frames = self.fbank_fn.num_frames_ready
|
| 75 |
mat = np.empty([frames, self.opts.mel_opts.num_bins])
|
| 76 |
for i in range(self.fbank_beg_idx, frames):
|
build/lib/ax_meeting/config.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
# -*- coding: utf-8 -*-
|
|
|
|
| 2 |
|
| 3 |
SAMPLE_RATE = 16000
|
| 4 |
|
|
@@ -17,6 +18,8 @@ MERGE_VAD_MAX_LEN_MS = 15 * 1000
|
|
| 17 |
# For diarization output segments, keep shorter to avoid mixing speakers
|
| 18 |
DIAR_MERGE_VAD_MAX_LEN_MS = 8 * 1000
|
| 19 |
|
| 20 |
-
# LLM summarization chunking
|
| 21 |
-
SUMMARY_CHUNK_CHARS = 1000
|
| 22 |
-
|
|
|
|
|
|
|
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
+
import os
|
| 3 |
|
| 4 |
SAMPLE_RATE = 16000
|
| 5 |
|
|
|
|
| 18 |
# For diarization output segments, keep shorter to avoid mixing speakers
|
| 19 |
DIAR_MERGE_VAD_MAX_LEN_MS = 8 * 1000
|
| 20 |
|
| 21 |
+
# LLM summarization chunking, measured in transcript characters instead of tokens
|
| 22 |
+
SUMMARY_CHUNK_CHARS = int(os.getenv("SUMMARY_CHUNK_CHARS", 12 * 1000))
|
| 23 |
+
|
| 24 |
+
# Target summary length range in Chinese characters
|
| 25 |
+
SUMMARY_TARGET_CHARS = (100, 500)
|
build/lib/ax_meeting/diar_asr_cli.py
CHANGED
|
@@ -1,21 +1,29 @@
|
|
| 1 |
-
# -*- coding: utf-8 -*-
|
| 2 |
-
import argparse
|
| 3 |
-
|
|
|
|
| 4 |
|
| 5 |
import numpy as np
|
| 6 |
import soundfile as sf
|
| 7 |
|
| 8 |
from ax_meeting.model_bundle import ModelBundle
|
| 9 |
from ax_meeting.utils.vad_utils import merge_vad
|
| 10 |
-
from ax_meeting.utils.ax_cam_bin import (
|
| 11 |
chunk,
|
| 12 |
distribute_spk,
|
| 13 |
do_clustering,
|
| 14 |
get_trans_sentence_sensevoice,
|
| 15 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
-
def load_audio(path: str, target_sr: int = 16000) -> np.ndarray:
|
| 19 |
audio, sr = sf.read(path, dtype="float32")
|
| 20 |
if audio.ndim > 1:
|
| 21 |
audio = audio.mean(axis=1)
|
|
@@ -27,33 +35,110 @@ def load_audio(path: str, target_sr: int = 16000) -> np.ndarray:
|
|
| 27 |
x_old = np.linspace(0, 1, num=len(audio), endpoint=False)
|
| 28 |
x_new = np.linspace(0, 1, num=new_len, endpoint=False)
|
| 29 |
audio = np.interp(x_new, x_old, audio).astype(np.float32)
|
| 30 |
-
return audio
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
def
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
vad_time = [[vad_t[0] / 1000, vad_t[1] / 1000] for vad_t in res_vad]
|
| 48 |
chunks = [c for (st, ed) in vad_time for c in chunk(st, ed)]
|
| 49 |
|
| 50 |
if not chunks:
|
| 51 |
return ""
|
| 52 |
|
| 53 |
-
embeddings = bundle.speaker_infer(speech, fs, chunks=chunks)
|
| 54 |
-
_, diar_results = do_clustering(chunks, embeddings, speaker_num=None, params=spk_params)
|
| 55 |
-
|
| 56 |
-
|
|
|
|
| 57 |
all_metadata = {"merged_words": [], "merged_timestamps": []}
|
| 58 |
audio_duration = speech.shape[0] / float(fs) if fs > 0 else 0.0
|
| 59 |
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
import argparse
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
|
| 6 |
import numpy as np
|
| 7 |
import soundfile as sf
|
| 8 |
|
| 9 |
from ax_meeting.model_bundle import ModelBundle
|
| 10 |
from ax_meeting.utils.vad_utils import merge_vad
|
| 11 |
+
from ax_meeting.utils.ax_cam_bin import (
|
| 12 |
chunk,
|
| 13 |
distribute_spk,
|
| 14 |
do_clustering,
|
| 15 |
get_trans_sentence_sensevoice,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _env_float(name: str, default: float) -> float:
|
| 20 |
+
raw = os.getenv(name)
|
| 21 |
+
if raw is None or raw == "":
|
| 22 |
+
return default
|
| 23 |
+
return float(raw)
|
| 24 |
|
| 25 |
|
| 26 |
+
def load_audio(path: str, target_sr: int = 16000) -> np.ndarray:
|
| 27 |
audio, sr = sf.read(path, dtype="float32")
|
| 28 |
if audio.ndim > 1:
|
| 29 |
audio = audio.mean(axis=1)
|
|
|
|
| 35 |
x_old = np.linspace(0, 1, num=len(audio), endpoint=False)
|
| 36 |
x_new = np.linspace(0, 1, num=new_len, endpoint=False)
|
| 37 |
audio = np.interp(x_new, x_old, audio).astype(np.float32)
|
| 38 |
+
return audio
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _merge_overlapping_vad(vad_segments: list[list[int]], max_gap_ms: int = 0) -> list[list[int]]:
|
| 42 |
+
if not vad_segments:
|
| 43 |
+
return []
|
| 44 |
+
ordered = sorted(vad_segments, key=lambda item: (item[0], item[1]))
|
| 45 |
+
merged = [ordered[0]]
|
| 46 |
+
for start_ms, end_ms in ordered[1:]:
|
| 47 |
+
last = merged[-1]
|
| 48 |
+
if start_ms <= last[1] + max_gap_ms:
|
| 49 |
+
last[1] = max(last[1], end_ms)
|
| 50 |
+
else:
|
| 51 |
+
merged.append([start_ms, end_ms])
|
| 52 |
+
return merged
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def detect_vad_segments_chunked(
|
| 56 |
+
bundle: ModelBundle,
|
| 57 |
+
speech: np.ndarray,
|
| 58 |
+
fs: int = 16000,
|
| 59 |
+
chunk_sec: float | None = None,
|
| 60 |
+
overlap_sec: float | None = None,
|
| 61 |
+
) -> list[list[int]]:
|
| 62 |
+
if speech.size == 0:
|
| 63 |
+
return []
|
| 64 |
+
|
| 65 |
+
chunk_sec = _env_float("DIAR_VAD_CHUNK_SEC", 300.0) if chunk_sec is None else chunk_sec
|
| 66 |
+
overlap_sec = _env_float("DIAR_VAD_OVERLAP_SEC", 2.0) if overlap_sec is None else overlap_sec
|
| 67 |
+
duration_sec = speech.shape[0] / float(fs)
|
| 68 |
+
if chunk_sec <= 0 or duration_sec <= chunk_sec:
|
| 69 |
+
return bundle.vad_infer(speech)
|
| 70 |
+
|
| 71 |
+
chunk_samples = max(int(chunk_sec * fs), 1)
|
| 72 |
+
overlap_samples = max(int(overlap_sec * fs), 0)
|
| 73 |
+
total_samples = speech.shape[0]
|
| 74 |
+
collected: list[list[int]] = []
|
| 75 |
+
|
| 76 |
+
for core_start in range(0, total_samples, chunk_samples):
|
| 77 |
+
core_end = min(core_start + chunk_samples, total_samples)
|
| 78 |
+
read_start = max(0, core_start - overlap_samples)
|
| 79 |
+
read_end = min(total_samples, core_end + overlap_samples)
|
| 80 |
+
chunk_audio = speech[read_start:read_end]
|
| 81 |
+
local_segments = bundle.vad_infer(chunk_audio)
|
| 82 |
+
core_start_ms = int(core_start / fs * 1000)
|
| 83 |
+
core_end_ms = int(core_end / fs * 1000)
|
| 84 |
+
read_start_ms = int(read_start / fs * 1000)
|
| 85 |
+
audio_end_ms = int(total_samples / fs * 1000)
|
| 86 |
+
|
| 87 |
+
for local_start_ms, local_end_ms in local_segments:
|
| 88 |
+
global_start_ms = max(0, int(local_start_ms) + read_start_ms)
|
| 89 |
+
global_end_ms = min(audio_end_ms, int(local_end_ms) + read_start_ms)
|
| 90 |
+
if global_end_ms <= global_start_ms:
|
| 91 |
+
continue
|
| 92 |
+
mid_ms = (global_start_ms + global_end_ms) // 2
|
| 93 |
+
if core_start_ms <= mid_ms < core_end_ms:
|
| 94 |
+
collected.append([global_start_ms, global_end_ms])
|
| 95 |
+
|
| 96 |
+
return _merge_overlapping_vad(collected, max_gap_ms=0)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _remap_speaker_labels_by_first_start(diar_results: list[list[float]]) -> list[list[float]]:
|
| 100 |
+
if not diar_results:
|
| 101 |
+
return []
|
| 102 |
+
first_start_by_spk: dict[int, float] = {}
|
| 103 |
+
for start_sec, _end_sec, spk in sorted(diar_results, key=lambda item: (item[0], item[1])):
|
| 104 |
+
spk_id = int(spk)
|
| 105 |
+
if spk_id not in first_start_by_spk:
|
| 106 |
+
first_start_by_spk[spk_id] = float(start_sec)
|
| 107 |
+
spk_map = {
|
| 108 |
+
spk_id: new_id
|
| 109 |
+
for new_id, spk_id in enumerate(
|
| 110 |
+
sorted(first_start_by_spk, key=lambda spk_id: (first_start_by_spk[spk_id], spk_id))
|
| 111 |
+
)
|
| 112 |
+
}
|
| 113 |
+
return [[start_sec, end_sec, spk_map[int(spk)]] for start_sec, end_sec, spk in diar_results]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def diar_asr(
|
| 117 |
+
bundle: ModelBundle,
|
| 118 |
+
speech: np.ndarray,
|
| 119 |
+
spk_params: dict | None = None,
|
| 120 |
+
fs: int = 16000,
|
| 121 |
+
vad_merge_max_len_ms: int = 15 * 1000,
|
| 122 |
+
) -> str:
|
| 123 |
+
if speech.size == 0:
|
| 124 |
+
return ""
|
| 125 |
+
|
| 126 |
+
res_vad = detect_vad_segments_chunked(bundle, speech, fs=fs)
|
| 127 |
+
if not res_vad:
|
| 128 |
+
return ""
|
| 129 |
+
|
| 130 |
+
vad_segments = merge_vad(res_vad, vad_merge_max_len_ms)
|
| 131 |
vad_time = [[vad_t[0] / 1000, vad_t[1] / 1000] for vad_t in res_vad]
|
| 132 |
chunks = [c for (st, ed) in vad_time for c in chunk(st, ed)]
|
| 133 |
|
| 134 |
if not chunks:
|
| 135 |
return ""
|
| 136 |
|
| 137 |
+
embeddings = bundle.speaker_infer(speech, fs, chunks=chunks)
|
| 138 |
+
_, diar_results = do_clustering(chunks, embeddings, speaker_num=None, params=spk_params)
|
| 139 |
+
diar_results = _remap_speaker_labels_by_first_start(diar_results)
|
| 140 |
+
|
| 141 |
+
lines = []
|
| 142 |
all_metadata = {"merged_words": [], "merged_timestamps": []}
|
| 143 |
audio_duration = speech.shape[0] / float(fs) if fs > 0 else 0.0
|
| 144 |
|
build/lib/ax_meeting/engines.py
CHANGED
|
@@ -11,6 +11,7 @@ import soundfile as sf
|
|
| 11 |
from ax_meeting.model_bundle import ModelBundle
|
| 12 |
from ax_meeting.config import SAMPLE_RATE, PAUSE_MS, MIN_SEGMENT_MS, MERGE_VAD_MAX_LEN_MS, DIAR_MERGE_VAD_MAX_LEN_MS
|
| 13 |
from ax_meeting.diar_utils import pick_speaker
|
|
|
|
| 14 |
from ax_meeting.utils.vad_utils import merge_vad
|
| 15 |
from ax_meeting.utils.ax_cam_bin import chunk, distribute_spk, do_clustering, get_trans_sentence_sensevoice
|
| 16 |
|
|
@@ -173,6 +174,11 @@ class VadAsrEngine:
|
|
| 173 |
out.append(VadAsrSegment(start_ms, end_ms, text.strip(), seg_audio))
|
| 174 |
return out
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
class DiarAsrEngine:
|
| 178 |
def __init__(
|
|
@@ -205,60 +211,13 @@ class DiarAsrEngine:
|
|
| 205 |
if not chunks:
|
| 206 |
return ""
|
| 207 |
speech = np.concatenate(chunks, axis=0)
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
embeddings = self.bundle.speaker_infer(speech, self.sample_rate, chunks=chunks)
|
| 220 |
-
_, diar_results = do_clustering(chunks, embeddings, speaker_num=None, params=spk_params)
|
| 221 |
-
|
| 222 |
-
lines = []
|
| 223 |
-
all_metadata = {"merged_words": [], "merged_timestamps": []}
|
| 224 |
-
audio_duration = speech.shape[0] / float(self.sample_rate) if self.sample_rate > 0 else 0.0
|
| 225 |
-
|
| 226 |
-
for i, segment in enumerate(vad_segments):
|
| 227 |
-
segment_start, segment_end = segment
|
| 228 |
-
start_sample = int(segment_start / 1000 * self.sample_rate)
|
| 229 |
-
end_sample = min(int(segment_end / 1000 * self.sample_rate), speech.shape[0])
|
| 230 |
-
segment_speech = speech[start_sample:end_sample]
|
| 231 |
-
time_offset_sec = segment_start / 1000.0
|
| 232 |
-
|
| 233 |
-
_, meta = self.bundle.asr_infer(
|
| 234 |
-
segment_speech,
|
| 235 |
-
output_timestamp=True,
|
| 236 |
-
key=f"segment_{i}",
|
| 237 |
-
)
|
| 238 |
-
|
| 239 |
-
seg_words = meta.get("merged_words", [])
|
| 240 |
-
seg_timestamps = meta.get("merged_timestamps", [])
|
| 241 |
-
if seg_words and seg_timestamps and len(seg_words) == len(seg_timestamps):
|
| 242 |
-
adjusted_timestamps = [
|
| 243 |
-
[
|
| 244 |
-
min(ts[0] + time_offset_sec, audio_duration),
|
| 245 |
-
min(ts[1] + time_offset_sec, audio_duration),
|
| 246 |
-
]
|
| 247 |
-
for ts in seg_timestamps
|
| 248 |
-
]
|
| 249 |
-
all_metadata["merged_words"].extend(seg_words)
|
| 250 |
-
all_metadata["merged_timestamps"].extend(adjusted_timestamps)
|
| 251 |
-
|
| 252 |
-
if all_metadata["merged_words"]:
|
| 253 |
-
output_asr = {
|
| 254 |
-
"merged_words": all_metadata["merged_words"],
|
| 255 |
-
"merged_timestamps": all_metadata["merged_timestamps"],
|
| 256 |
-
}
|
| 257 |
-
asr_timestamps = get_trans_sentence_sensevoice(output_asr)
|
| 258 |
-
sentence_info_with_spk = distribute_spk(asr_timestamps, diar_results)
|
| 259 |
-
lines = [
|
| 260 |
-
f"Speaker_{spk}: [{timeinterval[0]:.3f} {timeinterval[1]:.3f}] {text_string}"
|
| 261 |
-
for text_string, timeinterval, spk in sentence_info_with_spk
|
| 262 |
-
]
|
| 263 |
-
|
| 264 |
-
return "\n".join(lines)
|
|
|
|
| 11 |
from ax_meeting.model_bundle import ModelBundle
|
| 12 |
from ax_meeting.config import SAMPLE_RATE, PAUSE_MS, MIN_SEGMENT_MS, MERGE_VAD_MAX_LEN_MS, DIAR_MERGE_VAD_MAX_LEN_MS
|
| 13 |
from ax_meeting.diar_utils import pick_speaker
|
| 14 |
+
from ax_meeting.diar_asr_cli import diar_asr
|
| 15 |
from ax_meeting.utils.vad_utils import merge_vad
|
| 16 |
from ax_meeting.utils.ax_cam_bin import chunk, distribute_spk, do_clustering, get_trans_sentence_sensevoice
|
| 17 |
|
|
|
|
| 174 |
out.append(VadAsrSegment(start_ms, end_ms, text.strip(), seg_audio))
|
| 175 |
return out
|
| 176 |
|
| 177 |
+
def reset(self) -> None:
|
| 178 |
+
self.audio_chunks = []
|
| 179 |
+
self.total_samples = 0
|
| 180 |
+
self.last_processed_ms = 0
|
| 181 |
+
|
| 182 |
|
| 183 |
class DiarAsrEngine:
|
| 184 |
def __init__(
|
|
|
|
| 211 |
if not chunks:
|
| 212 |
return ""
|
| 213 |
speech = np.concatenate(chunks, axis=0)
|
| 214 |
+
return diar_asr(
|
| 215 |
+
self.bundle,
|
| 216 |
+
speech,
|
| 217 |
+
fs=self.sample_rate,
|
| 218 |
+
spk_params=spk_params,
|
| 219 |
+
vad_merge_max_len_ms=DIAR_MERGE_VAD_MAX_LEN_MS,
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
def reset(self) -> None:
|
| 223 |
+
self.audio_chunks = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
build/lib/ax_meeting/pipeline.py
CHANGED
|
@@ -151,3 +151,12 @@ class StreamingMeetingSession:
|
|
| 151 |
fs=self.sample_rate,
|
| 152 |
spk_params=getattr(self, "spk_params", None),
|
| 153 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
fs=self.sample_rate,
|
| 152 |
spk_params=getattr(self, "spk_params", None),
|
| 153 |
)
|
| 154 |
+
|
| 155 |
+
def release(self) -> None:
|
| 156 |
+
self.audio_chunks = []
|
| 157 |
+
self.total_samples = 0
|
| 158 |
+
self.last_processed_ms = 0
|
| 159 |
+
self.last_vad_check_ts = 0.0
|
| 160 |
+
self.processing = False
|
| 161 |
+
if hasattr(self, "spk_params"):
|
| 162 |
+
delattr(self, "spk_params")
|
build/lib/ax_meeting/server.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
import asyncio
|
| 3 |
from contextlib import asynccontextmanager
|
|
|
|
|
|
|
| 4 |
import json
|
| 5 |
import os
|
| 6 |
import uuid
|
|
@@ -19,6 +21,7 @@ from ax_meeting.summarizer import IncrementalSummarizer
|
|
| 19 |
|
| 20 |
APP_DIR = Path(__file__).parent
|
| 21 |
STATIC_DIR = APP_DIR / "static"
|
|
|
|
| 22 |
|
| 23 |
def preload_models() -> None:
|
| 24 |
print("Preloading AX models...")
|
|
@@ -43,6 +46,44 @@ TRANSCRIPT_STORE = {}
|
|
| 43 |
RECORDING_STORE = {}
|
| 44 |
RECORDING_DIR = APP_DIR / "recordings"
|
| 45 |
RECORDING_DIR.mkdir(exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
|
| 48 |
async def summarize_transcript_text(
|
|
@@ -51,6 +92,8 @@ async def summarize_transcript_text(
|
|
| 51 |
openai_api_key: str | None = None,
|
| 52 |
openai_model: str | None = None,
|
| 53 |
) -> str:
|
|
|
|
|
|
|
| 54 |
# 每次请求都新建一次总结器,避免复用上一次会话上下文。
|
| 55 |
summarizer = IncrementalSummarizer(
|
| 56 |
api_key=openai_api_key or None,
|
|
@@ -76,6 +119,7 @@ def index():
|
|
| 76 |
async def ws_endpoint(ws: WebSocket):
|
| 77 |
await ws.accept()
|
| 78 |
session_id = str(uuid.uuid4())
|
|
|
|
| 79 |
try:
|
| 80 |
await asyncio.to_thread(models.ensure_loaded)
|
| 81 |
except Exception as e:
|
|
@@ -106,20 +150,33 @@ async def ws_endpoint(ws: WebSocket):
|
|
| 106 |
session.spk_params = data.get("spk_params")
|
| 107 |
result = await session.finalize()
|
| 108 |
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
| 117 |
|
| 118 |
final_text = result.get("transcript", "")
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
await ws.send_json({"type": "final_transcript", "text": final_text})
|
| 121 |
await ws.send_json({"type": "summary", "text": summary})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
await ws.send_json({"type": "end_ack"})
|
|
|
|
| 123 |
|
| 124 |
elif msg_type == "ping":
|
| 125 |
await ws.send_json({"type": "pong"})
|
|
@@ -129,6 +186,10 @@ async def ws_endpoint(ws: WebSocket):
|
|
| 129 |
|
| 130 |
except WebSocketDisconnect:
|
| 131 |
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
|
| 134 |
@app.post("/diar_asr")
|
|
@@ -154,14 +215,18 @@ async def diar_asr_api(
|
|
| 154 |
temp_path = APP_DIR / f"_upload_{session_id}_{file.filename}"
|
| 155 |
content = await file.read()
|
| 156 |
temp_path.write_bytes(content)
|
|
|
|
| 157 |
try:
|
| 158 |
engine = DiarAsrEngine(models)
|
| 159 |
text = engine.transcribe(str(temp_path), spk_params=spk_params or None)
|
| 160 |
finally:
|
|
|
|
|
|
|
| 161 |
try:
|
| 162 |
temp_path.unlink(missing_ok=True)
|
| 163 |
except Exception:
|
| 164 |
pass
|
|
|
|
| 165 |
TRANSCRIPT_STORE[session_id] = text
|
| 166 |
return {"session_id": session_id, "text": text}
|
| 167 |
|
|
@@ -173,7 +238,12 @@ async def summary_api(
|
|
| 173 |
openai_api_key: str = Form(default=""),
|
| 174 |
openai_model: str = Form(default=""),
|
| 175 |
):
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
if not transcript:
|
| 178 |
raise HTTPException(status_code=400, detail="当前会话没有可总结的转录文本,请先完成导入识别或会议转写。")
|
| 179 |
|
|
@@ -187,12 +257,25 @@ async def summary_api(
|
|
| 187 |
except Exception as e:
|
| 188 |
raise HTTPException(status_code=500, detail=f"会议纪要生成失败: {e}") from e
|
| 189 |
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
|
| 193 |
@app.get("/export/{session_id}")
|
| 194 |
async def export_transcript(session_id: str):
|
| 195 |
-
text = TRANSCRIPT_STORE.get(session_id
|
|
|
|
|
|
|
| 196 |
return PlainTextResponse(text, media_type="text/plain; charset=utf-8")
|
| 197 |
|
| 198 |
|
|
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
import asyncio
|
| 3 |
from contextlib import asynccontextmanager
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
import gc
|
| 6 |
import json
|
| 7 |
import os
|
| 8 |
import uuid
|
|
|
|
| 21 |
|
| 22 |
APP_DIR = Path(__file__).parent
|
| 23 |
STATIC_DIR = APP_DIR / "static"
|
| 24 |
+
RESULT_DIR = Path(os.getenv("RESULT_TXT_DIR", "result_txt")).resolve()
|
| 25 |
|
| 26 |
def preload_models() -> None:
|
| 27 |
print("Preloading AX models...")
|
|
|
|
| 46 |
RECORDING_STORE = {}
|
| 47 |
RECORDING_DIR = APP_DIR / "recordings"
|
| 48 |
RECORDING_DIR.mkdir(exist_ok=True)
|
| 49 |
+
DISABLE_SUMMARY = os.getenv("DISABLE_SUMMARY", "0").lower() in {"1", "true", "yes", "on"}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _malloc_trim() -> None:
|
| 53 |
+
try:
|
| 54 |
+
import ctypes
|
| 55 |
+
|
| 56 |
+
ctypes.CDLL("libc.so.6").malloc_trim(0)
|
| 57 |
+
except Exception:
|
| 58 |
+
pass
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def release_process_memory() -> None:
|
| 62 |
+
gc.collect()
|
| 63 |
+
_malloc_trim()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def persist_result_texts(transcript: str, summary: str) -> tuple[Path, Path]:
|
| 67 |
+
RESULT_DIR.mkdir(parents=True, exist_ok=True)
|
| 68 |
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
| 69 |
+
transcript_path = RESULT_DIR / f"{stamp}_transcript.txt"
|
| 70 |
+
summary_path = RESULT_DIR / f"{stamp}_summary.txt"
|
| 71 |
+
transcript_path.write_text(transcript, encoding="utf-8")
|
| 72 |
+
summary_path.write_text(summary, encoding="utf-8")
|
| 73 |
+
return transcript_path, summary_path
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def clear_session_cache(session_id: str, session: StreamingMeetingSession | None = None) -> None:
|
| 77 |
+
TRANSCRIPT_STORE.pop(session_id, None)
|
| 78 |
+
recording_path = RECORDING_STORE.pop(session_id, None)
|
| 79 |
+
if recording_path:
|
| 80 |
+
try:
|
| 81 |
+
recording_path.unlink(missing_ok=True)
|
| 82 |
+
except Exception:
|
| 83 |
+
pass
|
| 84 |
+
if session is not None:
|
| 85 |
+
session.release()
|
| 86 |
+
release_process_memory()
|
| 87 |
|
| 88 |
|
| 89 |
async def summarize_transcript_text(
|
|
|
|
| 92 |
openai_api_key: str | None = None,
|
| 93 |
openai_model: str | None = None,
|
| 94 |
) -> str:
|
| 95 |
+
if DISABLE_SUMMARY:
|
| 96 |
+
return ""
|
| 97 |
# 每次请求都新建一次总结器,避免复用上一次会话上下文。
|
| 98 |
summarizer = IncrementalSummarizer(
|
| 99 |
api_key=openai_api_key or None,
|
|
|
|
| 119 |
async def ws_endpoint(ws: WebSocket):
|
| 120 |
await ws.accept()
|
| 121 |
session_id = str(uuid.uuid4())
|
| 122 |
+
session = None
|
| 123 |
try:
|
| 124 |
await asyncio.to_thread(models.ensure_loaded)
|
| 125 |
except Exception as e:
|
|
|
|
| 150 |
session.spk_params = data.get("spk_params")
|
| 151 |
result = await session.finalize()
|
| 152 |
|
| 153 |
+
summary = ""
|
| 154 |
+
if not DISABLE_SUMMARY:
|
| 155 |
+
try:
|
| 156 |
+
summary = await summarize_transcript_text(
|
| 157 |
+
result.get("transcript", ""),
|
| 158 |
+
openai_base_url=data.get("openai_base_url") or None,
|
| 159 |
+
)
|
| 160 |
+
except Exception as e:
|
| 161 |
+
summary = f"LLM 总结失败: {e}"
|
| 162 |
|
| 163 |
final_text = result.get("transcript", "")
|
| 164 |
+
try:
|
| 165 |
+
transcript_path, summary_path = persist_result_texts(final_text, summary)
|
| 166 |
+
except Exception as e:
|
| 167 |
+
await ws.send_json({"type": "error", "message": f"结果文件保存失败: {e}"})
|
| 168 |
+
continue
|
| 169 |
await ws.send_json({"type": "final_transcript", "text": final_text})
|
| 170 |
await ws.send_json({"type": "summary", "text": summary})
|
| 171 |
+
await ws.send_json(
|
| 172 |
+
{
|
| 173 |
+
"type": "saved_files",
|
| 174 |
+
"transcript_path": str(transcript_path),
|
| 175 |
+
"summary_path": str(summary_path),
|
| 176 |
+
}
|
| 177 |
+
)
|
| 178 |
await ws.send_json({"type": "end_ack"})
|
| 179 |
+
clear_session_cache(session_id, session=session)
|
| 180 |
|
| 181 |
elif msg_type == "ping":
|
| 182 |
await ws.send_json({"type": "pong"})
|
|
|
|
| 186 |
|
| 187 |
except WebSocketDisconnect:
|
| 188 |
return
|
| 189 |
+
finally:
|
| 190 |
+
if session is not None:
|
| 191 |
+
session.release()
|
| 192 |
+
release_process_memory()
|
| 193 |
|
| 194 |
|
| 195 |
@app.post("/diar_asr")
|
|
|
|
| 215 |
temp_path = APP_DIR / f"_upload_{session_id}_{file.filename}"
|
| 216 |
content = await file.read()
|
| 217 |
temp_path.write_bytes(content)
|
| 218 |
+
engine = None
|
| 219 |
try:
|
| 220 |
engine = DiarAsrEngine(models)
|
| 221 |
text = engine.transcribe(str(temp_path), spk_params=spk_params or None)
|
| 222 |
finally:
|
| 223 |
+
if engine is not None:
|
| 224 |
+
engine.reset()
|
| 225 |
try:
|
| 226 |
temp_path.unlink(missing_ok=True)
|
| 227 |
except Exception:
|
| 228 |
pass
|
| 229 |
+
release_process_memory()
|
| 230 |
TRANSCRIPT_STORE[session_id] = text
|
| 231 |
return {"session_id": session_id, "text": text}
|
| 232 |
|
|
|
|
| 238 |
openai_api_key: str = Form(default=""),
|
| 239 |
openai_model: str = Form(default=""),
|
| 240 |
):
|
| 241 |
+
if DISABLE_SUMMARY:
|
| 242 |
+
raise HTTPException(status_code=503, detail="summary disabled")
|
| 243 |
+
transcript = TRANSCRIPT_STORE.get(session_id)
|
| 244 |
+
if transcript is None:
|
| 245 |
+
raise HTTPException(status_code=404, detail="session not found")
|
| 246 |
+
transcript = transcript.strip()
|
| 247 |
if not transcript:
|
| 248 |
raise HTTPException(status_code=400, detail="当前会话没有可总结的转录文本,请先完成导入识别或会议转写。")
|
| 249 |
|
|
|
|
| 257 |
except Exception as e:
|
| 258 |
raise HTTPException(status_code=500, detail=f"会议纪要生成失败: {e}") from e
|
| 259 |
|
| 260 |
+
try:
|
| 261 |
+
transcript_path, summary_path = persist_result_texts(transcript, summary)
|
| 262 |
+
except Exception as e:
|
| 263 |
+
raise HTTPException(status_code=500, detail=f"结果文件保存失败: {e}") from e
|
| 264 |
+
|
| 265 |
+
clear_session_cache(session_id)
|
| 266 |
+
return {
|
| 267 |
+
"session_id": session_id,
|
| 268 |
+
"text": summary,
|
| 269 |
+
"transcript_path": str(transcript_path),
|
| 270 |
+
"summary_path": str(summary_path),
|
| 271 |
+
}
|
| 272 |
|
| 273 |
|
| 274 |
@app.get("/export/{session_id}")
|
| 275 |
async def export_transcript(session_id: str):
|
| 276 |
+
text = TRANSCRIPT_STORE.get(session_id)
|
| 277 |
+
if text is None:
|
| 278 |
+
return PlainTextResponse("not found", status_code=404)
|
| 279 |
return PlainTextResponse(text, media_type="text/plain; charset=utf-8")
|
| 280 |
|
| 281 |
|
build/lib/ax_meeting/summarizer.py
CHANGED
|
@@ -11,26 +11,24 @@ except Exception as e: # pragma: no cover
|
|
| 11 |
OpenAI = None
|
| 12 |
|
| 13 |
|
| 14 |
-
def
|
| 15 |
text = text.strip()
|
| 16 |
if not text:
|
| 17 |
return []
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
chunks = [chunks[0][i:i + max_chars] for i in range(0, len(chunks[0]), max_chars)]
|
| 33 |
-
return chunks
|
| 34 |
|
| 35 |
|
| 36 |
class IncrementalSummarizer:
|
|
@@ -46,24 +44,50 @@ class IncrementalSummarizer:
|
|
| 46 |
self.model = model if model is not None else os.getenv("OPENAI_MODEL", "AXERA-TECH/Qwen3-1.7B")
|
| 47 |
|
| 48 |
def summarize_incrementally(self, transcript: str) -> str:
|
| 49 |
-
|
|
|
|
| 50 |
if not chunks:
|
| 51 |
return ""
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
summary = ""
|
| 54 |
-
for idx, chunk in enumerate(chunks):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
prompt = (
|
| 56 |
-
"你是会议纪要助手。"
|
| 57 |
-
f"\n前
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
)
|
| 62 |
|
| 63 |
resp = self.client.chat.completions.create(
|
| 64 |
model=self.model,
|
| 65 |
messages=[
|
| 66 |
-
{"role": "system", "content": "你擅长
|
| 67 |
{"role": "user", "content": prompt},
|
| 68 |
],
|
| 69 |
temperature=0.2,
|
|
@@ -71,5 +95,5 @@ class IncrementalSummarizer:
|
|
| 71 |
|
| 72 |
summary = (resp.choices[0].message.content or "").strip()
|
| 73 |
summary = re.sub(r"<think>.*?</think>", "", summary, flags=re.DOTALL).strip()
|
| 74 |
-
print(f"Summary chunk {idx
|
| 75 |
return summary
|
|
|
|
| 11 |
OpenAI = None
|
| 12 |
|
| 13 |
|
| 14 |
+
def _split_text_fixed_size(text: str, chunk_chars: int) -> List[str]:
|
| 15 |
text = text.strip()
|
| 16 |
if not text:
|
| 17 |
return []
|
| 18 |
+
if chunk_chars <= 0:
|
| 19 |
+
raise ValueError("SUMMARY_CHUNK_CHARS must be > 0")
|
| 20 |
+
return [text[i:i + chunk_chars] for i in range(0, len(text), chunk_chars)]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _summary_target_range() -> tuple[int, int]:
|
| 24 |
+
if isinstance(SUMMARY_TARGET_CHARS, (tuple, list)) and len(SUMMARY_TARGET_CHARS) == 2:
|
| 25 |
+
min_chars = int(SUMMARY_TARGET_CHARS[0])
|
| 26 |
+
max_chars = int(SUMMARY_TARGET_CHARS[1])
|
| 27 |
+
else:
|
| 28 |
+
min_chars = max_chars = int(SUMMARY_TARGET_CHARS)
|
| 29 |
+
if min_chars > max_chars:
|
| 30 |
+
min_chars, max_chars = max_chars, min_chars
|
| 31 |
+
return min_chars, max_chars
|
|
|
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
class IncrementalSummarizer:
|
|
|
|
| 44 |
self.model = model if model is not None else os.getenv("OPENAI_MODEL", "AXERA-TECH/Qwen3-1.7B")
|
| 45 |
|
| 46 |
def summarize_incrementally(self, transcript: str) -> str:
|
| 47 |
+
transcript = transcript.strip()
|
| 48 |
+
chunks = _split_text_fixed_size(transcript, SUMMARY_CHUNK_CHARS)
|
| 49 |
if not chunks:
|
| 50 |
return ""
|
| 51 |
|
| 52 |
+
min_chars, max_chars = _summary_target_range()
|
| 53 |
+
total_chunks = len(chunks)
|
| 54 |
+
print(f"[summarizer] transcript_total_chars={len(transcript)}")
|
| 55 |
+
print(f"[summarizer] summary_chunk_chars={SUMMARY_CHUNK_CHARS}, total_chunks={total_chunks}")
|
| 56 |
+
|
| 57 |
summary = ""
|
| 58 |
+
for idx, chunk in enumerate(chunks, start=1):
|
| 59 |
+
print(f"[summarizer] chunk_{idx}_chars={len(chunk)}")
|
| 60 |
+
if idx == 1:
|
| 61 |
+
previous_summary_block = "无。这是第一轮请求。"
|
| 62 |
+
else:
|
| 63 |
+
previous_summary_block = (
|
| 64 |
+
"以下内容是上一轮请求返回的摘要,属于更早时序的内容,不是新的原始会议文本。\n"
|
| 65 |
+
f"{summary}"
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
prompt = (
|
| 69 |
+
"你是会议纪要助手。"
|
| 70 |
+
f"\n当前是第{idx}/{total_chunks}轮总结请求,请严格按照时间顺序理解内容。"
|
| 71 |
+
"\n请区分两类输入:"
|
| 72 |
+
"\n1. <previous_summary> 标签内是上一轮摘要,表示已处理过的、更早时序内容;"
|
| 73 |
+
"\n2. <current_transcript> 标签内是本轮新发送的原始 transcript,时序上晚于上一轮摘要。"
|
| 74 |
+
"\n<previous_summary>"
|
| 75 |
+
f"\n{previous_summary_block}"
|
| 76 |
+
"\n</previous_summary>"
|
| 77 |
+
"\n<current_transcript>"
|
| 78 |
+
"\n以下内容是本轮新发送的 transcript 原文,请与上一轮摘要衔接后理解:"
|
| 79 |
+
f"\n{chunk}"
|
| 80 |
+
"\n</current_transcript>"
|
| 81 |
+
"\n请基于 <previous_summary> 和 <current_transcript> 生成一个更新后的摘要,覆盖截至当前轮为止的全部会议信息。"
|
| 82 |
+
"\n请务必保留此前各轮与本轮中出现的关键决策、结论、待办事项、负责人、时间点、风险与分歧;若新内容与旧摘要有关,请在更新摘要时延续并整合,不要遗漏已有关键事实。"
|
| 83 |
+
f"\n请根据内容丰富度和文本长度,将摘要控制在约{min_chars}到{max_chars}字。"
|
| 84 |
+
"\n输出要求: 只输出摘要正文,不要标题,不要重复标签。/no_think"
|
| 85 |
)
|
| 86 |
|
| 87 |
resp = self.client.chat.completions.create(
|
| 88 |
model=self.model,
|
| 89 |
messages=[
|
| 90 |
+
{"role": "system", "content": "你擅长按时间顺序理解会议记录,并基于已有摘要与新增文本生成连贯的更新摘要。"},
|
| 91 |
{"role": "user", "content": prompt},
|
| 92 |
],
|
| 93 |
temperature=0.2,
|
|
|
|
| 95 |
|
| 96 |
summary = (resp.choices[0].message.content or "").strip()
|
| 97 |
summary = re.sub(r"<think>.*?</think>", "", summary, flags=re.DOTALL).strip()
|
| 98 |
+
print(f"Summary chunk {idx}: {summary}")
|
| 99 |
return summary
|
build/lib/ax_meeting/utils/ax_cam_bin.py
CHANGED
|
@@ -209,8 +209,8 @@ class AX_SpeakerEmbeddingInference:
|
|
| 209 |
if wav.ndim > 1:
|
| 210 |
wav = wav.reshape(-1)
|
| 211 |
|
| 212 |
-
|
| 213 |
-
max_len = max(
|
| 214 |
max_len = max(max_len, 57900)
|
| 215 |
|
| 216 |
def circle_pad_np(x: np.ndarray, target_len: int) -> np.ndarray:
|
|
@@ -220,14 +220,14 @@ class AX_SpeakerEmbeddingInference:
|
|
| 220 |
xcat = np.tile(x, n)
|
| 221 |
return xcat[:target_len]
|
| 222 |
|
| 223 |
-
wavs = [circle_pad_np(x, max_len) for x in wavs]
|
| 224 |
-
|
| 225 |
batch_size = 1
|
| 226 |
embeddings = []
|
| 227 |
-
for i in range(0, len(
|
| 228 |
-
|
| 229 |
feats_list = []
|
| 230 |
-
for
|
|
|
|
|
|
|
| 231 |
feat = compute_fbank(w, fs, n_mels=80, mean_nor=True)
|
| 232 |
if feat.shape[0] >= 360:
|
| 233 |
feat = feat[:360]
|
|
|
|
| 209 |
if wav.ndim > 1:
|
| 210 |
wav = wav.reshape(-1)
|
| 211 |
|
| 212 |
+
chunk_ranges = [(int(st * fs), int(ed * fs)) for st, ed in chunks]
|
| 213 |
+
max_len = max((max(ed - st, 0) for st, ed in chunk_ranges), default=0)
|
| 214 |
max_len = max(max_len, 57900)
|
| 215 |
|
| 216 |
def circle_pad_np(x: np.ndarray, target_len: int) -> np.ndarray:
|
|
|
|
| 220 |
xcat = np.tile(x, n)
|
| 221 |
return xcat[:target_len]
|
| 222 |
|
|
|
|
|
|
|
| 223 |
batch_size = 1
|
| 224 |
embeddings = []
|
| 225 |
+
for i in range(0, len(chunk_ranges), batch_size):
|
| 226 |
+
batch_ranges = chunk_ranges[i:i+batch_size]
|
| 227 |
feats_list = []
|
| 228 |
+
for st, ed in batch_ranges:
|
| 229 |
+
w = wav[st:ed]
|
| 230 |
+
w = circle_pad_np(w, max_len)
|
| 231 |
feat = compute_fbank(w, fs, n_mels=80, mean_nor=True)
|
| 232 |
if feat.shape[0] >= 360:
|
| 233 |
feat = feat[:360]
|
build/lib/ax_meeting/utils/frontend.py
CHANGED
|
@@ -11,6 +11,10 @@ root_dir = Path(__file__).resolve().parent
|
|
| 11 |
logger_initialized = {}
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
class WavFrontend:
|
| 15 |
"""Conventional frontend structure for ASR."""
|
| 16 |
|
|
@@ -51,9 +55,9 @@ class WavFrontend:
|
|
| 51 |
self.reset_status()
|
| 52 |
|
| 53 |
def fbank(self, waveform: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
| 54 |
-
waveform =
|
| 55 |
self.fbank_fn = knf.OnlineFbank(self.opts)
|
| 56 |
-
self.fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform
|
| 57 |
frames = self.fbank_fn.num_frames_ready
|
| 58 |
mat = np.empty([frames, self.opts.mel_opts.num_bins])
|
| 59 |
for i in range(frames):
|
|
@@ -63,9 +67,9 @@ class WavFrontend:
|
|
| 63 |
return feat, feat_len
|
| 64 |
|
| 65 |
def fbank_online(self, waveform: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
| 66 |
-
waveform =
|
| 67 |
# self.fbank_fn = knf.OnlineFbank(self.opts)
|
| 68 |
-
self.fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform
|
| 69 |
frames = self.fbank_fn.num_frames_ready
|
| 70 |
mat = np.empty([frames, self.opts.mel_opts.num_bins])
|
| 71 |
for i in range(self.fbank_beg_idx, frames):
|
|
|
|
| 11 |
logger_initialized = {}
|
| 12 |
|
| 13 |
|
| 14 |
+
def _prepare_waveform_buffer(waveform: np.ndarray) -> np.ndarray:
|
| 15 |
+
return np.ascontiguousarray(waveform * (1 << 15), dtype=np.float32)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
class WavFrontend:
|
| 19 |
"""Conventional frontend structure for ASR."""
|
| 20 |
|
|
|
|
| 55 |
self.reset_status()
|
| 56 |
|
| 57 |
def fbank(self, waveform: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
| 58 |
+
waveform = _prepare_waveform_buffer(waveform)
|
| 59 |
self.fbank_fn = knf.OnlineFbank(self.opts)
|
| 60 |
+
self.fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform)
|
| 61 |
frames = self.fbank_fn.num_frames_ready
|
| 62 |
mat = np.empty([frames, self.opts.mel_opts.num_bins])
|
| 63 |
for i in range(frames):
|
|
|
|
| 67 |
return feat, feat_len
|
| 68 |
|
| 69 |
def fbank_online(self, waveform: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
| 70 |
+
waveform = _prepare_waveform_buffer(waveform)
|
| 71 |
# self.fbank_fn = knf.OnlineFbank(self.opts)
|
| 72 |
+
self.fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform)
|
| 73 |
frames = self.fbank_fn.num_frames_ready
|
| 74 |
mat = np.empty([frames, self.opts.mel_opts.num_bins])
|
| 75 |
for i in range(self.fbank_beg_idx, frames):
|
build/lib/ax_meeting/utils/utils/frontend.py
CHANGED
|
@@ -12,6 +12,10 @@ root_dir = Path(__file__).resolve().parent
|
|
| 12 |
logger_initialized = {}
|
| 13 |
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
class WavFrontend:
|
| 16 |
"""Conventional frontend structure for ASR."""
|
| 17 |
|
|
@@ -52,9 +56,9 @@ class WavFrontend:
|
|
| 52 |
self.reset_status()
|
| 53 |
|
| 54 |
def fbank(self, waveform: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
| 55 |
-
waveform =
|
| 56 |
fbank_fn = knf.OnlineFbank(self.opts)
|
| 57 |
-
fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform
|
| 58 |
frames = fbank_fn.num_frames_ready
|
| 59 |
mat = np.empty([frames, self.opts.mel_opts.num_bins])
|
| 60 |
for i in range(frames):
|
|
@@ -64,9 +68,9 @@ class WavFrontend:
|
|
| 64 |
return feat, feat_len
|
| 65 |
|
| 66 |
def fbank_online(self, waveform: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
| 67 |
-
waveform =
|
| 68 |
# self.fbank_fn = knf.OnlineFbank(self.opts)
|
| 69 |
-
self.fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform
|
| 70 |
frames = self.fbank_fn.num_frames_ready
|
| 71 |
mat = np.empty([frames, self.opts.mel_opts.num_bins])
|
| 72 |
for i in range(self.fbank_beg_idx, frames):
|
|
|
|
| 12 |
logger_initialized = {}
|
| 13 |
|
| 14 |
|
| 15 |
+
def _prepare_waveform_buffer(waveform: np.ndarray) -> np.ndarray:
|
| 16 |
+
return np.ascontiguousarray(waveform * (1 << 15), dtype=np.float32)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
class WavFrontend:
|
| 20 |
"""Conventional frontend structure for ASR."""
|
| 21 |
|
|
|
|
| 56 |
self.reset_status()
|
| 57 |
|
| 58 |
def fbank(self, waveform: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
| 59 |
+
waveform = _prepare_waveform_buffer(waveform)
|
| 60 |
fbank_fn = knf.OnlineFbank(self.opts)
|
| 61 |
+
fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform)
|
| 62 |
frames = fbank_fn.num_frames_ready
|
| 63 |
mat = np.empty([frames, self.opts.mel_opts.num_bins])
|
| 64 |
for i in range(frames):
|
|
|
|
| 68 |
return feat, feat_len
|
| 69 |
|
| 70 |
def fbank_online(self, waveform: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
| 71 |
+
waveform = _prepare_waveform_buffer(waveform)
|
| 72 |
# self.fbank_fn = knf.OnlineFbank(self.opts)
|
| 73 |
+
self.fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform)
|
| 74 |
frames = self.fbank_fn.num_frames_ready
|
| 75 |
mat = np.empty([frames, self.opts.mel_opts.num_bins])
|
| 76 |
for i in range(self.fbank_beg_idx, frames):
|
dist/ax_meeting-0.1.1-py3-none-any.whl
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6e1f81ecca959cb6b14b217da82402d7c9090cd1cc782021ff55e28d5ffd7137
|
| 3 |
+
size 234146965
|
start_meeting_server.sh
CHANGED
|
@@ -1,8 +1,10 @@
|
|
| 1 |
OPENAI_API_KEY=xxx \
|
| 2 |
-
OPENAI_BASE_URL=http://127.0.0.1:
|
| 3 |
-
OPENAI_MODEL=AXERA-TECH/Qwen3
|
|
|
|
|
|
|
| 4 |
HOST=0.0.0.0 \
|
| 5 |
-
PORT=
|
| 6 |
SSL_CERT=cert.pem \
|
| 7 |
SSL_KEY=key.pem \
|
| 8 |
-
python3 -m ax_meeting.server
|
|
|
|
| 1 |
OPENAI_API_KEY=xxx \
|
| 2 |
+
OPENAI_BASE_URL=http://127.0.0.1:8000/v1 \
|
| 3 |
+
OPENAI_MODEL=AXERA-TECH/Qwen3.5-2B \
|
| 4 |
+
SUMMARY_CHUNK_CHARS=12000 \
|
| 5 |
+
RESULT_TXT_DIR=result_txt \
|
| 6 |
HOST=0.0.0.0 \
|
| 7 |
+
PORT=8090 \
|
| 8 |
SSL_CERT=cert.pem \
|
| 9 |
SSL_KEY=key.pem \
|
| 10 |
+
python3 -m ax_meeting.server
|
tests/test_lightweight.py
CHANGED
|
@@ -1,9 +1,40 @@
|
|
| 1 |
# -*- coding: utf-8 -*-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import unittest
|
|
|
|
| 3 |
from unittest.mock import patch
|
| 4 |
|
| 5 |
from ax_meeting.text_cleaner import clean_asr_text
|
| 6 |
from ax_meeting.diar_utils import pick_speaker
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
from ax_meeting import server
|
| 8 |
|
| 9 |
|
|
@@ -47,5 +78,152 @@ class TestServerStartup(unittest.TestCase):
|
|
| 47 |
mocked.assert_called_once_with()
|
| 48 |
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
if __name__ == "__main__":
|
| 51 |
unittest.main()
|
|
|
|
| 1 |
# -*- coding: utf-8 -*-
|
| 2 |
+
import importlib
|
| 3 |
+
import asyncio
|
| 4 |
+
import os
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import sys
|
| 7 |
+
import tempfile
|
| 8 |
+
import types
|
| 9 |
import unittest
|
| 10 |
+
from types import SimpleNamespace
|
| 11 |
from unittest.mock import patch
|
| 12 |
|
| 13 |
from ax_meeting.text_cleaner import clean_asr_text
|
| 14 |
from ax_meeting.diar_utils import pick_speaker
|
| 15 |
+
from ax_meeting import summarizer as summarizer_module
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
fake_model_bundle_module = types.ModuleType("ax_meeting.model_bundle")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class FakeModelBundle:
|
| 22 |
+
def ensure_loaded(self):
|
| 23 |
+
pass
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
fake_model_bundle_module.ModelBundle = FakeModelBundle
|
| 27 |
+
sys.modules.setdefault("ax_meeting.model_bundle", fake_model_bundle_module)
|
| 28 |
+
|
| 29 |
+
fake_engines_module = types.ModuleType("ax_meeting.engines")
|
| 30 |
+
fake_engines_module.DiarAsrEngine = object
|
| 31 |
+
sys.modules.setdefault("ax_meeting.engines", fake_engines_module)
|
| 32 |
+
|
| 33 |
+
fake_pipeline_module = types.ModuleType("ax_meeting.pipeline")
|
| 34 |
+
fake_pipeline_module.StreamingMeetingSession = object
|
| 35 |
+
sys.modules.setdefault("ax_meeting.pipeline", fake_pipeline_module)
|
| 36 |
+
|
| 37 |
+
from ax_meeting import config as config_module
|
| 38 |
from ax_meeting import server
|
| 39 |
|
| 40 |
|
|
|
|
| 78 |
mocked.assert_called_once_with()
|
| 79 |
|
| 80 |
|
| 81 |
+
class TestServerPersistence(unittest.TestCase):
|
| 82 |
+
def test_persist_result_texts_writes_timestamped_files(self):
|
| 83 |
+
with tempfile.TemporaryDirectory() as tmpdir, patch.object(server, "RESULT_DIR", Path(tmpdir)):
|
| 84 |
+
transcript_path, summary_path = server.persist_result_texts("transcript body", "summary body")
|
| 85 |
+
transcript_body = transcript_path.read_text(encoding="utf-8")
|
| 86 |
+
summary_body = summary_path.read_text(encoding="utf-8")
|
| 87 |
+
|
| 88 |
+
self.assertTrue(transcript_path.name.endswith("_transcript.txt"))
|
| 89 |
+
self.assertTrue(summary_path.name.endswith("_summary.txt"))
|
| 90 |
+
self.assertEqual(transcript_body, "transcript body")
|
| 91 |
+
self.assertEqual(summary_body, "summary body")
|
| 92 |
+
|
| 93 |
+
def test_clear_session_cache_removes_store_and_releases_session(self):
|
| 94 |
+
class FakeSession:
|
| 95 |
+
def __init__(self):
|
| 96 |
+
self.released = False
|
| 97 |
+
|
| 98 |
+
def release(self):
|
| 99 |
+
self.released = True
|
| 100 |
+
|
| 101 |
+
with tempfile.TemporaryDirectory() as tmpdir, patch.object(server, "release_process_memory") as mocked_release:
|
| 102 |
+
record_path = Path(tmpdir) / "meeting.mp3"
|
| 103 |
+
record_path.write_bytes(b"dummy")
|
| 104 |
+
server.TRANSCRIPT_STORE["sid"] = "hello"
|
| 105 |
+
server.RECORDING_STORE["sid"] = record_path
|
| 106 |
+
fake_session = FakeSession()
|
| 107 |
+
|
| 108 |
+
server.clear_session_cache("sid", session=fake_session)
|
| 109 |
+
|
| 110 |
+
self.assertNotIn("sid", server.TRANSCRIPT_STORE)
|
| 111 |
+
self.assertNotIn("sid", server.RECORDING_STORE)
|
| 112 |
+
self.assertFalse(record_path.exists())
|
| 113 |
+
self.assertTrue(fake_session.released)
|
| 114 |
+
mocked_release.assert_called_once_with()
|
| 115 |
+
|
| 116 |
+
def test_summary_api_persists_files_and_clears_cache(self):
|
| 117 |
+
server.TRANSCRIPT_STORE["sid"] = "meeting transcript"
|
| 118 |
+
fake_transcript_path = Path("/tmp/transcript.txt")
|
| 119 |
+
fake_summary_path = Path("/tmp/summary.txt")
|
| 120 |
+
|
| 121 |
+
async def run_test():
|
| 122 |
+
with patch.object(server, "summarize_transcript_text", return_value="meeting summary"), patch.object(
|
| 123 |
+
server, "persist_result_texts", return_value=(fake_transcript_path, fake_summary_path)
|
| 124 |
+
) as mocked_persist, patch.object(server, "clear_session_cache") as mocked_clear:
|
| 125 |
+
result = await server.summary_api(
|
| 126 |
+
session_id="sid",
|
| 127 |
+
openai_base_url="",
|
| 128 |
+
openai_api_key="",
|
| 129 |
+
openai_model="",
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
self.assertEqual(result["text"], "meeting summary")
|
| 133 |
+
self.assertEqual(result["transcript_path"], str(fake_transcript_path))
|
| 134 |
+
self.assertEqual(result["summary_path"], str(fake_summary_path))
|
| 135 |
+
mocked_persist.assert_called_once_with("meeting transcript", "meeting summary")
|
| 136 |
+
mocked_clear.assert_called_once_with("sid")
|
| 137 |
+
|
| 138 |
+
asyncio.run(run_test())
|
| 139 |
+
server.TRANSCRIPT_STORE.pop("sid", None)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class TestSummarizerHelpers(unittest.TestCase):
|
| 143 |
+
def test_split_text_fixed_size_empty(self):
|
| 144 |
+
self.assertEqual(summarizer_module._split_text_fixed_size(" ", 4), [])
|
| 145 |
+
|
| 146 |
+
def test_split_text_fixed_size_chunks_by_chars(self):
|
| 147 |
+
self.assertEqual(
|
| 148 |
+
summarizer_module._split_text_fixed_size("abcdefghij", 4),
|
| 149 |
+
["abcd", "efgh", "ij"],
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
def test_split_text_fixed_size_invalid_chunk_size(self):
|
| 153 |
+
with self.assertRaises(ValueError):
|
| 154 |
+
summarizer_module._split_text_fixed_size("abc", 0)
|
| 155 |
+
|
| 156 |
+
def test_summary_target_range_from_scalar(self):
|
| 157 |
+
with patch.object(summarizer_module, "SUMMARY_TARGET_CHARS", 120):
|
| 158 |
+
self.assertEqual(summarizer_module._summary_target_range(), (120, 120))
|
| 159 |
+
|
| 160 |
+
def test_summary_target_range_from_tuple(self):
|
| 161 |
+
with patch.object(summarizer_module, "SUMMARY_TARGET_CHARS", (100, 300)):
|
| 162 |
+
self.assertEqual(summarizer_module._summary_target_range(), (100, 300))
|
| 163 |
+
|
| 164 |
+
def test_summary_target_range_normalizes_reverse_order(self):
|
| 165 |
+
with patch.object(summarizer_module, "SUMMARY_TARGET_CHARS", [300, 100]):
|
| 166 |
+
self.assertEqual(summarizer_module._summary_target_range(), (100, 300))
|
| 167 |
+
|
| 168 |
+
def test_summary_chunk_chars_uses_environment_override(self):
|
| 169 |
+
old_value = os.environ.get("SUMMARY_CHUNK_CHARS")
|
| 170 |
+
try:
|
| 171 |
+
os.environ["SUMMARY_CHUNK_CHARS"] = "3456"
|
| 172 |
+
reloaded = importlib.reload(config_module)
|
| 173 |
+
self.assertEqual(reloaded.SUMMARY_CHUNK_CHARS, 3456)
|
| 174 |
+
finally:
|
| 175 |
+
if old_value is None:
|
| 176 |
+
os.environ.pop("SUMMARY_CHUNK_CHARS", None)
|
| 177 |
+
else:
|
| 178 |
+
os.environ["SUMMARY_CHUNK_CHARS"] = old_value
|
| 179 |
+
importlib.reload(config_module)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
class TestIncrementalSummarizer(unittest.TestCase):
|
| 183 |
+
def _make_response(self, content: str):
|
| 184 |
+
return SimpleNamespace(
|
| 185 |
+
choices=[
|
| 186 |
+
SimpleNamespace(
|
| 187 |
+
message=SimpleNamespace(content=content),
|
| 188 |
+
)
|
| 189 |
+
]
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
def test_summarize_incrementally_uses_previous_summary_and_returns_last_round(self):
|
| 193 |
+
fake_client = SimpleNamespace(
|
| 194 |
+
chat=SimpleNamespace(
|
| 195 |
+
completions=SimpleNamespace(
|
| 196 |
+
create=unittest.mock.Mock(
|
| 197 |
+
side_effect=[
|
| 198 |
+
self._make_response("第一轮摘要"),
|
| 199 |
+
self._make_response("<think>ignored</think>最终摘要"),
|
| 200 |
+
]
|
| 201 |
+
)
|
| 202 |
+
)
|
| 203 |
+
)
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
with patch.object(summarizer_module, "OpenAI", return_value=fake_client), patch.object(
|
| 207 |
+
summarizer_module, "SUMMARY_CHUNK_CHARS", 5
|
| 208 |
+
), patch.object(summarizer_module, "SUMMARY_TARGET_CHARS", (100, 300)):
|
| 209 |
+
summarizer = summarizer_module.IncrementalSummarizer(api_key="test-key")
|
| 210 |
+
summary = summarizer.summarize_incrementally("abcdefghij")
|
| 211 |
+
|
| 212 |
+
self.assertEqual(summary, "最终摘要")
|
| 213 |
+
create = fake_client.chat.completions.create
|
| 214 |
+
self.assertEqual(create.call_count, 2)
|
| 215 |
+
|
| 216 |
+
first_prompt = create.call_args_list[0].kwargs["messages"][1]["content"]
|
| 217 |
+
second_prompt = create.call_args_list[1].kwargs["messages"][1]["content"]
|
| 218 |
+
|
| 219 |
+
self.assertIn("<previous_summary>\n无。这是第一轮请求。\n</previous_summary>", first_prompt)
|
| 220 |
+
self.assertIn("<current_transcript>\n以下内容是本轮新发送的 transcript 原文,请与上一轮摘要衔接后理解:\nabcde", first_prompt)
|
| 221 |
+
self.assertIn("请务必保留此前各轮与本轮中出现的关键决策、结论、待办事项、负责人、时间点、风险与分歧", first_prompt)
|
| 222 |
+
self.assertIn("当前是第2/2轮总结请求", second_prompt)
|
| 223 |
+
self.assertIn("以下内容是上一轮请求返回的摘要", second_prompt)
|
| 224 |
+
self.assertIn("第一轮摘要", second_prompt)
|
| 225 |
+
self.assertIn("\nfghij\n</current_transcript>", second_prompt)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
if __name__ == "__main__":
|
| 229 |
unittest.main()
|
wav/cache_test/clip_01.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:323f80635ee6c7eadece0ea9f1ee8c71e9b4c458dc0eaf3b0c0439a90e15f590
|
| 3 |
+
size 160044
|
wav/cache_test/clip_02.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5be642f1eb0ec3b4cd9a965c755b422b996db313d5edc7ae8f8518d2b69a592d
|
| 3 |
+
size 160044
|
wav/cache_test/clip_03.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:e048137d192911cea15d626815ce43a1edb262329f7ff5b266d7cbce8f8874fa
|
| 3 |
+
size 160044
|
wav/cache_test/clip_04.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:06277334abab56f990b9016be95f99276077f252c99847f485b3a77668e32e29
|
| 3 |
+
size 160044
|
wav/cache_test/long_5min.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5df2a2f8573af97c3ff269891d853f0c887dcf15f933864df367ba6c88a36e86
|
| 3 |
+
size 4800044
|