@rookiestar/eng-lang-tutor 1.0.18 → 1.1.0

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.

package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rookiestar/eng-lang-tutor",
3
- "version": "1.0.18",
3
+ "version": "1.1.0",
4
4
  "description": "English language tutor skill for OpenClaw - Learn authentic American English expressions with gamification",
5
5
  "keywords": [
6
6
  "english",
@@ -197,6 +197,95 @@ class AudioConverter:
197
197
  return results
198
198
 
199
199
 
200
+ def convert_to_feishu_voice(
201
+ self,
202
+ input_path: Path,
203
+ output_path: Optional[Path] = None,
204
+ sample_rate: int = 16000,
205
+ bitrate: str = "24k"
206
+ ) -> ConversionResult:
207
+ """
208
+ 将音频转换为飞书语音气泡格式 (.m4a + libopus 编码)
209
+
210
+ 这个格式组合的特点:
211
+ - 文件扩展名: .m4a (MP4 容器)
212
+ - 音频编码: libopus
213
+ - 飞书插件会探测文件头,识别 libopus 编码触发语音气泡
214
+ - 其他平台作为普通音频附件播放
215
+
216
+ Args:
217
+ input_path: 输入文件路径
218
+ output_path: 输出文件路径(可选,默认同目录更换扩展名为 .m4a)
219
+ sample_rate: 采样率(默认 16000)
220
+ bitrate: 比特率(默认 24k)
221
+
222
+ Returns:
223
+ ConversionResult: 转换结果
224
+ """
225
+ input_path = Path(input_path)
226
+ if not input_path.exists():
227
+ return ConversionResult(
228
+ success=False,
229
+ error_message=f"Input file not found: {input_path}"
230
+ )
231
+
232
+ # 确定输出路径
233
+ if output_path is None:
234
+ output_path = input_path.with_suffix(".m4a")
235
+ else:
236
+ output_path = Path(output_path)
237
+
238
+ # 确保输出目录存在
239
+ output_path.parent.mkdir(parents=True, exist_ok=True)
240
+
241
+ # 构建 ffmpeg 命令
242
+ # 使用 -c:a libopus 编码,输出到 .m4a 容器
243
+ cmd = [
244
+ self.ffmpeg_path,
245
+ "-i", str(input_path),
246
+ "-c:a", "libopus", # Opus 编码器
247
+ "-ar", str(sample_rate), # 采样率
248
+ "-ac", "1", # 单声道
249
+ "-b:a", bitrate, # 比特率
250
+ "-y", # 覆盖输出文件
251
+ str(output_path)
252
+ ]
253
+
254
+ try:
255
+ result = subprocess.run(
256
+ cmd,
257
+ capture_output=True,
258
+ text=True,
259
+ timeout=60
260
+ )
261
+
262
+ if result.returncode != 0:
263
+ return ConversionResult(
264
+ success=False,
265
+ error_message=f"ffmpeg error: {result.stderr}"
266
+ )
267
+
268
+ # 获取音频时长
269
+ duration = get_audio_duration(output_path, self.ffmpeg_path)
270
+
271
+ return ConversionResult(
272
+ success=True,
273
+ output_path=output_path,
274
+ duration_seconds=duration
275
+ )
276
+
277
+ except subprocess.TimeoutExpired:
278
+ return ConversionResult(
279
+ success=False,
280
+ error_message="Conversion timeout (>60s)"
281
+ )
282
+ except Exception as e:
283
+ return ConversionResult(
284
+ success=False,
285
+ error_message=str(e)
286
+ )
287
+
288
+
200
289
  # 便捷函数
201
290
  def convert_mp3_to_opus(
202
291
  input_path: Path,
@@ -219,3 +308,24 @@ def convert_mp3_to_opus(
219
308
  format="opus",
220
309
  sample_rate=16000
221
310
  )
311
+
312
+
313
+ def convert_to_feishu_voice(
314
+ input_path: Path,
315
+ output_path: Optional[Path] = None
316
+ ) -> ConversionResult:
317
+ """
318
+ 将音频转换为飞书语音气泡格式 (.m4a + libopus)
319
+
320
+ Args:
321
+ input_path: 输入文件路径
322
+ output_path: 输出路径(可选,默认 .m4a)
323
+
324
+ Returns:
325
+ ConversionResult
326
+ """
327
+ converter = AudioConverter()
328
+ return converter.convert_to_feishu_voice(
329
+ input_path=input_path,
330
+ output_path=output_path
331
+ )
@@ -355,11 +355,11 @@ class StateManager:
355
355
  - error_message: str (if failed)
356
356
  """
357
357
  try:
358
- from .audio_composer import AudioComposer
359
- from .tts import TTSManager
358
+ from ..audio.composer import AudioComposer
359
+ from ..audio.tts import TTSManager
360
360
  except ImportError:
361
- from audio_composer import AudioComposer
362
- from tts import TTSManager
361
+ from audio.composer import AudioComposer
362
+ from audio.tts import TTSManager
363
363
 
364
364
  if target_date is None:
365
365
  target_date = date.today()
@@ -377,50 +377,74 @@ class StateManager:
377
377
  date_str = target_date.strftime('%Y-%m-%d')
378
378
  media_dir = Path.home() / '.openclaw' / 'media' / 'eng-lang-tutor' / date_str
379
379
  media_dir.mkdir(parents=True, exist_ok=True)
380
- output_path = media_dir / "keypoint_full.mp3"
380
+
381
+ # Intermediate MP3 and final Opus paths
382
+ mp3_path = media_dir / "keypoint_full.mp3"
383
+ opus_path = media_dir / "keypoint_full.opus"
381
384
 
382
385
  try:
383
386
  # Initialize TTS and composer (handle both package and direct imports)
384
387
  try:
385
- from .audio_composer import AudioComposer
386
- from .tts import TTSManager
388
+ from ..audio.composer import AudioComposer
389
+ from ..audio.tts import TTSManager
390
+ from ..audio.converter import convert_mp3_to_opus
387
391
  except ImportError:
388
- from audio_composer import AudioComposer
389
- from tts import TTSManager
392
+ from audio.composer import AudioComposer
393
+ from audio.tts import TTSManager
394
+ from audio.converter import convert_mp3_to_opus
390
395
 
391
396
  tts = TTSManager.from_env()
392
397
  composer = AudioComposer(tts)
393
398
 
394
- # Compose audio directly to media directory
395
- result = composer.compose_keypoint_audio(keypoint, output_path)
396
-
397
- if result.success:
398
- # Path relative to ~/.openclaw/media/ for message tool
399
- audio_path = f"eng-lang-tutor/{date_str}/keypoint_full.mp3"
400
-
401
- # Update keypoint with audio metadata
402
- keypoint['audio'] = {
403
- 'composed': audio_path,
404
- 'duration_seconds': result.duration_seconds,
405
- 'generated_at': datetime.now().isoformat()
406
- }
399
+ # Step 1: Compose audio to MP3 (intermediate format)
400
+ result = composer.compose_keypoint_audio(keypoint, mp3_path)
407
401
 
408
- # Save updated keypoint
409
- daily_path = self.get_daily_dir(target_date)
410
- file_path = daily_path / "keypoint.json"
411
- with open(file_path, 'w', encoding='utf-8') as f:
412
- json.dump(keypoint, f, ensure_ascii=False, indent=2)
413
-
414
- return {
415
- 'success': True,
416
- 'audio_path': audio_path,
417
- 'duration_seconds': result.duration_seconds
418
- }
419
- else:
402
+ if not result.success:
420
403
  return {
421
404
  'success': False,
422
405
  'error_message': result.error_message
423
406
  }
407
+
408
+ # Step 2: Convert to Opus format for Feishu voice bubble compatibility
409
+ # .opus = Ogg container + libopus codec
410
+ # - Feishu plugin: detects libopus → triggers voice bubble
411
+ # - Discord: native Opus support
412
+ # - Other platforms: may need fallback
413
+ convert_result = convert_mp3_to_opus(
414
+ input_path=mp3_path,
415
+ output_path=opus_path
416
+ )
417
+
418
+ if convert_result.success:
419
+ # Use Opus format
420
+ audio_path = f"eng-lang-tutor/{date_str}/keypoint_full.opus"
421
+ duration_seconds = convert_result.duration_seconds or result.duration_seconds
422
+ audio_format = 'opus'
423
+ else:
424
+ # Fallback to MP3 if conversion fails
425
+ audio_path = f"eng-lang-tutor/{date_str}/keypoint_full.mp3"
426
+ duration_seconds = result.duration_seconds
427
+ audio_format = 'mp3'
428
+
429
+ # Update keypoint with audio metadata
430
+ keypoint['audio'] = {
431
+ 'composed': audio_path,
432
+ 'duration_seconds': duration_seconds,
433
+ 'generated_at': datetime.now().isoformat(),
434
+ 'format': audio_format
435
+ }
436
+
437
+ # Save updated keypoint
438
+ daily_path = self.get_daily_dir(target_date)
439
+ file_path = daily_path / "keypoint.json"
440
+ with open(file_path, 'w', encoding='utf-8') as f:
441
+ json.dump(keypoint, f, ensure_ascii=False, indent=2)
442
+
443
+ return {
444
+ 'success': True,
445
+ 'audio_path': audio_path,
446
+ 'duration_seconds': duration_seconds
447
+ }
424
448
  except Exception as e:
425
449
  return {
426
450
  'success': False,