File size: 8,763 Bytes
8d6ad36
 
 
 
 
1e7dd80
8d6ad36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1e7dd80
 
 
 
 
 
 
 
8d6ad36
 
 
 
 
 
 
 
 
 
 
1e7dd80
 
 
 
 
 
 
 
 
8d6ad36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
"""Spaces adapter; the vendored WaveGen inference implementations are unchanged."""

from __future__ import annotations

import math
import hashlib
import os
from pathlib import Path
import re
import tempfile

import numpy as np
import soundfile as sf


MODEL_REPO = "LeeSangHoon/WaveGen"
MODEL_REVISION = "da53c835a39272c05e01526fe3c10b2b9d4ce60b"
MAX_SECONDS = 30
MAX_FILE_BYTES = 20 * 1024 * 1024
MAX_TEXT_CHARS = 2000
MODEL_CHOICES = [("WaveGen V-Spec (Recommended)", "V-Spec"),
                 ("WaveGen-Flash (4 Steps)", "Flash")]
ASSETS = {
    "V-Spec": ("WaveGen_VSpec/model_1400000.safetensors",
               "WaveGen_VSpec/WaveGen_VSpec.yaml", "WaveGen_VSpec/vocab.txt"),
    "Flash": ("WaveGen_Flash/model.safetensors",
              "WaveGen_Flash/WaveGen_Flash.yaml", "WaveGen_Flash/vocab.txt"),
}


def _number(value, name, low, high, *, integer=False):
    if (isinstance(value, bool) or not isinstance(value, (int, float))
            or not math.isfinite(value) or not low <= value <= high
            or (integer and value != int(value))):
        raise ValueError(f"{name} must be {'an integer' if integer else 'a number'} between {low} and {high}.")
    return int(value) if integer else float(value)


