3v324v23 commited on
Commit
d0b7182
·
1 Parent(s): 835ac16

Limit runtime admission to prevent OOM

Browse files
Files changed (4) hide show
  1. Dockerfile +5 -1
  2. app.py +111 -15
  3. static/app.js +57 -3
  4. static/index.html +2 -2
Dockerfile CHANGED
@@ -71,7 +71,11 @@ ENV HOME=/home/user \
71
  BACKEND_HOST=127.0.0.1 \
72
  BACKEND_PORT=8010 \
73
  AUDIO8_TTS_MEM_FRACTION_STATIC=0.60 \
74
- AUDIO8_TTS_MAX_RUNNING_REQUESTS=8 \
 
 
 
 
75
  AUDIO8_TTS_DISABLE_CUDA_GRAPH=0 \
76
  AUDIO8_TTS_ENABLE_TORCH_COMPILE=1 \
77
  AUDIO8_TTS_VERIFY_FAST_KV=1 \
 
71
  BACKEND_HOST=127.0.0.1 \
72
  BACKEND_PORT=8010 \
73
  AUDIO8_TTS_MEM_FRACTION_STATIC=0.60 \
74
+ AUDIO8_TTS_MAX_RUNNING_REQUESTS=2 \
75
+ AUDIO8_TTS_MAX_TEXT_UNITS=150 \
76
+ AUDIO8_TTS_MAX_RAW_TEXT_CHARS=1000 \
77
+ AUDIO8_TTS_MAX_NEW_TOKENS=1024 \
78
+ UI_MAX_CONCURRENCY=2 \
79
  AUDIO8_TTS_DISABLE_CUDA_GRAPH=0 \
80
  AUDIO8_TTS_ENABLE_TORCH_COMPILE=1 \
81
  AUDIO8_TTS_VERIFY_FAST_KV=1 \
app.py CHANGED
@@ -4,6 +4,7 @@ import asyncio
4
  import os
5
  import tempfile
6
  import time
 
7
  from pathlib import Path
8
  from typing import Annotated
9
 
@@ -18,7 +19,10 @@ ROOT = Path(__file__).resolve().parent
18
  BACKEND_URL = os.getenv("BACKEND_URL", "http://127.0.0.1:8010")
19
  MAX_UPLOAD_BYTES = 15 * 1024 * 1024
20
  ALLOWED_AUDIO_SUFFIXES = {".wav", ".mp3", ".flac", ".m4a", ".ogg"}
21
- GENERATION_LIMIT = asyncio.Semaphore(int(os.getenv("UI_MAX_CONCURRENCY", "4")))
 
 
 
22
 
