vomebook commited on
Commit
618c871
·
1 Parent(s): e46ffb6

fix: keep speech retries stable across proxy and mobile IP changes

Browse files

Bind secret random retry capabilities to request content rather than peer IP while retaining observed-peer and global rate limits. Use 192-bit reader tokens and wait for restored voice options before enabling playback.

Files changed (5) hide show
  1. README.md +6 -4
  2. static/tts.js +7 -4
  3. tests/test_service.py +3 -0
  4. tts/api.py +1 -1
  5. tts/service.py +6 -2
README.md CHANGED
@@ -69,7 +69,7 @@ short_description: 马列毛一言api,支持跨域,句子参数请看README.
69
  分段模式为可选字段,旧客户端无需改动:
70
 
71
  ```json
72
- {"text":"这是一篇长文,校长文章写得很好。我得亲自处理后面的内容。","progressive":true,"request_id":"client-retry-token-000001"}
73
  ```
74
 
75
  轮询 `/tts/jobs/{id}` 时,`progress.parts` 是已成功原子发布的有序列表,每项包含
@@ -83,9 +83,11 @@ short_description: 马列毛一言api,支持跨域,句子参数请看README.
83
  长文客户端可附加 `context_before/context_after`,各最多 32 个字符,只供多音字判断,
84
  **不会朗读或计入正文位置**;上下文和分段模式都参与音频缓存身份。
85
  试听页按原文自动携带相邻批的上下文,不靠替换正文修读音。
86
- 可选 `request_id` 是 20–64 位字母、数字、`_`、`-` 的重试标识:同一客户端、同一
87
- 正文与选项,在任务保留期内重试返回同一个任务 ID,避免响应丢失时重复占用订阅。
88
- 不得把一个标识用于不同请求。此机制不替代鉴权或长期任务存储。
 
 
89
 
90
  停止后可点击“从进度继续”。默认正文和进度仅留在当前页面内存;勾选“在本机保存”
91
  才写入本站浏览器存储,刷新后不会自动提交或播放,必须主动点击继续。
 
69
  分段模式为可选字段,旧客户端无需改动:
70
 
71
  ```json
72
+ {"text":"这是一篇长文,校长文章写得很好。我得亲自处理后面的内容。","progressive":true}
73
  ```
74
 
75
  轮询 `/tts/jobs/{id}` 时,`progress.parts` 是已成功原子发布的有序列表,每项包含
 
83
  长文客户端可附加 `context_before/context_after`,各最多 32 个字符,只供多音字判断,
84
  **不会朗读或计入正文位置**;上下文和分段模式都参与音频缓存身份。
85
  试听页按原文自动携带相邻批的上下文,不靠替换正文修读音。
86
+ 可选 `request_id` 是 32–64 位字母、数字、`_`、`-` 的**秘密重试标识**:同一正文与
87
+ 选项在任务保留期内返回同一个任务 ID,即使代理/移动网络 IP 改变,避免响应丢失时
88
+ 重复占用订阅。请用密码学随机数生成器创建至少 128 位随机值;试听页使用 192 位。
89
+ 持有标识并知道原请求即可恢复该任务,因此与临时音频链接一样不可公开,不能用可猜的
90
+ 常量或为不同请求重复使用。此机制不替代登录鉴权或长期任务存储,也不信任任意转发 IP。
91
 
92
  停止后可点击“从进度继续”。默认正文和进度仅留在当前页面内存;勾选“在本机保存”
93
  才写入本站浏览器存储,刷新后不会自动提交或播放,必须主动点击继续。
static/tts.js CHANGED
@@ -4,6 +4,7 @@ const audio = $("#audio");
4
  const preloader = new Audio();
5
  preloader.preload = "auto";
6
  let ready = false;
 
7
  let healthTimer;
8
  let session = null;
9
  let bookmark = null;
@@ -32,9 +33,9 @@ async function jsonRequest(path, options = {}, owner = null) {
32
  }
33
 
34
  function controls() {
35
- $("#generate").disabled = !ready || !!session;
36
  $("#stop").disabled = !session;
37
- $("#resume").disabled = !ready || !!session || !bookmark || bookmark.text !== $("#speech-text").value;
38
  }
39
 