def validate_request(ref_audio, ref_text, text, model_variant="V-Spec", seed=0,
                     speed=1.0, mel_temperature=0.0):
    """Bound decoded input and mirror native UTF-8 duration prediction on CPU."""
    if model_variant not in ASSETS:
        raise ValueError("Select WaveGen V-Spec or WaveGen-Flash.")
    for value, name in ((ref_text, "Reference transcript"), (text, "Text to generate")):
        if not isinstance(value, str) or not value.strip():
            raise ValueError(f"{name} is required.")
        if len(value) > MAX_TEXT_CHARS:
            raise ValueError(f"{name} must be at most {MAX_TEXT_CHARS:,} characters.")
    seed = _number(seed, "Seed", 0, 2**32 - 1, integer=True)
    speed = _number(speed, "Speed", 0.5, 2)
    mel_temperature = _number(mel_temperature, "Mel temperature", 0, 1)
    if not isinstance(ref_audio, (str, os.PathLike)) or not Path(ref_audio).is_file():
        raise ValueError("Upload or record reference speech first.")
    path = Path(ref_audio)
    if path.stat().st_size > MAX_FILE_BYTES:
        raise ValueError("Reference audio must be at most 20 MB.")
    try:
        info = sf.info(str(path))
        if not 0 < info.duration <= MAX_SECONDS:
            raise ValueError("Reference speech must be nonempty and at most 30 seconds.")
        if not 8000 <= info.samplerate <= 192000 or info.channels not in (1, 2):
            raise ValueError("Use mono or stereo reference speech at 8–192 kHz.")
        audio, _ = sf.read(str(path), dtype="float32", always_2d=True)
    except (RuntimeError, OSError) as exc:
        raise ValueError("Could not decode the reference audio. Try a WAV file.") from exc
    mono = audio.mean(axis=1)
    if not np.isfinite(audio).all() or not len(mono) or np.sqrt(np.mean(mono**2)) <= 1e-8:
        raise ValueError("Reference audio must contain audible, finite speech.")
    # torchaudio.Resample rounds the output length up, then the native loader
    # truncates/pads the reference to a whole 480-sample waveform patch.
    resampled_length = (info.frames * 24000 + info.samplerate - 1) // info.samplerate
    prompt_length = max(resampled_length // 480 * 480, 480)
    duration_ref_text = ref_text + (" " if len(ref_text[-1].encode("utf-8")) == 1 else "")
    total_length = prompt_length + int(
        prompt_length / len(duration_ref_text.encode("utf-8")) * len(text.encode("utf-8")) / speed
    )
    if total_length <= prompt_length:
        raise ValueError("Predicted speech is empty. Enter more text or reduce speed.")
    if total_length > MAX_SECONDS * 24000:
        raise ValueError("Reference plus predicted speech exceeds 30 seconds. Shorten the text or reference audio.")
    return dict(ref_audio=str(path), ref_text=ref_text, text=text, model_variant=model_variant,
                seed=seed, speed=speed, mel_temperature=mel_temperature,
                predicted_seconds=total_length / 24000)


def model_description(model_variant):
    if model_variant == "Flash":
        return "**WaveGen-Flash:** 4 steps · CFG 0 · config dropout · final Mel head skipped."
    return "**WaveGen V-Spec:** 50 steps · Prompt Anchor · Scheduled K3 (CFG 1.25) · config dropout · final Mel head skipped."


def load_models(*, model_dir=None, repo_id=MODEL_REPO, revision=MODEL_REVISION):
    """Call after importing spaces, before Gradio launch / GPU worker creation.

    CPU loading avoids the native loader's CUDA/BF16 device probes. ZeroGPU
    captures startup model.to('cuda'); actual CUDA checks belong in generate().
    """
    import torch
    from wavegen_v_spec.infer.utils_infer import WaveGen
    from wavegen_flash import WaveGenFlash

    torch.set_num_threads(1)
    factories = {"V-Spec": WaveGen, "Flash": WaveGenFlash}
    engines = {}
    resolved_revision = revision
    if model_dir is None and not re.fullmatch(r"[0-9a-fA-F]{40}", revision):
        from huggingface_hub import model_info
        resolved_revision = model_info(repo_id, revision=revision).sha
        if not resolved_revision or not re.fullmatch(r"[0-9a-fA-F]{40}", resolved_revision):
            raise RuntimeError("The model revision did not resolve to an immutable commit.")
    if model_dir is None:
        from huggingface_hub import hf_hub_download

        # On Linux, the normal Hub snapshot cache uses symlinks to extensionless
        # blobs. The native loader resolves paths before checking the format, so
        # request actual named files instead of passing snapshot symlinks to it.
        repo_key = hashlib.sha256(repo_id.encode("utf-8")).hexdigest()[:16]
        download_root = Path(tempfile.gettempdir()) / "wavegen-hub" / repo_key / resolved_revision
    for variant, (checkpoint, config, vocabulary) in ASSETS.items():
        print(f"Loading {variant} for inference", flush=True)
        options = dict(device="cpu", dtype="fp32")
        if variant == "V-Spec":
            options["use_ema"] = True
        if model_dir is not None:
            root = Path(model_dir)
            engine = factories[variant].from_checkpoint(
                checkpoint=root / checkpoint, vocab_file=root / vocabulary,
                config=root / config, **options)
        else:
            checkpoint_path, config_path, vocabulary_path = [hf_hub_download(
                repo_id=repo_id, filename=filename, revision=resolved_revision,
                local_dir=download_root,
            ) for filename in (checkpoint, config, vocabulary)]
            engine = factories[variant].from_checkpoint(
                checkpoint=checkpoint_path, config=config_path,
                vocab_file=vocabulary_path, **options)
            engine.hub_repo_id = repo_id
            engine.hub_revision = resolved_revision
        if engine.sample_rate != 24000:
            raise ValueError("The demo requires the 24 kHz model bundles.")
        engine.model.requires_grad_(False).to("cuda")
        engine.device = torch.device("cuda")
        engine.dtype = torch.bfloat16
        engines[variant] = engine
    return engines


def synthesize_request(engines, request):
    variant = request["model_variant"]
    engine = engines[variant]
    options = {key: request[key] for key in
               ("ref_audio", "ref_text", "text", "seed", "speed", "mel_temperature")}
    options["compute_final_mel"] = False
    if variant == "V-Spec":
        options.update(steps=50, cfg_schedule="scheduled_k3", cfg_strength=1.25,
                       inference_method="Prompt_xt_Anchor", inference_dropout=None,
                       max_duration_frames=1500)
    else:
        options["max_duration_seconds"] = MAX_SECONDS
    return engine.synthesize(**options)


def save_audio(sample_rate, waveform, output_dir):
    """Preserve native levels: return PCM16 WAV, not a Gradio numpy-audio tuple."""
    waveform = np.asarray(waveform)
    if sample_rate != 24000 or waveform.ndim != 1 or not waveform.size or not np.isfinite(waveform).all():
        raise RuntimeError("The model returned invalid audio.")
    if waveform.size > MAX_SECONDS * sample_rate:
        raise RuntimeError("The generated audio exceeds the demo duration limit.")
    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)
    with tempfile.NamedTemporaryFile(prefix="wavegen-", suffix=".wav", dir=output_dir, delete=False) as f:
        path = Path(f.name)
    try:
        sf.write(str(path), waveform, sample_rate, subtype="PCM_16")
    except Exception:
        path.unlink(missing_ok=True)
        raise
    return str(path)