23
  ENGLISH_REFERENCE_TEXT = (
24
  "hello nice to meet you, what would you like to talk about todat"
@@ -107,7 +111,94 @@ async def _backend_status() -> tuple[bool, dict]:
107
  return False, {}
108
 
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  async def _generate(payload: dict) -> httpx.Response:
 
111
  async with GENERATION_LIMIT:
112
  async with httpx.AsyncClient(timeout=httpx.Timeout(600.0)) as client:
113
  return await client.post(f"{BACKEND_URL}/v1/audio/speech", json=payload)
@@ -189,12 +280,8 @@ async def generate_speech(
189
  top_k: Annotated[int, Form()] = 50,
190
  max_new_tokens: Annotated[int, Form()] = 1024,
191
  ) -> Response:
192
- text = " ".join(text.split())
193
  reference_text = " ".join(reference_text.split())
194
- if not text:
195
- raise HTTPException(status_code=400, detail="Text must not be empty")
196
- if len(text) > 1000:
197
- raise HTTPException(status_code=400, detail="Text must be 1000 characters or fewer")
198
  if not reference_text:
199
  raise HTTPException(status_code=400, detail="Reference transcript is required")
200
  if not 0 <= temperature <= 2:
@@ -203,8 +290,11 @@ async def generate_speech(
203
  raise HTTPException(status_code=400, detail="Top P must be between 0 and 1")
204
  if not 1 <= top_k <= 200:
205
  raise HTTPException(status_code=400, detail="Top K must be between 1 and 200")
206
- if not 32 <= max_new_tokens <= 2048:
207
- raise HTTPException(status_code=400, detail="Max tokens must be between 32 and 2048")
 
 
 
208
 
209
  temporary_path: Path | None = None
210
  if reference_audio is not None and reference_audio.filename:
@@ -281,13 +371,19 @@ async def models_proxy() -> Response:
281
 
282
  @app.post("/v1/audio/speech")
283
  async def speech_proxy(request: Request) -> Response:
284
- body = await request.body()
285
- async with httpx.AsyncClient(timeout=httpx.Timeout(600.0)) as client:
286
- response = await client.post(
287
- f"{BACKEND_URL}/v1/audio/speech",
288
- content=body,
289
- headers={"Content-Type": request.headers.get("content-type", "application/json")},
290
- )
 
 
 
 
 
 
291
  forwarded_headers = {
292
  name: value
293
  for name, value in response.headers.items()
 
4
  import os
5
  import tempfile
6
  import time
7
+ import unicodedata
8
  from pathlib import Path
9
  from typing import Annotated
10
 
 
19
  BACKEND_URL = os.getenv("BACKEND_URL", "http://127.0.0.1:8010")
20
  MAX_UPLOAD_BYTES = 15 * 1024 * 1024
21
  ALLOWED_AUDIO_SUFFIXES = {".wav", ".mp3", ".flac", ".m4a", ".ogg"}
22
+ MAX_TEXT_UNITS = int(os.getenv("AUDIO8_TTS_MAX_TEXT_UNITS", "150"))
23
+ MAX_RAW_TEXT_CHARS = int(os.getenv("AUDIO8_TTS_MAX_RAW_TEXT_CHARS", "1000"))
24
+ MAX_NEW_TOKENS = int(os.getenv("AUDIO8_TTS_MAX_NEW_TOKENS", "1024"))
25
+ GENERATION_LIMIT = asyncio.Semaphore(int(os.getenv("UI_MAX_CONCURRENCY", "2")))
26
 
27
  ENGLISH_REFERENCE_TEXT = (
28
  "hello nice to meet you, what would you like to talk about todat"
 
111
  return False, {}
112
 
113
 
114
+ def _normalize_speech_text(text: str) -> str:
115
+ cleaned: list[str] = []
116
+ for character in text:
117
+ if character.isspace():
118
+ cleaned.append(" ")
119
+ elif unicodedata.category(character) not in {"Cc", "Cf", "Cs", "Co", "Cn"}:
120
+ cleaned.append(character)
121
+ return " ".join("".join(cleaned).split())
122
+
123
+
124
+ def _is_cjk_character(character: str) -> bool:
125
+ codepoint = ord(character)
126
+ return (
127
+ 0x3400 <= codepoint <= 0x4DBF
128
+ or 0x4E00 <= codepoint <= 0x9FFF
129
+ or 0xF900 <= codepoint <= 0xFAFF
130
+ or 0x3040 <= codepoint <= 0x30FF
131
+ or 0xAC00 <= codepoint <= 0xD7AF
132
+ )
133
+
134
+
135
+ def _count_speech_units(text: str) -> int:
136
+ units = 0
137
+ in_latin_word = False
138
+ for character in text:
139
+ if _is_cjk_character(character):
140
+ units += 1
141
+ in_latin_word = False
142
+ continue
143
+
144
+ name = unicodedata.name(character, "")
145
+ if character.isnumeric() or (character.isalpha() and "LATIN" in name):
146
+ if not in_latin_word:
147
+ units += 1
148
+ in_latin_word = True
149
+ elif character in {"'", "\u2019", "-"} and in_latin_word:
150
+ continue
151
+ else:
152
+ in_latin_word = False
153
+ if character.isalpha() or character.isnumeric():
154
+ units += 1
155
+ return units
156
+
157
+
158
+ def _validate_speech_text(text: str) -> str:
159
+ text = _normalize_speech_text(text)
160
+ if len(text) > MAX_RAW_TEXT_CHARS:
161
+ raise HTTPException(status_code=400, detail="Speech text is too long")
162
+ if not text:
163
+ raise HTTPException(status_code=400, detail="Text must not be empty")
164
+ units = _count_speech_units(text)
165
+ if units == 0:
166
+ raise HTTPException(status_code=400, detail="Text must contain readable characters")
167
+ if units > MAX_TEXT_UNITS:
168
+ raise HTTPException(
169
+ status_code=400,
170
+ detail=(
171
+ f"Text must be {MAX_TEXT_UNITS} Chinese characters or "
172
+ "English words or fewer"
173
+ ),
174
+ )
175
+ return text
176
+
177
+
178
+ def _validate_speech_payload(payload: dict) -> dict:
179
+ text = payload.get("input")
180
+ if not isinstance(text, str):
181
+ raise HTTPException(status_code=400, detail="Text must not be empty")
182
+ text = _validate_speech_text(text)
183
+
184
+ max_new_tokens = payload.get("max_new_tokens", MAX_NEW_TOKENS)
185
+ if isinstance(max_new_tokens, bool):
186
+ raise HTTPException(status_code=400, detail="Max tokens must be an integer")
187
+ try:
188
+ max_new_tokens = int(max_new_tokens)
189
+ except (TypeError, ValueError) as exc:
190
+ raise HTTPException(status_code=400, detail="Max tokens must be an integer") from exc
191
+ if not 32 <= max_new_tokens <= MAX_NEW_TOKENS:
192
+ raise HTTPException(
193
+ status_code=400,
194
+ detail=f"Max tokens must be between 32 and {MAX_NEW_TOKENS}",
195
+ )
196
+
197
+ return {**payload, "input": text, "max_new_tokens": max_new_tokens}
198
+
199
+
200
  async def _generate(payload: dict) -> httpx.Response:
201
+ payload = _validate_speech_payload(payload)
202
  async with GENERATION_LIMIT:
203
  async with httpx.AsyncClient(timeout=httpx.Timeout(600.0)) as client:
204
  return await client.post(f"{BACKEND_URL}/v1/audio/speech", json=payload)
 
280
  top_k: Annotated[int, Form()] = 50,
281
  max_new_tokens: Annotated[int, Form()] = 1024,
282
  ) -> Response:
283
+ text = _validate_speech_text(text)
284
  reference_text = " ".join(reference_text.split())
 
 
 
 
285
  if not reference_text:
286
  raise HTTPException(status_code=400, detail="Reference transcript is required")
287
  if not 0 <= temperature <= 2:
 
290
  raise HTTPException(status_code=400, detail="Top P must be between 0 and 1")
291
  if not 1 <= top_k <= 200:
292
  raise HTTPException(status_code=400, detail="Top K must be between 1 and 200")
293
+ if not 32 <= max_new_tokens <= MAX_NEW_TOKENS:
294
+ raise HTTPException(
295
+ status_code=400,
296
+ detail=f"Max tokens must be between 32 and {MAX_NEW_TOKENS}",
297
+ )
298
 
299
  temporary_path: Path | None = None
300
  if reference_audio is not None and reference_audio.filename:
 
371
 
372
  @app.post("/v1/audio/speech")
373
  async def speech_proxy(request: Request) -> Response:
374
+ try:
375
+ payload = await request.json()
376
+ except ValueError as exc:
377
+ raise HTTPException(status_code=400, detail="Request body must be valid JSON") from exc
378
+ if not isinstance(payload, dict):
379
+ raise HTTPException(status_code=400, detail="Request body must be a JSON object")
380
+
381
+ try:
382
+ response = await _generate(payload)
383
+ except httpx.ConnectError as exc:
384
+ raise HTTPException(status_code=503, detail="Model is still warming up") from exc
385
+ except httpx.TimeoutException as exc:
386
+ raise HTTPException(status_code=504, detail="Generation timed out") from exc
387
  forwarded_headers = {
388
  name: value
389
  for name, value in response.headers.items()
static/app.js CHANGED
@@ -9,6 +9,11 @@ const state = {
9
  };
10
 
11
  const MAX_REFERENCE_SECONDS = 30;
 
 
 
 
 
12
 
13
  const textPresets = {
14
  zh: "今天想和你分享一个好消息,Audio8 现在可以用更高效的方式生成自然流畅的语音。",
@@ -57,6 +62,44 @@ function formatDuration(seconds) {
57
  return seconds < 60 ? `${seconds.toFixed(1)}s` : `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
58
  }
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  function readAudioDuration(file) {
61
  return new Promise((resolve, reject) => {
62
  const probe = new Audio();
@@ -278,6 +321,13 @@ function setGenerating(generating) {
278
  async function submitGeneration(event) {
279
  event.preventDefault();
280
  if (!elements.consent.checked) return;
 
 
 
 
 
 
 
281
  if (state.source === "upload" && !elements.referenceAudio.files.length) {
282
  showOutput("error");
283
  elements.errorMessage.textContent = "Choose a reference audio file first.";
@@ -358,11 +408,15 @@ elements.referenceAudio.addEventListener("change", () => {
358
  });
359
 
360
  elements.speechText.addEventListener("input", () => {
361
- elements.characterCount.textContent = elements.speechText.value.length;
 
 
 
 
362
  });
363
 
364
  elements.consent.addEventListener("change", () => {
365
- elements.generateButton.disabled = !elements.consent.checked;
366
  });
367
 
368
  elements.resultAudio.addEventListener("loadedmetadata", () => {
@@ -371,7 +425,7 @@ elements.resultAudio.addEventListener("loadedmetadata", () => {
371
 
372
  elements.form.addEventListener("submit", submitGeneration);
373
 
374
- elements.characterCount.textContent = elements.speechText.value.length;
375
  refreshIcons();
376
  loadExamples().catch((error) => {
377
  elements.errorMessage.textContent = error.message;
 
9
  };
10
 
11
  const MAX_REFERENCE_SECONDS = 30;
12
+ const MAX_SPEECH_UNITS = 150;
13
+ const INVISIBLE_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g;
14
+ const CJK_CHARACTER = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
15
+ const LATIN_OR_NUMBER = /[\p{Script=Latin}\p{Number}]/u;
16
+ const LETTER_OR_NUMBER = /[\p{Letter}\p{Number}]/u;
17
 
18
  const textPresets = {
19
  zh: "今天想和你分享一个好消息,Audio8 现在可以用更高效的方式生成自然流畅的语音。",
 
62
  return seconds < 60 ? `${seconds.toFixed(1)}s` : `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
63
  }
64
 
65
+ function normalizeSpeechText(value, trim = true) {
66
+ const withoutInvisible = value.replace(INVISIBLE_CHARACTERS, (character) => (
67
+ /\s/.test(character) ? " " : ""
68
+ ));
69
+ const normalized = withoutInvisible.replace(/\s+/g, " ");
70
+ return trim ? normalized.trim() : normalized;
71
+ }
72
+
73
+ function countSpeechUnits(value) {
74
+ let units = 0;
75
+ let inLatinWord = false;
76
+ for (const character of value) {
77
+ if (CJK_CHARACTER.test(character)) {
78
+ units += 1;
79
+ inLatinWord = false;
80
+ } else if (LATIN_OR_NUMBER.test(character)) {
81
+ if (!inLatinWord) units += 1;
82
+ inLatinWord = true;
83
+ } else if (["'", "\u2019", "-"].includes(character) && inLatinWord) {
84
+ continue;
85
+ } else {
86
+ inLatinWord = false;
87
+ if (LETTER_OR_NUMBER.test(character)) units += 1;
88
+ }
89
+ }
90
+ return units;
91
+ }
92
+
93
+ function updateSpeechLimit() {
94
+ const units = countSpeechUnits(elements.speechText.value);
95
+ elements.characterCount.textContent = units;
96
+ elements.speechText.setAttribute("aria-invalid", String(units > MAX_SPEECH_UNITS));
97
+ elements.generateButton.disabled = (
98
+ !elements.consent.checked || units === 0 || units > MAX_SPEECH_UNITS
99
+ );
100
+ return units;
101
+ }
102
+
103
  function readAudioDuration(file) {
104
  return new Promise((resolve, reject) => {
105
  const probe = new Audio();
 
321
  async function submitGeneration(event) {
322
  event.preventDefault();
323
  if (!elements.consent.checked) return;
324
+ elements.speechText.value = normalizeSpeechText(elements.speechText.value);
325
+ const speechUnits = updateSpeechLimit();
326
+ if (speechUnits === 0 || speechUnits > MAX_SPEECH_UNITS) {
327
+ showOutput("error");
328
+ elements.errorMessage.textContent = `Speech text must be ${MAX_SPEECH_UNITS} Chinese characters or English words or fewer.`;
329
+ return;
330
+ }
331
  if (state.source === "upload" && !elements.referenceAudio.files.length) {
332
  showOutput("error");
333
  elements.errorMessage.textContent = "Choose a reference audio file first.";
 
408
  });
409
 
410
  elements.speechText.addEventListener("input", () => {
411
+ const normalized = normalizeSpeechText(elements.speechText.value, false);
412
+ if (normalized !== elements.speechText.value) {
413
+ elements.speechText.value = normalized;
414
+ }
415
+ updateSpeechLimit();
416
  });
417
 
418
  elements.consent.addEventListener("change", () => {
419
+ updateSpeechLimit();
420
  });
421
 
422
  elements.resultAudio.addEventListener("loadedmetadata", () => {
 
425
 
426
  elements.form.addEventListener("submit", submitGeneration);
427
 
428
+ updateSpeechLimit();
429
  refreshIcons();
430
  loadExamples().catch((error) => {
431
  elements.errorMessage.textContent = error.message;
static/index.html CHANGED
@@ -123,7 +123,7 @@
123
  <span class="step-index">02</span>
124
  <h2>Speech text</h2>
125
  </div>
126
- <span class="character-count"><span id="characterCount">0</span>/1000</span>
127
  </div>
128
  <label class="sr-only" for="speechText">Text to generate</label>
129
  <textarea
@@ -161,7 +161,7 @@
161
  </label>
162
  <label>
163
  Max tokens
164
- <input name="max_new_tokens" type="number" min="32" max="2048" step="32" value="1024" />
165
  </label>
166
  </div>
167
  </details>
 
123
  <span class="step-index">02</span>
124
  <h2>Speech text</h2>
125
  </div>
126
+ <span class="character-count"><span id="characterCount">0</span>/150</span>
127
  </div>
128
  <label class="sr-only" for="speechText">Text to generate</label>
129
  <textarea
 
161
  </label>
162
  <label>
163
  Max tokens
164
+ <input name="max_new_tokens" type="number" min="32" max="1024" step="32" value="1024" />
165
  </label>
166
  </div>
167
  </details>