voidful commited on
Commit
2311ffd
·
verified ·
1 Parent(s): 2fa9373

Tune raw reference audio conditioning

Browse files
Files changed (3) hide show
  1. README.md +2 -2
  2. app.py +71 -16
  3. 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=SHORT_TEXT_CFG_MIN,
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 = DEFAULT_CFG,
3845
  steps: int = DEFAULT_STEPS,
3846
  speed: float = 1.0,
3847
  ):
3848
  if not reference_wav:
3849
  raise gr.Error("請先錄音或上傳參考音檔。")
3850
- return pcm16_audio_output(
3851
- *_synthesize(
3852
- text,
3853
- None,
3854
- cfg=cfg,
3855
- steps=steps,
3856
- speed=speed,
3857
- request_seed=REFERENCE_GENERATION_SEED,
3858
- reference_wav_path=reference_wav,
 
 
 
 
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–12 秒、單一語者且背景乾淨的授權參考音檔;"
3920
- "錄音會直接作為模型的 reference audio,不轉成 ECAPA 音色。"
 
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
- [reference_text, reference_audio, cfg_input, steps_input, speed_input],
 
 
 
 
 
 
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 "reference_wav_path=reference_wav" in reference_source
 
 
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"))