File size: 6,296 Bytes
b8095c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
282a18f
 
 
 
b8095c5
 
 
282a18f
 
 
 
 
 
 
 
 
 
 
 
b8095c5
282a18f
b8095c5
 
282a18f
b8095c5
282a18f
 
 
 
b8095c5
282a18f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b8095c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c4aa5d2
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
import os
import io
import spaces
import torch
import torchaudio
import numpy as np
import gradio as gr
from pocket_tts import TTSModel

# 1. Load the Pocket-TTS model at startup (CPU-only, ~100M params)
print("Loading Kyutai Pocket-TTS model...")
tts_model = TTSModel.load_model()
print("Pocket-TTS model loaded successfully!")

# Official Kyutai Pocket-TTS English voice list
# See: https://huggingface.co/kyutai/tts-voices
VOICES = [
    "alba",
    "anna",
    "azelma",
    "bill_boerst",
    "caro_davy",
    "charles",
    "cosette",
    "eponine",
    "eve",
    "fantine",
    "george",
    "jane",
    "jean",
    "javert",
    "marius",
    "mary",
    "michael",
    "paul",
    "peter_yearsley",
    "stuart_bell",
    "vera",
]

# Pre-cache voice states at startup for faster inference
print("Pre-caching voice states...")
voice_states = {}
for voice_name in VOICES:
    try:
        voice_states[voice_name] = tts_model.get_state_for_audio_prompt(voice_name)
        print(f"  Cached voice: {voice_name}")
    except Exception as e:
        print(f"  Warning: Could not cache voice '{voice_name}': {e}")
print("Voice states cached!")


def change_speed_pitch_preserved(audio_np: np.ndarray, sample_rate: int, speed: float) -> np.ndarray:
    """
    Adjusts speech speed while preserving pitch/formants without echo.
    Tries backends in quality order: pyrubberband (RubberBand) > sox tempo -s (WSOLA) > audiotsm WSOLA.
    Deliberately AVOIDS librosa.effects.time_stretch (phase vocoder) -> that is the echo source.
    speed >1 = faster/shorter, speed <1 = slower/longer
    """
    if abs(speed - 1.0) < 0.02:
        return audio_np
    # Clamp to avoid extreme WSOLA artifacts
    speed = float(np.clip(speed, 0.5, 2.0))
    x = audio_np.astype(np.float32)
    # 1) Best quality: RubberBand (if binary available)
    try:
        import pyrubberband as rb
        # rb.time_stretch expects rate = speed ( >1 faster)
        y = rb.time_stretch(x, sample_rate, rate=speed)
        return y.astype(np.float32)
    except Exception as e:
        print(f"pyrubberband unavailable: {e}")
    # 2) SoX WSOLA via torchaudio (fast, good, but deprecated API)
    try:
        tensor = torch.from_numpy(x).float()
        if tensor.dim() == 1:
            tensor = tensor.unsqueeze(0)
        # Newer torchaudio >=2.4 moved sox_effects; try both
        try:
            import torchaudio.sox_effects as sox_effects
            effects = [["tempo", "-s", str(speed)]]
            stretched, _ = sox_effects.apply_effects_tensor(tensor, sample_rate, effects)
            return stretched.squeeze(0).numpy().astype(np.float32)
        except Exception:
            # Fallback: torchaudio.functional - not tempo, skip
            raise
    except Exception as e:
        print(f"SoX tempo fallback: {e}")
    # 3) Pure-python WSOLA via audiotsm (no system deps, no phase-vocoder echo)
    try:
        from audiotsm import wsola
        from audiotsm.io.array import ArrayReader, ArrayWriter
        # audiotsm expects (channels, samples) float32
        channels = 1
        reader = ArrayReader(x[np.newaxis, :])
        writer = ArrayWriter(channels)
        # speed >1 = faster, so wsola speed param is same
        tsm = wsola(channels=channels, speed=speed)
        tsm.run(reader, writer)
        y = writer.data[0]
        return y.astype(np.float32)
    except Exception as e:
        print(f"audiotsm WSOLA fallback failed: {e}")
    # 4) Last resort: no DSP, return original and let client do playbackRate
    print("All time-stretch backends failed; returning original audio (use client playbackRate)")
    return audio_np

@spaces.GPU
def synthesize(text: str, voice: str, speed: float = 1.0):
    """
    Generates audio from Pocket-TTS and applies clean server-side speed adjustment.
    """
    if not text or not text.strip():
        raise gr.Error("Text prompt cannot be empty.")

    clean_text = text.strip()
    clean_voice = voice.lower().strip()
    if clean_voice not in VOICES:
        clean_voice = "alba"

    speed_factor = max(0.5, min(2.0, float(speed) if speed else 1.0))

    # 1. Get the cached voice state, or load it on demand
    if clean_voice in voice_states:
        voice_state = voice_states[clean_voice]
    else:
        voice_state = tts_model.get_state_for_audio_prompt(clean_voice)

    # 2. Generate audio using the official API
    audio_tensor = tts_model.generate_audio(voice_state, clean_text)

    # Convert to numpy
    audio_np = audio_tensor.numpy().astype(np.float32)
    sample_rate = tts_model.sample_rate

    # 3. Adjust speed with pitch preservation
    if abs(speed_factor - 1.0) >= 0.02:
        audio_np = change_speed_pitch_preserved(audio_np, sample_rate, speed_factor)

    # Normalize audio to prevent clipping
    max_val = np.max(np.abs(audio_np))
    if max_val > 0:
        audio_np = (audio_np / max_val) * 0.95

    # Return in Gradio (sample_rate, numpy_int16_array) format
    int16_audio = (audio_np * 32767).astype(np.int16)
    return (sample_rate, int16_audio)


# --- Gradio UI & API Interface ---
with gr.Blocks(title="Kyutai Pocket-TTS Server") as demo:
    gr.Markdown("# 🎙️ Kyutai Pocket-TTS Server with Speed Control")

    with gr.Row():
        with gr.Column():
            text_input = gr.Textbox(
                label="Text to Synthesize",
                placeholder="Enter text to speak...",
                lines=4,
                value="The quick brown fox jumps over the lazy dog."
            )
            voice_input = gr.Dropdown(
                label="Voice",
                choices=VOICES,
                value="alba"
            )
            speed_slider = gr.Slider(
                label="Speed Multiplier",
                minimum=0.5,
                maximum=2.0,
                step=0.05,
                value=1.0
            )
            generate_btn = gr.Button("Generate Speech", variant="primary")

        with gr.Column():
            audio_output = gr.Audio(label="Synthesized Audio", type="numpy")

    generate_btn.click(
        fn=synthesize,
        inputs=[text_input, voice_input, speed_slider],
        outputs=audio_output,
        api_name="predict"
    )

if __name__ == "__main__":
    demo.queue().launch(server_name="0.0.0.0", server_port=7860)