File size: 8,052 Bytes
0508269
 
1f78826
 
0508269
 
 
 
 
 
22319c6
0508269
 
cf10e17
1f78826
0508269
 
 
 
 
 
1f78826
 
 
 
 
 
 
 
 
 
 
c8d8960
1f78826
 
f5f2a93
0508269
 
 
 
1f78826
 
b37d230
1f78826
 
 
 
 
 
 
 
 
 
 
0508269
 
ed4eb4d
770c46f
 
ed4eb4d
770c46f
ed4eb4d
 
1f78826
d0712f0
 
0508269
 
 
 
 
ed4eb4d
 
 
 
 
0508269
22319c6
 
ed4eb4d
 
 
 
9a248de
 
ed4eb4d
 
 
 
 
0508269
 
 
d0712f0
 
 
babe9bf
 
 
 
 
 
 
 
d0712f0
 
 
0508269
 
 
 
 
 
 
 
 
 
 
 
babe9bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58665f1
 
 
babe9bf
58665f1
 
 
 
1f78826
 
58665f1
0508269
 
 
fcdab42
 
 
1f78826
fcdab42
 
 
ed4eb4d
fcdab42
 
babe9bf
 
 
fcdab42
babe9bf
 
fcdab42
babe9bf
 
fcdab42
 
 
ed4eb4d
fcdab42
1f78826
fcdab42
0508269
 
9d7c23a
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
181
182
183
184
185
186
187
188
from __future__ import annotations

import spaces  # imported first, as ZeroGPU requires

import tempfile
import uuid
from pathlib import Path

import gradio as gr
import torch
import soundfile as sf
import torchaudio
from huggingface_hub import hf_hub_download
from pyharp import ModelCard, build_endpoint
from pyharp.tags import Channels, SampleRate, Subcategory

from data.tokenizer import AudioTokenizer, TextTokenizer
from inference_speech_editing_scale import inference_one_sample
from models.voicecraft import VoiceCraft


model_card = ModelCard(
    name="VoiceCraft",
    description=(
        "Edit English speech while retaining the surrounding recording. "
        "The model is fixed-rate and mono, at 16 kHz, so the result is resampled back to "
        "the input's rate and copied across its channels: the output has the "
        "format it came in with, but no stereo image. "
        "Repository: https://github.com/jasonppy/VoiceCraft Paper: "
        "https://arxiv.org/abs/2403.16973"
    ),
    author="Puyuan Peng, Po-Yao Huang, Abdelrahman Mohamed, David Harwath",
    tags=[Subcategory.EDITING, SampleRate(16000), Channels(1), 'speech-editing', 'domain:speech'],
)

DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
CODEC_RATE = 50
OUTPUT_DIR = Path(tempfile.gettempdir()) / "voicecraft_outputs"
OUTPUT_DIR.mkdir(exist_ok=True)

# A lossless input keeps its container. A lossy one comes back as WAV, since encoding the result
# lossily again would expose the artifacts of the first encode and add a second set.
LOSSLESS = {".wav", ".flac"}

# pyharp runs process_fn in a worker, which imports this file as __mp_main__ and starts with the
# server, so the model and tokenizers are loaded there alone
if __name__ != "__main__":
    model = VoiceCraft.from_pretrained(
        "pyp1/VoiceCraft_830M_TTSEnhanced", revision="15ff81999431f57897a945a0d261a561a02ea342"
    ).to(DEVICE).eval()
    codec_path = hf_hub_download("pyp1/VoiceCraft", "encodec_4cb2048_giga.th",
                                 revision="52b0f909d652ea016966804767320f13ede39abd")
    audio_tokenizer = AudioTokenizer(device=DEVICE, signature=codec_path)
    text_tokenizer = TextTokenizer(backend="espeak")


# The whole recording is the model's context, and the replacement is generated a token at a
# time, so the cost grows with both, and faster than the text: a 500-character transcript
# measured at about 17 s, while a 1000-character one outran the 60 s reservation below
MAX_INPUT_SECONDS = 60
MAX_TRANSCRIPT_CHARACTERS = 500


