Spaces:
Running on Zero
Running on Zero
Tune raw reference audio conditioning
Browse files- README.md +2 -2
- app.py +71 -16
- tests/test_latency_first_mode.py +38 -1
README.md
CHANGED
|
@@ -21,9 +21,9 @@ models:
|
|
| 21 |
## 模式
|
| 22 |
|
| 23 |
- **內建語者**:預設為內建語者 A;Speaker B 使用模型庫 `checkpoints/speaker_b_embedding.pt`,固定 seed 56789 與 projector scale 1.125。長文會自動以最多 80 個字元分段。
|
| 24 |
-
- **參考音色**:錄音直接送入模型原生的 raw reference-audio conditioning,不經 ECAPA centroid;固定 seed 1337。長文同樣自動分段。
|
| 25 |
|
| 26 |
兩種模式的每個文字分段都只生成一次,分段間加入短靜音串接;不需要切換獨立長文模式。
|
| 27 |
|
| 28 |
TTS model snapshot 固定為
|
| 29 |
-
`6f7cab914a1e27c56b504ec663c0144dc25cc0a3`,預設 CFG 為 3.0,NFE 為 10。
|
|
|
|
| 21 |
## 模式
|
| 22 |
|
| 23 |
- **內建語者**:預設為內建語者 A;Speaker B 使用模型庫 `checkpoints/speaker_b_embedding.pt`,固定 seed 56789 與 projector scale 1.125。長文會自動以最多 80 個字元分段。
|
| 24 |
+
- **參考音色**:錄音直接送入模型原生的 raw reference-audio conditioning,不經 ECAPA centroid;超過 6 秒固定取中央 6 秒,CFG 2.0,固定 seed 1337。長文同樣自動分段。
|
| 25 |
|
| 26 |
兩種模式的每個文字分段都只生成一次,分段間加入短靜音串接;不需要切換獨立長文模式。
|
| 27 |
|
| 28 |
TTS model snapshot 固定為
|
| 29 |
+
`6f7cab914a1e27c56b504ec663c0144dc25cc0a3`,內建語者預設 CFG 為 3.0,參考音色預設 CFG 為 2.0,NFE 為 10。
|
app.py
CHANGED
|
@@ -9,6 +9,7 @@ import json
|
|
| 9 |
import os
|
| 10 |
from pathlib import Path
|
| 11 |
import secrets
|
|
|
|
| 12 |
import threading
|
| 13 |
from types import MappingProxyType
|
| 14 |
|
|
@@ -61,6 +62,7 @@ except ImportError:
|
|
| 61 |
|
| 62 |
import gradio as gr
|
| 63 |
import numpy as np
|
|
|
|
| 64 |
import torch
|
| 65 |
from huggingface_hub import snapshot_download
|
| 66 |
from transformers import PreTrainedTokenizerFast
|
|
@@ -199,6 +201,7 @@ MODEL_REVISION = "6f7cab914a1e27c56b504ec663c0144dc25cc0a3"
|
|
| 199 |
ECAPA_REPO_ID = "speechbrain/spkrec-ecapa-voxceleb"
|
| 200 |
ECAPA_REVISION = "0f99f2d0ebe89ac095bcc5903c4dd8f72b367286"
|
| 201 |
DEFAULT_CFG = 3.0
|
|
|
|
| 202 |
DEFAULT_STEPS = 10
|
| 203 |
TARGET_CPS = 4.0
|
| 204 |
ACTIVE_PACE_TARGET_CPS = 4.00
|
|
@@ -276,6 +279,7 @@ SPEAKER_B_EMBEDDING_SHA256 = (
|
|
| 276 |
"e9556e14723c140985a104c1659d1ff8a5078d2fa28ce2fb756f04906641a8a7"
|
| 277 |
)
|
| 278 |
REFERENCE_GENERATION_SEED = 1_337
|
|
|
|
| 279 |
GLYPH_ASSET_MANIFEST_RELATIVE_PATH = "glyph_assets_v1/manifest.json"
|
| 280 |
# The asset builder replaces this unavailable sentinel with the exact frozen
|
| 281 |
# manifest SHA-256 before a candidate can expose the V5 profile.
|
|
@@ -2363,6 +2367,36 @@ def _fast_text_chunks(text: str) -> tuple[str, ...]:
|
|
| 2363 |
return tuple(chunks)
|
| 2364 |
|
| 2365 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2366 |
def _generate_fast_chunk(
|
| 2367 |
text: str,
|
| 2368 |
centroid: torch.Tensor | None,
|
|
@@ -2371,6 +2405,7 @@ def _generate_fast_chunk(
|
|
| 2371 |
steps: int,
|
| 2372 |
request_seed: int,
|
| 2373 |
reference_wav_path: str = "",
|
|
|
|
| 2374 |
) -> np.ndarray:
|
| 2375 |
"""Run one model call and return it without candidate qualification."""
|
| 2376 |
|
|
@@ -2390,7 +2425,7 @@ def _generate_fast_chunk(
|
|
| 2390 |
text,
|
| 2391 |
float(cfg),
|
| 2392 |
short_text_unit_threshold=SHORT_TEXT_CFG_UNITS,
|
| 2393 |
-
short_text_min_cfg=
|
| 2394 |
)
|
| 2395 |
set_generation_seed(request_seed)
|
| 2396 |
kwargs = {
|
|
@@ -2453,6 +2488,7 @@ def _synthesize(
|
|
| 2453 |
request_seed: int | None = None,
|
| 2454 |
speaker_projector_scale: float = 1.0,
|
| 2455 |
reference_wav_path: str = "",
|
|
|
|
| 2456 |
) -> tuple[int, np.ndarray]:
|
| 2457 |
"""Latency-first single-pass synthesis with no output qualification."""
|
| 2458 |
|
|
@@ -2486,6 +2522,7 @@ def _synthesize(
|
|
| 2486 |
steps=int(steps),
|
| 2487 |
request_seed=seed,
|
| 2488 |
reference_wav_path=reference_wav_path,
|
|
|
|
| 2489 |
)
|
| 2490 |
for chunk in chunks
|
| 2491 |
)
|
|
@@ -3841,23 +3878,29 @@ def tts_speaker(
|
|
| 3841 |
def tts_reference(
|
| 3842 |
text: str,
|
| 3843 |
reference_wav: str,
|
| 3844 |
-
cfg: float =
|
| 3845 |
steps: int = DEFAULT_STEPS,
|
| 3846 |
speed: float = 1.0,
|
| 3847 |
):
|
| 3848 |
if not reference_wav:
|
| 3849 |
raise gr.Error("請先錄音或上傳參考音檔。")
|
| 3850 |
-
|
| 3851 |
-
|
| 3852 |
-
|
| 3853 |
-
|
| 3854 |
-
|
| 3855 |
-
|
| 3856 |
-
|
| 3857 |
-
|
| 3858 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3859 |
)
|
| 3860 |
-
|
|
|
|
|
|
|
| 3861 |
|
| 3862 |
EXAMPLE_TEXTS = [
|
| 3863 |
"今天天氣真好,我們一起去散步吧。",
|
|
@@ -3884,7 +3927,12 @@ with gr.Blocks(title="BlueMagpie-TTS Demo", theme=gr.themes.Soft()) as demo:
|
|
| 3884 |
with gr.Row():
|
| 3885 |
cfg_input = gr.Number(
|
| 3886 |
value=DEFAULT_CFG,
|
| 3887 |
-
label="CFG",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3888 |
interactive=True,
|
| 3889 |
)
|
| 3890 |
steps_input = gr.Number(
|
|
@@ -3916,8 +3964,9 @@ with gr.Blocks(title="BlueMagpie-TTS Demo", theme=gr.themes.Soft()) as demo:
|
|
| 3916 |
|
| 3917 |
with gr.Tab("參考音色"):
|
| 3918 |
gr.Markdown(
|
| 3919 |
-
"建議錄製 6
|
| 3920 |
-
"錄音會直接作為模型的
|
|
|
|
| 3921 |
)
|
| 3922 |
with gr.Row():
|
| 3923 |
with gr.Column():
|
|
@@ -3932,7 +3981,13 @@ with gr.Blocks(title="BlueMagpie-TTS Demo", theme=gr.themes.Soft()) as demo:
|
|
| 3932 |
reference_output = gr.Audio(label="合成結果", type="numpy")
|
| 3933 |
reference_button.click(
|
| 3934 |
tts_reference,
|
| 3935 |
-
[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3936 |
reference_output,
|
| 3937 |
)
|
| 3938 |
|
|
|
|
| 9 |
import os
|
| 10 |
from pathlib import Path
|
| 11 |
import secrets
|
| 12 |
+
import tempfile
|
| 13 |
import threading
|
| 14 |
from types import MappingProxyType
|
| 15 |
|
|
|
|
| 62 |
|
| 63 |
import gradio as gr
|
| 64 |
import numpy as np
|
| 65 |
+
import soundfile as sf
|
| 66 |
import torch
|
| 67 |
from huggingface_hub import snapshot_download
|
| 68 |
from transformers import PreTrainedTokenizerFast
|
|
|
|
| 201 |
ECAPA_REPO_ID = "speechbrain/spkrec-ecapa-voxceleb"
|
| 202 |
ECAPA_REVISION = "0f99f2d0ebe89ac095bcc5903c4dd8f72b367286"
|
| 203 |
DEFAULT_CFG = 3.0
|
| 204 |
+
REFERENCE_CFG = 2.0
|
| 205 |
DEFAULT_STEPS = 10
|
| 206 |
TARGET_CPS = 4.0
|
| 207 |
ACTIVE_PACE_TARGET_CPS = 4.00
|
|
|
|
| 279 |
"e9556e14723c140985a104c1659d1ff8a5078d2fa28ce2fb756f04906641a8a7"
|
| 280 |
)
|
| 281 |
REFERENCE_GENERATION_SEED = 1_337
|
| 282 |
+
REFERENCE_AUDIO_SECONDS = 6.0
|
| 283 |
GLYPH_ASSET_MANIFEST_RELATIVE_PATH = "glyph_assets_v1/manifest.json"
|
| 284 |
# The asset builder replaces this unavailable sentinel with the exact frozen
|
| 285 |
# manifest SHA-256 before a candidate can expose the V5 profile.
|
|
|
|
| 2367 |
return tuple(chunks)
|
| 2368 |
|
| 2369 |
|
| 2370 |
+
def _prepare_reference_audio(reference_wav: str) -> tuple[str, str | None]:
|
| 2371 |
+
"""Deterministically center-crop long references to the trained 6s cap."""
|
| 2372 |
+
|
| 2373 |
+
info = sf.info(reference_wav)
|
| 2374 |
+
max_frames = int(round(REFERENCE_AUDIO_SECONDS * int(info.samplerate)))
|
| 2375 |
+
if int(info.frames) <= max_frames:
|
| 2376 |
+
return reference_wav, None
|
| 2377 |
+
start = max(0, (int(info.frames) - max_frames) // 2)
|
| 2378 |
+
waveform, sample_rate = sf.read(
|
| 2379 |
+
reference_wav,
|
| 2380 |
+
start=start,
|
| 2381 |
+
frames=max_frames,
|
| 2382 |
+
dtype="float32",
|
| 2383 |
+
always_2d=True,
|
| 2384 |
+
)
|
| 2385 |
+
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as handle:
|
| 2386 |
+
temporary_path = handle.name
|
| 2387 |
+
try:
|
| 2388 |
+
sf.write(
|
| 2389 |
+
temporary_path,
|
| 2390 |
+
waveform,
|
| 2391 |
+
sample_rate,
|
| 2392 |
+
subtype="PCM_16",
|
| 2393 |
+
)
|
| 2394 |
+
except Exception:
|
| 2395 |
+
os.unlink(temporary_path)
|
| 2396 |
+
raise
|
| 2397 |
+
return temporary_path, temporary_path
|
| 2398 |
+
|
| 2399 |
+
|
| 2400 |
def _generate_fast_chunk(
|
| 2401 |
text: str,
|
| 2402 |
centroid: torch.Tensor | None,
|
|
|
|
| 2405 |
steps: int,
|
| 2406 |
request_seed: int,
|
| 2407 |
reference_wav_path: str = "",
|
| 2408 |
+
short_text_min_cfg: float = SHORT_TEXT_CFG_MIN,
|
| 2409 |
) -> np.ndarray:
|
| 2410 |
"""Run one model call and return it without candidate qualification."""
|
| 2411 |
|
|
|
|
| 2425 |
text,
|
| 2426 |
float(cfg),
|
| 2427 |
short_text_unit_threshold=SHORT_TEXT_CFG_UNITS,
|
| 2428 |
+
short_text_min_cfg=float(short_text_min_cfg),
|
| 2429 |
)
|
| 2430 |
set_generation_seed(request_seed)
|
| 2431 |
kwargs = {
|
|
|
|
| 2488 |
request_seed: int | None = None,
|
| 2489 |
speaker_projector_scale: float = 1.0,
|
| 2490 |
reference_wav_path: str = "",
|
| 2491 |
+
short_text_min_cfg: float = SHORT_TEXT_CFG_MIN,
|
| 2492 |
) -> tuple[int, np.ndarray]:
|
| 2493 |
"""Latency-first single-pass synthesis with no output qualification."""
|
| 2494 |
|
|
|
|
| 2522 |
steps=int(steps),
|
| 2523 |
request_seed=seed,
|
| 2524 |
reference_wav_path=reference_wav_path,
|
| 2525 |
+
short_text_min_cfg=short_text_min_cfg,
|
| 2526 |
)
|
| 2527 |
for chunk in chunks
|
| 2528 |
)
|
|
|
|
| 3878 |
def tts_reference(
|
| 3879 |
text: str,
|
| 3880 |
reference_wav: str,
|
| 3881 |
+
cfg: float = REFERENCE_CFG,
|
| 3882 |
steps: int = DEFAULT_STEPS,
|
| 3883 |
speed: float = 1.0,
|
| 3884 |
):
|
| 3885 |
if not reference_wav:
|
| 3886 |
raise gr.Error("請先錄音或上傳參考音檔。")
|
| 3887 |
+
prepared_reference, temporary_reference = _prepare_reference_audio(reference_wav)
|
| 3888 |
+
try:
|
| 3889 |
+
return pcm16_audio_output(
|
| 3890 |
+
*_synthesize(
|
| 3891 |
+
text,
|
| 3892 |
+
None,
|
| 3893 |
+
cfg=cfg,
|
| 3894 |
+
steps=steps,
|
| 3895 |
+
speed=speed,
|
| 3896 |
+
request_seed=REFERENCE_GENERATION_SEED,
|
| 3897 |
+
reference_wav_path=prepared_reference,
|
| 3898 |
+
short_text_min_cfg=REFERENCE_CFG,
|
| 3899 |
+
)
|
| 3900 |
)
|
| 3901 |
+
finally:
|
| 3902 |
+
if temporary_reference is not None:
|
| 3903 |
+
os.unlink(temporary_reference)
|
| 3904 |
|
| 3905 |
EXAMPLE_TEXTS = [
|
| 3906 |
"今天天氣真好,我們一起去散步吧。",
|
|
|
|
| 3927 |
with gr.Row():
|
| 3928 |
cfg_input = gr.Number(
|
| 3929 |
value=DEFAULT_CFG,
|
| 3930 |
+
label="內建語者 CFG",
|
| 3931 |
+
interactive=True,
|
| 3932 |
+
)
|
| 3933 |
+
reference_cfg_input = gr.Number(
|
| 3934 |
+
value=REFERENCE_CFG,
|
| 3935 |
+
label="參考���色 CFG",
|
| 3936 |
interactive=True,
|
| 3937 |
)
|
| 3938 |
steps_input = gr.Number(
|
|
|
|
| 3964 |
|
| 3965 |
with gr.Tab("參考音色"):
|
| 3966 |
gr.Markdown(
|
| 3967 |
+
"建議錄製至少 6 秒、單一語者且背景乾淨的授權參考音檔;"
|
| 3968 |
+
"超過 6 秒會固定取中央 6 秒。錄音會直接作為模型的 "
|
| 3969 |
+
"reference audio,不轉成 ECAPA 音色。"
|
| 3970 |
)
|
| 3971 |
with gr.Row():
|
| 3972 |
with gr.Column():
|
|
|
|
| 3981 |
reference_output = gr.Audio(label="合成結果", type="numpy")
|
| 3982 |
reference_button.click(
|
| 3983 |
tts_reference,
|
| 3984 |
+
[
|
| 3985 |
+
reference_text,
|
| 3986 |
+
reference_audio,
|
| 3987 |
+
reference_cfg_input,
|
| 3988 |
+
steps_input,
|
| 3989 |
+
speed_input,
|
| 3990 |
+
],
|
| 3991 |
reference_output,
|
| 3992 |
)
|
| 3993 |
|
tests/test_latency_first_mode.py
CHANGED
|
@@ -4,6 +4,7 @@ import os
|
|
| 4 |
from pathlib import Path
|
| 5 |
|
| 6 |
import numpy as np
|
|
|
|
| 7 |
import torch
|
| 8 |
|
| 9 |
|
|
@@ -222,12 +223,16 @@ def test_reference_conditioning_uses_raw_reference_audio_once():
|
|
| 222 |
synthesize_source = ast.get_source_segment(source, _function_node("_synthesize"))
|
| 223 |
|
| 224 |
assert "REFERENCE_GENERATION_SEED = 1_337" in source
|
|
|
|
|
|
|
| 225 |
assert reference_source is not None
|
| 226 |
assert "extract_windowed_speaker_embedding(" not in reference_source
|
| 227 |
assert "_get_ecapa_encoder(" not in reference_source
|
| 228 |
assert "None," in reference_source
|
| 229 |
assert "request_seed=REFERENCE_GENERATION_SEED" in reference_source
|
| 230 |
-
assert "
|
|
|
|
|
|
|
| 231 |
assert "retry" not in reference_source
|
| 232 |
assert fast_source is not None
|
| 233 |
assert 'kwargs["reference_wav_path"] = reference_wav_path' in fast_source
|
|
@@ -235,6 +240,38 @@ def test_reference_conditioning_uses_raw_reference_audio_once():
|
|
| 235 |
assert "reference_wav_path=reference_wav_path" in synthesize_source
|
| 236 |
|
| 237 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
def test_reference_projector_gain_is_scoped_and_removed():
|
| 239 |
source = APP_PATH.read_text(encoding="utf-8")
|
| 240 |
synthesize_source = ast.get_source_segment(source, _function_node("_synthesize"))
|
|
|
|
| 4 |
from pathlib import Path
|
| 5 |
|
| 6 |
import numpy as np
|
| 7 |
+
import soundfile as sf
|
| 8 |
import torch
|
| 9 |
|
| 10 |
|
|
|
|
| 223 |
synthesize_source = ast.get_source_segment(source, _function_node("_synthesize"))
|
| 224 |
|
| 225 |
assert "REFERENCE_GENERATION_SEED = 1_337" in source
|
| 226 |
+
assert "REFERENCE_CFG = 2.0" in source
|
| 227 |
+
assert "REFERENCE_AUDIO_SECONDS = 6.0" in source
|
| 228 |
assert reference_source is not None
|
| 229 |
assert "extract_windowed_speaker_embedding(" not in reference_source
|
| 230 |
assert "_get_ecapa_encoder(" not in reference_source
|
| 231 |
assert "None," in reference_source
|
| 232 |
assert "request_seed=REFERENCE_GENERATION_SEED" in reference_source
|
| 233 |
+
assert "cfg: float = REFERENCE_CFG" in reference_source
|
| 234 |
+
assert "reference_wav_path=prepared_reference" in reference_source
|
| 235 |
+
assert "short_text_min_cfg=REFERENCE_CFG" in reference_source
|
| 236 |
assert "retry" not in reference_source
|
| 237 |
assert fast_source is not None
|
| 238 |
assert 'kwargs["reference_wav_path"] = reference_wav_path' in fast_source
|
|
|
|
| 240 |
assert "reference_wav_path=reference_wav_path" in synthesize_source
|
| 241 |
|
| 242 |
|
| 243 |
+
def test_reference_audio_center_crop_matches_the_trained_six_second_cap(tmp_path):
|
| 244 |
+
prepare = _isolated_function(
|
| 245 |
+
"_prepare_reference_audio",
|
| 246 |
+
{
|
| 247 |
+
"os": os,
|
| 248 |
+
"sf": sf,
|
| 249 |
+
"tempfile": __import__("tempfile"),
|
| 250 |
+
"REFERENCE_AUDIO_SECONDS": 6.0,
|
| 251 |
+
},
|
| 252 |
+
)
|
| 253 |
+
sample_rate = 10
|
| 254 |
+
waveform = np.linspace(-0.5, 0.5, 100, dtype=np.float32)
|
| 255 |
+
source_path = tmp_path / "reference.wav"
|
| 256 |
+
sf.write(source_path, waveform, sample_rate, subtype="FLOAT")
|
| 257 |
+
|
| 258 |
+
prepared_path, temporary_path = prepare(str(source_path))
|
| 259 |
+
try:
|
| 260 |
+
prepared, prepared_rate = sf.read(prepared_path, dtype="float32")
|
| 261 |
+
assert prepared_rate == sample_rate
|
| 262 |
+
assert prepared.shape == (60,)
|
| 263 |
+
np.testing.assert_allclose(prepared, waveform[20:80], atol=1.0 / 32768.0)
|
| 264 |
+
finally:
|
| 265 |
+
assert temporary_path is not None
|
| 266 |
+
os.unlink(temporary_path)
|
| 267 |
+
|
| 268 |
+
short_path = tmp_path / "short.wav"
|
| 269 |
+
sf.write(short_path, waveform[:40], sample_rate, subtype="FLOAT")
|
| 270 |
+
unchanged_path, unchanged_temporary = prepare(str(short_path))
|
| 271 |
+
assert unchanged_path == str(short_path)
|
| 272 |
+
assert unchanged_temporary is None
|
| 273 |
+
|
| 274 |
+
|
| 275 |
def test_reference_projector_gain_is_scoped_and_removed():
|
| 276 |
source = APP_PATH.read_text(encoding="utf-8")
|
| 277 |
synthesize_source = ast.get_source_segment(source, _function_node("_synthesize"))
|