OppaAI commited on
Commit
a85e71f
·
1 Parent(s): 3c6d396

feat: add Fish Speech TTS backend support and improve MioTTS preset management and UI layout

Browse files
Files changed (4) hide show
  1. .gitignore +1 -1
  2. backend/fishtts.py +225 -0
  3. backend/miotts.py +69 -0
  4. ui/css.py +2 -2
.gitignore CHANGED
@@ -5,4 +5,4 @@ uv.lock
5
  pyproject.toml
6
  backend/__pycache__/
7
  core/__pycache__/
8
- harvard.wav
 
5
  pyproject.toml
6
  backend/__pycache__/
7
  core/__pycache__/
8
+ *.wav
backend/fishtts.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Fish Speech S2-Pro on Modal
3
+ ============================
4
+ Architecture:
5
+ tools/api_server.py (fish-speech) — TTS API on :8080
6
+
7
+ Model: fishaudio/s2-pro (~8GB on disk, needs A100 40GB)
8
+
9
+ Deploy:
10
+ modal deploy backend/fish_tts.py
11
+
12
+ One-off test:
13
+ modal run backend/fish_tts.py
14
+
15
+ API:
16
+ POST /v1/tts
17
+ - multipart/form-data with fields: text, reference_id (optional),
18
+ reference_audio (optional file), reference_text (optional)
19
+ - returns: audio/wav stream
20
+
21
+ Health:
22
+ GET /v1/health
23
+ """
24
+
25
+ import subprocess
26
+ import time
27
+ from pathlib import Path
28
+
29
+ import modal
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Configuration
33
+ # ---------------------------------------------------------------------------
34
+ HF_REPO = "fishaudio/s2-pro"
35
+ CHECKPOINTS = Path("/models/checkpoints/s2-pro")
36
+ TTS_PORT = 8080
37
+ MINUTES = 60
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Shared volume — model weights downloaded once, reused on warm containers
41
+ # ---------------------------------------------------------------------------
42
+ volume = modal.Volume.from_name("fish-tts-models", create_if_missing=True)
43
+ MODELS_DIR = Path("/models")
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Container image
47
+ # ---------------------------------------------------------------------------
48
+ image = (
49
+ modal.Image.from_registry("nvidia/cuda:12.4.0-runtime-ubuntu22.04", add_python="3.11")
50
+ .apt_install(
51
+ "git", "curl", "libsndfile1", "ffmpeg", "build-essential",
52
+ "portaudio19-dev", "clang",
53
+ )
54
+ .run_commands(
55
+ # Clone fish-speech at v1.5.1 (last stable before S2-Pro refactor)
56
+ # but use main for S2-Pro since v1.5.1 predates it.
57
+ "git clone --depth 1 https://github.com/fishaudio/fish-speech.git /opt/fish-speech",
58
+ )
59
+ .pip_install(
60
+ "torch==2.4.1", "torchvision", "torchaudio",
61
+ extra_index_url="https://download.pytorch.org/whl/cu124",
62
+ )
63
+ .run_commands(
64
+ # Install fish-speech dependencies
65
+ "pip install -e /opt/fish-speech",
66
+ )
67
+ .pip_install("huggingface_hub")
68
+ )
69
+
70
+ app = modal.App("fish-tts", image=image)
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Helpers
74
+ # ---------------------------------------------------------------------------
75
+ def _download_model():
76
+ from huggingface_hub import snapshot_download
77
+ if not CHECKPOINTS.exists() or not any(CHECKPOINTS.iterdir()):
78
+ CHECKPOINTS.mkdir(parents=True, exist_ok=True)
79
+ print(f"Downloading {HF_REPO} ...")
80
+ snapshot_download(repo_id=HF_REPO, local_dir=str(CHECKPOINTS))
81
+ print("Download complete.")
82
+ else:
83
+ print(f"Model already cached: {CHECKPOINTS}")
84
+
85
+
86
+ def _wait_for_port(port: int, label: str, timeout: int = 180):
87
+ import httpx
88
+ deadline = time.time() + timeout
89
+ while time.time() < deadline:
90
+ try:
91
+ r = httpx.get(f"http://localhost:{port}/v1/health", timeout=2)
92
+ if r.status_code == 200:
93
+ print(f"{label} is ready on :{port}")
94
+ return
95
+ except Exception:
96
+ pass
97
+ time.sleep(2)
98
+ raise RuntimeError(f"{label} did not become ready within {timeout}s")
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Modal class — A100 40GB for S2-Pro (4B model, ~8GB weights + activations)
103
+ # ---------------------------------------------------------------------------
104
+ @app.cls(
105
+ gpu="T4",
106
+ timeout=10 * MINUTES,
107
+ scaledown_window=5 * MINUTES,
108
+ min_containers=0,
109
+ volumes={str(MODELS_DIR): volume},
110
+ )
111
+ @modal.concurrent(max_inputs=4)
112
+ class FishTTSServer:
113
+
114
+ @modal.enter()
115
+ def startup(self):
116
+ # 1. Download model weights (no-op if already cached)
117
+ _download_model()
118
+ volume.commit()
119
+
120
+ # 2. Start api_server.py
121
+ cmd = [
122
+ "python", "/opt/fish-speech/tools/api_server.py",
123
+ "--llama-checkpoint-path", str(CHECKPOINTS),
124
+ "--decoder-checkpoint-path", str(CHECKPOINTS / "codec.pth"),
125
+ "--listen", f"0.0.0.0:{TTS_PORT}",
126
+ "--half", # fp16 to save VRAM
127
+ ]
128
+ print("Starting Fish Speech API server:", " ".join(cmd))
129
+ self.proc = subprocess.Popen(cmd)
130
+ _wait_for_port(TTS_PORT, "Fish Speech API server")
131
+
132
+ @modal.exit()
133
+ def teardown(self):
134
+ try:
135
+ self.proc.terminate()
136
+ except Exception:
137
+ pass
138
+
139
+ @modal.web_server(port=TTS_PORT, startup_timeout=5 * MINUTES)
140
+ def serve(self):
141
+ # api_server.py is already running on TTS_PORT.
142
+ # Modal forwards incoming HTTP traffic to it.
143
+ pass
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Register a named voice reference (for --reference_id in TTS requests)
148
+ #
149
+ # Usage:
150
+ # modal run backend/fish_tts.py::register_voice_cli \
151
+ # --audio-path ./Aiko.wav --reference-id Aiko --reference-text "こんにちは"
152
+ #
153
+ # After this, use reference_id=Aiko in requests (no re-upload needed).
154
+ # ---------------------------------------------------------------------------
155
+ @app.function(
156
+ gpu="T4",
157
+ image=image,
158
+ volumes={str(MODELS_DIR): volume},
159
+ timeout=10 * MINUTES,
160
+ )
161
+ def register_voice(audio_bytes: bytes, audio_filename: str, reference_id: str, reference_text: str = ""):
162
+ import subprocess as sp
163
+
164
+ voices_dir = MODELS_DIR / "voices" / reference_id
165
+ voices_dir.mkdir(parents=True, exist_ok=True)
166
+
167
+ # Write audio file
168
+ audio_path = voices_dir / audio_filename
169
+ audio_path.write_bytes(audio_bytes)
170
+
171
+ # Write reference text if provided
172
+ if reference_text:
173
+ (voices_dir / "text.txt").write_text(reference_text)
174
+
175
+ # Encode reference audio to VQ tokens using the VQ encoder
176
+ encoded_path = voices_dir / "encoded.npy"
177
+ encode_cmd = [
178
+ "python", "/opt/fish-speech/tools/vqgan/encode_audio.py",
179
+ "--input", str(audio_path),
180
+ "--output", str(encoded_path),
181
+ "--checkpoint", str(CHECKPOINTS / "codec.pth"),
182
+ ]
183
+ print("Encoding reference audio:", " ".join(encode_cmd))
184
+ result = sp.run(encode_cmd, capture_output=True, text=True)
185
+ if result.returncode != 0:
186
+ # Fallback: just store the wav — api_server supports raw audio too
187
+ print("VQ encode failed (may not be needed), storing raw wav.")
188
+ print(result.stderr)
189
+ else:
190
+ print("Encoded successfully.")
191
+
192
+ volume.commit()
193
+ print(f"Voice '{reference_id}' registered at {voices_dir}")
194
+ print("Files:", list(voices_dir.iterdir()))
195
+
196
+
197
+ @app.local_entrypoint()
198
+ def register_voice_cli(audio_path: str, reference_id: str, reference_text: str = ""):
199
+ """
200
+ Usage:
201
+ modal run backend/fish_tts.py::register_voice_cli \\
202
+ --audio-path ./Aiko.wav --reference-id Aiko --reference-text "こんにちは"
203
+ """
204
+ data = Path(audio_path).read_bytes()
205
+ register_voice.remote(data, Path(audio_path).name, reference_id, reference_text)
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Quick smoke test: modal run backend/fish_tts.py
210
+ # ---------------------------------------------------------------------------
211
+ @app.local_entrypoint()
212
+ def main():
213
+ import httpx, base64
214
+ from pathlib import Path
215
+
216
+ url = "https://oppa-ai-org--fish-tts-fishttserver-serve.modal.run/v1/tts"
217
+ resp = httpx.post(
218
+ url,
219
+ data={"text": "こんにちは、魚の音声です。"},
220
+ timeout=120,
221
+ )
222
+ resp.raise_for_status()
223
+ out = Path("/tmp/fish_tts_test.wav")
224
+ out.write_bytes(resp.content)
225
+ print(f"✓ {len(resp.content)} bytes → {out}")
backend/miotts.py CHANGED
@@ -165,12 +165,22 @@ class TTSServer:
165
  _wait_for_port(LLAMA_PORT, "llama-server")
166
 
167
  # 3. Start run_server.py (MioTTS synthesis API)
 
 
 
 
 
 
 
 
 
168
  tts_cmd = [
169
  "/root/.local/bin/uv", "run",
170
  "python", "run_server.py",
171
  "--llm-base-url", f"http://localhost:{LLAMA_PORT}/v1",
172
  "--host", "0.0.0.0",
173
  "--port", str(TTS_PORT),
 
174
  ]
175
  print("Starting MioTTS run_server.py:", " ".join(tts_cmd))
176
  self.tts_proc = subprocess.Popen(tts_cmd, cwd="/opt/miotts")
@@ -191,6 +201,65 @@ class TTSServer:
191
  pass
192
 
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  # ---------------------------------------------------------------------------
195
  # Quick smoke test: modal run backend/miotts.py
196
  # ---------------------------------------------------------------------------
 
165
  _wait_for_port(LLAMA_PORT, "llama-server")
166
 
167
  # 3. Start run_server.py (MioTTS synthesis API)
168
+ presets_dir = MODELS_DIR / "presets"
169
+ presets_dir.mkdir(parents=True, exist_ok=True)
170
+ # Seed with built-in presets (jp_female, jp_male, en_female, en_male)
171
+ # on first run, so custom presets can coexist on the persistent volume.
172
+ subprocess.run(
173
+ "cp -n /opt/miotts/presets/* " + str(presets_dir) + "/ 2>/dev/null || true",
174
+ shell=True,
175
+ )
176
+ volume.commit()
177
  tts_cmd = [
178
  "/root/.local/bin/uv", "run",
179
  "python", "run_server.py",
180
  "--llm-base-url", f"http://localhost:{LLAMA_PORT}/v1",
181
  "--host", "0.0.0.0",
182
  "--port", str(TTS_PORT),
183
+ "--presets-dir", str(presets_dir),
184
  ]
185
  print("Starting MioTTS run_server.py:", " ".join(tts_cmd))
186
  self.tts_proc = subprocess.Popen(tts_cmd, cwd="/opt/miotts")
 
201
  pass
202
 
203
 
204
+ # ---------------------------------------------------------------------------
205
+ # Register a named voice preset from reference audio
206
+ #
207
+ # Usage:
208
+ # modal run backend/miotts.py::register_preset \
209
+ # --audio-path /path/to/Aiko.wav --preset-id Aiko
210
+ #
211
+ # After this completes, the running server's volume will contain
212
+ # /models/presets/Aiko.* (pre-encoded reference). Restart the app (or wait
213
+ # for the container to scale down/up) so run_server.py picks up the new
214
+ # preset, then use reference_preset_id=Aiko / {"type":"preset","preset_id":"Aiko"}.
215
+ # ---------------------------------------------------------------------------
216
+ @app.function(
217
+ gpu="T4",
218
+ image=image,
219
+ volumes={str(MODELS_DIR): volume},
220
+ timeout=10 * MINUTES,
221
+ )
222
+ def register_preset(audio_bytes: bytes, audio_filename: str, preset_id: str):
223
+ import subprocess as sp
224
+
225
+ presets_dir = MODELS_DIR / "presets"
226
+ presets_dir.mkdir(parents=True, exist_ok=True)
227
+
228
+ # Seed built-in presets too, in case this runs before the server ever has.
229
+ sp.run(
230
+ f"cp -n /opt/miotts/presets/* {presets_dir}/ 2>/dev/null || true",
231
+ shell=True,
232
+ )
233
+
234
+ # Write the uploaded reference audio into the container's filesystem.
235
+ local_audio = Path("/tmp") / audio_filename
236
+ local_audio.write_bytes(audio_bytes)
237
+
238
+ cmd = [
239
+ "/root/.local/bin/uv", "run", "python", "scripts/generate_preset.py",
240
+ "--audio", str(local_audio),
241
+ "--preset-id", preset_id,
242
+ "--output-dir", str(presets_dir),
243
+ ]
244
+ print("Running:", " ".join(cmd))
245
+ sp.run(cmd, cwd="/opt/miotts", check=True)
246
+
247
+ volume.commit()
248
+ print(f"Preset '{preset_id}' registered in {presets_dir}")
249
+ print("Files:", list(presets_dir.glob(f"{preset_id}*")))
250
+
251
+
252
+ @app.local_entrypoint()
253
+ def register_preset_cli(audio_path: str, preset_id: str):
254
+ """
255
+ Usage:
256
+ modal run backend/miotts.py::register_preset_cli \\
257
+ --audio-path /local/path/to/Aiko.wav --preset-id Aiko
258
+ """
259
+ data = Path(audio_path).read_bytes()
260
+ register_preset.remote(data, Path(audio_path).name, preset_id)
261
+
262
+
263
  # ---------------------------------------------------------------------------
264
  # Quick smoke test: modal run backend/miotts.py
265
  # ---------------------------------------------------------------------------
ui/css.py CHANGED
@@ -33,7 +33,7 @@ html, body, .gradio-container, main, footer {
33
  #aiko-avatar-card {
34
  position: relative;
35
  border-radius: 22px;
36
- overflow: hidden;
37
  border: 1px solid rgba(155,127,212,0.34);
38
  background: #080810;
39
  box-shadow: 0 22px 80px rgba(0,0,0,0.42);
@@ -212,7 +212,7 @@ div:has(> #aiko-chatbot) {
212
  #aiko-input-row {
213
  position: absolute;
214
  left: 16px;
215
- right: 16px;
216
  bottom: 16px;
217
  display: flex;
218
  gap: 6px;
 
33
  #aiko-avatar-card {
34
  position: relative;
35
  border-radius: 22px;
36
+ overflow: visible;
37
  border: 1px solid rgba(155,127,212,0.34);
38
  background: #080810;
39
  box-shadow: 0 22px 80px rgba(0,0,0,0.42);
 
212
  #aiko-input-row {
213
  position: absolute;
214
  left: 16px;
215
+ right: 28px;
216
  bottom: 16px;
217
  display: flex;
218
  gap: 6px;