@rookiestar/eng-lang-tutor 1.0.3 → 1.0.5

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.

@@ -5,23 +5,99 @@
5
5
  *
6
6
  * This script runs automatically after npm install and:
7
7
  * 1. Installs the skill to ~/.openclaw/skills/eng-lang-tutor/
8
- * 2. Migrates data from old data/ directory if needed (handled by Python code)
8
+ * 2. Creates Python venv and installs dependencies
9
+ * 3. Checks for system dependencies (ffmpeg)
10
+ * 4. Migrates data from old data/ directory if needed (handled by Python code)
9
11
  */
10
12
 
11
13
  const path = require('path');
12
14
  const fs = require('fs');
13
15
  const os = require('os');
16
+ const { execSync, spawn } = require('child_process');
14
17
 
15
18
  const SKILL_NAME = 'eng-lang-tutor';
16
19
  const SKILLS_DIR = path.join(os.homedir(), '.openclaw', 'skills');
17
20
  const SKILL_TARGET = path.join(SKILLS_DIR, SKILL_NAME);
21
+ const VENV_DIR = path.join(os.homedir(), '.venvs', SKILL_NAME);
18
22
 
19
23
  // Get the package root directory
20
24
  const PACKAGE_ROOT = path.resolve(__dirname, '..');
21
25
 
