@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
|
Binary file
|
|
@@ -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
|
+
)
|
|
Binary file
|
|
@@ -355,11 +355,11 @@ class StateManager:
|
|
|
355
355
|
- error_message: str (if failed)
|
|
356
356
|
"""
|
|
357
357
|
try:
|
|
358
|
-
from .
|
|
359
|
-
from .tts import TTSManager
|
|
358
|
+
from ..audio.composer import AudioComposer
|
|
359
|
+
from ..audio.tts import TTSManager
|
|
360
360
|
except ImportError:
|
|
361
|
-
from
|
|
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
|
-
|
|
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 .
|
|
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
|
|
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
|
|
395
|
-
result = composer.compose_keypoint_audio(keypoint,
|
|
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
|
-
|
|
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,
|