dsh-vidi 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +162 -41
- package/lib/index.mjs +537 -20
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -821,12 +821,14 @@ function Pick(props) {
|
|
|
821
821
|
const room = window.innerHeight - r.bottom - 8, above = r.top - 8;
|
|
822
822
|
const up = room < 220 && above > room;
|
|
823
823
|
const maxH = Math.max(120, Math.min(320, up ? above - 4 : room - 4));
|
|
824
|
-
const
|
|
825
|
-
const
|
|
824
|
+
const minWidth = Math.min(Math.max(r.width, 180), window.innerWidth - 16);
|
|
825
|
+
const maxWidth = Math.max(minWidth, Math.min(380, window.innerWidth - 16));
|
|
826
|
+
const side = r.left + maxWidth > window.innerWidth - 8 ? { right: Math.max(8, window.innerWidth - r.right) } : { left: Math.max(8, r.left) };
|
|
826
827
|
setPlace({
|
|
827
828
|
...vars,
|
|
828
|
-
|
|
829
|
-
|
|
829
|
+
...side,
|
|
830
|
+
minWidth,
|
|
831
|
+
maxWidth,
|
|
830
832
|
maxHeight: maxH,
|
|
831
833
|
...up ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 },
|
|
832
834
|
fontFamily: cs.fontFamily
|
|
@@ -34605,6 +34607,19 @@ function RestreamPanel(props) {
|
|
|
34605
34607
|
}
|
|
34606
34608
|
|
|
34607
34609
|
// src/client/Settings.tsx
|
|
34610
|
+
/** Who runs a speech engine (engines of one vendor share its key). */
|
|
34611
|
+
const vendorOf = (p) => p.vendor ?? p.id;
|
|
34612
|
+
/**
|
|
34613
|
+
* Subtitle models worth suggesting first: the fast, cheap tier of each family
|
|
34614
|
+
* (flash / mini / turbo / air / lite / haiku), newest first, and the one DSH
|
|
34615
|
+
* uses by default.
|
|
34616
|
+
*/
|
|
34617
|
+
function recommended(models, fallback) {
|
|
34618
|
+
const ver = (n) => Number(/(\d+(?:\.\d+)?)/.exec(n)?.[1] ?? 0);
|
|
34619
|
+
const picks = models.filter((m) => /flash|mini|turbo|\bair\b|-air|lite|haiku/i.test(`${m.model} ${m.name}`) && !/embed|tts|audio|image|vision-preview|realtime/i.test(m.model)).sort((a, b) => ver(b.model) - ver(a.model)).slice(0, 2).map((m) => m.model);
|
|
34620
|
+
if (fallback && models.some((m) => m.model === fallback)) picks.unshift(fallback);
|
|
34621
|
+
return new Set(picks);
|
|
34622
|
+
}
|
|
34608
34623
|
const engineLabel = (t, id) => {
|
|
34609
34624
|
const v = t(`engine.${id}`);
|
|
34610
34625
|
return v === `engine.${id}` ? id : v;
|
|
@@ -34628,6 +34643,13 @@ function Settings(props) {
|
|
|
34628
34643
|
const [msg, setMsg] = (0, react.useState)(null);
|
|
34629
34644
|
const cur = providers.find((p) => p.id === engine);
|
|
34630
34645
|
const key = cur?.key ?? { set: false };
|
|
34646
|
+
const vendorName = (v) => {
|
|
34647
|
+
const x = t(`vendor.${v}`);
|
|
34648
|
+
return x === `vendor.${v}` ? name(v) : x;
|
|
34649
|
+
};
|
|
34650
|
+
const vendors = [...new Map(providers.map((p) => [vendorOf(p), p])).values()];
|
|
34651
|
+
const curVendor = cur ? vendorOf(cur) : "local";
|
|
34652
|
+
const vendorEngines = providers.filter((p) => vendorOf(p) === curVendor);
|
|
34631
34653
|
(0, react.useEffect)(() => {
|
|
34632
34654
|
if (!open) return;
|
|
34633
34655
|
const close = (e) => {
|
|
@@ -34646,9 +34668,6 @@ function Settings(props) {
|
|
|
34646
34668
|
fetch("/live/api/models").then((r) => r.json()).then(setModels).catch(() => {});
|
|
34647
34669
|
}, [open, models]);
|
|
34648
34670
|
const modelKey = (m) => m ? `${m.provider}/${m.model}` : "";
|
|
34649
|
-
const modelName = (m) => {
|
|
34650
|
-
return (models?.models.find((x) => modelKey(x) === modelKey(m)))?.name ?? m?.model ?? "";
|
|
34651
|
-
};
|
|
34652
34671
|
const chooseModel = (key) => {
|
|
34653
34672
|
const row = models?.models.find((x) => modelKey(x) === key);
|
|
34654
34673
|
const subsModel = row ? {
|
|
@@ -34661,8 +34680,14 @@ function Settings(props) {
|
|
|
34661
34680
|
});
|
|
34662
34681
|
post$1("settings", { subsModel });
|
|
34663
34682
|
};
|
|
34664
|
-
const
|
|
34665
|
-
const
|
|
34683
|
+
const current = models?.chosen ?? models?.fallback ?? models?.current ?? void 0;
|
|
34684
|
+
const providersOf = [...new Map([...current && !(models?.models ?? []).some((m) => m.provider === current.provider) ? [[current.provider, {
|
|
34685
|
+
id: current.provider,
|
|
34686
|
+
name: current.provider
|
|
34687
|
+
}]] : [], ...(models?.models ?? []).map((m) => [m.provider, {
|
|
34688
|
+
id: m.provider,
|
|
34689
|
+
name: m.providerName
|
|
34690
|
+
}])]).values()];
|
|
34666
34691
|
const sees = !!models?.models.find((x) => modelKey(x) === modelKey(current))?.image;
|
|
34667
34692
|
const saveKey = async (value) => {
|
|
34668
34693
|
if (!cur) return;
|
|
@@ -34702,19 +34727,35 @@ function Settings(props) {
|
|
|
34702
34727
|
className: "dshl-pop settings right",
|
|
34703
34728
|
children: [
|
|
34704
34729
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
34705
|
-
className: "srow",
|
|
34730
|
+
className: "srow col",
|
|
34706
34731
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34707
34732
|
title: t("set.engine.tip"),
|
|
34708
34733
|
children: t("set.engine")
|
|
34709
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.
|
|
34710
|
-
|
|
34711
|
-
|
|
34712
|
-
|
|
34713
|
-
|
|
34714
|
-
|
|
34715
|
-
|
|
34716
|
-
|
|
34717
|
-
|
|
34734
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
34735
|
+
className: "duo",
|
|
34736
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
|
|
34737
|
+
value: curVendor,
|
|
34738
|
+
disabled,
|
|
34739
|
+
onChange: (v) => {
|
|
34740
|
+
const first = providers.find((p) => vendorOf(p) === v);
|
|
34741
|
+
if (first) onEngine(first.id);
|
|
34742
|
+
},
|
|
34743
|
+
options: vendors.map((p) => ({
|
|
34744
|
+
value: vendorOf(p),
|
|
34745
|
+
label: vendorName(vendorOf(p)),
|
|
34746
|
+
hint: p.ready ? void 0 : t(p.online ? "set.engine.noKey" : "set.engine.notInstalled"),
|
|
34747
|
+
group: p.online ? t(p.region === "cn" ? "set.engine.cn" : "set.engine.intl") : void 0
|
|
34748
|
+
}))
|
|
34749
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
|
|
34750
|
+
value: engine,
|
|
34751
|
+
disabled: disabled || vendorEngines.length < 2,
|
|
34752
|
+
onChange: onEngine,
|
|
34753
|
+
options: vendorEngines.map((p, i) => ({
|
|
34754
|
+
value: p.id,
|
|
34755
|
+
label: p.model ?? name(p.id),
|
|
34756
|
+
hint: t(`engine.note.${p.id}`) !== `engine.note.${p.id}` ? t(`engine.note.${p.id}`) : i === 0 && vendorEngines.length > 1 ? t("set.recommended") : void 0
|
|
34757
|
+
}))
|
|
34758
|
+
})]
|
|
34718
34759
|
})]
|
|
34719
34760
|
}),
|
|
34720
34761
|
cur?.online && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
@@ -34722,7 +34763,7 @@ function Settings(props) {
|
|
|
34722
34763
|
children: [
|
|
34723
34764
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34724
34765
|
title: t(shared ? "set.key.tip.shared" : "set.key.tip"),
|
|
34725
|
-
children: t("set.key").replace("{s}",
|
|
34766
|
+
children: t("set.key").replace("{s}", vendorName(curVendor))
|
|
34726
34767
|
}),
|
|
34727
34768
|
key.set && !draft && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
34728
34769
|
className: "keyline",
|
|
@@ -34783,28 +34824,60 @@ function Settings(props) {
|
|
|
34783
34824
|
}),
|
|
34784
34825
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
34785
34826
|
className: "srow col",
|
|
34786
|
-
children: [
|
|
34787
|
-
|
|
34788
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34827
|
+
children: [
|
|
34828
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34789
34829
|
title: t("set.model.tip"),
|
|
34790
34830
|
children: t("set.model")
|
|
34791
|
-
}),
|
|
34792
|
-
|
|
34793
|
-
|
|
34794
|
-
|
|
34795
|
-
value: "",
|
|
34796
|
-
|
|
34797
|
-
|
|
34798
|
-
|
|
34799
|
-
|
|
34800
|
-
|
|
34801
|
-
|
|
34802
|
-
|
|
34803
|
-
|
|
34804
|
-
|
|
34805
|
-
|
|
34806
|
-
|
|
34807
|
-
|
|
34831
|
+
}),
|
|
34832
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
34833
|
+
className: "duo",
|
|
34834
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
|
|
34835
|
+
value: current?.provider ?? "",
|
|
34836
|
+
placeholder: "…",
|
|
34837
|
+
onChange: (v) => {
|
|
34838
|
+
const list = (models?.models ?? []).filter((m) => m.provider === v);
|
|
34839
|
+
const rec = recommended(list, models?.fallback?.provider === v ? models.fallback.model : void 0);
|
|
34840
|
+
const pick = list.find((m) => rec.has(m.model)) ?? list[0];
|
|
34841
|
+
if (pick) chooseModel(modelKey(pick));
|
|
34842
|
+
},
|
|
34843
|
+
options: providersOf.map((p) => ({
|
|
34844
|
+
value: p.id,
|
|
34845
|
+
label: p.name
|
|
34846
|
+
}))
|
|
34847
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Pick, {
|
|
34848
|
+
value: modelKey(current),
|
|
34849
|
+
placeholder: "…",
|
|
34850
|
+
onChange: chooseModel,
|
|
34851
|
+
options: (() => {
|
|
34852
|
+
const listed = (models?.models ?? []).filter((m) => m.provider === current?.provider);
|
|
34853
|
+
const list = current && !listed.some((m) => m.model === current.model) ? [{
|
|
34854
|
+
provider: current.provider,
|
|
34855
|
+
providerName: current.provider,
|
|
34856
|
+
model: current.model,
|
|
34857
|
+
name: current.model,
|
|
34858
|
+
image: false
|
|
34859
|
+
}, ...listed] : listed;
|
|
34860
|
+
const rec = recommended(list, models?.fallback?.provider === current?.provider ? models?.fallback?.model : void 0);
|
|
34861
|
+
const tag = (m) => [modelKey(m) === modelKey(models?.fallback) ? t("set.model.dshDefault") : "", m.image ? t("set.model.image") : ""].filter(Boolean).join(" · ") || void 0;
|
|
34862
|
+
return [...list.filter((m) => rec.has(m.model)).map((m) => ({
|
|
34863
|
+
value: modelKey(m),
|
|
34864
|
+
label: m.name,
|
|
34865
|
+
hint: tag(m),
|
|
34866
|
+
group: t("set.recommended")
|
|
34867
|
+
})), ...list.filter((m) => !rec.has(m.model)).map((m) => ({
|
|
34868
|
+
value: modelKey(m),
|
|
34869
|
+
label: m.name,
|
|
34870
|
+
hint: tag(m),
|
|
34871
|
+
group: rec.size ? t("set.allModels") : void 0
|
|
34872
|
+
}))];
|
|
34873
|
+
})()
|
|
34874
|
+
})]
|
|
34875
|
+
}),
|
|
34876
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34877
|
+
className: "hint",
|
|
34878
|
+
children: t(sees ? "set.model.sees" : "set.model.text")
|
|
34879
|
+
})
|
|
34880
|
+
]
|
|
34808
34881
|
}),
|
|
34809
34882
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
34810
34883
|
className: "srow col",
|
|
@@ -36848,8 +36921,31 @@ const en = {
|
|
|
36848
36921
|
"set.engine.notInstalled": "not installed",
|
|
36849
36922
|
"engine.local": "This computer (Whisper)",
|
|
36850
36923
|
"engine.openai": "OpenAI",
|
|
36924
|
+
"engine.openai-translate": "OpenAI live translation",
|
|
36925
|
+
"set.engine.intl": "Online",
|
|
36926
|
+
"set.recommended": "Recommended",
|
|
36927
|
+
"set.allModels": "All models",
|
|
36928
|
+
"set.model.dshDefault": "DSH default",
|
|
36929
|
+
"engine.note.openai-translate": "listens and translates",
|
|
36930
|
+
"vendor.local": "This computer",
|
|
36931
|
+
"vendor.openai": "OpenAI",
|
|
36932
|
+
"vendor.gemini": "Google",
|
|
36933
|
+
"vendor.deepgram": "Deepgram",
|
|
36934
|
+
"vendor.elevenlabs": "ElevenLabs",
|
|
36935
|
+
"vendor.mistral": "Mistral",
|
|
36936
|
+
"vendor.groq": "Groq",
|
|
36937
|
+
"vendor.dashscope": "Alibaba Cloud Bailian",
|
|
36938
|
+
"vendor.volc": "Volcengine",
|
|
36939
|
+
"vendor.siliconflow": "SiliconFlow",
|
|
36940
|
+
"vendor.glm": "Zhipu",
|
|
36941
|
+
"set.engine.cn": "Online · mainland China",
|
|
36851
36942
|
"engine.gemini": "Google Gemini",
|
|
36943
|
+
"engine.deepgram": "Deepgram",
|
|
36944
|
+
"engine.elevenlabs": "ElevenLabs",
|
|
36945
|
+
"engine.mistral": "Mistral (Voxtral)",
|
|
36852
36946
|
"engine.groq": "Groq",
|
|
36947
|
+
"engine.dashscope": "Alibaba Cloud (Qwen-ASR)",
|
|
36948
|
+
"engine.volc": "Volcengine (Doubao)",
|
|
36853
36949
|
"engine.siliconflow": "SiliconFlow",
|
|
36854
36950
|
"engine.glm": "Zhipu GLM",
|
|
36855
36951
|
"set.local.missing": "Local recognition (whisper.cpp) isn’t installed on this computer. Pick an online service above and add its key.",
|
|
@@ -37184,8 +37280,31 @@ const zh = {
|
|
|
37184
37280
|
"set.engine.notInstalled": "未安装",
|
|
37185
37281
|
"engine.local": "本机(Whisper)",
|
|
37186
37282
|
"engine.openai": "OpenAI",
|
|
37283
|
+
"engine.openai-translate": "OpenAI 实时翻译",
|
|
37284
|
+
"set.engine.intl": "在线 · 海外",
|
|
37285
|
+
"set.recommended": "推荐",
|
|
37286
|
+
"set.allModels": "全部模型",
|
|
37287
|
+
"set.model.dshDefault": "DSH 默认",
|
|
37288
|
+
"engine.note.openai-translate": "边听边翻",
|
|
37289
|
+
"vendor.local": "本机",
|
|
37290
|
+
"vendor.openai": "OpenAI",
|
|
37291
|
+
"vendor.gemini": "Google",
|
|
37292
|
+
"vendor.deepgram": "Deepgram",
|
|
37293
|
+
"vendor.elevenlabs": "ElevenLabs",
|
|
37294
|
+
"vendor.mistral": "Mistral",
|
|
37295
|
+
"vendor.groq": "Groq",
|
|
37296
|
+
"vendor.dashscope": "阿里云百炼",
|
|
37297
|
+
"vendor.volc": "火山引擎",
|
|
37298
|
+
"vendor.siliconflow": "硅基流动",
|
|
37299
|
+
"vendor.glm": "智谱",
|
|
37300
|
+
"set.engine.cn": "在线 · 国内",
|
|
37187
37301
|
"engine.gemini": "Google Gemini",
|
|
37302
|
+
"engine.deepgram": "Deepgram",
|
|
37303
|
+
"engine.elevenlabs": "ElevenLabs",
|
|
37304
|
+
"engine.mistral": "Mistral(Voxtral)",
|
|
37188
37305
|
"engine.groq": "Groq",
|
|
37306
|
+
"engine.dashscope": "阿里云百炼(Qwen-ASR)",
|
|
37307
|
+
"engine.volc": "火山引擎(豆包语音)",
|
|
37189
37308
|
"engine.siliconflow": "硅基流动",
|
|
37190
37309
|
"engine.glm": "智谱 GLM",
|
|
37191
37310
|
"set.local.missing": "这台电脑没装本地识别(whisper.cpp)。在上面换一个在线服务,填上它的密钥就能用。",
|
|
@@ -37795,7 +37914,9 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, .
|
|
|
37795
37914
|
.dshl-pop.right { left: auto; right: 0; }
|
|
37796
37915
|
.dshl-pop.up { top: auto; bottom: calc(100% + 6px); }
|
|
37797
37916
|
.dshl .srow { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 46px; font-size: 13.5px; }
|
|
37798
|
-
.dshl-pop.settings { width:
|
|
37917
|
+
.dshl-pop.settings { width: 380px; padding: 6px 12px; }
|
|
37918
|
+
.dshl .srow .duo { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); gap: 8px; }
|
|
37919
|
+
.dshl .srow .duo > .dshl-select { width: 100%; }
|
|
37799
37920
|
.dshl-pop.settings .srow + .srow { border-top: 1px solid var(--line-soft); }
|
|
37800
37921
|
.dshl-pop.settings .srow > .dshl-select, .dshl-pop.settings .srow > .dshl-seg { width: 168px; }
|
|
37801
37922
|
.dshl-pop.settings .srow > .dshl-seg button { flex: 1; padding: 0 6px; }
|
package/lib/index.mjs
CHANGED
|
@@ -2979,7 +2979,7 @@ var LiveJob = class LiveJob extends EventEmitter {
|
|
|
2979
2979
|
// src/downloads.ts
|
|
2980
2980
|
const run = promisify(execFile);
|
|
2981
2981
|
/** ISO 639-2 codes for MP4 subtitle tracks. */
|
|
2982
|
-
const ISO3 = {
|
|
2982
|
+
const ISO3$1 = {
|
|
2983
2983
|
zh: "chi",
|
|
2984
2984
|
"zh-Hant": "chi",
|
|
2985
2985
|
en: "eng",
|
|
@@ -3125,7 +3125,7 @@ var Downloads = class {
|
|
|
3125
3125
|
const file = join(tmp, `${i}.srt`);
|
|
3126
3126
|
writeFileSync(file, exportCues(cues, t.langs, "srt", title));
|
|
3127
3127
|
inputs.push("-i", file);
|
|
3128
|
-
meta.push(`-metadata:s:s:${i}`, `language=${ISO3[t.code] ?? ISO3[t.code.slice(0, 2)] ?? "und"}`, `-metadata:s:s:${i}`, `title=${t.name}`, `-disposition:s:${i}`, i === 0 ? "default" : "0");
|
|
3128
|
+
meta.push(`-metadata:s:s:${i}`, `language=${ISO3$1[t.code] ?? ISO3$1[t.code.slice(0, 2)] ?? "und"}`, `-metadata:s:s:${i}`, `title=${t.name}`, `-disposition:s:${i}`, i === 0 ? "default" : "0");
|
|
3129
3129
|
});
|
|
3130
3130
|
try {
|
|
3131
3131
|
await run("ffmpeg", [
|
|
@@ -3300,7 +3300,12 @@ const KEY_REFS = {
|
|
|
3300
3300
|
"GLM_API_KEY",
|
|
3301
3301
|
"ZHIPUAI_API_KEY",
|
|
3302
3302
|
"ZAI_API_KEY"
|
|
3303
|
-
]
|
|
3303
|
+
],
|
|
3304
|
+
deepgram: ["DEEPGRAM_API_KEY"],
|
|
3305
|
+
elevenlabs: ["ELEVENLABS_API_KEY", "XI_API_KEY"],
|
|
3306
|
+
mistral: ["MISTRAL_API_KEY"],
|
|
3307
|
+
dashscope: ["DASHSCOPE_API_KEY", "BAILIAN_API_KEY"],
|
|
3308
|
+
volc: ["VOLC_SPEECH_API_KEY", "VOLCENGINE_SPEECH_API_KEY"]
|
|
3304
3309
|
};
|
|
3305
3310
|
const KEY_NAMES = Object.keys(KEY_REFS);
|
|
3306
3311
|
const mask = (k) => k.length > 10 ? `${k.slice(0, 3)}…${k.slice(-4)}` : "••••";
|
|
@@ -4071,39 +4076,107 @@ var OpenAIProvider = class {
|
|
|
4071
4076
|
const SERVICES = [
|
|
4072
4077
|
{
|
|
4073
4078
|
id: "openai",
|
|
4079
|
+
key: "openai",
|
|
4074
4080
|
label: "OpenAI",
|
|
4075
4081
|
keyUrl: "https://platform.openai.com/api-keys",
|
|
4076
4082
|
model: "gpt-live-transcribe",
|
|
4083
|
+
region: "intl",
|
|
4077
4084
|
kind: "realtime"
|
|
4078
4085
|
},
|
|
4086
|
+
{
|
|
4087
|
+
id: "openai-translate",
|
|
4088
|
+
key: "openai",
|
|
4089
|
+
label: "OpenAI live translation",
|
|
4090
|
+
keyUrl: "https://platform.openai.com/api-keys",
|
|
4091
|
+
model: "gpt-realtime-translate",
|
|
4092
|
+
region: "intl",
|
|
4093
|
+
kind: "translate"
|
|
4094
|
+
},
|
|
4079
4095
|
{
|
|
4080
4096
|
id: "gemini",
|
|
4097
|
+
key: "gemini",
|
|
4081
4098
|
label: "Google Gemini",
|
|
4082
4099
|
keyUrl: "https://aistudio.google.com/apikey",
|
|
4083
4100
|
model: "auto",
|
|
4101
|
+
modelLabel: "gemini-3.5-transcribe",
|
|
4102
|
+
region: "intl",
|
|
4084
4103
|
kind: "gemini"
|
|
4085
4104
|
},
|
|
4105
|
+
{
|
|
4106
|
+
id: "deepgram",
|
|
4107
|
+
key: "deepgram",
|
|
4108
|
+
label: "Deepgram",
|
|
4109
|
+
keyUrl: "https://console.deepgram.com/",
|
|
4110
|
+
model: "nova-3",
|
|
4111
|
+
region: "intl",
|
|
4112
|
+
kind: "deepgram"
|
|
4113
|
+
},
|
|
4114
|
+
{
|
|
4115
|
+
id: "elevenlabs",
|
|
4116
|
+
key: "elevenlabs",
|
|
4117
|
+
label: "ElevenLabs",
|
|
4118
|
+
keyUrl: "https://elevenlabs.io/app/settings/api-keys",
|
|
4119
|
+
model: "scribe_v2",
|
|
4120
|
+
region: "intl",
|
|
4121
|
+
kind: "elevenlabs"
|
|
4122
|
+
},
|
|
4123
|
+
{
|
|
4124
|
+
id: "mistral",
|
|
4125
|
+
key: "mistral",
|
|
4126
|
+
label: "Mistral",
|
|
4127
|
+
keyUrl: "https://console.mistral.ai/api-keys",
|
|
4128
|
+
model: "voxtral-mini-latest",
|
|
4129
|
+
region: "intl",
|
|
4130
|
+
kind: "whisper-api",
|
|
4131
|
+
base: "https://api.mistral.ai/v1"
|
|
4132
|
+
},
|
|
4086
4133
|
{
|
|
4087
4134
|
id: "groq",
|
|
4135
|
+
key: "groq",
|
|
4088
4136
|
label: "Groq",
|
|
4089
4137
|
keyUrl: "https://console.groq.com/keys",
|
|
4090
4138
|
model: "whisper-large-v3-turbo",
|
|
4139
|
+
region: "intl",
|
|
4091
4140
|
kind: "whisper-api",
|
|
4092
4141
|
base: "https://api.groq.com/openai/v1"
|
|
4093
4142
|
},
|
|
4143
|
+
{
|
|
4144
|
+
id: "dashscope",
|
|
4145
|
+
key: "dashscope",
|
|
4146
|
+
label: "Alibaba Cloud Bailian",
|
|
4147
|
+
keyUrl: "https://bailian.console.aliyun.com/?tab=model#/api-key",
|
|
4148
|
+
model: "qwen3-asr-flash",
|
|
4149
|
+
region: "cn",
|
|
4150
|
+
kind: "dashscope",
|
|
4151
|
+
base: "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
|
4152
|
+
},
|
|
4153
|
+
{
|
|
4154
|
+
id: "volc",
|
|
4155
|
+
key: "volc",
|
|
4156
|
+
label: "Volcengine Doubao",
|
|
4157
|
+
keyUrl: "https://console.volcengine.com/speech/new/",
|
|
4158
|
+
model: "volc.bigasr.auc_turbo",
|
|
4159
|
+
modelLabel: "Seed-ASR 极速版",
|
|
4160
|
+
region: "cn",
|
|
4161
|
+
kind: "volc"
|
|
4162
|
+
},
|
|
4094
4163
|
{
|
|
4095
4164
|
id: "siliconflow",
|
|
4165
|
+
key: "siliconflow",
|
|
4096
4166
|
label: "SiliconFlow",
|
|
4097
4167
|
keyUrl: "https://cloud.siliconflow.cn/account/ak",
|
|
4098
4168
|
model: "FunAudioLLM/SenseVoiceSmall",
|
|
4169
|
+
region: "cn",
|
|
4099
4170
|
kind: "whisper-api",
|
|
4100
4171
|
base: "https://api.siliconflow.cn/v1"
|
|
4101
4172
|
},
|
|
4102
4173
|
{
|
|
4103
4174
|
id: "glm",
|
|
4175
|
+
key: "glm",
|
|
4104
4176
|
label: "Zhipu GLM",
|
|
4105
4177
|
keyUrl: "https://bigmodel.cn/usercenter/proj-mgmt/apikeys",
|
|
4106
4178
|
model: "glm-asr-2512",
|
|
4179
|
+
region: "cn",
|
|
4107
4180
|
kind: "whisper-api",
|
|
4108
4181
|
base: "https://open.bigmodel.cn/api/paas/v4"
|
|
4109
4182
|
}
|
|
@@ -4123,6 +4196,8 @@ function speechServiceOf(id, baseURL) {
|
|
|
4123
4196
|
if (/api\.groq\.com|^ groq$/.test(k)) return "groq";
|
|
4124
4197
|
if (/siliconflow/.test(k)) return "siliconflow";
|
|
4125
4198
|
if (/bigmodel\.cn|(^|\.)z\.ai|^ (glm|zhipu|zhipuai|zai)$/.test(k)) return "glm";
|
|
4199
|
+
if (/api\.mistral\.ai|^ mistral$/.test(k)) return "mistral";
|
|
4200
|
+
if (/dashscope|maas\.aliyuncs\.com|^ (dashscope|bailian|aliyun|qwen)$/.test(k)) return "dashscope";
|
|
4126
4201
|
}
|
|
4127
4202
|
const TIMEOUT = 3e4;
|
|
4128
4203
|
async function fail(r) {
|
|
@@ -4176,6 +4251,172 @@ function whisperApiEngine(svc, model, key) {
|
|
|
4176
4251
|
}
|
|
4177
4252
|
};
|
|
4178
4253
|
}
|
|
4254
|
+
/** Glossary terms from a recogniser hint ("Glossary: a, b, c. …"), for services that take a word list. */
|
|
4255
|
+
const termsOf = (prompt) => (/Glossary: ([^\n]*?)\.(?:\s|$)/.exec(prompt)?.[1] ?? "").split(", ").map((x) => x.trim()).filter(Boolean);
|
|
4256
|
+
/** Three-letter language codes some services answer with, to the two-letter ones used here. */
|
|
4257
|
+
const ISO3 = {
|
|
4258
|
+
eng: "en",
|
|
4259
|
+
zho: "zh",
|
|
4260
|
+
cmn: "zh",
|
|
4261
|
+
yue: "yue",
|
|
4262
|
+
jpn: "ja",
|
|
4263
|
+
kor: "ko",
|
|
4264
|
+
fra: "fr",
|
|
4265
|
+
deu: "de",
|
|
4266
|
+
spa: "es",
|
|
4267
|
+
por: "pt",
|
|
4268
|
+
rus: "ru",
|
|
4269
|
+
ita: "it",
|
|
4270
|
+
ara: "ar",
|
|
4271
|
+
hin: "hi",
|
|
4272
|
+
vie: "vi",
|
|
4273
|
+
tha: "th",
|
|
4274
|
+
ind: "id"
|
|
4275
|
+
};
|
|
4276
|
+
const lang2 = (l) => {
|
|
4277
|
+
if (!l) return void 0;
|
|
4278
|
+
const k = l.toLowerCase().split(/[-_]/)[0];
|
|
4279
|
+
return ISO3[k] ?? ISO[k] ?? k;
|
|
4280
|
+
};
|
|
4281
|
+
/** Deepgram (Nova-3): one request per chunk, glossary as key terms, language detected per chunk. */
|
|
4282
|
+
function deepgramEngine(model, key) {
|
|
4283
|
+
return {
|
|
4284
|
+
name: "deepgram",
|
|
4285
|
+
async transcribe(wav, lang, prompt) {
|
|
4286
|
+
const apiKey = key();
|
|
4287
|
+
if (!apiKey) throw new Error("no Deepgram key");
|
|
4288
|
+
const q = new URLSearchParams({
|
|
4289
|
+
model,
|
|
4290
|
+
smart_format: "true",
|
|
4291
|
+
punctuate: "true"
|
|
4292
|
+
});
|
|
4293
|
+
if (lang !== "auto") q.set("language", lang);
|
|
4294
|
+
else q.set("detect_language", "true");
|
|
4295
|
+
for (const t of termsOf(prompt ?? "").slice(0, 50)) q.append("keyterm", t);
|
|
4296
|
+
const r = await fetch(`https://api.deepgram.com/v1/listen?${q}`, {
|
|
4297
|
+
method: "POST",
|
|
4298
|
+
headers: {
|
|
4299
|
+
Authorization: `Token ${apiKey}`,
|
|
4300
|
+
"content-type": "audio/wav"
|
|
4301
|
+
},
|
|
4302
|
+
body: new Uint8Array(wav),
|
|
4303
|
+
signal: AbortSignal.timeout(TIMEOUT)
|
|
4304
|
+
});
|
|
4305
|
+
if (!r.ok) await fail(r);
|
|
4306
|
+
const ch = (await r.json()).results?.channels?.[0];
|
|
4307
|
+
return {
|
|
4308
|
+
text: (ch?.alternatives?.[0]?.transcript ?? "").trim(),
|
|
4309
|
+
lang: lang2(ch?.detected_language)
|
|
4310
|
+
};
|
|
4311
|
+
}
|
|
4312
|
+
};
|
|
4313
|
+
}
|
|
4314
|
+
/** ElevenLabs Scribe: multipart upload per chunk; glossary as key terms. */
|
|
4315
|
+
function elevenLabsEngine(model, key) {
|
|
4316
|
+
return {
|
|
4317
|
+
name: "elevenlabs",
|
|
4318
|
+
async transcribe(wav, lang, prompt) {
|
|
4319
|
+
const apiKey = key();
|
|
4320
|
+
if (!apiKey) throw new Error("no ElevenLabs key");
|
|
4321
|
+
const form = new FormData();
|
|
4322
|
+
form.append("file", new Blob([new Uint8Array(wav)], { type: "audio/wav" }), "chunk.wav");
|
|
4323
|
+
form.append("model_id", model);
|
|
4324
|
+
form.append("tag_audio_events", "false");
|
|
4325
|
+
if (lang !== "auto") form.append("language_code", lang);
|
|
4326
|
+
for (const t of termsOf(prompt ?? "").slice(0, 50)) form.append("keyterms", t);
|
|
4327
|
+
const r = await fetch("https://api.elevenlabs.io/v1/speech-to-text", {
|
|
4328
|
+
method: "POST",
|
|
4329
|
+
headers: { "xi-api-key": apiKey },
|
|
4330
|
+
body: form,
|
|
4331
|
+
signal: AbortSignal.timeout(TIMEOUT)
|
|
4332
|
+
});
|
|
4333
|
+
if (!r.ok) await fail(r);
|
|
4334
|
+
const j = await r.json();
|
|
4335
|
+
return {
|
|
4336
|
+
text: (j.text ?? "").replace(/\([^)]*\)/g, "").replace(/\s+/g, " ").trim(),
|
|
4337
|
+
lang: lang2(j.language_code)
|
|
4338
|
+
};
|
|
4339
|
+
}
|
|
4340
|
+
};
|
|
4341
|
+
}
|
|
4342
|
+
/** Alibaba Cloud Bailian (Qwen3-ASR-Flash), OpenAI-compatible chat call with the chunk inline. */
|
|
4343
|
+
function dashscopeEngine(base, model, key) {
|
|
4344
|
+
return {
|
|
4345
|
+
name: "dashscope",
|
|
4346
|
+
async transcribe(wav, lang) {
|
|
4347
|
+
const apiKey = key();
|
|
4348
|
+
if (!apiKey) throw new Error("no Alibaba Cloud Bailian key");
|
|
4349
|
+
const body = {
|
|
4350
|
+
model,
|
|
4351
|
+
stream: false,
|
|
4352
|
+
messages: [{
|
|
4353
|
+
role: "user",
|
|
4354
|
+
content: [{
|
|
4355
|
+
type: "input_audio",
|
|
4356
|
+
input_audio: { data: `data:audio/wav;base64,${wav.toString("base64")}` }
|
|
4357
|
+
}]
|
|
4358
|
+
}],
|
|
4359
|
+
asr_options: {
|
|
4360
|
+
enable_itn: true,
|
|
4361
|
+
...lang !== "auto" ? { language: lang } : {}
|
|
4362
|
+
}
|
|
4363
|
+
};
|
|
4364
|
+
const r = await fetch(`${base}/chat/completions`, {
|
|
4365
|
+
method: "POST",
|
|
4366
|
+
headers: {
|
|
4367
|
+
Authorization: `Bearer ${apiKey}`,
|
|
4368
|
+
"content-type": "application/json"
|
|
4369
|
+
},
|
|
4370
|
+
body: JSON.stringify(body),
|
|
4371
|
+
signal: AbortSignal.timeout(TIMEOUT)
|
|
4372
|
+
});
|
|
4373
|
+
if (!r.ok) await fail(r);
|
|
4374
|
+
const m = (await r.json()).choices?.[0]?.message;
|
|
4375
|
+
return {
|
|
4376
|
+
text: (m?.content ?? "").replace(/\s+/g, " ").trim(),
|
|
4377
|
+
lang: lang2(m?.annotations?.[0]?.language)
|
|
4378
|
+
};
|
|
4379
|
+
}
|
|
4380
|
+
};
|
|
4381
|
+
}
|
|
4382
|
+
/** Volcengine Doubao speech (recording recognition, flash edition): one request per chunk, API key from the new speech console. */
|
|
4383
|
+
function volcEngine(resource, key) {
|
|
4384
|
+
return {
|
|
4385
|
+
name: "volc",
|
|
4386
|
+
async transcribe(wav) {
|
|
4387
|
+
const apiKey = key();
|
|
4388
|
+
if (!apiKey) throw new Error("no Volcengine speech key");
|
|
4389
|
+
const body = {
|
|
4390
|
+
user: { uid: "dsh-vidi" },
|
|
4391
|
+
audio: { data: wav.toString("base64") },
|
|
4392
|
+
request: {
|
|
4393
|
+
model_name: "bigmodel",
|
|
4394
|
+
enable_itn: true,
|
|
4395
|
+
enable_punc: true
|
|
4396
|
+
}
|
|
4397
|
+
};
|
|
4398
|
+
const r = await fetch("https://openspeech.bytedance.com/api/v3/auc/bigmodel/recognize/flash", {
|
|
4399
|
+
method: "POST",
|
|
4400
|
+
headers: {
|
|
4401
|
+
"X-Api-Key": apiKey,
|
|
4402
|
+
"X-Api-Resource-Id": resource,
|
|
4403
|
+
"X-Api-Request-Id": crypto.randomUUID(),
|
|
4404
|
+
"X-Api-Sequence": "-1",
|
|
4405
|
+
"content-type": "application/json"
|
|
4406
|
+
},
|
|
4407
|
+
body: JSON.stringify(body),
|
|
4408
|
+
signal: AbortSignal.timeout(TIMEOUT)
|
|
4409
|
+
});
|
|
4410
|
+
const code = r.headers.get("X-Api-Status-Code");
|
|
4411
|
+
if (code === "20000003") return { text: "" };
|
|
4412
|
+
if (!r.ok || code && code !== "20000000") {
|
|
4413
|
+
const msg = r.headers.get("X-Api-Message") ?? (await r.text().catch(() => "")).slice(0, 200);
|
|
4414
|
+
throw new Error(`Volcengine ${code ?? r.status}: ${msg}`);
|
|
4415
|
+
}
|
|
4416
|
+
return { text: ((await r.json()).result?.text ?? "").trim() };
|
|
4417
|
+
}
|
|
4418
|
+
};
|
|
4419
|
+
}
|
|
4179
4420
|
/** Newest Gemini Flash model the key can use (when the setting says "auto"). */
|
|
4180
4421
|
async function pickGeminiModel(apiKey) {
|
|
4181
4422
|
const r = await fetch("https://generativelanguage.googleapis.com/v1beta/models?pageSize=200", {
|
|
@@ -4183,7 +4424,10 @@ async function pickGeminiModel(apiKey) {
|
|
|
4183
4424
|
signal: AbortSignal.timeout(TIMEOUT)
|
|
4184
4425
|
});
|
|
4185
4426
|
if (!r.ok) await fail(r);
|
|
4186
|
-
const
|
|
4427
|
+
const j = await r.json();
|
|
4428
|
+
const transcribe = (j.models ?? []).map((m) => m.name.replace(/^models\//, "")).filter((n) => /^gemini-\d+(\.\d+)?-transcribe$/.test(n)).sort((a, b) => Number(/\d+(\.\d+)?/.exec(b)[0]) - Number(/\d+(\.\d+)?/.exec(a)[0]));
|
|
4429
|
+
if (transcribe[0]) return transcribe[0];
|
|
4430
|
+
const flash = (j.models ?? []).filter((m) => m.supportedGenerationMethods?.includes("generateContent")).map((m) => m.name.replace(/^models\//, "")).filter((n) => /^gemini-\d+(\.\d+)?-flash(-preview)?$/.test(n));
|
|
4187
4431
|
const version = (n) => Number(/^gemini-(\d+(?:\.\d+)?)/.exec(n)[1]);
|
|
4188
4432
|
flash.sort((a, b) => version(b) - version(a) || (a.includes("preview") ? 1 : 0) - (b.includes("preview") ? 1 : 0));
|
|
4189
4433
|
return flash[0] ?? "gemini-2.5-flash";
|
|
@@ -4210,6 +4454,7 @@ function geminiEngine(model, key, onUsage, log) {
|
|
|
4210
4454
|
async transcribe(wav, lang, prompt) {
|
|
4211
4455
|
const apiKey = key();
|
|
4212
4456
|
if (!apiKey) throw new Error("no Google Gemini key");
|
|
4457
|
+
if (/-transcribe$/.test(chosen)) return transcribeModel(apiKey, chosen, wav, lang, prompt ?? "", onUsage);
|
|
4213
4458
|
const instruction = [
|
|
4214
4459
|
"Transcribe the speech in this audio exactly as spoken, in the language spoken (do not translate).",
|
|
4215
4460
|
lang !== "auto" ? `The speaker uses language code "${lang}".` : "",
|
|
@@ -4271,20 +4516,95 @@ function geminiEngine(model, key, onUsage, log) {
|
|
|
4271
4516
|
}
|
|
4272
4517
|
};
|
|
4273
4518
|
}
|
|
4519
|
+
const BCP47 = {
|
|
4520
|
+
zh: "zh-CN",
|
|
4521
|
+
en: "en-US",
|
|
4522
|
+
ja: "ja-JP",
|
|
4523
|
+
ko: "ko-KR",
|
|
4524
|
+
fr: "fr-FR",
|
|
4525
|
+
de: "de-DE",
|
|
4526
|
+
es: "es-ES",
|
|
4527
|
+
pt: "pt-BR",
|
|
4528
|
+
ru: "ru-RU",
|
|
4529
|
+
it: "it-IT"
|
|
4530
|
+
};
|
|
4531
|
+
/** Gemini's dedicated transcription model (Interactions API): verbatim text, glossary as custom vocabulary. */
|
|
4532
|
+
async function transcribeModel(apiKey, model, wav, lang, prompt, onUsage) {
|
|
4533
|
+
const vocab = termsOf(prompt).slice(0, 100);
|
|
4534
|
+
const body = {
|
|
4535
|
+
model: `models/${model}`,
|
|
4536
|
+
input: [{
|
|
4537
|
+
type: "audio",
|
|
4538
|
+
data: wav.toString("base64"),
|
|
4539
|
+
mime_type: "audio/wav"
|
|
4540
|
+
}],
|
|
4541
|
+
generation_config: { transcription_config: {
|
|
4542
|
+
...lang !== "auto" ? { language_codes: [BCP47[lang] ?? lang] } : {},
|
|
4543
|
+
...vocab.length ? { custom_vocabulary: vocab } : {},
|
|
4544
|
+
mode: { type: "verbatim" }
|
|
4545
|
+
} }
|
|
4546
|
+
};
|
|
4547
|
+
const r = await fetch("https://generativelanguage.googleapis.com/v1beta/interactions", {
|
|
4548
|
+
method: "POST",
|
|
4549
|
+
headers: {
|
|
4550
|
+
"x-goog-api-key": apiKey,
|
|
4551
|
+
"content-type": "application/json"
|
|
4552
|
+
},
|
|
4553
|
+
body: JSON.stringify(body),
|
|
4554
|
+
signal: AbortSignal.timeout(TIMEOUT)
|
|
4555
|
+
});
|
|
4556
|
+
if (!r.ok) await fail(r);
|
|
4557
|
+
const j = await r.json();
|
|
4558
|
+
if (j.usage) onUsage(model, {
|
|
4559
|
+
inputTokens: j.usage.total_input_tokens,
|
|
4560
|
+
outputTokens: j.usage.total_output_tokens
|
|
4561
|
+
});
|
|
4562
|
+
const clean = (j.steps ?? []).filter((st) => st.type === "model_output").flatMap((st) => st.content ?? []).filter((c) => c.type === "text").map((c) => c.text ?? "").join("").replace(/\s+/g, " ").trim();
|
|
4563
|
+
return {
|
|
4564
|
+
text: clean,
|
|
4565
|
+
lang: lang !== "auto" ? lang : scriptLang(clean)
|
|
4566
|
+
};
|
|
4567
|
+
}
|
|
4568
|
+
/** The language a transcript is written in, when its script says so (kana, hangul, Han); else unknown. */
|
|
4569
|
+
function scriptLang(text) {
|
|
4570
|
+
const n = (re) => (text.match(re) ?? []).length;
|
|
4571
|
+
const han = n(/\p{Script=Han}/gu), kana = n(/[\p{Script=Hiragana}\p{Script=Katakana}]/gu), hangul = n(/\p{Script=Hangul}/gu);
|
|
4572
|
+
const letters = n(/\p{L}/gu);
|
|
4573
|
+
if (!letters) return void 0;
|
|
4574
|
+
if (kana > letters * .1) return "ja";
|
|
4575
|
+
if (hangul > letters * .3) return "ko";
|
|
4576
|
+
if (han > letters * .3) return "zh";
|
|
4577
|
+
}
|
|
4274
4578
|
/** Check a key before saving it: a cheap authenticated read on the service. */
|
|
4275
4579
|
async function verifyKey(svc, key) {
|
|
4276
4580
|
try {
|
|
4277
|
-
const
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4581
|
+
const probe = svc.kind === "gemini" ? {
|
|
4582
|
+
url: "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1",
|
|
4583
|
+
headers: { "x-goog-api-key": key }
|
|
4584
|
+
} : svc.key === "openai" ? {
|
|
4585
|
+
url: "https://api.openai.com/v1/models",
|
|
4586
|
+
headers: { Authorization: `Bearer ${key}` }
|
|
4587
|
+
} : svc.kind === "deepgram" ? {
|
|
4588
|
+
url: "https://api.deepgram.com/v1/projects",
|
|
4589
|
+
headers: { Authorization: `Token ${key}` }
|
|
4590
|
+
} : svc.kind === "elevenlabs" ? {
|
|
4591
|
+
url: "https://api.elevenlabs.io/v1/models",
|
|
4592
|
+
headers: { "xi-api-key": key }
|
|
4593
|
+
} : svc.id === "glm" || svc.kind === "volc" ? null : {
|
|
4594
|
+
url: `${svc.base}/models`,
|
|
4595
|
+
headers: { Authorization: `Bearer ${key}` }
|
|
4596
|
+
};
|
|
4597
|
+
if (!probe) return { ok: true };
|
|
4598
|
+
const r = await fetch(probe.url, {
|
|
4599
|
+
headers: probe.headers,
|
|
4281
4600
|
signal: AbortSignal.timeout(1e4)
|
|
4282
4601
|
});
|
|
4283
4602
|
if (r.ok) return { ok: true };
|
|
4284
4603
|
const body = await r.json().catch(() => ({}));
|
|
4604
|
+
const detail = typeof body.detail === "object" ? body.detail?.message : body.detail;
|
|
4285
4605
|
return {
|
|
4286
4606
|
ok: false,
|
|
4287
|
-
message: (typeof body.error === "object" ? body.error?.message : body.error) ?? body.message ?? `HTTP ${r.status}`
|
|
4607
|
+
message: (typeof body.error === "object" ? body.error?.message : body.error) ?? body.message ?? detail ?? body.err_msg ?? `HTTP ${r.status}`
|
|
4288
4608
|
};
|
|
4289
4609
|
} catch (err) {
|
|
4290
4610
|
return {
|
|
@@ -4293,6 +4613,185 @@ async function verifyKey(svc, key) {
|
|
|
4293
4613
|
};
|
|
4294
4614
|
}
|
|
4295
4615
|
}
|
|
4616
|
+
// src/providers/openai-translate.ts
|
|
4617
|
+
/** Output languages gpt-realtime-translate speaks (the input can be any of 70+). */
|
|
4618
|
+
const TRANSLATE_TARGETS = [
|
|
4619
|
+
"es",
|
|
4620
|
+
"pt",
|
|
4621
|
+
"fr",
|
|
4622
|
+
"ja",
|
|
4623
|
+
"ru",
|
|
4624
|
+
"zh",
|
|
4625
|
+
"de",
|
|
4626
|
+
"ko",
|
|
4627
|
+
"hi",
|
|
4628
|
+
"id",
|
|
4629
|
+
"vi",
|
|
4630
|
+
"it",
|
|
4631
|
+
"en"
|
|
4632
|
+
];
|
|
4633
|
+
/**
|
|
4634
|
+
* OpenAI live interpretation (gpt-realtime-translate): audio streams in, the
|
|
4635
|
+
* translation and the source transcript stream back as text, so no separate
|
|
4636
|
+
* translation pass is needed. The service has no turns of its own, so the
|
|
4637
|
+
* shared pause detector decides where a subtitle ends; text that arrives
|
|
4638
|
+
* shortly after a pause still belongs to the line before it.
|
|
4639
|
+
*/
|
|
4640
|
+
var OpenAITranslateProvider = class {
|
|
4641
|
+
opts;
|
|
4642
|
+
name = "openai-translate";
|
|
4643
|
+
translates = true;
|
|
4644
|
+
ws;
|
|
4645
|
+
chunker;
|
|
4646
|
+
up = new Upsampler();
|
|
4647
|
+
emit;
|
|
4648
|
+
onError;
|
|
4649
|
+
stopped = false;
|
|
4650
|
+
reconnects = 0;
|
|
4651
|
+
/** Text of the line being collected (source transcript and translation). */
|
|
4652
|
+
src = "";
|
|
4653
|
+
tr = "";
|
|
4654
|
+
lineStart = 0;
|
|
4655
|
+
seq = 0;
|
|
4656
|
+
/** A finished stretch of audio whose text may still be arriving. */
|
|
4657
|
+
closing;
|
|
4658
|
+
constructor(opts) {
|
|
4659
|
+
this.opts = opts;
|
|
4660
|
+
if (!TRANSLATE_TARGETS.includes(opts.target)) throw new Error(`OpenAI live translation speaks only ${TRANSLATE_TARGETS.join(", ")}; choose one of these as the subtitle language, or another engine`);
|
|
4661
|
+
this.chunker = new PauseChunker(opts.minSec, opts.maxSec, (c) => this.onChunk(c));
|
|
4662
|
+
}
|
|
4663
|
+
async start(emit, onError) {
|
|
4664
|
+
this.emit = emit;
|
|
4665
|
+
this.onError = onError;
|
|
4666
|
+
await this.connect();
|
|
4667
|
+
}
|
|
4668
|
+
connect() {
|
|
4669
|
+
return new Promise((resolve, reject) => {
|
|
4670
|
+
const url = this.opts.url ?? `wss://api.openai.com/v1/realtime/translations?model=${encodeURIComponent(this.opts.model)}`;
|
|
4671
|
+
const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${this.opts.apiKey}` } });
|
|
4672
|
+
this.ws = ws;
|
|
4673
|
+
let opened = false;
|
|
4674
|
+
ws.onopen = () => {
|
|
4675
|
+
opened = true;
|
|
4676
|
+
this.reconnects = 0;
|
|
4677
|
+
this.send({
|
|
4678
|
+
type: "session.update",
|
|
4679
|
+
session: { audio: {
|
|
4680
|
+
input: {
|
|
4681
|
+
transcription: { model: "gpt-realtime-whisper" },
|
|
4682
|
+
noise_reduction: { type: "far_field" }
|
|
4683
|
+
},
|
|
4684
|
+
output: { language: this.opts.target }
|
|
4685
|
+
} }
|
|
4686
|
+
});
|
|
4687
|
+
resolve();
|
|
4688
|
+
};
|
|
4689
|
+
ws.onmessage = (ev) => {
|
|
4690
|
+
try {
|
|
4691
|
+
this.onEvent(JSON.parse(String(ev.data)));
|
|
4692
|
+
} catch {}
|
|
4693
|
+
};
|
|
4694
|
+
ws.onerror = () => {
|
|
4695
|
+
if (!opened) reject(/* @__PURE__ */ new Error("could not reach OpenAI live translation (check the API key and network)"));
|
|
4696
|
+
};
|
|
4697
|
+
ws.onclose = (ev) => {
|
|
4698
|
+
if (!opened || this.stopped || this.ws !== ws) return;
|
|
4699
|
+
this.opts.log?.(`openai-translate: connection closed (${ev.code} ${ev.reason || ""}), reconnecting`);
|
|
4700
|
+
if (++this.reconnects > 5) {
|
|
4701
|
+
this.onError?.(/* @__PURE__ */ new Error("OpenAI live translation keeps dropping"));
|
|
4702
|
+
return;
|
|
4703
|
+
}
|
|
4704
|
+
setTimeout(() => {
|
|
4705
|
+
if (!this.stopped) this.connect().catch((err) => this.onError?.(err));
|
|
4706
|
+
}, 1e3 * this.reconnects);
|
|
4707
|
+
};
|
|
4708
|
+
});
|
|
4709
|
+
}
|
|
4710
|
+
send(event) {
|
|
4711
|
+
if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(event));
|
|
4712
|
+
}
|
|
4713
|
+
push(pcm) {
|
|
4714
|
+
if (this.stopped) return;
|
|
4715
|
+
this.send({
|
|
4716
|
+
type: "session.input_audio_buffer.append",
|
|
4717
|
+
audio: this.up.push(Buffer.from(pcm)).toString("base64")
|
|
4718
|
+
});
|
|
4719
|
+
this.chunker.push(pcm);
|
|
4720
|
+
}
|
|
4721
|
+
backlog() {
|
|
4722
|
+
return this.closing ? this.chunker.now - this.closing.start : 0;
|
|
4723
|
+
}
|
|
4724
|
+
draft() {
|
|
4725
|
+
if (!this.src.trim() && !this.tr.trim()) return;
|
|
4726
|
+
this.emit?.({
|
|
4727
|
+
key: `ot:${this.seq}`,
|
|
4728
|
+
start: this.lineStart,
|
|
4729
|
+
end: Math.max(this.chunker.now, this.lineStart + .5),
|
|
4730
|
+
src: this.src.trim(),
|
|
4731
|
+
tr: this.tr.trim() ? { [this.opts.target]: this.tr.trim() } : void 0,
|
|
4732
|
+
final: false
|
|
4733
|
+
});
|
|
4734
|
+
}
|
|
4735
|
+
/** A pause ended a stretch: wait a moment for its last words, then finish the line. */
|
|
4736
|
+
onChunk({ start, end }) {
|
|
4737
|
+
if (this.closing) this.finish();
|
|
4738
|
+
if (!this.src.trim() && !this.tr.trim()) {
|
|
4739
|
+
this.lineStart = end;
|
|
4740
|
+
return;
|
|
4741
|
+
}
|
|
4742
|
+
this.lineStart = start;
|
|
4743
|
+
this.closing = {
|
|
4744
|
+
start,
|
|
4745
|
+
end,
|
|
4746
|
+
timer: setTimeout(() => this.finish(), 1500)
|
|
4747
|
+
};
|
|
4748
|
+
}
|
|
4749
|
+
finish() {
|
|
4750
|
+
const c = this.closing;
|
|
4751
|
+
if (!c) return;
|
|
4752
|
+
clearTimeout(c.timer);
|
|
4753
|
+
this.closing = void 0;
|
|
4754
|
+
const src = this.src.trim(), tr = this.tr.trim();
|
|
4755
|
+
if (src || tr) this.emit?.({
|
|
4756
|
+
key: `ot:${this.seq}`,
|
|
4757
|
+
start: c.start,
|
|
4758
|
+
end: Math.max(c.end, c.start + .5),
|
|
4759
|
+
src: src || tr,
|
|
4760
|
+
tr: tr ? { [this.opts.target]: tr } : void 0,
|
|
4761
|
+
final: true
|
|
4762
|
+
});
|
|
4763
|
+
this.seq++;
|
|
4764
|
+
this.src = "";
|
|
4765
|
+
this.tr = "";
|
|
4766
|
+
this.lineStart = c.end;
|
|
4767
|
+
}
|
|
4768
|
+
onEvent(ev) {
|
|
4769
|
+
switch (ev.type) {
|
|
4770
|
+
case "session.input_transcript.delta":
|
|
4771
|
+
this.src += String(ev.delta ?? "");
|
|
4772
|
+
this.draft();
|
|
4773
|
+
break;
|
|
4774
|
+
case "session.output_transcript.delta":
|
|
4775
|
+
this.tr += String(ev.delta ?? "");
|
|
4776
|
+
this.draft();
|
|
4777
|
+
break;
|
|
4778
|
+
case "error": {
|
|
4779
|
+
const err = ev.error ?? {};
|
|
4780
|
+
this.onError?.(/* @__PURE__ */ new Error(`OpenAI: ${err.message ?? "error"}`));
|
|
4781
|
+
break;
|
|
4782
|
+
}
|
|
4783
|
+
}
|
|
4784
|
+
}
|
|
4785
|
+
async stop() {
|
|
4786
|
+
this.chunker.flush(.5);
|
|
4787
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
4788
|
+
this.finish();
|
|
4789
|
+
this.stopped = true;
|
|
4790
|
+
try {
|
|
4791
|
+
this.ws?.close();
|
|
4792
|
+
} catch {}
|
|
4793
|
+
}
|
|
4794
|
+
};
|
|
4296
4795
|
// src/tools.ts
|
|
4297
4796
|
const CLOCK = (s) => {
|
|
4298
4797
|
const h = Math.floor(s / 3600), m = Math.floor(s / 60) % 60, ss = Math.floor(s % 60);
|
|
@@ -4831,7 +5330,7 @@ function apply(ctx, config) {
|
|
|
4831
5330
|
].includes(x.name)).every((x) => x.ok);
|
|
4832
5331
|
});
|
|
4833
5332
|
/** Recognition to use when nothing was chosen: this computer if it can, else the first online service with a key. */
|
|
4834
|
-
const defaultEngine = () => localReady ? "local" : SERVICES.find((s) => keys.get(s.
|
|
5333
|
+
const defaultEngine = () => localReady ? "local" : SERVICES.find((s) => keys.get(s.key))?.id ?? "local";
|
|
4835
5334
|
const settingsFile = join(config.dataDir, "settings.json");
|
|
4836
5335
|
const prefs = () => {
|
|
4837
5336
|
try {
|
|
@@ -4865,7 +5364,7 @@ function apply(ctx, config) {
|
|
|
4865
5364
|
function makeProvider(opts, glossary, usage, job) {
|
|
4866
5365
|
const svc = service(opts.provider);
|
|
4867
5366
|
if (svc) {
|
|
4868
|
-
if (!keys.get(svc.
|
|
5367
|
+
if (!keys.get(svc.key)) throw new Error(`no ${svc.label} key yet: add one in the video page settings or on the DSH Models page`);
|
|
4869
5368
|
if (svc.kind === "realtime") return new OpenAIProvider({
|
|
4870
5369
|
apiKey: keys.get("openai"),
|
|
4871
5370
|
model: config.openaiModel,
|
|
@@ -4877,11 +5376,24 @@ function apply(ctx, config) {
|
|
|
4877
5376
|
log,
|
|
4878
5377
|
onUsage: (u) => usage.add("openai", config.openaiModel, u)
|
|
4879
5378
|
});
|
|
4880
|
-
|
|
5379
|
+
if (svc.kind === "translate") {
|
|
5380
|
+
const target = opts.tgtLangs[0];
|
|
5381
|
+
if (!target) throw new Error("OpenAI live translation needs a subtitle language to translate into");
|
|
5382
|
+
return new OpenAITranslateProvider({
|
|
5383
|
+
apiKey: keys.get("openai"),
|
|
5384
|
+
model: svc.model,
|
|
5385
|
+
target,
|
|
5386
|
+
minSec: 2,
|
|
5387
|
+
maxSec: 8,
|
|
5388
|
+
log
|
|
5389
|
+
});
|
|
5390
|
+
}
|
|
5391
|
+
const key = () => keys.get(svc.key);
|
|
5392
|
+
const base = keys.bases.get(svc.key) ?? svc.base;
|
|
4881
5393
|
return new LocalProvider({
|
|
4882
|
-
engine: svc.kind === "gemini" ? geminiEngine(config.geminiModel, key, (model, u) => usage.add("gemini", model, u), log) : whisperApiEngine({
|
|
5394
|
+
engine: svc.kind === "gemini" ? geminiEngine(config.geminiModel, key, (model, u) => usage.add("gemini", model, u), log) : svc.kind === "deepgram" ? deepgramEngine(svc.model, key) : svc.kind === "elevenlabs" ? elevenLabsEngine(svc.model, key) : svc.kind === "dashscope" ? dashscopeEngine(base, svc.model, key) : svc.kind === "volc" ? volcEngine(svc.model, key) : whisperApiEngine({
|
|
4883
5395
|
...svc,
|
|
4884
|
-
base
|
|
5396
|
+
base
|
|
4885
5397
|
}, svc.model, key),
|
|
4886
5398
|
lang: opts.srcLang === "auto" ? "auto" : opts.srcLang,
|
|
4887
5399
|
minSec: 3,
|
|
@@ -5052,14 +5564,19 @@ function apply(ctx, config) {
|
|
|
5052
5564
|
dataDir: config.dataDir,
|
|
5053
5565
|
providers: [{
|
|
5054
5566
|
id: "local",
|
|
5567
|
+
vendor: "local",
|
|
5568
|
+
model: "whisper-large-v3-turbo",
|
|
5055
5569
|
online: false,
|
|
5056
5570
|
ready: localReady
|
|
5057
5571
|
}, ...SERVICES.map((s) => ({
|
|
5058
5572
|
id: s.id,
|
|
5573
|
+
vendor: s.key,
|
|
5574
|
+
model: s.modelLabel ?? s.model,
|
|
5059
5575
|
label: s.label,
|
|
5060
5576
|
online: true,
|
|
5061
|
-
|
|
5062
|
-
|
|
5577
|
+
region: s.region,
|
|
5578
|
+
ready: keys.info(s.key).set,
|
|
5579
|
+
key: keys.info(s.key),
|
|
5063
5580
|
keyUrl: s.keyUrl
|
|
5064
5581
|
}))],
|
|
5065
5582
|
defaultEngine: defaultEngine(),
|
|
@@ -5076,10 +5593,10 @@ function apply(ctx, config) {
|
|
|
5076
5593
|
const check = await verifyKey(svc, key);
|
|
5077
5594
|
if (!check.ok) return send(res, 400, { error: check.message ?? "key rejected" });
|
|
5078
5595
|
}
|
|
5079
|
-
await keys.save(svc.
|
|
5080
|
-
return send(res, 200, { key: keys.info(svc.
|
|
5596
|
+
await keys.save(svc.key, key);
|
|
5597
|
+
return send(res, 200, { key: keys.info(svc.key) });
|
|
5081
5598
|
}
|
|
5082
|
-
const key = keys.get(svc.
|
|
5599
|
+
const key = keys.get(svc.key);
|
|
5083
5600
|
if (!key) return send(res, 400, { error: "no key" });
|
|
5084
5601
|
const check = await verifyKey(svc, key);
|
|
5085
5602
|
return send(res, check.ok ? 200 : 400, check.ok ? { ok: true } : { error: check.message });
|
|
@@ -5196,7 +5713,7 @@ function apply(ctx, config) {
|
|
|
5196
5713
|
const svc = opts.provider ? service(opts.provider) : void 0;
|
|
5197
5714
|
if (svc) {
|
|
5198
5715
|
await keys.refresh();
|
|
5199
|
-
if (!keys.get(svc.
|
|
5716
|
+
if (!keys.get(svc.key)) return send(res, 400, { error: `no ${svc.label} key` });
|
|
5200
5717
|
}
|
|
5201
5718
|
savePrefs({ last: {
|
|
5202
5719
|
...lastOptions(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vidi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "A video tool for DeepSeek Harness: watch live streams, videos and podcasts with real-time subtitles in any language, then clip and download",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "PolyForm-Noncommercial-1.0.0",
|