@rookiestar/eng-lang-tutor 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @rookiestar/eng-lang-tutor might be problematic. Click here for more details.

Files changed (37) hide show
  1. package/CLAUDE.md +5 -4
  2. package/README.md +16 -1
  3. package/README_EN.md +16 -1
  4. package/SKILL.md +113 -92
  5. package/docs/OPENCLAW_DEPLOYMENT.md +7 -4
  6. package/package.json +1 -1
  7. package/requirements.txt +2 -0
  8. package/scripts/__pycache__/audio_composer.cpython-313.pyc +0 -0
  9. package/scripts/__pycache__/audio_converter.cpython-313.pyc +0 -0
  10. package/scripts/__pycache__/audio_enhancer.cpython-313.pyc +0 -0
  11. package/scripts/__pycache__/state_manager.cpython-313.pyc +0 -0
  12. package/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  13. package/scripts/audio_composer.py +389 -0
  14. package/scripts/audio_converter.py +245 -0
  15. package/scripts/cli.py +223 -0
  16. package/scripts/constants.py +56 -0
  17. package/scripts/feishu_voice.py +421 -0
  18. package/scripts/gamification.py +48 -34
  19. package/scripts/scorer.py +14 -32
  20. package/scripts/state_manager.py +131 -207
  21. package/scripts/tts/__pycache__/__init__.cpython-313.pyc +0 -0
  22. package/scripts/tts/__pycache__/base.cpython-313.pyc +0 -0
  23. package/scripts/tts/__pycache__/manager.cpython-313.pyc +0 -0
  24. package/scripts/tts/manager.py +14 -4
  25. package/scripts/tts/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  26. package/scripts/tts/providers/__pycache__/xunfei.cpython-313.pyc +0 -0
  27. package/scripts/tts/providers/xunfei.py +10 -1
  28. package/scripts/utils.py +78 -0
  29. package/templates/prompt_templates.md +47 -1235
  30. package/templates/prompts/display_guide.md +106 -0
  31. package/templates/prompts/initialization.md +213 -0
  32. package/templates/prompts/keypoint_generation.md +268 -0
  33. package/templates/prompts/output_rules.md +106 -0
  34. package/templates/prompts/quiz_generation.md +187 -0
  35. package/templates/prompts/responses.md +291 -0
  36. package/templates/prompts/shared_enums.md +97 -0
  37. package/templates/state_schema.json +1 -6
