@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.
- package/npm-scripts/install.js +88 -10
- package/package.json +1 -1
- package/requirements.txt +7 -0
- package/scripts/__pycache__/audio_composer.cpython-313.pyc +0 -0
- package/scripts/__pycache__/audio_converter.cpython-313.pyc +0 -0
- package/scripts/__pycache__/audio_enhancer.cpython-313.pyc +0 -0
- package/scripts/__pycache__/state_manager.cpython-313.pyc +0 -0
- package/scripts/audio_composer.py +389 -0
- package/scripts/audio_converter.py +245 -0
- package/scripts/cli.py +21 -1
- package/scripts/eng-lang-tutor +16 -0
- package/scripts/feishu_voice.py +421 -0
- package/scripts/state_manager.py +90 -1
- package/scripts/tts/__pycache__/manager.cpython-313.pyc +0 -0
- package/scripts/tts/manager.py +14 -4
- package/scripts/tts/providers/__pycache__/xunfei.cpython-313.pyc +0 -0
- package/scripts/tts/providers/xunfei.py +10 -1
- package/scripts/__pycache__/cli.cpython-313.pyc +0 -0
- package/scripts/__pycache__/command_parser.cpython-313.pyc +0 -0
- package/scripts/__pycache__/constants.cpython-313.pyc +0 -0
- package/scripts/__pycache__/cron_push.cpython-313.pyc +0 -0
- package/scripts/__pycache__/dedup.cpython-313.pyc +0 -0
- package/scripts/__pycache__/gamification.cpython-313.pyc +0 -0
- package/scripts/__pycache__/scorer.cpython-313.pyc +0 -0
package/npm-scripts/install.js
CHANGED
|
@@ -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.
|
|
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
|
-
║
|
|
88
|
-
║
|
|
89
|
-
║ pip install -r ${SKILL_TARGET}/requirements.txt ║
|
|
166
|
+
║ Python venv: ║
|
|
167
|
+
║ ${VENV_DIR} ║
|
|
90
168
|
║ ║
|
|
91
|
-
║
|
|
169
|
+
║ Usage: ║
|
|
170
|
+
║ ${VENV_DIR}/bin/python ${SKILL_TARGET}/scripts/cli.py ║
|
|
92
171
|
║ ║
|
|
93
|
-
║
|
|
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
|
-
║
|
|
100
|
-
║
|
|
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
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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|