26
+ function checkFfmpeg() {
27
+ try {
28
+ execSync('ffmpeg -version', { stdio: 'ignore' });
29
+ return true;
30
+ } catch (e) {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ function setupPythonVenv() {
36
+ const requirementsPath = path.join(SKILL_TARGET, 'requirements.txt');
37
+
38
+ // Check if requirements.txt exists
39
+ if (!fs.existsSync(requirementsPath)) {
40
+ console.log('⚠️ requirements.txt not found, skipping Python setup\n');
41
+ return false;
42
+ }
43
+
44
+ // Check if venv already exists and has dependencies
45
+ const venvPython = path.join(VENV_DIR, 'bin', 'python');
46
+ const venvPip = path.join(VENV_DIR, 'bin', 'pip');
47
+
48
+ if (fs.existsSync(venvPython) && fs.existsSync(venvPip)) {
49
+ // Check if websocket-client is installed (key dependency)
50
+ try {
51
+ execSync(`${venvPython} -c "import websocket"`, { stdio: 'ignore' });
52
+ console.log('✓ Python venv already set up with dependencies\n');
53
+ return true;
54
+ } catch (e) {
55
+ console.log('→ Updating Python dependencies...');
56
+ }
57
+ } else {
58
+ console.log('→ Creating Python virtual environment...');
59
+ try {
60
+ execSync(`python3 -m venv ${VENV_DIR}`, { stdio: 'inherit' });
61
+ console.log('✓ Created venv at ' + VENV_DIR);
62
+ } catch (e) {
63
+ console.log('⚠️ Failed to create venv: ' + e.message);
64
+ return false;
65
+ }
66
+ }
67
+
68
+ // Install dependencies
69
+ console.log('→ Installing Python dependencies...');
70
+ try {
71
+ execSync(`${venvPip} install -q -r ${requirementsPath}`, { stdio: 'inherit' });
72
+ console.log('✓ Python dependencies installed\n');
73
+ return true;
74
+ } catch (e) {
75
+ console.log('⚠️ Failed to install Python dependencies: ' + e.message);
76
+ console.log(' You may need to run manually:');
77
+ console.log(` ${venvPip} install -r ${requirementsPath}\n`);
78
+ return false;
79
+ }
80
+ }
81
+
22
82
  function install() {
23
83
  console.log(`\n📦 Setting up ${SKILL_NAME} skill...\n`);
24
84
 
85
+ // Check for ffmpeg
86
+ const hasFfmpeg = checkFfmpeg();
87
+ if (!hasFfmpeg) {
88
+ console.log('⚠️ WARNING: ffmpeg is not installed. Audio generation will not work.');
89
+ console.log(' Install it with:');
90
+ if (process.platform === 'darwin') {
91
+ console.log(' brew install ffmpeg');
92
+ } else if (process.platform === 'linux') {
93
+ console.log(' sudo apt-get install ffmpeg # Debian/Ubuntu');
94
+ console.log(' sudo yum install ffmpeg # RHEL/CentOS');
95
+ }
96
+ console.log('');
97
+ } else {
98
+ console.log('✓ ffmpeg is installed\n');
99
+ }
100
+
25
101
  // Create skills directory if it doesn't exist
26
102
  if (!fs.existsSync(SKILLS_DIR)) {
27
103
  fs.mkdirSync(SKILLS_DIR, { recursive: true });
@@ -75,6 +151,9 @@ function install() {
75
151
 
76
152
  console.log(`✓ Copied ${copiedCount} items to ${SKILL_TARGET}`);
77
153
 
154
+ // Setup Python venv and install dependencies
155
+ setupPythonVenv();
156
+
78
157
  // Show post-install message
79
158
  console.log(`
80
159
  ╔═══════════════════════════════════════════════════════════════╗
@@ -84,21 +163,20 @@ function install() {
84
163
  ║ ${SKILL_NAME} has been installed to: ║
85
164
  ║ ${SKILL_TARGET}
86
165
  ║ ║
87
- Next steps:
88
- 1. Install Python dependencies:
89
- ║ pip install -r ${SKILL_TARGET}/requirements.txt ║
166
+ Python venv:
167
+ ${VENV_DIR}
90
168
  ║ ║
91
- 2. Restart your OpenClaw agent
169
+ Usage:
170
+ ║ ${VENV_DIR}/bin/python ${SKILL_TARGET}/scripts/cli.py ║
92
171
  ║ ║
93
- 3. Configure through onboarding (first time only)
172
+ Or use the wrapper script:
173
+ ║ ${SKILL_TARGET}/scripts/eng-lang-tutor ║
94
174
  ║ ║
95
175
  ║ Data location: ║
96
176
  ║ ~/.openclaw/state/eng-lang-tutor/ ║
97
- ║ (or set OPENCLAW_STATE_DIR env var) ║
98
177
  ║ ║
99
- Commands:
100
- npx eng-lang-tutor install - Reinstall skill
101
- ║ npx eng-lang-tutor uninstall - Remove skill ║
178
+ Environment variables (required for TTS):
179
+ XUNFEI_APPID, XUNFEI_API_KEY, XUNFEI_API_SECRET
102
180
  ║ ║
103
181
  ╚═══════════════════════════════════════════════════════════════╝
104
182
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rookiestar/eng-lang-tutor",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "English language tutor skill for OpenClaw - Learn authentic American English expressions with gamification",
5
5
  "keywords": [
6
6
  "english",
package/requirements.txt CHANGED
@@ -1,5 +1,7 @@
1
1
  # Core dependencies
2
2
  websocket-client>=1.6.0 # For XunFei TTS WebSocket API
3
+ certifi>=2024.0.0 # SSL certificate bundle for HTTPS/WebSocket connections
4
+ aiohttp>=3.8.0 # Async HTTP client for Feishu API
3
5
 
4
6
  # Optional: Edge-TTS support (uncomment if needed)
5
7
  # edge-tts>=6.1.0
@@ -7,3 +9,8 @@ websocket-client>=1.6.0 # For XunFei TTS WebSocket API
7
9
  # Development dependencies
8
10
  pytest>=7.0.0
9
11
  pytest-asyncio>=0.21.0
12
+
13
+ # System dependencies (must be installed separately):
14
+ # - ffmpeg: Required for audio composition
15
+ # macOS: brew install ffmpeg
16
+ # Ubuntu/Debian: sudo apt-get install ffmpeg
@@ -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 hasattr(self, 'temp_dir') and 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