fix: keep speech retries stable across proxy and mobile IP changes
Browse filesBind 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.
- README.md +6 -4
- static/tts.js +7 -4
- tests/test_service.py +3 -0
- tts/api.py +1 -1
- tts/service.py +6 -2
README.md
CHANGED
|
@@ -69,7 +69,7 @@ short_description: 马列毛一言api,支持跨域,句子参数请看README.
|
|
| 69 |
分段模式为可选字段,旧客户端无需改动:
|
| 70 |
|
| 71 |
```json
|
| 72 |
-
{"text":"这是一篇长文,校长文章写得很好。我得亲自处理后面的内容。","progressive":true
|
| 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` 是
|
| 87 |
-
|
| 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(
|
| 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 |
-
|
|
|
|
|
|
|
| 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=
|
| 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_-]{
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|