Nnow2024 commited on
Commit
3592794
·
verified ·
1 Parent(s): fd15bdd

support long audio

Browse files
Files changed (38) hide show
  1. .gitattributes +12 -0
  2. .pytest_cache/v/cache/nodeids +9 -0
  3. app/config.py +6 -3
  4. app/summarizer.py +50 -26
  5. ax_meeting.egg-info/PKG-INFO +46 -50
  6. ax_meeting/_upload_1566a625-52a6-4663-a62b-7570dac7b7dd_20200327_2P.wav +3 -0
  7. ax_meeting/_upload_1676128a-2d16-45b0-a3d5-faa94dea89bd_20200327_2P.wav +3 -0
  8. ax_meeting/_upload_176e329f-da53-4e64-85ca-4ba3f7ac76a3_20200327_2P.wav +3 -0
  9. ax_meeting/_upload_911af462-37e5-4314-911b-78fd3024cb7f_20200327_2P.wav +3 -0
  10. ax_meeting/_upload_cfac62fb-1bd0-425f-8e31-317aa922604e_20200327_2P.wav +3 -0
  11. ax_meeting/_upload_e4b352ae-a156-4e46-b41f-37313325fcfe_20200327_2P.wav +3 -0
  12. ax_meeting/_upload_eb77c179-4271-48e8-8fda-8e0dc528417c_20200327_2P.wav +3 -0
  13. ax_meeting/config.py +6 -3
  14. ax_meeting/diar_asr_cli.py +112 -27
  15. ax_meeting/engines.py +16 -57
  16. ax_meeting/pipeline.py +9 -0
  17. ax_meeting/server.py +95 -12
  18. ax_meeting/summarizer.py +50 -26
  19. ax_meeting/utils/ax_cam_bin.py +7 -7
  20. ax_meeting/utils/frontend.py +8 -4
  21. ax_meeting/utils/utils/frontend.py +8 -4
  22. build/lib/ax_meeting/config.py +6 -3
  23. build/lib/ax_meeting/diar_asr_cli.py +112 -27
  24. build/lib/ax_meeting/engines.py +16 -57
  25. build/lib/ax_meeting/pipeline.py +9 -0
  26. build/lib/ax_meeting/server.py +95 -12
  27. build/lib/ax_meeting/summarizer.py +50 -26
  28. build/lib/ax_meeting/utils/ax_cam_bin.py +7 -7
  29. build/lib/ax_meeting/utils/frontend.py +8 -4
  30. build/lib/ax_meeting/utils/utils/frontend.py +8 -4
  31. dist/ax_meeting-0.1.1-py3-none-any.whl +2 -2
  32. start_meeting_server.sh +6 -4
  33. tests/test_lightweight.py +178 -0
  34. wav/cache_test/clip_01.wav +3 -0
  35. wav/cache_test/clip_02.wav +3 -0
  36. wav/cache_test/clip_03.wav +3 -0
  37. wav/cache_test/clip_04.wav +3 -0
  38. 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
- SUMMARY_TARGET_CHARS = 100
 
 
 
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 _split_text(text: str, max_chars: int) -> List[str]:
15
  text = text.strip()
16
  if not text:
17
  return []
18
- chunks = []
19
- buf = []
20
- cur = 0
21
- for line in text.splitlines():
22
- if cur + len(line) + 1 > max_chars and buf:
23
- chunks.append("\n".join(buf))
24
- buf = []
25
- cur = 0
26
- buf.append(line)
27
- cur += len(line) + 1
28
- if buf:
29
- chunks.append("\n".join(buf))
30
- # Fallback if a single line is too long
31
- if len(chunks) == 1 and len(chunks[0]) > max_chars:
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
- chunks = _split_text(transcript, SUMMARY_CHUNK_CHARS)
 
50
  if not chunks:
51
  return ""
52
 
 
 
 
 
 
53
  summary = ""
54
- for idx, chunk in enumerate(chunks):
 
 
 
 
 
 
 
 
 
