dsh-vidi 0.5.1 → 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 +279 -56
- package/lib/index.mjs +626 -36
- 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
|
|
@@ -1603,7 +1605,7 @@ function fragmentsAreEqual(frag, otherFrag) {
|
|
|
1603
1605
|
/**
|
|
1604
1606
|
* Object representing parsed data from an HLS Segment. Found in {@link hls.js#LevelDetails.fragments}.
|
|
1605
1607
|
*/
|
|
1606
|
-
var Fragment = class extends BaseSegment {
|
|
1608
|
+
var Fragment$1 = class extends BaseSegment {
|
|
1607
1609
|
constructor(type, base) {
|
|
1608
1610
|
super(base);
|
|
1609
1611
|
this._decryptdata = null;
|
|
@@ -9753,7 +9755,7 @@ var M3U8Parser = class M3U8Parser {
|
|
|
9753
9755
|
let discontinuityCounter = 0;
|
|
9754
9756
|
let currentBitrate = 0;
|
|
9755
9757
|
let prevFrag = null;
|
|
9756
|
-
let frag = new Fragment(type, base);
|
|
9758
|
+
let frag = new Fragment$1(type, base);
|
|
9757
9759
|
let result;
|
|
9758
9760
|
let i;
|
|
9759
9761
|
let levelkeys;
|
|
@@ -9771,7 +9773,7 @@ var M3U8Parser = class M3U8Parser {
|
|
|
9771
9773
|
while ((result = LEVEL_PLAYLIST_REGEX_FAST.exec(string)) !== null) {
|
|
9772
9774
|
if (createNextFrag) {
|
|
9773
9775
|
createNextFrag = false;
|
|
9774
|
-
frag = new Fragment(type, base);
|
|
9776
|
+
frag = new Fragment$1(type, base);
|
|
9775
9777
|
frag.playlistOffset = totalduration;
|
|
9776
9778
|
frag.setStart(totalduration);
|
|
9777
9779
|
frag.sn = currentSN;
|
|
@@ -9819,7 +9821,7 @@ var M3U8Parser = class M3U8Parser {
|
|
|
9819
9821
|
const byteRange = frag.byteRange;
|
|
9820
9822
|
if (byteRange.length === 2) frag.bitrate = (byteRange[1] - byteRange[0]) * 8 / frag.duration | 0;
|
|
9821
9823
|
if (level.iframesOnly && byteRange[0] && ((_currentInitSegment = currentInitSegment) == null ? void 0 : _currentInitSegment.cc) !== discontinuityCounter) {
|
|
9822
|
-
const init = new Fragment(type, base);
|
|
9824
|
+
const init = new Fragment$1(type, base);
|
|
9823
9825
|
init.relurl = frag.relurl;
|
|
9824
9826
|
init.setByteRange(`${Math.min(byteRange[0], 1316)}@0`);
|
|
9825
9827
|
init.level = id;
|
|
@@ -9953,7 +9955,7 @@ var M3U8Parser = class M3U8Parser {
|
|
|
9953
9955
|
case "MAP": {
|
|
9954
9956
|
const mapAttrs = new AttrList(value1, level);
|
|
9955
9957
|
if (frag.duration) {
|
|
9956
|
-
const init = new Fragment(type, base);
|
|
9958
|
+
const init = new Fragment$1(type, base);
|
|
9957
9959
|
setInitSegment(init, mapAttrs, id, levelkeys);
|
|
9958
9960
|
currentInitSegment = init;
|
|
9959
9961
|
frag.initSegment = currentInitSegment;
|
|
@@ -33343,6 +33345,95 @@ var Hls = class Hls {
|
|
|
33343
33345
|
};
|
|
33344
33346
|
Hls.defaultConfig = void 0;
|
|
33345
33347
|
|
|
33348
|
+
// src/wrap.ts
|
|
33349
|
+
/**
|
|
33350
|
+
* Line breaking for subtitles drawn on a canvas (burned into clips and
|
|
33351
|
+
* restreams, screenshots): break where the text allows, never start a line
|
|
33352
|
+
* with closing punctuation, and spread the text evenly over the lines it
|
|
33353
|
+
* needs — so a long line wraps as two halves instead of a full line plus one
|
|
33354
|
+
* orphaned character.
|
|
33355
|
+
*/
|
|
33356
|
+
/** Characters that may not begin a line (they stay with the text before them). */
|
|
33357
|
+
const NO_START = new Set(Array.from(",。、;:!?)》」』】〕〉”’…—,.;:!?)]}%"));
|
|
33358
|
+
/** Where a sentence pauses: breaking right after these reads best. */
|
|
33359
|
+
const PAUSE = ",。、;:!?,;:!? ";
|
|
33360
|
+
/** Greedy breaking at `width`: Latin text at spaces, CJK between any two characters. */
|
|
33361
|
+
function greedy(chars, width, measure) {
|
|
33362
|
+
const lines = [];
|
|
33363
|
+
let cur = "";
|
|
33364
|
+
for (const ch of chars) {
|
|
33365
|
+
const next = cur + ch;
|
|
33366
|
+
if (cur && measure(next) > width && !NO_START.has(ch)) {
|
|
33367
|
+
const sp = cur.lastIndexOf(" ");
|
|
33368
|
+
const pause = Math.max(...Array.from(PAUSE).map((p) => cur.lastIndexOf(p)));
|
|
33369
|
+
if (/[A-Za-z0-9]/.test(ch) && /[A-Za-z0-9]$/.test(cur) && sp > cur.length * .3) {
|
|
33370
|
+
lines.push(cur.slice(0, sp));
|
|
33371
|
+
cur = cur.slice(sp + 1) + ch;
|
|
33372
|
+
} else if (pause > cur.length * .6 && pause < cur.length - 1) {
|
|
33373
|
+
lines.push(cur.slice(0, pause + 1).trimEnd());
|
|
33374
|
+
cur = cur.slice(pause + 1).trimStart() + ch;
|
|
33375
|
+
} else {
|
|
33376
|
+
lines.push(cur.trimEnd());
|
|
33377
|
+
cur = ch.trimStart();
|
|
33378
|
+
}
|
|
33379
|
+
} else cur = next;
|
|
33380
|
+
}
|
|
33381
|
+
if (cur.trim()) lines.push(cur.trimEnd());
|
|
33382
|
+
return lines;
|
|
33383
|
+
}
|
|
33384
|
+
/** Wrap `text` to at most `maxWidth`, using as few lines as possible, each about the same length. */
|
|
33385
|
+
function wrapBalanced(text, maxWidth, measure) {
|
|
33386
|
+
const chars = Array.from(text.trim());
|
|
33387
|
+
const first = greedy(chars, maxWidth, measure);
|
|
33388
|
+
if (first.length < 2) return first;
|
|
33389
|
+
let lo = measure(text) / first.length * .9, hi = maxWidth, best = first;
|
|
33390
|
+
for (let i = 0; i < 14 && hi - lo > 1; i++) {
|
|
33391
|
+
const mid = (lo + hi) / 2;
|
|
33392
|
+
const lines = greedy(chars, mid, measure);
|
|
33393
|
+
if (lines.length <= first.length) {
|
|
33394
|
+
best = lines;
|
|
33395
|
+
hi = mid;
|
|
33396
|
+
} else lo = mid;
|
|
33397
|
+
}
|
|
33398
|
+
return atPauses(best, maxWidth, measure);
|
|
33399
|
+
}
|
|
33400
|
+
/** Move each break to a nearby pause (within 4 characters) when both lines still fit. */
|
|
33401
|
+
function atPauses(lines, maxWidth, measure) {
|
|
33402
|
+
const out = lines.map((l) => Array.from(l));
|
|
33403
|
+
for (let k = 0; k + 1 < out.length; k++) {
|
|
33404
|
+
const a = out[k], b = out[k + 1];
|
|
33405
|
+
if (PAUSE.includes(a.at(-1) ?? "")) continue;
|
|
33406
|
+
const sep = /[\x21-\x7e]$/.test(a.join("")) && /^[\x21-\x7e]/.test(b.join("")) ? [" "] : [];
|
|
33407
|
+
let pick;
|
|
33408
|
+
for (let d = 1; d <= 4 && pick === void 0; d++) for (const shift of [d, -d]) {
|
|
33409
|
+
const cut = a.length + shift;
|
|
33410
|
+
if (cut <= 0) continue;
|
|
33411
|
+
const joined = [
|
|
33412
|
+
...a,
|
|
33413
|
+
...sep,
|
|
33414
|
+
...b
|
|
33415
|
+
];
|
|
33416
|
+
if (cut >= joined.length) continue;
|
|
33417
|
+
if (!PAUSE.includes(joined[cut - 1])) continue;
|
|
33418
|
+
const na = joined.slice(0, cut).join("").trimEnd(), nb = joined.slice(cut).join("").trimStart();
|
|
33419
|
+
if (nb && measure(na) <= maxWidth && measure(nb) <= maxWidth) {
|
|
33420
|
+
pick = shift;
|
|
33421
|
+
break;
|
|
33422
|
+
}
|
|
33423
|
+
}
|
|
33424
|
+
if (pick !== void 0) {
|
|
33425
|
+
const joined = [
|
|
33426
|
+
...a,
|
|
33427
|
+
...sep,
|
|
33428
|
+
...b
|
|
33429
|
+
], cut = a.length + pick;
|
|
33430
|
+
out[k] = Array.from(joined.slice(0, cut).join("").trimEnd());
|
|
33431
|
+
out[k + 1] = Array.from(joined.slice(cut).join("").trimStart());
|
|
33432
|
+
}
|
|
33433
|
+
}
|
|
33434
|
+
return out.map((l) => l.join(""));
|
|
33435
|
+
}
|
|
33436
|
+
|
|
33346
33437
|
// src/client/subStyle.tsx
|
|
33347
33438
|
const STORE = "dsh-live.subStyle";
|
|
33348
33439
|
/** First line is the main one (largest); the original, when not first, is the smallest. */
|
|
@@ -33400,11 +33491,29 @@ function visibleRows(texts, style) {
|
|
|
33400
33491
|
return true;
|
|
33401
33492
|
});
|
|
33402
33493
|
}
|
|
33494
|
+
let measurer;
|
|
33403
33495
|
function SubtitleOverlay({ texts, style, draft }) {
|
|
33404
33496
|
const rows = visibleRows(texts, style);
|
|
33405
|
-
|
|
33497
|
+
const box = (0, react.useRef)(null);
|
|
33498
|
+
const [width, setWidth] = (0, react.useState)(0);
|
|
33499
|
+
(0, react.useLayoutEffect)(() => {
|
|
33500
|
+
const el = box.current;
|
|
33501
|
+
if (!el) return;
|
|
33502
|
+
const ro = new ResizeObserver(() => setWidth(el.clientWidth));
|
|
33503
|
+
ro.observe(el);
|
|
33504
|
+
setWidth(el.clientWidth);
|
|
33505
|
+
return () => ro.disconnect();
|
|
33506
|
+
}, []);
|
|
33507
|
+
const lines = (text, size, weight) => {
|
|
33508
|
+
if (measurer === void 0) measurer = typeof document === "undefined" ? null : document.createElement("canvas").getContext("2d");
|
|
33509
|
+
if (!width || !measurer || !box.current) return [text];
|
|
33510
|
+
const px = size / 100 * (width / .92);
|
|
33511
|
+
measurer.font = `${weight} ${px}px ${getComputedStyle(box.current).fontFamily}`;
|
|
33512
|
+
return wrapBalanced(text, (width - px * .9) * .97, (t) => measurer.measureText(t).width);
|
|
33513
|
+
};
|
|
33406
33514
|
const edge = style.position === "top" ? { top: `${style.offset}%` } : { bottom: `${style.offset}%` };
|
|
33407
33515
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
33516
|
+
ref: box,
|
|
33408
33517
|
className: "dshl-sub",
|
|
33409
33518
|
"data-pos": style.position,
|
|
33410
33519
|
style: {
|
|
@@ -33414,7 +33523,7 @@ function SubtitleOverlay({ texts, style, draft }) {
|
|
|
33414
33523
|
children: rows.map((l, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
33415
33524
|
className: `${i === 0 ? "tgt" : "src"} ${l.key === "src" && draft ? "draft" : ""} ${l.key.startsWith("site:") ? "site" : ""}`,
|
|
33416
33525
|
style: { fontSize: `${l.size}cqw` },
|
|
33417
|
-
children: texts[l.key]
|
|
33526
|
+
children: lines(texts[l.key].trim(), l.size, i === 0 ? 600 : 400).map((ln, k) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [k > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("br", {}), ln] }, k))
|
|
33418
33527
|
}) }, l.key))
|
|
33419
33528
|
});
|
|
33420
33529
|
}
|
|
@@ -33429,14 +33538,7 @@ function drawSubtitles(g, W, H, texts, style) {
|
|
|
33429
33538
|
const blocks = rows.map((l, i) => {
|
|
33430
33539
|
const px = l.size / 100 * W;
|
|
33431
33540
|
g.font = `${i === 0 ? 600 : 400} ${px}px ${family}`;
|
|
33432
|
-
const
|
|
33433
|
-
const lines = [];
|
|
33434
|
-
let cur = "";
|
|
33435
|
-
for (const ch of words) if (g.measureText(cur + ch).width > W * .88 && cur) {
|
|
33436
|
-
lines.push(cur);
|
|
33437
|
-
cur = ch.trimStart();
|
|
33438
|
-
} else cur += ch;
|
|
33439
|
-
if (cur) lines.push(cur);
|
|
33541
|
+
const lines = wrapBalanced(String(texts[l.key]), W * .88, (t) => g.measureText(t).width);
|
|
33440
33542
|
return {
|
|
33441
33543
|
px,
|
|
33442
33544
|
weight: i === 0 ? 600 : 400,
|
|
@@ -34505,6 +34607,19 @@ function RestreamPanel(props) {
|
|
|
34505
34607
|
}
|
|
34506
34608
|
|
|
34507
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
|
+
}
|
|
34508
34623
|
const engineLabel = (t, id) => {
|
|
34509
34624
|
const v = t(`engine.${id}`);
|
|
34510
34625
|
return v === `engine.${id}` ? id : v;
|
|
@@ -34528,6 +34643,13 @@ function Settings(props) {
|
|
|
34528
34643
|
const [msg, setMsg] = (0, react.useState)(null);
|
|
34529
34644
|
const cur = providers.find((p) => p.id === engine);
|
|
34530
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);
|
|
34531
34653
|
(0, react.useEffect)(() => {
|
|
34532
34654
|
if (!open) return;
|
|
34533
34655
|
const close = (e) => {
|
|
@@ -34546,9 +34668,6 @@ function Settings(props) {
|
|
|
34546
34668
|
fetch("/live/api/models").then((r) => r.json()).then(setModels).catch(() => {});
|
|
34547
34669
|
}, [open, models]);
|
|
34548
34670
|
const modelKey = (m) => m ? `${m.provider}/${m.model}` : "";
|
|
34549
|
-
const modelName = (m) => {
|
|
34550
|
-
return (models?.models.find((x) => modelKey(x) === modelKey(m)))?.name ?? m?.model ?? "";
|
|
34551
|
-
};
|
|
34552
34671
|
const chooseModel = (key) => {
|
|
34553
34672
|
const row = models?.models.find((x) => modelKey(x) === key);
|
|
34554
34673
|
const subsModel = row ? {
|
|
@@ -34561,8 +34680,14 @@ function Settings(props) {
|
|
|
34561
34680
|
});
|
|
34562
34681
|
post$1("settings", { subsModel });
|
|
34563
34682
|
};
|
|
34564
|
-
const
|
|
34565
|
-
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()];
|
|
34566
34691
|
const sees = !!models?.models.find((x) => modelKey(x) === modelKey(current))?.image;
|
|
34567
34692
|
const saveKey = async (value) => {
|
|
34568
34693
|
if (!cur) return;
|
|
@@ -34602,19 +34727,35 @@ function Settings(props) {
|
|
|
34602
34727
|
className: "dshl-pop settings right",
|
|
34603
34728
|
children: [
|
|
34604
34729
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
34605
|
-
className: "srow",
|
|
34730
|
+
className: "srow col",
|
|
34606
34731
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34607
34732
|
title: t("set.engine.tip"),
|
|
34608
34733
|
children: t("set.engine")
|
|
34609
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.
|
|
34610
|
-
|
|
34611
|
-
|
|
34612
|
-
|
|
34613
|
-
|
|
34614
|
-
|
|
34615
|
-
|
|
34616
|
-
|
|
34617
|
-
|
|
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
|
+
})]
|
|
34618
34759
|
})]
|
|
34619
34760
|
}),
|
|
34620
34761
|
cur?.online && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
@@ -34622,7 +34763,7 @@ function Settings(props) {
|
|
|
34622
34763
|
children: [
|
|
34623
34764
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34624
34765
|
title: t(shared ? "set.key.tip.shared" : "set.key.tip"),
|
|
34625
|
-
children: t("set.key").replace("{s}",
|
|
34766
|
+
children: t("set.key").replace("{s}", vendorName(curVendor))
|
|
34626
34767
|
}),
|
|
34627
34768
|
key.set && !draft && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
34628
34769
|
className: "keyline",
|
|
@@ -34683,28 +34824,60 @@ function Settings(props) {
|
|
|
34683
34824
|
}),
|
|
34684
34825
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
34685
34826
|
className: "srow col",
|
|
34686
|
-
children: [
|
|
34687
|
-
|
|
34688
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34827
|
+
children: [
|
|
34828
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
34689
34829
|
title: t("set.model.tip"),
|
|
34690
34830
|
children: t("set.model")
|
|
34691
|
-
}),
|
|
34692
|
-
|
|
34693
|
-
|
|
34694
|
-
|
|
34695
|
-
value: "",
|
|
34696
|
-
|
|
34697
|
-
|
|
34698
|
-
|
|
34699
|
-
|
|
34700
|
-
|
|
34701
|
-
|
|
34702
|
-
|
|
34703
|
-
|
|
34704
|
-
|
|
34705
|
-
|
|
34706
|
-
|
|
34707
|
-
|
|
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
|
+
]
|
|
34708
34881
|
}),
|
|
34709
34882
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
34710
34883
|
className: "srow col",
|
|
@@ -36748,8 +36921,31 @@ const en = {
|
|
|
36748
36921
|
"set.engine.notInstalled": "not installed",
|
|
36749
36922
|
"engine.local": "This computer (Whisper)",
|
|
36750
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",
|
|
36751
36942
|
"engine.gemini": "Google Gemini",
|
|
36943
|
+
"engine.deepgram": "Deepgram",
|
|
36944
|
+
"engine.elevenlabs": "ElevenLabs",
|
|
36945
|
+
"engine.mistral": "Mistral (Voxtral)",
|
|
36752
36946
|
"engine.groq": "Groq",
|
|
36947
|
+
"engine.dashscope": "Alibaba Cloud (Qwen-ASR)",
|
|
36948
|
+
"engine.volc": "Volcengine (Doubao)",
|
|
36753
36949
|
"engine.siliconflow": "SiliconFlow",
|
|
36754
36950
|
"engine.glm": "Zhipu GLM",
|
|
36755
36951
|
"set.local.missing": "Local recognition (whisper.cpp) isn’t installed on this computer. Pick an online service above and add its key.",
|
|
@@ -37084,8 +37280,31 @@ const zh = {
|
|
|
37084
37280
|
"set.engine.notInstalled": "未安装",
|
|
37085
37281
|
"engine.local": "本机(Whisper)",
|
|
37086
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": "在线 · 国内",
|
|
37087
37301
|
"engine.gemini": "Google Gemini",
|
|
37302
|
+
"engine.deepgram": "Deepgram",
|
|
37303
|
+
"engine.elevenlabs": "ElevenLabs",
|
|
37304
|
+
"engine.mistral": "Mistral(Voxtral)",
|
|
37088
37305
|
"engine.groq": "Groq",
|
|
37306
|
+
"engine.dashscope": "阿里云百炼(Qwen-ASR)",
|
|
37307
|
+
"engine.volc": "火山引擎(豆包语音)",
|
|
37089
37308
|
"engine.siliconflow": "硅基流动",
|
|
37090
37309
|
"engine.glm": "智谱 GLM",
|
|
37091
37310
|
"set.local.missing": "这台电脑没装本地识别(whisper.cpp)。在上面换一个在线服务,填上它的密钥就能用。",
|
|
@@ -37389,6 +37608,8 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, .
|
|
|
37389
37608
|
.dshl-sub .src { color: #e8e8e8; }
|
|
37390
37609
|
.dshl-sub .draft { color: #b8b8b8; font-style: italic; }
|
|
37391
37610
|
.dshl-sub div + div { margin-top: .3em; }
|
|
37611
|
+
/* A line that wraps is split into even halves, not a full line and one orphaned character. */
|
|
37612
|
+
.dshl-sub div { line-break: strict; }
|
|
37392
37613
|
.dshl-stage:not(.idle) .dshl-sub[data-pos=bottom] { transform: translate(-50%, -64px); }
|
|
37393
37614
|
.dshl-stage:not(.idle) .dshl-sub[data-pos=top] { transform: translate(-50%, 34px); }
|
|
37394
37615
|
|
|
@@ -37693,7 +37914,9 @@ html[data-platform='darwin'] .dshl.page .dshl-top :is(button, input, label, a, .
|
|
|
37693
37914
|
.dshl-pop.right { left: auto; right: 0; }
|
|
37694
37915
|
.dshl-pop.up { top: auto; bottom: calc(100% + 6px); }
|
|
37695
37916
|
.dshl .srow { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 46px; font-size: 13.5px; }
|
|
37696
|
-
.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%; }
|
|
37697
37920
|
.dshl-pop.settings .srow + .srow { border-top: 1px solid var(--line-soft); }
|
|
37698
37921
|
.dshl-pop.settings .srow > .dshl-select, .dshl-pop.settings .srow > .dshl-seg { width: 168px; }
|
|
37699
37922
|
.dshl-pop.settings .srow > .dshl-seg button { flex: 1; padding: 0 6px; }
|
package/lib/index.mjs
CHANGED
|
@@ -1157,6 +1157,94 @@ function findOriginal(dir) {
|
|
|
1157
1157
|
const f = readdirSync(dir).find((n) => /^original\.(mp4|mkv|webm|m4a|mp3)$/.test(n));
|
|
1158
1158
|
return f ? join(dir, f) : void 0;
|
|
1159
1159
|
}
|
|
1160
|
+
// src/wrap.ts
|
|
1161
|
+
/**
|
|
1162
|
+
* Line breaking for subtitles drawn on a canvas (burned into clips and
|
|
1163
|
+
* restreams, screenshots): break where the text allows, never start a line
|
|
1164
|
+
* with closing punctuation, and spread the text evenly over the lines it
|
|
1165
|
+
* needs — so a long line wraps as two halves instead of a full line plus one
|
|
1166
|
+
* orphaned character.
|
|
1167
|
+
*/
|
|
1168
|
+
/** Characters that may not begin a line (they stay with the text before them). */
|
|
1169
|
+
const NO_START = new Set(Array.from(",。、;:!?)》」』】〕〉”’…—,.;:!?)]}%"));
|
|
1170
|
+
/** Where a sentence pauses: breaking right after these reads best. */
|
|
1171
|
+
const PAUSE = ",。、;:!?,;:!? ";
|
|
1172
|
+
/** Greedy breaking at `width`: Latin text at spaces, CJK between any two characters. */
|
|
1173
|
+
function greedy(chars, width, measure) {
|
|
1174
|
+
const lines = [];
|
|
1175
|
+
let cur = "";
|
|
1176
|
+
for (const ch of chars) {
|
|
1177
|
+
const next = cur + ch;
|
|
1178
|
+
if (cur && measure(next) > width && !NO_START.has(ch)) {
|
|
1179
|
+
const sp = cur.lastIndexOf(" ");
|
|
1180
|
+
const pause = Math.max(...Array.from(PAUSE).map((p) => cur.lastIndexOf(p)));
|
|
1181
|
+
if (/[A-Za-z0-9]/.test(ch) && /[A-Za-z0-9]$/.test(cur) && sp > cur.length * .3) {
|
|
1182
|
+
lines.push(cur.slice(0, sp));
|
|
1183
|
+
cur = cur.slice(sp + 1) + ch;
|
|
1184
|
+
} else if (pause > cur.length * .6 && pause < cur.length - 1) {
|
|
1185
|
+
lines.push(cur.slice(0, pause + 1).trimEnd());
|
|
1186
|
+
cur = cur.slice(pause + 1).trimStart() + ch;
|
|
1187
|
+
} else {
|
|
1188
|
+
lines.push(cur.trimEnd());
|
|
1189
|
+
cur = ch.trimStart();
|
|
1190
|
+
}
|
|
1191
|
+
} else cur = next;
|
|
1192
|
+
}
|
|
1193
|
+
if (cur.trim()) lines.push(cur.trimEnd());
|
|
1194
|
+
return lines;
|
|
1195
|
+
}
|
|
1196
|
+
/** Wrap `text` to at most `maxWidth`, using as few lines as possible, each about the same length. */
|
|
1197
|
+
function wrapBalanced(text, maxWidth, measure) {
|
|
1198
|
+
const chars = Array.from(text.trim());
|
|
1199
|
+
const first = greedy(chars, maxWidth, measure);
|
|
1200
|
+
if (first.length < 2) return first;
|
|
1201
|
+
let lo = measure(text) / first.length * .9, hi = maxWidth, best = first;
|
|
1202
|
+
for (let i = 0; i < 14 && hi - lo > 1; i++) {
|
|
1203
|
+
const mid = (lo + hi) / 2;
|
|
1204
|
+
const lines = greedy(chars, mid, measure);
|
|
1205
|
+
if (lines.length <= first.length) {
|
|
1206
|
+
best = lines;
|
|
1207
|
+
hi = mid;
|
|
1208
|
+
} else lo = mid;
|
|
1209
|
+
}
|
|
1210
|
+
return atPauses(best, maxWidth, measure);
|
|
1211
|
+
}
|
|
1212
|
+
/** Move each break to a nearby pause (within 4 characters) when both lines still fit. */
|
|
1213
|
+
function atPauses(lines, maxWidth, measure) {
|
|
1214
|
+
const out = lines.map((l) => Array.from(l));
|
|
1215
|
+
for (let k = 0; k + 1 < out.length; k++) {
|
|
1216
|
+
const a = out[k], b = out[k + 1];
|
|
1217
|
+
if (PAUSE.includes(a.at(-1) ?? "")) continue;
|
|
1218
|
+
const sep = /[\x21-\x7e]$/.test(a.join("")) && /^[\x21-\x7e]/.test(b.join("")) ? [" "] : [];
|
|
1219
|
+
let pick;
|
|
1220
|
+
for (let d = 1; d <= 4 && pick === void 0; d++) for (const shift of [d, -d]) {
|
|
1221
|
+
const cut = a.length + shift;
|
|
1222
|
+
if (cut <= 0) continue;
|
|
1223
|
+
const joined = [
|
|
1224
|
+
...a,
|
|
1225
|
+
...sep,
|
|
1226
|
+
...b
|
|
1227
|
+
];
|
|
1228
|
+
if (cut >= joined.length) continue;
|
|
1229
|
+
if (!PAUSE.includes(joined[cut - 1])) continue;
|
|
1230
|
+
const na = joined.slice(0, cut).join("").trimEnd(), nb = joined.slice(cut).join("").trimStart();
|
|
1231
|
+
if (nb && measure(na) <= maxWidth && measure(nb) <= maxWidth) {
|
|
1232
|
+
pick = shift;
|
|
1233
|
+
break;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
if (pick !== void 0) {
|
|
1237
|
+
const joined = [
|
|
1238
|
+
...a,
|
|
1239
|
+
...sep,
|
|
1240
|
+
...b
|
|
1241
|
+
], cut = a.length + pick;
|
|
1242
|
+
out[k] = Array.from(joined.slice(0, cut).join("").trimEnd());
|
|
1243
|
+
out[k + 1] = Array.from(joined.slice(cut).join("").trimStart());
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
return out.map((l) => l.join(""));
|
|
1247
|
+
}
|
|
1160
1248
|
// src/restream.ts
|
|
1161
1249
|
/** Platforms people push to; only a label on our side — the address and key come from the platform's own streaming tool. */
|
|
1162
1250
|
const PLATFORMS = [
|
|
@@ -1247,22 +1335,7 @@ async function drawSubtitles(rows, style, w, h) {
|
|
|
1247
1335
|
const blocks = rows.map((r) => {
|
|
1248
1336
|
const px = Math.round(r.size / 100 * w);
|
|
1249
1337
|
g.font = `${r.main ? 600 : 500} ${px}px ${FONT}`;
|
|
1250
|
-
const lines =
|
|
1251
|
-
let cur = "";
|
|
1252
|
-
for (const ch of Array.from(r.text)) {
|
|
1253
|
-
const next = cur + ch;
|
|
1254
|
-
if (cur && g.measureText(next).width > maxW - px * .9) {
|
|
1255
|
-
const sp = cur.lastIndexOf(" ");
|
|
1256
|
-
if (sp > cur.length * .5 && /[A-Za-z]/.test(ch)) {
|
|
1257
|
-
lines.push(cur.slice(0, sp));
|
|
1258
|
-
cur = cur.slice(sp + 1) + ch;
|
|
1259
|
-
} else {
|
|
1260
|
-
lines.push(cur);
|
|
1261
|
-
cur = ch.trimStart();
|
|
1262
|
-
}
|
|
1263
|
-
} else cur = next;
|
|
1264
|
-
}
|
|
1265
|
-
if (cur.trim()) lines.push(cur);
|
|
1338
|
+
const lines = wrapBalanced(r.text, maxW - px * .9, (t) => g.measureText(t).width);
|
|
1266
1339
|
return {
|
|
1267
1340
|
px,
|
|
1268
1341
|
main: r.main,
|
|
@@ -2906,7 +2979,7 @@ var LiveJob = class LiveJob extends EventEmitter {
|
|
|
2906
2979
|
// src/downloads.ts
|
|
2907
2980
|
const run = promisify(execFile);
|
|
2908
2981
|
/** ISO 639-2 codes for MP4 subtitle tracks. */
|
|
2909
|
-
const ISO3 = {
|
|
2982
|
+
const ISO3$1 = {
|
|
2910
2983
|
zh: "chi",
|
|
2911
2984
|
"zh-Hant": "chi",
|
|
2912
2985
|
en: "eng",
|
|
@@ -3052,7 +3125,7 @@ var Downloads = class {
|
|
|
3052
3125
|
const file = join(tmp, `${i}.srt`);
|
|
3053
3126
|
writeFileSync(file, exportCues(cues, t.langs, "srt", title));
|
|
3054
3127
|
inputs.push("-i", file);
|
|
3055
|
-
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");
|
|
3056
3129
|
});
|
|
3057
3130
|
try {
|
|
3058
3131
|
await run("ffmpeg", [
|
|
@@ -3227,7 +3300,12 @@ const KEY_REFS = {
|
|
|
3227
3300
|
"GLM_API_KEY",
|
|
3228
3301
|
"ZHIPUAI_API_KEY",
|
|
3229
3302
|
"ZAI_API_KEY"
|
|
3230
|
-
]
|
|
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"]
|
|
3231
3309
|
};
|
|
3232
3310
|
const KEY_NAMES = Object.keys(KEY_REFS);
|
|
3233
3311
|
const mask = (k) => k.length > 10 ? `${k.slice(0, 3)}…${k.slice(-4)}` : "••••";
|
|
@@ -3998,39 +4076,107 @@ var OpenAIProvider = class {
|
|
|
3998
4076
|
const SERVICES = [
|
|
3999
4077
|
{
|
|
4000
4078
|
id: "openai",
|
|
4079
|
+
key: "openai",
|
|
4001
4080
|
label: "OpenAI",
|
|
4002
4081
|
keyUrl: "https://platform.openai.com/api-keys",
|
|
4003
4082
|
model: "gpt-live-transcribe",
|
|
4083
|
+
region: "intl",
|
|
4004
4084
|
kind: "realtime"
|
|
4005
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
|
+
},
|
|
4006
4095
|
{
|
|
4007
4096
|
id: "gemini",
|
|
4097
|
+
key: "gemini",
|
|
4008
4098
|
label: "Google Gemini",
|
|
4009
4099
|
keyUrl: "https://aistudio.google.com/apikey",
|
|
4010
4100
|
model: "auto",
|
|
4101
|
+
modelLabel: "gemini-3.5-transcribe",
|
|
4102
|
+
region: "intl",
|
|
4011
4103
|
kind: "gemini"
|
|
4012
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
|
+
},
|
|
4013
4133
|
{
|
|
4014
4134
|
id: "groq",
|
|
4135
|
+
key: "groq",
|
|
4015
4136
|
label: "Groq",
|
|
4016
4137
|
keyUrl: "https://console.groq.com/keys",
|
|
4017
4138
|
model: "whisper-large-v3-turbo",
|
|
4139
|
+
region: "intl",
|
|
4018
4140
|
kind: "whisper-api",
|
|
4019
4141
|
base: "https://api.groq.com/openai/v1"
|
|
4020
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
|
+
},
|
|
4021
4163
|
{
|
|
4022
4164
|
id: "siliconflow",
|
|
4165
|
+
key: "siliconflow",
|
|
4023
4166
|
label: "SiliconFlow",
|
|
4024
4167
|
keyUrl: "https://cloud.siliconflow.cn/account/ak",
|
|
4025
4168
|
model: "FunAudioLLM/SenseVoiceSmall",
|
|
4169
|
+
region: "cn",
|
|
4026
4170
|
kind: "whisper-api",
|
|
4027
4171
|
base: "https://api.siliconflow.cn/v1"
|
|
4028
4172
|
},
|
|
4029
4173
|
{
|
|
4030
4174
|
id: "glm",
|
|
4175
|
+
key: "glm",
|
|
4031
4176
|
label: "Zhipu GLM",
|
|
4032
4177
|
keyUrl: "https://bigmodel.cn/usercenter/proj-mgmt/apikeys",
|
|
4033
4178
|
model: "glm-asr-2512",
|
|
4179
|
+
region: "cn",
|
|
4034
4180
|
kind: "whisper-api",
|
|
4035
4181
|
base: "https://open.bigmodel.cn/api/paas/v4"
|
|
4036
4182
|
}
|
|
@@ -4050,6 +4196,8 @@ function speechServiceOf(id, baseURL) {
|
|
|
4050
4196
|
if (/api\.groq\.com|^ groq$/.test(k)) return "groq";
|
|
4051
4197
|
if (/siliconflow/.test(k)) return "siliconflow";
|
|
4052
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";
|
|
4053
4201
|
}
|
|
4054
4202
|
const TIMEOUT = 3e4;
|
|
4055
4203
|
async function fail(r) {
|
|
@@ -4103,6 +4251,172 @@ function whisperApiEngine(svc, model, key) {
|
|
|
4103
4251
|
}
|
|
4104
4252
|
};
|
|
4105
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
|
+
}
|
|
4106
4420
|
/** Newest Gemini Flash model the key can use (when the setting says "auto"). */
|
|
4107
4421
|
async function pickGeminiModel(apiKey) {
|
|
4108
4422
|
const r = await fetch("https://generativelanguage.googleapis.com/v1beta/models?pageSize=200", {
|
|
@@ -4110,7 +4424,10 @@ async function pickGeminiModel(apiKey) {
|
|
|
4110
4424
|
signal: AbortSignal.timeout(TIMEOUT)
|
|
4111
4425
|
});
|
|
4112
4426
|
if (!r.ok) await fail(r);
|
|
4113
|
-
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));
|
|
4114
4431
|
const version = (n) => Number(/^gemini-(\d+(?:\.\d+)?)/.exec(n)[1]);
|
|
4115
4432
|
flash.sort((a, b) => version(b) - version(a) || (a.includes("preview") ? 1 : 0) - (b.includes("preview") ? 1 : 0));
|
|
4116
4433
|
return flash[0] ?? "gemini-2.5-flash";
|
|
@@ -4137,6 +4454,7 @@ function geminiEngine(model, key, onUsage, log) {
|
|
|
4137
4454
|
async transcribe(wav, lang, prompt) {
|
|
4138
4455
|
const apiKey = key();
|
|
4139
4456
|
if (!apiKey) throw new Error("no Google Gemini key");
|
|
4457
|
+
if (/-transcribe$/.test(chosen)) return transcribeModel(apiKey, chosen, wav, lang, prompt ?? "", onUsage);
|
|
4140
4458
|
const instruction = [
|
|
4141
4459
|
"Transcribe the speech in this audio exactly as spoken, in the language spoken (do not translate).",
|
|
4142
4460
|
lang !== "auto" ? `The speaker uses language code "${lang}".` : "",
|
|
@@ -4198,20 +4516,95 @@ function geminiEngine(model, key, onUsage, log) {
|
|
|
4198
4516
|
}
|
|
4199
4517
|
};
|
|
4200
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
|
+
}
|
|
4201
4578
|
/** Check a key before saving it: a cheap authenticated read on the service. */
|
|
4202
4579
|
async function verifyKey(svc, key) {
|
|
4203
4580
|
try {
|
|
4204
|
-
const
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
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,
|
|
4208
4600
|
signal: AbortSignal.timeout(1e4)
|
|
4209
4601
|
});
|
|
4210
4602
|
if (r.ok) return { ok: true };
|
|
4211
4603
|
const body = await r.json().catch(() => ({}));
|
|
4604
|
+
const detail = typeof body.detail === "object" ? body.detail?.message : body.detail;
|
|
4212
4605
|
return {
|
|
4213
4606
|
ok: false,
|
|
4214
|
-
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}`
|
|
4215
4608
|
};
|
|
4216
4609
|
} catch (err) {
|
|
4217
4610
|
return {
|
|
@@ -4220,6 +4613,185 @@ async function verifyKey(svc, key) {
|
|
|
4220
4613
|
};
|
|
4221
4614
|
}
|
|
4222
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
|
+
};
|
|
4223
4795
|
// src/tools.ts
|
|
4224
4796
|
const CLOCK = (s) => {
|
|
4225
4797
|
const h = Math.floor(s / 3600), m = Math.floor(s / 60) % 60, ss = Math.floor(s % 60);
|
|
@@ -4758,7 +5330,7 @@ function apply(ctx, config) {
|
|
|
4758
5330
|
].includes(x.name)).every((x) => x.ok);
|
|
4759
5331
|
});
|
|
4760
5332
|
/** Recognition to use when nothing was chosen: this computer if it can, else the first online service with a key. */
|
|
4761
|
-
const defaultEngine = () => localReady ? "local" : SERVICES.find((s) => keys.get(s.
|
|
5333
|
+
const defaultEngine = () => localReady ? "local" : SERVICES.find((s) => keys.get(s.key))?.id ?? "local";
|
|
4762
5334
|
const settingsFile = join(config.dataDir, "settings.json");
|
|
4763
5335
|
const prefs = () => {
|
|
4764
5336
|
try {
|
|
@@ -4792,7 +5364,7 @@ function apply(ctx, config) {
|
|
|
4792
5364
|
function makeProvider(opts, glossary, usage, job) {
|
|
4793
5365
|
const svc = service(opts.provider);
|
|
4794
5366
|
if (svc) {
|
|
4795
|
-
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`);
|
|
4796
5368
|
if (svc.kind === "realtime") return new OpenAIProvider({
|
|
4797
5369
|
apiKey: keys.get("openai"),
|
|
4798
5370
|
model: config.openaiModel,
|
|
@@ -4804,11 +5376,24 @@ function apply(ctx, config) {
|
|
|
4804
5376
|
log,
|
|
4805
5377
|
onUsage: (u) => usage.add("openai", config.openaiModel, u)
|
|
4806
5378
|
});
|
|
4807
|
-
|
|
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;
|
|
4808
5393
|
return new LocalProvider({
|
|
4809
|
-
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({
|
|
4810
5395
|
...svc,
|
|
4811
|
-
base
|
|
5396
|
+
base
|
|
4812
5397
|
}, svc.model, key),
|
|
4813
5398
|
lang: opts.srcLang === "auto" ? "auto" : opts.srcLang,
|
|
4814
5399
|
minSec: 3,
|
|
@@ -4979,14 +5564,19 @@ function apply(ctx, config) {
|
|
|
4979
5564
|
dataDir: config.dataDir,
|
|
4980
5565
|
providers: [{
|
|
4981
5566
|
id: "local",
|
|
5567
|
+
vendor: "local",
|
|
5568
|
+
model: "whisper-large-v3-turbo",
|
|
4982
5569
|
online: false,
|
|
4983
5570
|
ready: localReady
|
|
4984
5571
|
}, ...SERVICES.map((s) => ({
|
|
4985
5572
|
id: s.id,
|
|
5573
|
+
vendor: s.key,
|
|
5574
|
+
model: s.modelLabel ?? s.model,
|
|
4986
5575
|
label: s.label,
|
|
4987
5576
|
online: true,
|
|
4988
|
-
|
|
4989
|
-
|
|
5577
|
+
region: s.region,
|
|
5578
|
+
ready: keys.info(s.key).set,
|
|
5579
|
+
key: keys.info(s.key),
|
|
4990
5580
|
keyUrl: s.keyUrl
|
|
4991
5581
|
}))],
|
|
4992
5582
|
defaultEngine: defaultEngine(),
|
|
@@ -5003,10 +5593,10 @@ function apply(ctx, config) {
|
|
|
5003
5593
|
const check = await verifyKey(svc, key);
|
|
5004
5594
|
if (!check.ok) return send(res, 400, { error: check.message ?? "key rejected" });
|
|
5005
5595
|
}
|
|
5006
|
-
await keys.save(svc.
|
|
5007
|
-
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) });
|
|
5008
5598
|
}
|
|
5009
|
-
const key = keys.get(svc.
|
|
5599
|
+
const key = keys.get(svc.key);
|
|
5010
5600
|
if (!key) return send(res, 400, { error: "no key" });
|
|
5011
5601
|
const check = await verifyKey(svc, key);
|
|
5012
5602
|
return send(res, check.ok ? 200 : 400, check.ok ? { ok: true } : { error: check.message });
|
|
@@ -5123,7 +5713,7 @@ function apply(ctx, config) {
|
|
|
5123
5713
|
const svc = opts.provider ? service(opts.provider) : void 0;
|
|
5124
5714
|
if (svc) {
|
|
5125
5715
|
await keys.refresh();
|
|
5126
|
-
if (!keys.get(svc.
|
|
5716
|
+
if (!keys.get(svc.key)) return send(res, 400, { error: `no ${svc.label} key` });
|
|
5127
5717
|
}
|
|
5128
5718
|
savePrefs({ last: {
|
|
5129
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",
|