def process_fn(audio_path: str, target_text: str, start_time: float, end_time: float) -> str:
    # Checked before the GPU is reserved, so a request the model cannot take spends none of the
    # caller's ZeroGPU allowance
    if not audio_path:
        raise gr.Error("Upload a speech recording.")
    target_text = target_text.strip()
    if not target_text:
        raise gr.Error("Enter the full transcript with the replacement text.")
    if len(target_text) > MAX_TRANSCRIPT_CHARACTERS:
        raise gr.Error(
            f"The transcript is {len(target_text)} characters long. Shorten it to "
            f"{MAX_TRANSCRIPT_CHARACTERS} or fewer."
        )

    # soundfile rather than torchaudio, whose ffmpeg backend fails outside a GPU call under
    # ZeroGPU's emulation
    try:
        duration = sf.info(audio_path).duration
    except Exception as exc:
        raise gr.Error(f"Could not read the audio file: {exc}") from exc
    if duration == 0:
        raise gr.Error("The audio file is empty.")
    if duration > MAX_INPUT_SECONDS:
        raise gr.Error(
            f"The recording is {duration:.1f} s long. Trim it to {MAX_INPUT_SECONDS} seconds or "
            "less."
        )
    if not 0 <= start_time < end_time <= duration:
        raise gr.Error(f"Select an edit interval within the {duration:.2f} second recording.")

    return regenerate(audio_path, target_text, start_time, end_time, duration)


# The replacement is generated token by token until the model decides it has ended, and it
# sometimes runs on, stretching a two-second edit into ten seconds of speech. A result is
# accepted when the recording has grown by no more than this many seconds, and otherwise the
# edit is sampled again, up to the limit below, keeping the shortest result.
MAX_GROWTH_SECONDS = 2.0
MAX_ATTEMPTS = 3


@spaces.GPU(duration=60)
def regenerate(audio_path: str, target_text: str, start_time: float, end_time: float,
               duration: float) -> str:
    start = max(start_time - 0.08, 1 / CODEC_RATE)
    end = min(end_time + 0.08, duration)
    mask = torch.tensor([[round(start * CODEC_RATE), round(end * CODEC_RATE)]])
    decode_config = {
        "top_k": 0,
        "top_p": 0.9,
        "temperature": 1.0,
        "stop_repetition": 3,
        "kvcache": 1,
        "codec_sr": CODEC_RATE,
        "silence_tokens": [1388, 1898, 131],
    }
    best = None
    for attempt in range(MAX_ATTEMPTS):
        generated = inference_one_sample(
            model,
            model.args,
            model.args.phn2num,
            text_tokenizer,
            audio_tokenizer,
            audio_path,
            target_text,
            mask,
            DEVICE,
            decode_config,
        )
        candidate = generated[0].detach().cpu().float()
        seconds = candidate.shape[-1] / 16000
        print(f"VoiceCraft: attempt {attempt + 1} gives {seconds:.1f} s for a {duration:.1f} s "
              "recording", flush=True)
        if best is None or candidate.shape[-1] < best.shape[-1]:
            best = candidate
        if seconds <= duration + MAX_GROWTH_SECONDS:
            break
        # A sample that ran on is more likely to stop with silence penalized harder
        decode_config["stop_repetition"] = max(1, decode_config["stop_repetition"] - 1)

    # The model is mono at 16 kHz, so the result is resampled back to the input's rate and copied
    # across its channels
    input_info = sf.info(audio_path)
    audio = best
    if input_info.samplerate != 16000:
        audio = torchaudio.functional.resample(audio, 16000, input_info.samplerate)
    audio = audio[:1].T.repeat(1, input_info.channels)

    input_ext = Path(audio_path).suffix.lower()
    output_path = OUTPUT_DIR / f"{uuid.uuid4().hex}{input_ext if input_ext in LOSSLESS else '.wav'}"
    sf.write(str(output_path), audio.numpy(), input_info.samplerate)
    return str(output_path)


if __name__ == "__main__":
    with gr.Blocks(title="VoiceCraft") as demo:
        build_endpoint(
            model_card=model_card,
            input_components=[
                gr.Audio(type="filepath", label="Speech Recording")
                .harp_required(True)
                .set_info("English speech to edit, up to 60 seconds."),
                gr.Textbox(label="Target Transcript", lines=4)
                .harp_required(True)
                .set_info("The whole transcript of the recording, as it should read after the "
                          "edit, up to 500 characters. Only the words inside the edit interval "
                          "are regenerated, so change those and leave the rest as spoken."),
                gr.Number(label="Edit Start (seconds)", value=0.5, precision=2)
                .set_info("Where the words to replace begin, in seconds from the start of the "
                          "recording. Read the time off the ruler of the input track."),
                gr.Number(label="Edit End (seconds)", value=1.5, precision=2)
                .set_info("Where the words to replace end, in seconds. The interval should cover "
                          "whole words, and a little silence on either side helps."),
            ],
            output_components=[
                gr.Audio(type="filepath", label="Edited Speech")
                .set_info("The edited recording, at its own sample rate and channel count."),
            ],
            process_fn=process_fn,
        )


    demo.queue(default_concurrency_limit=1).launch(show_error=True, pwa=True)