55
  prompt = (
56
- "你是会议纪要助手。"\
57
- f"\n前情提要(可为空): {summary}"\
58
- f"\n本段会议文本(第{idx + 1}段):\n{chunk}"\
59
- f"\n请将本段容总结为约{SUMMARY_TARGET_CHARS}字中文摘要"\
60
- "输出要求: 只输出摘要正文, 不要/no_think"
 
 
 
 
 
 
 
 
 
 
 
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 + 1}: {summary}")
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
- python -m ax_meeting.server
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
- axengine 依赖提示
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
- 使用包内自签证书(默认打包在 `ax_meeting/certs/`
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
- ![meeting_demo.png](assert/meeting_demo.png)
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
+ ![meeting_ui.jpg](assert/meeting_ui.jpg)
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
- SUMMARY_TARGET_CHARS = 100
 
 
 
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
- from pathlib import Path
 
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 diar_asr(
34
- bundle: ModelBundle,
35
- speech: np.ndarray,
36
- spk_params: dict | None = None,
37
- fs: int = 16000,
38
- ) -> str:
39
- if speech.size == 0:
40
- return ""
41
-
42
- res_vad = bundle.vad_infer(speech)
43
- if not res_vad:
44
- return ""
45
-
46
- vad_segments = merge_vad(res_vad, 15 * 1000)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- lines = []
 
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
- # return diar_asr(self.bundle, speech, fs=self.sample_rate, spk_params=spk_params)
209
- res_vad = self.bundle.vad_infer(speech)
210
- if not res_vad:
211
- return ""
212
-
213
- vad_segments = merge_vad(res_vad, DIAR_MERGE_VAD_MAX_LEN_MS)
214
- vad_time = [[vad_t[0] / 1000, vad_t[1] / 1000] for vad_t in res_vad]
215
- chunks = [c for (st, ed) in vad_time for c in chunk(st, ed)]
216
- if not chunks:
217
- return ""
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
- # Summarize
110
- try:
111
- summary = await summarize_transcript_text(
112
- result.get("transcript", ""),
113
- openai_base_url=data.get("openai_base_url") or None,
114
- )
115
- except Exception as e:
116
- summary = f"LLM 总结失败: {e}"
 
117
 
118
  final_text = result.get("transcript", "")
119
- TRANSCRIPT_STORE[session_id] = final_text
 
 
 
 
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
- transcript = TRANSCRIPT_STORE.get(session_id, "").strip()
 
 
 
 
 
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
- return {"session_id": session_id, "text": summary}
 
 
 
 
 
 
 
 
 
 
 
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 _split_text(text: str, max_chars: int) -> List[str]:
15
  text = text.strip()
16
  if not text:
17
  return []
18
- chunks = []
19
- buf = []
20
- cur = 0
21
- for line in text.splitlines():
22
- if cur + len(line) + 1 > max_chars and buf:
23
- chunks.append("\n".join(buf))
24
- buf = []
25
- cur = 0
26
- buf.append(line)
27
- cur += len(line) + 1
28
- if buf:
29
- chunks.append("\n".join(buf))
30
- # Fallback if a single line is too long
31
- if len(chunks) == 1 and len(chunks[0]) > max_chars:
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
- chunks = _split_text(transcript, SUMMARY_CHUNK_CHARS)
 
50
  if not chunks:
51
  return ""
52
 
 
 
 
 
 
53
  summary = ""
54
- for idx, chunk in enumerate(chunks):
 
 
 
 
 
 
 
 
 
55
  prompt = (
56
- "你是会议纪要助手。"\
57
- f"\n前情提要(可为空): {summary}"\
58
- f"\n本段会议文本(第{idx + 1}段):\n{chunk}"\
59
- f"\n请将本段容总结为约{SUMMARY_TARGET_CHARS}字中文摘要"\
60
- "输出要求: 只输出摘要正文, 不要/no_think"
 
 
 
 
 
 
 
 
 
 
 
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 + 1}: {summary}")
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
- wavs = [wav[int(st * fs):int(ed * fs)] for st, ed in chunks]
213
- max_len = max([x.shape[0] for x in wavs])
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(wavs), batch_size):
228
- batch_wavs = wavs[i:i+batch_size]
229
  feats_list = []
230
- for w in batch_wavs:
 
 
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 = waveform * (1 << 15)
55
  self.fbank_fn = knf.OnlineFbank(self.opts)
56
- self.fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform.tolist())
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 = waveform * (1 << 15)
67
  # self.fbank_fn = knf.OnlineFbank(self.opts)
68
- self.fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform.tolist())
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 = waveform * (1 << 15)
56
  fbank_fn = knf.OnlineFbank(self.opts)
57
- fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform.tolist())
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 = waveform * (1 << 15)
68
  # self.fbank_fn = knf.OnlineFbank(self.opts)
