Jacky2305 commited on
Commit
253435b
·
1 Parent(s): 8e9b3a3

Use user's OpenVoice V2 elderly voice conversion (Gradio SDK)

Browse files
Files changed (8) hide show
  1. Dockerfile +0 -21
  2. README.md +17 -96
  3. app.py +61 -199
  4. app_simple.py +0 -227
  5. inference.py +0 -191
  6. inference_simple.py +0 -22
  7. packages.txt +1 -0
  8. requirements.txt +18 -24
Dockerfile DELETED
@@ -1,21 +0,0 @@
1
- FROM python:3.11-slim-bookworm
2
-
3
- ENV DEBIAN_FRONTEND=noninteractive
4
- ENV PIP_NO_CACHE_DIR=1
5
- ENV PYTHONUNBUFFERED=1
6
-
7
- RUN apt-get update && apt-get install -y --no-install-recommends \
8
- ffmpeg libsndfile1 libportaudio2 libasound2 \
9
- && rm -rf /var/lib/apt/lists/*
10
-
11
- WORKDIR /app
12
-
13
- # Ultra-minimal deps: only what's needed for pitch/formant shifting
14
- RUN pip install --no-cache-dir --timeout=120 \
15
- numpy scipy librosa soundfile praat-parselmouth gradio fastapi uvicorn pydantic httpx anyio starlette websockets
16
-
17
- COPY app_simple.py app.py
18
- COPY inference_simple.py inference.py
19
-
20
- EXPOSE 7860
21
- CMD ["python", "app.py"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,104 +1,25 @@
1
  ---
2
- title: Blender Headless API
3
- emoji: 🦖
4
- colorFrom: blue
5
- colorTo: purple
6
- sdk: docker
7
- app_port: 7860
 
8
  pinned: false
9
  ---
10
 
11
- # Blender Headless API
12
 
13
- 在 Hugging Face Space(CPU basic)上跑一个常驻的 FastAPI 服务,
14
- 每次请求用 `blender --background --python worker.py` 无头执行一组
15
- 白名单 3D 操作(建模 / 材质 / 灯光 / 相机 / 动画 / 渲染 / 导出),
16
- 供 OpenClaw Agent 通过 HTTP 调用。
17
 
18
- ## 目录结构
 
19
 
20
- ```
21
- .
22
- ├── Dockerfile # 安装 Blender + 启动 FastAPI
23
- ├── requirements.txt
24
- ├── app.py # FastAPI 服务,接收请求,拉起 blender 子进程
25
- ├── worker.py # 运行在 Blender 内部(bpy)的操作分发器
26
- └── example_request.json
27
- ```
28
 
29
- ## API
30
-
31
- ### `GET /health`
32
- 检查服务和 Blender 二进制是否就绪。
33
-
34
- ### `POST /execute`
35
- ```json
36
- {
37
- "scene_b64": null,
38
- "ops": [
39
- {"op": "create_object", "type": "sphere", "name": "Ball", "location": [0,0,1]},
40
- {"op": "add_material", "target": "Ball", "color": [0.8, 0.1, 0.1, 1.0], "roughness": 0.3},
41
- {"op": "add_light", "type": "SUN", "location": [4,-4,6], "energy": 3},
42
- {"op": "set_camera", "location": [6,-6,4], "look_at": [0,0,1]},
43
- {"op": "set_render_settings", "resolution": [960, 540], "samples": 32},
44
- {"op": "render_image", "filename": "ball.png"},
45
- {"op": "export_glb", "filename": "ball.glb"}
46
- ]
47
- }
48
- ```
49
-
50
- 返回:
51
- ```json
52
- {
53
- "job_id": "a1b2c3d4e5f6",
54
- "ops_result": {"ok": true, "results": [...], "errors": []},
55
- "files": ["ball.png", "ball.glb"],
56
- "download_base": "/files/a1b2c3d4e5f6/"
57
- }
58
- ```
59
-
60
- ### `GET /files/{job_id}/{filename}`
61
- 下载生成的文件(渲染图 / glb / fbx / blend)。
62
-
63
- ### `DELETE /jobs/{job_id}`
64
- 清理某次任务的临时文件。
65
-
66
- ## 支持的 op(在 `worker.py` 里维护白名单)
67
-
68
- | op | 说明 |
69
- |---|---|
70
- | `create_object` | 创建基础几何体(sphere/cube/cylinder/cone/plane/monkey/torus/empty) |
71
- | `move_object` | 绝对位置或相对位移 |
72
- | `set_transform` | 设置位置/旋转(度)/缩放 |
73
- | `add_material` | 创建/应用材质(颜色、金属度、粗糙度) |
74
- | `set_camera` | 创建相机,支持 look_at 自动朝向 |
75
- | `add_light` | 创建灯光(POINT/SUN/AREA/SPOT) |
76
- | `add_animation` | 按关键帧设置位置/旋转/缩放 |
77
- | `set_render_settings` | 分辨率/引擎/采样数/帧率/起止帧 |
78
- | `render_image` | 渲染单帧 PNG |
79
- | `render_animation` | 渲染动画序列帧 |
80
- | `import_model` | 导入 glb/gltf/fbx/obj |
81
- | `export_glb` / `export_fbx` | 导出模型 |
82
- | `delete_object` | 删除物体 |
83
- | `save_blend` | 保存 .blend 文件 |
84
-
85
- 不支持任意 Python 执行——Agent 只能组合调用这些 op,避免生成失控脚本
86
- (死循环、超大渲染、越权文件访问)。
87
-
88
- ## 部署前需要改的地方
89
-
90
- 1. `Dockerfile` 里的 `BLENDER_VERSION`,默认是 `4.2.9`(4.2 LTS 最新补丁版)。
91
- 如需其他版本,去 https://download.blender.org/release/ 确认对应文件名。
92
- 2. `JOB_TIMEOUT_SECONDS`(Dockerfile 里的环境变量),默认 180 秒,
93
- 渲染复杂场景时按需调大——但 HF Space CPU basic 的算力有限,
94
- 建议先用低分辨率/低采样数验证流程。
95
- 3. 认证:这个 MVP 没有做鉴权,Space 一旦公开任何人都能调用你的 `/execute`。
96
- 建议在 `app.py` 加一个简单的 API Key header 校验(`X-API-Key`),
97
- OpenClaw 那边配置对应的 key。
98
-
99
- ## OpenClaw 集成建议
100
-
101
- 按你之前的设计,把它包成一个 skill:
102
- `blender_execute` = bash wrapper 拼装 ops.json → POST 到这个 Space 的
103
- `/execute` → 轮询/直接拿到 `files` 列表 → 用 `GET /files/...` 下载渲染结果
104
- 或 glb,交给你现有的 video-gen / 3d-video pipeline 继续处理。
 
1
  ---
2
+ title: 老年声音转换
3
+ emoji: 🎙️
4
+ colorFrom: gray
5
+ colorTo: yellow
6
+ sdk: gradio
7
+ sdk_version: 4.44.0
8
+ app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # 老年声音转换
13
 
14
+ 上传一段录音 + 一段几秒钟的老年人声音样本,基于 OpenVoice V2 的零样本音色转换,
15
+ 把录音的音色替换成老年人音色(保留原始语调、节奏、内容)。
 
 
16
 
17
+ 首次启动会自动从 Hugging Face Hub 下载 `myshell-ai/OpenVoiceV2` 的转换器权重
18
+ (约几百 MB),之后会缓存在 Space 里。CPU basic 上单次转换约 10~30 秒。
19
 
20
+ ## 部署步骤
 
 
 
 
 
 
 
21
 
22
+ 1. 在 Hugging Face 新建一个 Space:SDK 选 **Gradio**,Hardware 选 **CPU basic**
23
+ 2. 把本文件夹里的 `app.py`、`requirements.txt`、`packages.txt`、`README.md`
24
+ 四个文件都传到 Space 仓库根目录(可以直接在网页上传,或 `git push`)
25
+ 3. 等 Space 自动 build 完成即可使用
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -1,217 +1,79 @@
1
- #!/usr/bin/env python3
2
- """Gradio Web UI for Elderly Voice Conversion (RVC-based)."""
3
  import os
4
  import tempfile
5
- from pathlib import Path
6
  import gradio as gr
7
- import torch
8
- import numpy as np
9
- import soundfile as sf
10
- import librosa
11
 
12
- from inference import convert_voice, vc, MODEL_PATH
 
13
 
14
- # Check GPU availability
15
- device = "cuda" if torch.cuda.is_available() else "cpu"
16
- has_gpu = torch.cuda.is_available()
17
 
18
- # UI Configuration
19
- TITLE = "🎭 Elderly Voice Converter"
20
- DESCRIPTION = """
21
- 基于 RVC (Retrieval-based Voice Conversion) 的声音转换工具,将输入音频转换为老年人声音特征。
22
 
23
- **使用方法:**
24
- 1. 上传音频文件(支持 wav, mp3, flac 等格式)
25
- 2. 调整参数(音调偏移建议 -2 到 -5 半音,让声音更沉稳)
26
- 3. 点击转换,等待处理完成
27
- 4. 下载或试听结果
28
 
29
- **参数说明:**
30
- - **音调偏移**:负值降低音调(更沉稳),正值升高。老年声建议 -2 ~ -5
31
- - **F0 方法**:rmvpe 效果最好,crepe 需 GPU,harvest/pm 速度较快
32
- - **Index 率**:控制模型特征强度,0.5-0.8 通常效果较好
33
- - **保护无声辅音**:避免气声过重,建议 0.33
34
- """
35
 
36
- # Example audio (we'll create a simple one if needed)
37
- EXAMPLES_DIR = Path("/app/examples")
38
- EXAMPLES_DIR.mkdir(exist_ok=True)
39
 
 
 
 
 
 
 
 
 
 
40
 
41
- def process_audio(
42
- audio_file,
43
- pitch_shift,
44
- f0_method,
45
- index_rate,
46
- filter_radius,
47
- rms_mix_rate,
48
- protect,
49
- ):
50
- """Process uploaded audio and return converted audio."""
51
- if audio_file is None:
52
- return None, "请先上传音频文件"
53
-
54
- if vc is None:
55
- return None, "❌ 模型未加载,请检查模型文件是否存在"
56
-
57
- try:
58
- # Create temp files
59
- with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_in:
60
- # Load and save input audio at 16kHz
61
- y, sr = librosa.load(audio_file, sr=16000, mono=True)
62
- sf.write(tmp_in.name, y, 16000)
63
- input_path = tmp_in.name
64
-
65
- with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_out:
66
- output_path = tmp_out.name
67
-
68
- # Convert
69
- convert_voice(
70
- input_audio_path=input_path,
71
- output_audio_path=output_path,
72
- f0_up_key=pitch_shift,
73
- f0_method=f0_method,
74
- index_rate=index_rate,
75
- filter_radius=filter_radius,
76
- rms_mix_rate=rms_mix_rate,
77
- protect=protect,
78
  )
79
-
80
- # Clean up input temp file
81
- os.unlink(input_path)
82
-
83
- return output_path, "✅ 转换完成!"
84
-
85
- except Exception as e:
86
- return None, f"❌ 转换失败: {str(e)}"
87
 
 
 
88
 
89
- def create_interface():
90
- with gr.Blocks(title=TITLE, theme=gr.themes.Soft()) as demo:
91
- gr.Markdown(f"# {TITLE}")
92
- gr.Markdown(DESCRIPTION)
93
-
94
- with gr.Row():
95
- with gr.Column(scale=1):
96
- # Input
97
- audio_input = gr.Audio(
98
- label="输入音频",
99
- type="filepath",
100
- sources=["upload", "microphone"],
101
- )
102
-
103
- # Parameters
104
- with gr.Group():
105
- gr.Markdown("### 转换参数")
106
- pitch_shift = gr.Slider(
107
- minimum=-12,
108
- maximum=12,
109
- value=-3,
110
- step=1,
111
- label="音调偏移 (半音)",
112
- info="负值=更低沉(老年声建议 -2~-5)"
113
- )
114
- f0_method = gr.Dropdown(
115
- choices=["rmvpe", "crepe", "crepe-tiny", "harvest", "pm", "dio"],
116
- value="rmvpe",
117
- label="F0 提取方法",
118
- info="rmvpe 推荐;crepe 需 GPU"
119
- )
120
- index_rate = gr.Slider(
121
- minimum=0.0,
122
- maximum=1.0,
123
- value=0.75,
124
- step=0.05,
125
- label="Index 率",
126
- info="模型特征强度,0.5-0.8 推荐"
127
- )
128
- filter_radius = gr.Slider(
129
- minimum=0,
130
- maximum=7,
131
- value=3,
132
- step=1,
133
- label="滤波半径",
134
- info="音调平滑,0=关闭"
135
- )
136
- rms_mix_rate = gr.Slider(
137
- minimum=0.0,
138
- maximum=1.0,
139
- value=0.25,
140
- step=0.05,
141
- label="RMS 混合率",
142
- info="音量包络混合"
143
- )
144
- protect = gr.Slider(
145
- minimum=0.0,
146
- maximum=0.5,
147
- value=0.33,
148
- step=0.01,
149
- label="保护无声辅音",
150
- info="避免气声过重"
151
- )
152
-
153
- convert_btn = gr.Button("🎭 转换为老年声", variant="primary", size="lg")
154
- status = gr.Textbox(label="状态", interactive=False)
155
-
156
- with gr.Column(scale=1):
157
- # Output
158
- audio_output = gr.Audio(
159
- label="转换结果",
160
- type="filepath",
161
- )
162
- gr.Markdown("""
163
- ### 💡 提示
164
- - **音频时长**:建议 10-60 秒,过长处理较慢
165
- - **采样率**:自动重采样到 16kHz
166
- - **音调建议**:
167
- - 男性转老年男声:-3~-5
168
- - 女性转老年女声:-2~-4
169
- - 女性转老年男声:-5~-8 (需配合 index_rate)
170
- - **F0 方法选择**:
171
- - `rmvpe`:平衡速度/质量,CPU 可用 ⭐推荐
172
- - `crepe`:质量最好,需 GPU
173
- - `harvest`:传统方法,速度快
174
- """)
175
-
176
- # Model info
177
- with gr.Accordion("模型信息", open=False):
178
- gr.Markdown(f"""
179
- - **设备**: {device.upper()} {'✅' if has_gpu else '⚠️ (CPU 模式较慢)'}
180
- - **模型路径**: `{MODEL_PATH or '未找到'}`
181
- - **基础模型**: RVC v2 (768 channels)
182
- - **目标音色**: 老年/长者声音特征
183
- """)
184
-
185
- convert_btn.click(
186
- fn=process_audio,
187
- inputs=[
188
- audio_input,
189
- pitch_shift,
190
- f0_method,
191
- index_rate,
192
- filter_radius,
193
- rms_mix_rate,
194
- protect,
195
- ],
196
- outputs=[audio_output, status],
197
- api_name="convert",
198
- )
199
-
200
- # Example section (if we have examples)
201
- gr.Examples(
202
- examples=[],
203
- inputs=audio_input,
204
- label="示例音频 (可上传自己的)",
205
  )
206
-
207
- return demo
208
 
 
 
 
 
 
209
 
210
  if __name__ == "__main__":
211
- demo = create_interface()
212
- demo.launch(
213
- server_name="0.0.0.0",
214
- server_port=7860,
215
- show_error=True,
216
- share=False,
217
- )
 
 
 
1
  import os
2
  import tempfile
3
+
4
  import gradio as gr
5
+ from huggingface_hub import snapshot_download
 
 
 
6
 
7
+ from openvoice import se_extractor
8
+ from openvoice.api import ToneColorConverter
9
 
10
+ DEVICE = "cpu"
 
 
11
 
12
+ # Downloads OpenVoice V2's tone-color converter checkpoint from the HF Hub
13
+ # the first time the Space boots. Cached afterwards in the Space's storage.
14
+ CKPT_DIR = snapshot_download(repo_id="myshell-ai/OpenVoiceV2")
15
+ CONVERTER_DIR = os.path.join(CKPT_DIR, "converter")
16
 
17
+ tone_color_converter = ToneColorConverter(
18
+ os.path.join(CONVERTER_DIR, "config.json"), device=DEVICE
19
+ )
20
+ tone_color_converter.load_ckpt(os.path.join(CONVERTER_DIR, "checkpoint.pth"))
 
21
 
 
 
 
 
 
 
22
 
23
+ def convert_voice(source_audio, elderly_reference_audio):
24
+ if source_audio is None or elderly_reference_audio is None:
25
+ raise gr.Error("请先上传「原始录音」和「老年人参考音频」两个文件")
26
 
27
+ with tempfile.TemporaryDirectory() as tmp:
28
+ # vad=True uses lightweight Silero VAD to find clean speech segments
29
+ # (no transcription needed), which is enough for tone-color extraction.
30
+ src_se, _ = se_extractor.get_se(
31
+ source_audio, tone_color_converter, target_dir=tmp, vad=True
32
+ )
33
+ tgt_se, _ = se_extractor.get_se(
34
+ elderly_reference_audio, tone_color_converter, target_dir=tmp, vad=True
35
+ )
36
 
37
+ output_path = os.path.join(tmp, "output.wav")
38
+ tone_color_converter.convert(
39
+ audio_src_path=source_audio,
40
+ src_se=src_se,
41
+ tgt_se=tgt_se,
42
+ output_path=output_path,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  )
 
 
 
 
 
 
 
 
44
 
45
+ with open(output_path, "rb") as f:
46
+ data = f.read()
47
 
48
+ # Write result outside the tempdir so Gradio can still read it after cleanup
49
+ final_path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
50
+ with open(final_path, "wb") as f:
51
+ f.write(data)
52
+ return final_path
53
+
54
+
55
+ with gr.Blocks(title="老年声音转换") as demo:
56
+ gr.Markdown(
57
+ "# 声音转老年音色\n"
58
+ "1. 上传要转换的原始录音\n"
59
+ "2. 上传一段几秒钟的老年人声音样本(参考音色)\n"
60
+ "3. 点击「开始转换」\n\n"
61
+ "基于 [OpenVoice V2](https://github.com/myshell-ai/OpenVoice) 的零样本音色转换,"
62
+ "CPU 上单次转换大约需要 10~30 秒,请耐心等待。"
63
+ )
64
+ with gr.Row():
65
+ source_audio = gr.Audio(label="要转换的原始录音", type="filepath")
66
+ elderly_reference_audio = gr.Audio(
67
+ label="老年人参考音频(几秒钟即可)", type="filepath"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  )
69
+ convert_btn = gr.Button("开始转换", variant="primary")
70
+ output_audio = gr.Audio(label="转换结果", type="filepath")
71
 
72
+ convert_btn.click(
73
+ fn=convert_voice,
74
+ inputs=[source_audio, elderly_reference_audio],
75
+ outputs=output_audio,
76
+ )
77
 
78
  if __name__ == "__main__":
79
+ demo.launch()
 
 
 
 
 
 
app_simple.py DELETED
@@ -1,227 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Simple Elderly Voice Converter using librosa for pitch shift + simple formant shift."""
3
- import os
4
- import tempfile
5
- import gradio as gr
6
- import numpy as np
7
- import soundfile as sf
8
- import librosa
9
- import librosa.effects
10
-
11
- TITLE = "🎭 Elderly Voice Converter (Pitch + Formant Shift)"
12
- DESCRIPTION = """
13
- 基于 **音调偏移 + 共振峰偏移** 的轻量级声音转换,模拟老年人声线特征。
14
-
15
- **原理:**
16
- - **降低基频 (Pitch)**:使用 librosa PSOLA 算法降低音调
17
- - **降低共振峰**:通过频谱包络偏移模拟声道变长
18
- - **添加气声**:模拟声带闭合不全的气息感
19
-
20
- **使用方法:**
21
- 1. 上传音频文件 (wav, mp3, flac 等)
22
- 2. 调整参数:
23
- - **Pitch Shift (半音)**:建议 -3 到 -6 (男声) / -2 到 -5 (女声)
24
- - **Formant Shift (半音)**:建议 -2 到 -4
25
- - **Breathiness**:0-0.3,添加气声感
26
- 3. 点击转换
27
- """
28
-
29
-
30
- def convert_voice_simple(
31
- input_audio_path: str,
32
- output_audio_path: str,
33
- pitch_shift: float = -4.0, # semitones
34
- formant_shift: float = -3.0, # semitones
35
- breathiness: float = 0.15,
36
- ):
37
- """
38
- Convert voice to elderly-like using pitch + formant shifting.
39
-
40
- Args:
41
- input_audio_path: Input audio file
42
- output_audio_path: Output audio file
43
- pitch_shift: Pitch shift in semitones (negative = lower)
44
- formant_shift: Formant shift in semitones (negative = lower)
45
- breathiness: Amount of breathiness to add (0-0.5)
46
- """
47
- # Load audio
48
- y, sr = librosa.load(input_audio_path, sr=None, mono=True)
49
-
50
- # 1. Pitch shift using librosa's PSOLA (reliable)
51
- if pitch_shift != 0:
52
- y = librosa.effects.pitch_shift(y, sr=sr, n_steps=pitch_shift)
53
-
54
- # 2. Formant shift via spectral envelope manipulation
55
- if formant_shift != 0:
56
- y = shift_formants(y, sr, formant_shift)
57
-
58
- # 3. Add breathiness
59
- if breathiness > 0:
60
- noise = np.random.normal(0, breathiness * 0.1 * np.std(y), len(y))
61
- y = y * (1 - breathiness) + noise * breathiness
62
-
63
- # Normalize to prevent clipping
64
- y = y / (np.max(np.abs(y)) + 1e-6) * 0.95
65
-
66
- # Save
67
- sf.write(output_audio_path, y, sr, subtype='PCM_16')
68
- return output_audio_path
69
-
70
-
71
- def shift_formants(y, sr, formant_shift_semitones):
72
- """
73
- Shift formants by manipulating spectral envelope.
74
- Positive = higher formants, Negative = lower formants.
75
- """
76
- if formant_shift_semitones == 0:
77
- return y
78
-
79
- # Convert semitones to frequency ratio
80
- ratio = 2 ** (formant_shift_semitones / 12.0)
81
-
82
- # STFT
83
- n_fft = 2048
84
- hop_length = 512
85
- stft = librosa.stft(y, n_fft=n_fft, hop_length=hop_length)
86
- magnitude = np.abs(stft)
87
- phase = np.angle(stft)
88
-
89
- # Shift spectral envelope along frequency axis
90
- # Simple approach: interpolate magnitude spectrum
91
- n_freqs = magnitude.shape[0]
92
- new_freqs = np.linspace(0, n_freqs - 1, int(n_freqs / ratio))
93
-
94
- if ratio < 1: # Lower formants (stretch)
95
- # Interpolate to expand
96
- from scipy.interpolate import interp1d
97
- interp_func = interp1d(np.arange(n_freqs), magnitude, axis=0, kind='linear', fill_value='extrapolate')
98
- magnitude = interp_func(new_freqs)
99
- # Truncate or pad to original size
100
- if magnitude.shape[0] > n_freqs:
101
- magnitude = magnitude[:n_freqs]
102
- else:
103
- pad = np.zeros((n_freqs - magnitude.shape[0], magnitude.shape[1]))
104
- magnitude = np.vstack([magnitude, pad])
105
- else: # Higher formants (compress)
106
- from scipy.interpolate import interp1d
107
- interp_func = interp1d(np.arange(n_freqs), magnitude, axis=0, kind='linear', fill_value='extrapolate')
108
- magnitude = interp_func(new_freqs)
109
- if magnitude.shape[0] < n_freqs:
110
- pad = np.zeros((n_freqs - magnitude.shape[0], magnitude.shape[1]))
111
- magnitude = np.vstack([magnitude, pad])
112
- else:
113
- magnitude = magnitude[:n_freqs]
114
-
115
- # Reconstruct
116
- stft_new = magnitude * np.exp(1j * phase)
117
- y_new = librosa.istft(stft_new, hop_length=hop_length, length=len(y))
118
-
119
- return y_new
120
-
121
-
122
- def process_audio(
123
- audio_file,
124
- pitch_shift,
125
- formant_shift,
126
- breathiness,
127
- ):
128
- if audio_file is None:
129
- return None, "请先上传音频文件"
130
-
131
- try:
132
- with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp_out:
133
- output_path = tmp_out.name
134
-
135
- convert_voice_simple(
136
- input_audio_path=audio_file,
137
- output_audio_path=output_path,
138
- pitch_shift=pitch_shift,
139
- formant_shift=formant_shift,
140
- breathiness=breathiness,
141
- )
142
-
143
- return output_path, "✅ 转换完成!"
144
- except Exception as e:
145
- import traceback
146
- return None, f"❌ 转换失败: {str(e)}"
147
-
148
-
149
- def create_interface():
150
- with gr.Blocks(title=TITLE, theme=gr.themes.Soft()) as demo:
151
- gr.Markdown(f"# {TITLE}")
152
- gr.Markdown(DESCRIPTION)
153
-
154
- with gr.Row():
155
- with gr.Column(scale=1):
156
- audio_input = gr.Audio(
157
- label="输入音频",
158
- type="filepath",
159
- sources=["upload", "microphone"],
160
- )
161
-
162
- with gr.Group():
163
- gr.Markdown("### 参数")
164
- pitch_shift = gr.Slider(
165
- minimum=-12,
166
- maximum=0,
167
- value=-4,
168
- step=0.5,
169
- label="Pitch Shift (半音)",
170
- info="负值降低音调,建议 -3 ~ -6"
171
- )
172
- formant_shift = gr.Slider(
173
- minimum=-8,
174
- maximum=0,
175
- value=-3,
176
- step=0.5,
177
- label="Formant Shift (半音)",
178
- info="负值降低共振峰,建议 -2 ~ -4"
179
- )
180
- breathiness = gr.Slider(
181
- minimum=0.0,
182
- maximum=0.5,
183
- value=0.15,
184
- step=0.05,
185
- label="Breathiness (气声感)",
186
- info="模拟声带闭合不全,0.1-0.3 推荐"
187
- )
188
-
189
- convert_btn = gr.Button("🎭 转换为老年声", variant="primary", size="lg")
190
- status = gr.Textbox(label="状态", interactive=False)
191
-
192
- with gr.Column(scale=1):
193
- audio_output = gr.Audio(
194
- label="转换结果",
195
- type="filepath",
196
- )
197
- gr.Markdown("""
198
- ### 💡 参数建议
199
- | 声音类型 | Pitch Shift | Formant Shift | Breathiness |
200
- |---------|-------------|---------------|-------------|
201
- | 男性→老年男声 | -4 ~ -6 | -2 ~ -3 | 0.1 ~ 0.2 |
202
- | 女性→老年女声 | -2 ~ -4 | -1 ~ -2 | 0.1 ~ 0.2 |
203
- | 女性→老年男声 | -6 ~ -9 | -3 ~ -5 | 0.15 ~ 0.25 |
204
- | 儿童→老年声 | -8 ~ -12 | -4 ~ -6 | 0.2 ~ 0.3 |
205
-
206
- **注意**:这是基于 DSP 的近似模拟,非 AI 声音转换。
207
- 效果取决于原音频质量,建议使用清晰、单人、少背景噪音的音频。
208
- """)
209
-
210
- convert_btn.click(
211
- fn=process_audio,
212
- inputs=[audio_input, pitch_shift, formant_shift, breathiness],
213
- outputs=[audio_output, status],
214
- api_name="convert",
215
- )
216
-
217
- return demo
218
-
219
-
220
- if __name__ == "__main__":
221
- demo = create_interface()
222
- demo.launch(
223
- server_name="0.0.0.0",
224
- server_port=7860,
225
- show_error=True,
226
- share=False,
227
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
inference.py DELETED
@@ -1,191 +0,0 @@
1
- #!/usr/bin/env python3
2
- """RVC Voice Conversion Inference for Elderly Voice."""
3
- import os
4
- import sys
5
- import torch
6
- import numpy as np
7
- import soundfile as sf
8
- from pathlib import Path
9
-
10
- # Add RVC to path
11
- rvc_path = Path("/app/rvc")
12
- sys.path.insert(0, str(rvc_path))
13
-
14
- # Import RVC modules with correct paths
15
- from infer.vc.pipeline import Pipeline
16
- from infer.hubert import load_hubert_model, extract_hubert_features
17
- from infer.module.models import SynthesizerTrnMs768NSFsid, SynthesizerTrnMs768NSFsid_nono
18
- from infer.audio import load_audio
19
- from tools.cuda_graph import run_cuda_graph
20
-
21
- # Model paths
22
- MODEL_DIR = Path("/app/models")
23
- ELDERLY_MODEL_DIR = MODEL_DIR / "elderly"
24
-
25
- def find_model_files():
26
- """Find the elderly voice model files."""
27
- pth_files = list(ELDERLY_MODEL_DIR.glob("*.pth"))
28
- index_files = list(ELDERLY_MODEL_DIR.glob("*.index"))
29
-
30
- if not pth_files:
31
- pth_files = list((MODEL_DIR / "weights").glob("*.pth")) if (MODEL_DIR / "weights").exists() else []
32
- if not index_files:
33
- index_files = list((MODEL_DIR / "weights").glob("*.index")) if (MODEL_DIR / "weights").exists() else []
34
-
35
- model_path = str(pth_files[0]) if pth_files else None
36
- index_path = str(index_files[0]) if index_files else None
37
-
38
- return model_path, index_path
39
-
40
- MODEL_PATH, INDEX_PATH = find_model_files()
41
-
42
- # Device
43
- device = "cuda" if torch.cuda.is_available() else "cpu"
44
- is_half = torch.cuda.is_available()
45
- print(f"Using device: {device}, is_half: {is_half}")
46
- print(f"Model: {MODEL_PATH}")
47
- print(f"Index: {INDEX_PATH}")
48
-
49
- # Global objects (initialized lazily)
50
- _pipeline = None
51
- _hubert_model = None
52
- _net_g = None
53
- _tgt_sr = None
54
- _if_f0 = None
55
- _version = None
56
-
57
- def load_model():
58
- """Load the RVC model and initialize pipeline."""
59
- global _pipeline, _hubert_model, _net_g, _tgt_sr, _if_f0, _version
60
-
61
- if MODEL_PATH is None or not os.path.exists(MODEL_PATH):
62
- raise RuntimeError(f"Model not found: {MODEL_PATH}")
63
-
64
- # Load hubert model
65
- _hubert_model = load_hubert_model(device, is_half)
66
-
67
- # Load the model checkpoint
68
- cpt = torch.load(MODEL_PATH, map_location=torch.device("cpu"))
69
- cpt["config"][-3] = cpt["weight"]["emb_g.weight"].shape[0]
70
- _if_f0 = cpt.get("f0", 1)
71
- _version = cpt.get("version", "v1")
72
-
73
- if _version == "v1":
74
- if _if_f0 == 1:
75
- _net_g = SynthesizerTrnMs768NSFsid(*cpt["config"], is_half=False)
76
- else:
77
- _net_g = SynthesizerTrnMs768NSFsid_nono(*cpt["config"])
78
- elif _version == "v2":
79
- if _if_f0 == 1:
80
- _net_g = SynthesizerTrnMs768NSFsid(*cpt["config"], is_half=False)
81
- else:
82
- _net_g = SynthesizerTrnMs768NSFsid_nono(*cpt["config"])
83
-
84
- del _net_g.enc_q
85
- _net_g.load_state_dict(cpt["weight"], strict=False)
86
- _net_g = _net_g.float()
87
- _net_g.eval().to(device)
88
- _net_g.remove_weight_norm()
89
-
90
- if is_half:
91
- _net_g = _net_g.half()
92
- else:
93
- _net_g = _net_g.float()
94
-
95
- _tgt_sr = cpt["config"][-1]
96
-
97
- # Create config object for pipeline
98
- class PipelineConfig:
99
- def __init__(self, device, is_half):
100
- self.device = torch.device(device)
101
- self.is_half = is_half
102
- if is_half:
103
- self.x_pad, self.x_query, self.x_center, self.x_max = 3, 10, 60, 65
104
- else:
105
- self.x_pad, self.x_query, self.x_center, self.x_max = 1, 6, 38, 41
106
-
107
- config = PipelineConfig(device, is_half)
108
- _pipeline = Pipeline(_tgt_sr, config)
109
-
110
- print("Model loaded successfully")
111
-
112
- def convert_voice(
113
- input_audio_path: str,
114
- output_audio_path: str,
115
- f0_up_key: int = 0,
116
- f0_method: str = "rmvpe",
117
- index_rate: float = 0.75,
118
- filter_radius: int = 3,
119
- rms_mix_rate: float = 0.25,
120
- protect: float = 0.33,
121
- ):
122
- """
123
- Convert input audio to elderly voice using RVC Pipeline.
124
-
125
- Args:
126
- input_audio_path: Path to input audio file
127
- output_audio_path: Path to save output audio
128
- f0_up_key: Pitch shift in semitones (negative = lower pitch)
129
- f0_method: Pitch extraction method (pm, rmvpe, fcpe)
130
- index_rate: Index influence (0-1)
131
- filter_radius: Median filter radius for pitch (not used in pipeline directly)
132
- rms_mix_rate: RMS mix rate for volume
133
- protect: Protect voiceless consonants
134
- """
135
- global _pipeline, _hubert_model, _net_g, _tgt_sr, _if_f0, _version
136
-
137
- # Lazy load model
138
- if _pipeline is None:
139
- load_model()
140
-
141
- # Use provided index path or fallback
142
- index_path = INDEX_PATH if INDEX_PATH and os.path.exists(INDEX_PATH) else ""
143
-
144
- # Load audio
145
- audio = load_audio(input_audio_path, 16000)
146
-
147
- # Prepare times array for profiling
148
- times = [0.0, 0.0, 0.0]
149
-
150
- # Run pipeline
151
- audio_opt = _pipeline.pipeline(
152
- model=_hubert_model,
153
- net_g=_net_g,
154
- sid=0,
155
- audio=audio,
156
- times=times,
157
- f0_up_key=f0_up_key,
158
- f0_method=f0_method,
159
- file_index=index_path,
160
- index_rate=index_rate,
161
- if_f0=_if_f0,
162
- tgt_sr=_tgt_sr,
163
- resample_sr=0,
164
- rms_mix_rate=rms_mix_rate,
165
- version=_version,
166
- protect=protect,
167
- )
168
-
169
- # Save output
170
- sf.write(output_audio_path, audio_opt, _tgt_sr, subtype='PCM_16')
171
- return output_audio_path
172
-
173
-
174
- if __name__ == "__main__":
175
- import argparse
176
- parser = argparse.ArgumentParser()
177
- parser.add_argument("--input", required=True, help="Input audio file")
178
- parser.add_argument("--output", required=True, help="Output audio file")
179
- parser.add_argument("--pitch", type=int, default=-2, help="Pitch shift (semitones, negative for deeper)")
180
- parser.add_argument("--method", default="rmvpe", help="F0 method")
181
- parser.add_argument("--index-rate", type=float, default=0.75, help="Index rate")
182
- args = parser.parse_args()
183
-
184
- convert_voice(
185
- args.input,
186
- args.output,
187
- f0_up_key=args.pitch,
188
- f0_method=args.method,
189
- index_rate=args.index_rate
190
- )
191
- print(f"Converted: {args.output}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
inference_simple.py DELETED
@@ -1,22 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Simple inference module for compatibility."""
3
- # The conversion logic is in app.py directly
4
- # This file exists for import compatibility
5
-
6
- def convert_voice(input_audio_path, output_audio_path, **kwargs):
7
- """Placeholder - actual implementation in app.py"""
8
- pass
9
-
10
- MODEL_PATH = None
11
- vc = None
12
-
13
- if __name__ == "__main__":
14
- import argparse
15
- parser = argparse.ArgumentParser()
16
- parser.add_argument("--input", required=True)
17
- parser.add_argument("--output", required=True)
18
- parser.add_argument("--pitch", type=float, default=-4)
19
- parser.add_argument("--formant", type=float, default=-3)
20
- parser.add_argument("--breathiness", type=float, default=0.15)
21
- args = parser.parse_args()
22
- print(f"Use app.py for conversion: {args.input} -> {args.output}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ ffmpeg
requirements.txt CHANGED
@@ -1,24 +1,18 @@
1
- # Minimal RVC inference dependencies (torch/torchaudio installed separately)
2
- numpy>=1.24.0,<2
3
- scipy>=1.10.0,<2
4
- librosa>=0.10.0,<0.11
5
- soundfile>=0.12.0,<1
6
- faiss-cpu>=1.13.0,<2
7
- praat-parselmouth>=0.4.5,<1
8
- transformers>=4.49.0,<4.50
9
- tqdm>=4.67.0,<5
10
- huggingface_hub>=0.20.0
11
- ffmpeg-python>=0.2.0,<1
12
- av>=15.1.0,<16
13
- onnxruntime>=1.24.4,<2
14
- scikit-learn>=1.6.0,<2
15
- PyYAML>=6.0
16
-
17
- gradio>=4.44.0
18
- fastapi>=0.88.0,<0.100
19
- uvicorn>=0.20.0,<0.23
20
- pydantic>=1.10.13,<2
21
- httpx>=0.23.0,<0.24
22
- anyio>=3.6.2,<4
23
- starlette>=0.22.0,<0.28
24
- websockets>=10.4,<11
 
1
+ torch==2.1.0
2
+ torchaudio==2.1.0
3
+ librosa==0.9.1
4
+ pydub==0.25.1
5
+ wavmark==0.0.3
6
+ numpy==1.22.0
7
+ eng_to_ipa==0.0.2
8
+ inflect==7.0.0
9
+ unidecode==1.3.7
10
+ whisper-timestamped==1.14.2
11
+ pypinyin
12
+ cn2an
13
+ jieba
14
+ langid
15
+ gradio==4.44.0
16
+ huggingface_hub
17
+ soundfile
18
+ git+https://github.com/myshell-ai/OpenVoice.git