Use user's OpenVoice V2 elderly voice conversion (Gradio SDK)
Browse files- Dockerfile +0 -21
- README.md +17 -96
- app.py +61 -199
- app_simple.py +0 -227
- inference.py +0 -191
- inference_simple.py +0 -22
- packages.txt +1 -0
- 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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
| 7 |
-
|
|
|
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
-
#
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 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 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 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 |
-
|
| 6 |
import gradio as gr
|
| 7 |
-
import
|
| 8 |
-
import numpy as np
|
| 9 |
-
import soundfile as sf
|
| 10 |
-
import librosa
|
| 11 |
|
| 12 |
-
from
|
|
|
|
| 13 |
|
| 14 |
-
|
| 15 |
-
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 16 |
-
has_gpu = torch.cuda.is_available()
|
| 17 |
|
| 18 |
-
#
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 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 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 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 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 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 |
-
|
| 208 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
|
| 210 |
if __name__ == "__main__":
|
| 211 |
-
demo
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|