69
- self.fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform.tolist())
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
- SUMMARY_TARGET_CHARS = 100
 
 
 
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
- from pathlib import Path
 
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 diar_asr(
34
- bundle: ModelBundle,
35
- speech: np.ndarray,
36
- spk_params: dict | None = None,
37
- fs: int = 16000,
38
- ) -> str:
39
- if speech.size == 0:
40
- return ""
41
-
42
- res_vad = bundle.vad_infer(speech)
43
- if not res_vad:
44
- return ""
45
-
46
- vad_segments = merge_vad(res_vad, 15 * 1000)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- lines = []
 
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
- # return diar_asr(self.bundle, speech, fs=self.sample_rate, spk_params=spk_params)
209
- res_vad = self.bundle.vad_infer(speech)
210
- if not res_vad:
211
- return ""
212
-
213
- vad_segments = merge_vad(res_vad, DIAR_MERGE_VAD_MAX_LEN_MS)
214
- vad_time = [[vad_t[0] / 1000, vad_t[1] / 1000] for vad_t in res_vad]
215
- chunks = [c for (st, ed) in vad_time for c in chunk(st, ed)]
216
- if not chunks:
217
- return ""
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
- # Summarize
110
- try:
111
- summary = await summarize_transcript_text(
112
- result.get("transcript", ""),
113
- openai_base_url=data.get("openai_base_url") or None,
114
- )
115
- except Exception as e:
116
- summary = f"LLM 总结失败: {e}"
 
117
 
118
  final_text = result.get("transcript", "")
119
- TRANSCRIPT_STORE[session_id] = final_text
 
 
 
 
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
- transcript = TRANSCRIPT_STORE.get(session_id, "").strip()
 
 
 
 
 
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
- return {"session_id": session_id, "text": summary}
 
 
 
 
 
 
 
 
 
 
 
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 _split_text(text: str, max_chars: int) -> List[str]:
15
  text = text.strip()
16
  if not text:
17
  return []
18
- chunks = []
19
- buf = []
20
- cur = 0
21
- for line in text.splitlines():
22
- if cur + len(line) + 1 > max_chars and buf:
23
- chunks.append("\n".join(buf))
24
- buf = []
25
- cur = 0
26
- buf.append(line)
27
- cur += len(line) + 1
28
- if buf:
29
- chunks.append("\n".join(buf))
30
- # Fallback if a single line is too long
31
- if len(chunks) == 1 and len(chunks[0]) > max_chars:
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
- chunks = _split_text(transcript, SUMMARY_CHUNK_CHARS)
 
50
  if not chunks:
51
  return ""
52
 
 
 
 
 
 
53
  summary = ""
54
- for idx, chunk in enumerate(chunks):
 
 
 
 
 
 
 
 
 
55
  prompt = (
56
- "你是会议纪要助手。"\
57
- f"\n前情提要(可为空): {summary}"\
58
- f"\n本段会议文本(第{idx + 1}段):\n{chunk}"\
59
- f"\n请将本段容总结为约{SUMMARY_TARGET_CHARS}字中文摘要"\
60
- "输出要求: 只输出摘要正文, 不要/no_think"
 
 
 
 
 
 
 
 
 
 
 
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 + 1}: {summary}")
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
- wavs = [wav[int(st * fs):int(ed * fs)] for st, ed in chunks]
213
- max_len = max([x.shape[0] for x in wavs])
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(wavs), batch_size):
228
- batch_wavs = wavs[i:i+batch_size]
229
  feats_list = []
230
- for w in batch_wavs:
 
 
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 = waveform * (1 << 15)
55
  self.fbank_fn = knf.OnlineFbank(self.opts)
56
- self.fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform.tolist())
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 = waveform * (1 << 15)
67
  # self.fbank_fn = knf.OnlineFbank(self.opts)
68
- self.fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform.tolist())
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 = waveform * (1 << 15)
56
  fbank_fn = knf.OnlineFbank(self.opts)
57
- fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform.tolist())
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 = waveform * (1 << 15)
68
  # self.fbank_fn = knf.OnlineFbank(self.opts)
69
- self.fbank_fn.accept_waveform(self.opts.frame_opts.samp_freq, waveform.tolist())
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:99bb01894a15b4a080f24cace042a8f9c0cf43d634acb57079af23d61c3a5bc0
3
- size 234144959
 
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:8001/v1 \
3
- OPENAI_MODEL=AXERA-TECH/Qwen3-1.7B \
 
 
4
  HOST=0.0.0.0 \
5
- PORT=8000 \
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