@@ -0,0 +1,389 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 知识点音频合成器 - 将多个音频片段合并为单个文件
4
+
5
+ 音频结构:
6
+ - expressions: 引导语 [1s] 内容 [2s]
7
+ - alternatives: 引导语 [1s] 内容 [2s]
8
+ - dialogues: 引导语 [1s] 对话行1 [0.5s] 对话行2 ...
9
+
10
+ 使用示例:
11
+ from scripts.audio_composer import AudioComposer
12
+ from scripts.tts import TTSManager
13
+
14
+ tts = TTSManager.from_env()
15
+ composer = AudioComposer(tts)
16
+
17
+ result = composer.compose_keypoint_audio(keypoint, Path("output.mp3"))
18
+ """
19
+
20
+ import subprocess
21
+ import tempfile
22
+ import shutil
23
+ from pathlib import Path
24
+ from typing import Optional, List
25
+ from dataclasses import dataclass
26
+
27
+ try:
28
+ from .tts import TTSManager
29
+ except ImportError:
30
+ from tts import TTSManager
31
+
32
+
33
+ @dataclass
34
+ class CompositionResult:
35
+ """音频合成结果"""
36
+ success: bool
37
+ audio_path: Optional[Path] = None
38
+ duration_seconds: Optional[float] = None
39
+ error_message: Optional[str] = None
40
+
41
+
42
+ class AudioComposer:
43
+ """
44
+ 知识点音频合成器
45
+
46
+ 将 expressions + alternatives + dialogues 合并为单个音频文件
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ tts_manager: TTSManager,
52
+ ffmpeg_path: Optional[str] = None
53
+ ):
54
+ """
55
+ 初始化音频合成器
56
+
57
+ Args:
58
+ tts_manager: TTS 管理器实例
59
+ ffmpeg_path: ffmpeg 可执行文件路径(默认自动检测)
60
+ """
61
+ self.tts = tts_manager
62
+ self.ffmpeg_path = ffmpeg_path or shutil.which("ffmpeg")
63
+ if not self.ffmpeg_path:
64
+ raise RuntimeError(
65
+ "ffmpeg not found. Install it with: brew install ffmpeg (macOS) "
66
+ "or apt-get install ffmpeg (Ubuntu)"
67
+ )
68
+
69
+ # 创建临时目录用于存放中间文件
70
+ self.temp_dir = Path(tempfile.mkdtemp(prefix="audio_composer_"))
71
+
72
+ def __del__(self):
73
+ """清理临时目录"""
74
+ if self.temp_dir.exists():
75
+ shutil.rmtree(self.temp_dir, ignore_errors=True)
76
+
77
+ def compose_keypoint_audio(
78
+ self,
79
+ keypoint: dict,
80
+ output_path: Path,
81
+ lead_in_silence: float = 1.0, # 引导语后留白
82
+ section_silence: float = 2.0, # 内容后留白(段落间隔)
83
+ dialogue_silence: float = 0.5, # 对话行之间留白
84
+ narrator_voice: str = "henry", # 旁白音色(男声)
85
+ voice_a: str = "mary", # 对话 A 音色(女声)
86
+ voice_b: str = "henry", # 对话 B 音色(男声,沉稳)
87
+ speed: float = 0.9 # 语速
88
+ ) -> CompositionResult:
89
+ """
90
+ 合成知识点音频
91
+
92
+ Args:
93
+ keypoint: 知识点数据
94
+ output_path: 输出文件路径
95
+ lead_in_silence: 引导语后留白时长(秒)
96
+ section_silence: 内容后留白时长(秒)
97
+ dialogue_silence: 对话行之间留白时长(秒)
98
+ narrator_voice: 旁白音色
99
+ voice_a: 对话 A 角色音色
100
+ voice_b: 对话 B 角色音色
101
+ speed: 语速
102
+
103
+ Returns:
104
+ CompositionResult: 合成结果
105
+ """
106
+ try:
107
+ output_path = Path(output_path)
108
+ output_path.parent.mkdir(parents=True, exist_ok=True)
109
+
110
+ segments: List[Path] = []
111
+ segment_index = 0
112
+
113
+ # 1. Expressions 部分
114
+ expressions = keypoint.get("expressions", [])
115
+ if expressions:
116
+ # 引导语
117
+ lead_in = self._synthesize_segment(
118
+ text="Key expressions",
119
+ voice=narrator_voice,
120
+ speed=speed,
121
+ index=segment_index
122
+ )
123
+ segments.append(lead_in)
124
+ segment_index += 1
125
+
126
+ # 引导语后留白
127
+ silence_1s = self._generate_silence(lead_in_silence)
128
+ segments.append(silence_1s)
129
+
130
+ # 内容
131
+ phrases = [expr.get("phrase", "") for expr in expressions]
132
+ content_text = ". ".join(p for p in phrases if p)
133
+ if content_text:
134
+ content = self._synthesize_segment(
135
+ text=content_text,
136
+ voice=narrator_voice,
137
+ speed=speed,
138
+ index=segment_index
139
+ )
140
+ segments.append(content)
141
+ segment_index += 1
142
+
143
+ # 内容后留白
144
+ silence_2s = self._generate_silence(section_silence)
145
+ segments.append(silence_2s)
146
+
147
+ # 2. Alternatives 部分
148
+ alternatives = keypoint.get("alternatives", [])
149
+ if alternatives:
150
+ # 引导语
151
+ lead_in = self._synthesize_segment(
152
+ text="You can also say",
153
+ voice=narrator_voice,
154
+ speed=speed,
155
+ index=segment_index
156
+ )
157
+ segments.append(lead_in)
158
+ segment_index += 1
159
+
160
+ # 引导语后留白
161
+ silence_1s = self._generate_silence(lead_in_silence)
162
+ segments.append(silence_1s)
163
+
164
+ # 内容
165
+ content_text = ". ".join(alt for alt in alternatives if alt)
166
+ if content_text:
167
+ content = self._synthesize_segment(
168
+ text=content_text,
169
+ voice=narrator_voice,
170
+ speed=speed,
171
+ index=segment_index
172
+ )
173
+ segments.append(content)
174
+ segment_index += 1
175
+
176
+ # 内容后留白
177
+ silence_2s = self._generate_silence(section_silence)
178
+ segments.append(silence_2s)
179
+
180
+ # 3. Dialogues 部分
181
+ examples = keypoint.get("examples", [])
182
+ if examples:
183
+ # 引导语
184
+ lead_in = self._synthesize_segment(
185
+ text="Dialogue",
186
+ voice=narrator_voice,
187
+ speed=speed,
188
+ index=segment_index
189
+ )
190
+ segments.append(lead_in)
191
+ segment_index += 1
192
+
193
+ # 引导语后留白
194
+ silence_1s = self._generate_silence(lead_in_silence)
195
+ segments.append(silence_1s)
196
+
197
+ # 对话内容
198
+ silence_05s = self._generate_silence(dialogue_silence)
199
+ for example in examples:
200
+ dialogue = example.get("dialogue", [])
201
+ for line in dialogue:
202
+ if ":" in line:
203
+ speaker, text = line.split(":", 1)
204
+ speaker = speaker.strip()
205
+ text = text.strip()
206
+
207
+ if not text:
208
+ continue
209
+
210
+ # A = henry (男声), B = catherine (女声)
211
+ voice = voice_a if speaker.upper() == "A" else voice_b
212
+
213
+ segment = self._synthesize_segment(
214
+ text=text,
215
+ voice=voice,
216
+ speed=speed,
217
+ index=segment_index
218
+ )
219
+ segments.append(segment)
220
+ segment_index += 1
221
+
222
+ # 对话行之间留白
223
+ segments.append(silence_05s)
224
+
225
+ if not segments:
226
+ return CompositionResult(
227
+ success=False,
228
+ error_message="No audio content to compose"
229
+ )
230
+
231
+ # 4. 拼接所有片段
232
+ final_audio = self._concatenate_segments(segments, output_path)
233
+
234
+ # 5. 获取时长
235
+ duration = self._get_duration(final_audio)
236
+
237
+ return CompositionResult(
238
+ success=True,
239
+ audio_path=final_audio,
240
+ duration_seconds=duration
241
+ )
242
+
243
+ except Exception as e:
244
+ return CompositionResult(
245
+ success=False,
246
+ error_message=str(e)
247
+ )
248
+
249
+ def _synthesize_segment(
250
+ self,
251
+ text: str,
252
+ voice: str,
253
+ speed: float,
254
+ index: int
255
+ ) -> Path:
256
+ """
257
+ 合成单个音频片段
258
+
259
+ Args:
260
+ text: 文本
261
+ voice: 音色
262
+ speed: 语速
263
+ index: 片段索引
264
+
265
+ Returns:
266
+ 音频文件路径
267
+ """
268
+ output_path = self.temp_dir / f"segment_{index}.mp3"
269
+
270
+ result = self.tts.synthesize(
271
+ text=text,
272
+ output_path=output_path,
273
+ voice=voice,
274
+ speed=speed
275
+ )
276
+
277
+ if not result.success:
278
+ raise RuntimeError(f"TTS synthesis failed: {result.error_message}")
279
+
280
+ return output_path
281
+
282
+ def _generate_silence(self, duration: float) -> Path:
283
+ """
284
+ 生成空白音频
285
+
286
+ Args:
287
+ duration: 时长(秒)
288
+
289
+ Returns:
290
+ 空白音频文件路径
291
+ """
292
+ output_path = self.temp_dir / f"silence_{duration}.mp3"
293
+
294
+ if output_path.exists():
295
+ return output_path
296
+
297
+ cmd = [
298
+ self.ffmpeg_path,
299
+ "-f", "lavfi",
300
+ "-i", f"anullsrc=r=16000:cl=mono",
301
+ "-t", str(duration),
302
+ "-y",
303
+ str(output_path)
304
+ ]
305
+
306
+ result = subprocess.run(
307
+ cmd,
308
+ capture_output=True,
309
+ text=True,
310
+ timeout=30
311
+ )
312
+
313
+ if result.returncode != 0:
314
+ raise RuntimeError(f"Failed to generate silence: {result.stderr}")
315
+
316
+ return output_path
317
+
318
+ def _concatenate_segments(
319
+ self,
320
+ segments: List[Path],
321
+ output_path: Path
322
+ ) -> Path:
323
+ """
324
+ 拼接多个音频片段
325
+
326
+ Args:
327
+ segments: 音频片段路径列表
328
+ output_path: 输出文件路径
329
+
330
+ Returns:
331
+ 拼接后的音频文件路径
332
+ """
333
+ # 创建文件列表
334
+ list_file = self.temp_dir / "concat_list.txt"
335
+ with open(list_file, "w") as f:
336
+ for seg in segments:
337
+ # 需要转义路径中的特殊字符
338
+ escaped_path = str(seg).replace("'", "'\\''")
339
+ f.write(f"file '{escaped_path}'\n")
340
+
341
+ cmd = [
342
+ self.ffmpeg_path,
343
+ "-f", "concat",
344
+ "-safe", "0",
345
+ "-i", str(list_file),
346
+ "-c", "copy",
347
+ "-y",
348
+ str(output_path)
349
+ ]
350
+
351
+ result = subprocess.run(
352
+ cmd,
353
+ capture_output=True,
354
+ text=True,
355
+ timeout=120
356
+ )
357
+
358
+ if result.returncode != 0:
359
+ raise RuntimeError(f"Failed to concatenate audio: {result.stderr}")
360
+
361
+ return output_path
362
+
363
+ def _get_duration(self, audio_path: Path) -> float:
364
+ """
365
+ 获取音频时长
366
+
367
+ Args:
368
+ audio_path: 音频文件路径
369
+
370
+ Returns:
371
+ 时长(秒)
372
+ """
373
+ cmd = [
374
+ self.ffmpeg_path,
375
+ "-i", str(audio_path),
376
+ "-hide_banner",
377
+ "-f", "null",
378
+ "-"
379
+ ]
380
+
381
+ result = subprocess.run(cmd, capture_output=True, text=True)
382
+
383
+ # 从 stderr 中解析时长,格式: " Duration: 00:00:03.45, ..."
384
+ import re
385
+ match = re.search(r"Duration: (\d+):(\d+):(\d+\.?\d*)", result.stderr)
386
+ if match:
387
+ hours, minutes, seconds = match.groups()
388
+ return int(hours) * 3600 + int(minutes) * 60 + float(seconds)
389
+ return 0.0
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 音频格式转换器 - 将 MP3 转换为飞书语音格式
4
+
5
+ 飞书语音消息要求:
6
+ - 格式: Opus / Speex / AAC / AMR
7
+ - 采样率: 8000Hz / 16000Hz
8
+ - 声道: 单声道
9
+ """
10
+
11
+ import subprocess
12
+ import shutil
13
+ from pathlib import Path
14
+ from typing import Optional
15
+ from dataclasses import dataclass
16
+
17
+
18
+ @dataclass
19
+ class ConversionResult:
20
+ """转换结果"""
21
+ success: bool
22
+ output_path: Optional[Path] = None
23
+ error_message: Optional[str] = None
24
+ duration_seconds: Optional[float] = None
25
+
26
+
27
+ class AudioConverter:
28
+ """音频格式转换器"""
29
+
30
+ # 飞书支持的语音格式
31
+ SUPPORTED_FORMATS = ["opus", "speex", "aac", "amr"]
32
+ SUPPORTED_SAMPLE_RATES = [8000, 16000]
33
+
34
+ def __init__(self, ffmpeg_path: Optional[str] = None):
35
+ """
36
+ 初始化转换器
37
+
38
+ Args:
39
+ ffmpeg_path: ffmpeg 可执行文件路径(默认自动检测)
40
+ """
41
+ self.ffmpeg_path = ffmpeg_path or shutil.which("ffmpeg")
42
+ if not self.ffmpeg_path:
43
+ raise RuntimeError(
44
+ "ffmpeg not found. Install it with: brew install ffmpeg (macOS) "
45
+ "or apt-get install ffmpeg (Ubuntu)"
46
+ )
47
+
48
+ def convert_to_voice(
49
+ self,
50
+ input_path: Path,
51
+ output_path: Optional[Path] = None,
52
+ format: str = "opus",
53
+ sample_rate: int = 16000,
54
+ bitrate: str = "24k"
55
+ ) -> ConversionResult:
56
+ """
57
+ 将音频文件转换为飞书语音格式
58
+
59
+ Args:
60
+ input_path: 输入文件路径(支持 MP3, WAV, M4A 等)
61
+ output_path: 输出文件路径(可选,默认同目录更换扩展名)
62
+ format: 输出格式(opus, speex, aac, amr)
63
+ sample_rate: 采样率(8000 或 16000)
64
+ bitrate: 比特率(默认 24k,适合语音)
65
+
66
+ Returns:
67
+ ConversionResult: 转换结果
68
+ """
69
+ # 参数验证
70
+ if format not in self.SUPPORTED_FORMATS:
71
+ return ConversionResult(
72
+ success=False,
73
+ error_message=f"Unsupported format: {format}. Supported: {self.SUPPORTED_FORMATS}"
74
+ )
75
+
76
+ if sample_rate not in self.SUPPORTED_SAMPLE_RATES:
77
+ return ConversionResult(
78
+ success=False,
79
+ error_message=f"Unsupported sample rate: {sample_rate}. Supported: {self.SUPPORTED_SAMPLE_RATES}"
80
+ )
81
+
82
+ input_path = Path(input_path)
83
+ if not input_path.exists():
84
+ return ConversionResult(
85
+ success=False,
86
+ error_message=f"Input file not found: {input_path}"
87
+ )
88
+
89
+ # 确定输出路径
90
+ if output_path is None:
91
+ output_path = input_path.with_suffix(f".{format}")
92
+ else:
93
+ output_path = Path(output_path)
94
+
95
+ # 确保输出目录存在
96
+ output_path.parent.mkdir(parents=True, exist_ok=True)
97
+
98
+ # 构建 ffmpeg 命令
99
+ codec_map = {
100
+ "opus": "libopus",
101
+ "speex": "libspeex",
102
+ "aac": "aac",
103
+ "amr": "libvo_amrwbenc"
104
+ }
105
+
106
+ cmd = [
107
+ self.ffmpeg_path,
108
+ "-i", str(input_path), # 输入文件
109
+ "-acodec", codec_map[format], # 编码器
110
+ "-ar", str(sample_rate), # 采样率
111
+ "-ac", "1", # 单声道
112
+ "-ab", bitrate, # 比特率
113
+ "-y", # 覆盖输出文件
114
+ str(output_path)
115
+ ]
116
+
117
+ # 特定格式优化
118
+ if format == "opus":
119
+ # Opus 针对语音优化
120
+ cmd.extend(["-application", "audio"])
121
+ elif format == "speex":
122
+ # Speex 针对语音优化
123
+ cmd.extend(["-compression_level", "10"])
124
+
125
+ try:
126
+ result = subprocess.run(
127
+ cmd,
128
+ capture_output=True,
129
+ text=True,
130
+ timeout=60 # 60秒超时
131
+ )
132
+
133
+ if result.returncode != 0:
134
+ return ConversionResult(
135
+ success=False,
136
+ error_message=f"ffmpeg error: {result.stderr}"
137
+ )
138
+
139
+ # 获取音频时长
140
+ duration = self._get_duration(output_path)
141
+
142
+ return ConversionResult(
143
+ success=True,
144
+ output_path=output_path,
145
+ duration_seconds=duration
146
+ )
147
+
148
+ except subprocess.TimeoutExpired:
149
+ return ConversionResult(
150
+ success=False,
151
+ error_message="Conversion timeout (>60s)"
152
+ )
153
+ except Exception as e:
154
+ return ConversionResult(
155
+ success=False,
156
+ error_message=str(e)
157
+ )
158
+
159
+ def _get_duration(self, audio_path: Path) -> float:
160
+ """获取音频时长(秒)"""
161
+ cmd = [
162
+ self.ffmpeg_path,
163
+ "-i", str(audio_path),
164
+ "-hide_banner",
165
+ "-f", "null",
166
+ "-"
167
+ ]
168
+
169
+ result = subprocess.run(cmd, capture_output=True, text=True)
170
+
171
+ # 从 stderr 中解析时长,格式: " Duration: 00:00:03.45, ..."
172
+ import re
173
+ match = re.search(r"Duration: (\d+):(\d+):(\d+\.?\d*)", result.stderr)
174
+ if match:
175
+ hours, minutes, seconds = match.groups()
176
+ return int(hours) * 3600 + int(minutes) * 60 + float(seconds)
177
+ return 0.0
178
+
179
+ def batch_convert(
180
+ self,
181
+ input_dir: Path,
182
+ output_dir: Optional[Path] = None,
183
+ format: str = "opus",
184
+ sample_rate: int = 16000
185
+ ) -> dict:
186
+ """
187
+ 批量转换目录中的音频文件
188
+
189
+ Args:
190
+ input_dir: 输入目录
191
+ output_dir: 输出目录(可选,默认在输入目录下创建 voice/ 子目录)
192
+ format: 输出格式
193
+ sample_rate: 采样率
194
+
195
+ Returns:
196
+ 转换结果字典 {原文件名: ConversionResult}
197
+ """
198
+ input_dir = Path(input_dir)
199
+ if output_dir is None:
200
+ output_dir = input_dir / "voice"
201
+ else:
202
+ output_dir = Path(output_dir)
203
+
204
+ results = {}
205
+
206
+ # 支持的输入格式
207
+ input_extensions = [".mp3", ".wav", ".m4a", ".flac", ".ogg"]
208
+
209
+ for input_file in input_dir.glob("*"):
210
+ if input_file.suffix.lower() not in input_extensions:
211
+ continue
212
+
213
+ output_file = output_dir / input_file.with_suffix(f".{format}").name
214
+ results[input_file.name] = self.convert_to_voice(
215
+ input_path=input_file,
216
+ output_path=output_file,
217
+ format=format,
218
+ sample_rate=sample_rate
219
+ )
220
+
221
+ return results
222
+
223
+
224
+ # 便捷函数
225
+ def convert_mp3_to_opus(
226
+ input_path: Path,
227
+ output_path: Optional[Path] = None
228
+ ) -> ConversionResult:
229
+ """
230
+ 将 MP3 转换为 Opus 格式(飞书推荐)
231
+
232
+ Args:
233
+ input_path: MP3 文件路径
234
+ output_path: 输出路径(可选)
235
+
236
+ Returns:
237
+ ConversionResult
238
+ """
239
+ converter = AudioConverter()
240
+ return converter.convert_to_voice(
241
+ input_path=input_path,
242
+ output_path=output_path,
243
+ format="opus",
244
+ sample_rate=16000
245
+ )