40
  async function health() {
@@ -100,7 +101,7 @@ function stop({ keepalive = false, message = "已停止,可从刚才的位置
100
  }
101
 
102
  function nonce() {
103
- const bytes = crypto.getRandomValues(new Uint8Array(16));
104
  return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("");
105
  }
106
 
@@ -398,5 +399,7 @@ if (bookmark) {
398
  jsonRequest("/tts/voices").then((data) => {
399
  $("#voice").replaceChildren(...data.voices.map((voice) => new Option(voice.name, voice.id)));
400
  if (bookmark) $("#voice").value = bookmark.voice;
401
- }).catch(() => { $("#status").textContent = "音色列表加载失败,请刷新重试"; });
 
 
402
  health();
 
4
  const preloader = new Audio();
5
  preloader.preload = "auto";
6
  let ready = false;
7
+ let voicesReady = false;
8
  let healthTimer;
9
  let session = null;
10
  let bookmark = null;
 
33
  }
34
 
35
  function controls() {
36
+ $("#generate").disabled = !ready || !voicesReady || !!session;
37
  $("#stop").disabled = !session;
38
+ $("#resume").disabled = !ready || !voicesReady || !!session || !bookmark || bookmark.text !== $("#speech-text").value;
39
  }
40
 
41
  async function health() {
 
101
  }
102
 
103
  function nonce() {
104
+ const bytes = crypto.getRandomValues(new Uint8Array(24));
105
  return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("");
106
  }
107
 
 
399
  jsonRequest("/tts/voices").then((data) => {
400
  $("#voice").replaceChildren(...data.voices.map((voice) => new Option(voice.name, voice.id)));
401
  if (bookmark) $("#voice").value = bookmark.voice;
402
+ voicesReady = true;
403
+ controls();
404
+ }).catch(() => { $("#status").textContent = "音色列表加载失败,请刷新重试"; controls(); });
405
  health();
tests/test_service.py CHANGED
@@ -246,6 +246,9 @@ class ServiceTests(unittest.IsolatedAsyncioTestCase):
246
  first = service.submit("长文", "zf_001", 1, "a", **options)
247
  again = service.submit("长文", "zf_001", 1, "a", **options)
248
  self.assertEqual(first["id"], again["id"])
 
 
 
249
  with self.assertRaises(SpeechError) as caught:
250
  service.submit("校长", "zf_001", 1, "a", **options)
251
  self.assertEqual(caught.exception.code, "request_conflict")
 
246
  first = service.submit("长文", "zf_001", 1, "a", **options)
247
  again = service.submit("长文", "zf_001", 1, "a", **options)
248
  self.assertEqual(first["id"], again["id"])
249
+ migrated = service.submit("长文", "zf_001", 1, "different-proxy-or-mobile-peer", **options)
250
+ self.assertEqual(first["id"], migrated["id"])
251
+ self.assertEqual(len(service.jobs), 1)
252
  with self.assertRaises(SpeechError) as caught:
253
  service.submit("校长", "zf_001", 1, "a", **options)
254
  self.assertEqual(caught.exception.code, "request_conflict")
tts/api.py CHANGED
@@ -97,7 +97,7 @@ class SynthesisRequest(BaseModel):
97
  progressive: bool = False
98
  context_before: str = Field(default="", max_length=MAX_CONTEXT)
99
  context_after: str = Field(default="", max_length=MAX_CONTEXT)
100
- request_id: str | None = Field(default=None, min_length=20, max_length=64, pattern=r"^[A-Za-z0-9_-]+$")
101
 
102
 
103
  class LeasedAudio(FileResponse):
 
97
  progressive: bool = False
98
  context_before: str = Field(default="", max_length=MAX_CONTEXT)
99
  context_after: str = Field(default="", max_length=MAX_CONTEXT)
100
+ request_id: str | None = Field(default=None, min_length=32, max_length=64, pattern=r"^[A-Za-z0-9_-]+$")
101
 
102
 
103
  class LeasedAudio(FileResponse):
tts/service.py CHANGED
@@ -203,7 +203,7 @@ class Service:
203
  validate_context(context_after)
204
  if not isinstance(progressive, bool):
205
  raise SpeechError("invalid_options", "分段播放选项无效")
206
- if request_id is not None and (not isinstance(request_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{20,64}", request_id)):
207
  raise SpeechError("invalid_request_id", "请求重试标识无效")
208
  if voice not in VOICES or not 0.7 <= speed <= 1.4:
209
  raise SpeechError("invalid_options", "请选择支持的音色和 0.7–1.4 倍合成语速")
@@ -220,7 +220,11 @@ class Service:
220
  raise SpeechError("cancel_pending", "上一音频仍在传输,请稍后重试", 429)
221
  if request_id:
222
  for job in self.jobs.values():
223
- if job.client == client and job.request_id == request_id:
 
 
 
 
224
  if job.key != key:
225
  raise SpeechError("request_conflict", "重试标识不能用于不同的正文或选项", 409)
226
  return self.describe(job.id)
 
203
  validate_context(context_after)
204
  if not isinstance(progressive, bool):
205
  raise SpeechError("invalid_options", "分段播放选项无效")
206
+ if request_id is not None and (not isinstance(request_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{32,64}", request_id)):
207
  raise SpeechError("invalid_request_id", "请求重试标识无效")
208
  if voice not in VOICES or not 0.7 <= speed <= 1.4:
209
  raise SpeechError("invalid_options", "请选择支持的音色和 0.7–1.4 倍合成语速")
 
220
  raise SpeechError("cancel_pending", "上一音频仍在传输,请稍后重试", 429)
221
  if request_id:
222
  for job in self.jobs.values():
223
+ # This unguessable retry capability is deliberately independent
224
+ # of peer IP: HF proxy hops and mobile networks can change it
225
+ # between an accepted POST and a lost-response retry. Rate
226
+ # accounting still uses the observed peer, never arbitrary XFF.
227
+ if job.request_id == request_id:
228
  if job.key != key:
229
  raise SpeechError("request_conflict", "重试标识不能用于不同的正文或选项", 409)
230
  return self.describe(job.id)