Switch to librosa pitch_shift + custom formant shift (no parselmouth dependency)
Browse files- app_simple.py +66 -27
app_simple.py
CHANGED
|
@@ -1,21 +1,20 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
-
"""Simple Elderly Voice Converter using
|
| 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
|
| 10 |
-
from parselmouth.praat import call
|
| 11 |
|
| 12 |
TITLE = "🎭 Elderly Voice Converter (Pitch + Formant Shift)"
|
| 13 |
DESCRIPTION = """
|
| 14 |
基于 **音调偏移 + 共振峰偏移** 的轻量级声音转换,模拟老年人声线特征。
|
| 15 |
|
| 16 |
**原理:**
|
| 17 |
-
- **降低基频 (Pitch)**:
|
| 18 |
-
- **降低共振峰**:声道变长
|
| 19 |
- **添加气声**:模拟声带闭合不全的气息感
|
| 20 |
|
| 21 |
**使用方法:**
|
|
@@ -48,39 +47,78 @@ def convert_voice_simple(
|
|
| 48 |
# Load audio
|
| 49 |
y, sr = librosa.load(input_audio_path, sr=None, mono=True)
|
| 50 |
|
| 51 |
-
#
|
| 52 |
-
sound = parselmouth.Sound(y, sampling_frequency=sr)
|
| 53 |
-
|
| 54 |
-
# 1. Pitch shift using PSOLA (Change pitch command)
|
| 55 |
if pitch_shift != 0:
|
| 56 |
-
|
| 57 |
-
# Change pitch: pitch_ratio, time_step, min_pitch, max_pitch
|
| 58 |
-
sound = call(sound, "Change pitch", pitch_ratio, 0.01, 50, 500)
|
| 59 |
|
| 60 |
-
# 2. Formant shift
|
| 61 |
if formant_shift != 0:
|
| 62 |
-
|
| 63 |
-
# Change formants: formant_ratio, new_pitch_median
|
| 64 |
-
sound = call(sound, "Change formants", formant_ratio, 1.0)
|
| 65 |
|
| 66 |
-
# 3. Add breathiness
|
| 67 |
if breathiness > 0:
|
| 68 |
-
noise = np.random.normal(0, breathiness * 0.1, len(
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
# Get result
|
| 72 |
-
result_audio = sound.values[0].astype(np.float32)
|
| 73 |
-
result_sr = sound.sampling_frequency
|
| 74 |
|
| 75 |
-
#
|
| 76 |
-
|
| 77 |
-
result_audio = librosa.resample(result_audio, orig_sr=result_sr, target_sr=sr)
|
| 78 |
|
| 79 |
# Save
|
| 80 |
-
sf.write(output_audio_path,
|
| 81 |
return output_audio_path
|
| 82 |
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
def process_audio(
|
| 85 |
audio_file,
|
| 86 |
pitch_shift,
|
|
@@ -104,6 +142,7 @@ def process_audio(
|
|
| 104 |
|
| 105 |
return output_path, "✅ 转换完成!"
|
| 106 |
except Exception as e:
|
|
|
|
| 107 |
return None, f"❌ 转换失败: {str(e)}"
|
| 108 |
|
| 109 |
|
|
|
|
| 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 |
**使用方法:**
|
|
|
|
| 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,
|
|
|
|
| 142 |
|
| 143 |
return output_path, "✅ 转换完成!"
|
| 144 |
except Exception as e:
|
| 145 |
+
import traceback
|
| 146 |
return None, f"❌ 转换失败: {str(e)}"
|
| 147 |
|
| 148 |
|