nixamp 0.28.43 → 0.28.45
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/README.md +8 -3
- package/dist/accounts.js +9 -1
- package/dist/live-interpreter.d.ts +1 -72
- package/dist/live-interpreter.js +1 -269
- package/dist/live-recovery.d.ts +1 -6
- package/dist/live-recovery.js +1 -11
- package/dist/live-voice.d.ts +6 -76
- package/dist/live-voice.js +6 -352
- package/dist/owner.js +4 -0
- package/dist/paywall.js +1 -1
- package/dist/pcm-wav.d.ts +1 -2
- package/dist/pcm-wav.js +1 -27
- package/dist/server.d.ts +16 -0
- package/dist/server.js +77 -6
- package/dist/shared-translation.d.ts +6 -53
- package/dist/shared-translation.js +7 -336
- package/dist/speaker-turns.d.ts +1 -32
- package/dist/speaker-turns.js +1 -30
- package/dist/speech.d.ts +3 -5
- package/dist/speech.js +4 -7
- package/dist/tickets.js +1 -1
- package/dist/translation-passes.d.ts +5 -96
- package/dist/translation-passes.js +5 -236
- package/dist/upgrade-allowance.d.ts +1 -23
- package/dist/upgrade-allowance.js +1 -73
- package/dist/voice-profile.d.ts +1 -7
- package/dist/voice-profile.js +1 -53
- package/dist/voice-provider.d.ts +1 -9
- package/dist/voice-provider.js +1 -27
- package/package.json +4 -2
- package/src/accounts.ts +9 -1
- package/src/live-interpreter.ts +1 -213
- package/src/live-recovery.ts +1 -10
- package/src/live-voice.ts +6 -304
- package/src/owner.ts +3 -0
- package/src/paywall.ts +1 -1
- package/src/pcm-wav.ts +1 -26
- package/src/server.ts +57 -6
- package/src/shared-translation.ts +7 -222
- package/src/speaker-turns.ts +1 -34
- package/src/speech.ts +4 -5
- package/src/tickets.ts +1 -1
- package/src/translation-passes.ts +6 -239
- package/src/types/auth-system.d.ts +12 -3
- package/src/upgrade-allowance.ts +1 -72
- package/src/voice-profile.ts +1 -46
- package/src/voice-provider.ts +1 -22
- package/web/dist/.well-known/openwebring.json +6 -0
- package/web/dist/assets/{ar-CR2NEW77.js → ar-8fDonUoB.js} +1 -1
- package/web/dist/assets/{cs-ivXHTYBK.js → cs-DCClolTy.js} +1 -1
- package/web/dist/assets/{da-DQEGAEdk.js → da-C3RPhXUd.js} +1 -1
- package/web/dist/assets/{de-B5VaSv3O.js → de-CYBoEomk.js} +1 -1
- package/web/dist/assets/{es-DUTtfGnk.js → es-DS3ls4xT.js} +1 -1
- package/web/dist/assets/{fi-CzQZtpO5.js → fi-DbCZpO89.js} +1 -1
- package/web/dist/assets/{fr-C2g4DUBr.js → fr-D0z3UpdL.js} +1 -1
- package/web/dist/assets/{hi-DKB7ZsZ8.js → hi-BTU3sRlQ.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-Bc75Z5iH.js → hls-3VKVEQE3-CQXTVHn_.js} +1 -1
- package/web/dist/assets/{hu--oH7WvAR.js → hu-B37Q8ldT.js} +1 -1
- package/web/dist/assets/{id-CjlpBIJc.js → id-4pdXNa4G.js} +1 -1
- package/web/dist/assets/index-BDBbN5Qy.js +22 -0
- package/web/dist/assets/{it-Dbctup0F.js → it-CTe69-UY.js} +1 -1
- package/web/dist/assets/{mpegts-D9VECdKk.js → mpegts-BBmkDppm.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CSb7Fz4U.js → mpegts-LO6RVLD6-CUVdjYFi.js} +1 -1
- package/web/dist/assets/{nl-BYw8ZbV_.js → nl-CxrmHrMn.js} +1 -1
- package/web/dist/assets/{ru-DFGYCrj-.js → ru-zjVCfIKq.js} +1 -1
- package/web/dist/assets/{sv-DbO1Sc1v.js → sv-CEoiT2Lr.js} +1 -1
- package/web/dist/assets/{uk-BTXHwsjV.js → uk-ZC6JzQvD.js} +1 -1
- package/web/dist/assets/{vi-DJ-vIPLS.js → vi-D9anF-cl.js} +1 -1
- package/web/dist/assets/{zh-D3Gju_Y2.js → zh-BeLYdC0I.js} +1 -1
- package/web/dist/index.html +6 -5
- package/web/dist/sw.js +23 -22
- package/web/dist/assets/index-CF04ablL.js +0 -3
package/src/server.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { Readable } from "node:stream";
|
|
|
20
20
|
import { pipeline as pipeStream } from "node:stream/promises";
|
|
21
21
|
import { relative } from "node:path";
|
|
22
22
|
import { SharedTranslations, type SharedEvent } from "./shared-translation.ts";
|
|
23
|
-
import { TranslationPasses } from "./translation-passes.ts";
|
|
23
|
+
import { TranslationPasses, coinpayKeyFrom, readCoinpayWebhook, verifyCoinpaySignature } from "./translation-passes.ts";
|
|
24
24
|
import { LiveVoice, LIVE_VOICE_LANGUAGES, LIVE_VOICE_MODEL, type VoiceRequest } from "./live-voice.ts";
|
|
25
25
|
import { Connections, type Kind } from "./connections.ts";
|
|
26
26
|
import {
|
|
@@ -255,6 +255,24 @@ export interface ServeOptions {
|
|
|
255
255
|
rtmp: string[];
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Read DATABASE_URL the way libpq does.
|
|
260
|
+
*
|
|
261
|
+
* node-postgres reads `sslmode=require` as verify-full, so a cluster that
|
|
262
|
+
* signs its own certificate (dev2's shared Postgres) refuses every query with
|
|
263
|
+
* "self signed certificate" while the process itself stays up and healthy:
|
|
264
|
+
* pages serve, accounts and follows and events all fail. libpq, and every
|
|
265
|
+
* other client, read `require` as "encrypt, do not verify", and pg honours
|
|
266
|
+
* that reading when the URL carries `uselibpqcompat=true`. Railway's internal
|
|
267
|
+
* URL had no sslmode at all, which is why this never came up there. Only
|
|
268
|
+
* `require` and `prefer` are rewritten: `no-verify` is pg's own spelling and
|
|
269
|
+
* has no libpq meaning, and `verify-*` asks for exactly what pg does anyway.
|
|
270
|
+
*/
|
|
271
|
+
export function libpqDatabaseUrl(raw: string | undefined): string | undefined {
|
|
272
|
+
if (!raw || !/[?&]sslmode=(require|prefer)\b/.test(raw) || /[?&]uselibpqcompat=/.test(raw)) return raw;
|
|
273
|
+
return `${raw}${raw.includes("?") ? "&" : "?"}uselibpqcompat=true`;
|
|
274
|
+
}
|
|
275
|
+
|
|
258
276
|
/**
|
|
259
277
|
* Flags are parsed by hand: three of them do not justify a dependency, and the
|
|
260
278
|
* failure mode of a wrong `--port` should be a message rather than NaN.
|
|
@@ -1796,6 +1814,8 @@ export interface HandlerOptions {
|
|
|
1796
1814
|
speech?: Speech;
|
|
1797
1815
|
liveVoice?: LiveVoice;
|
|
1798
1816
|
translationPasses?: TranslationPasses;
|
|
1817
|
+
/** Signing secret of nixamp's CoinPay business, for /api/v1/webhooks/coinpay. */
|
|
1818
|
+
coinpayWebhookSecret?: string;
|
|
1799
1819
|
sharedTranslations?: SharedTranslations;
|
|
1800
1820
|
/** Translation: texts in another language, by a model here. The same optional library. */
|
|
1801
1821
|
translator?: Translator;
|
|
@@ -2417,6 +2437,35 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2417
2437
|
return;
|
|
2418
2438
|
}
|
|
2419
2439
|
|
|
2440
|
+
// --- CoinPay ----------------------------------------------------------
|
|
2441
|
+
//
|
|
2442
|
+
// Ahead of the share-key check because the caller is CoinPay, which signs
|
|
2443
|
+
// the body instead. The body is only a nudge: settle() asks CoinPay for the
|
|
2444
|
+
// payment itself before anything is credited.
|
|
2445
|
+
if (path === "/api/v1/webhooks/coinpay") {
|
|
2446
|
+
if (request.method !== "POST") { json(response, 405, { error: "POST only" }); return; }
|
|
2447
|
+
const secret = options.coinpayWebhookSecret ?? "";
|
|
2448
|
+
if (!secret) { json(response, 503, { error: "CoinPay webhooks are not configured here." }); return; }
|
|
2449
|
+
let raw: string;
|
|
2450
|
+
try { raw = await readBody(request); } catch { json(response, 413, { error: "body too large" }); return; }
|
|
2451
|
+
const signature = request.headers["x-coinpay-signature"];
|
|
2452
|
+
if (!verifyCoinpaySignature(raw, Array.isArray(signature) ? signature[0] : signature, secret)) {
|
|
2453
|
+
json(response, 401, { error: "invalid signature" }); return;
|
|
2454
|
+
}
|
|
2455
|
+
let event: ReturnType<typeof readCoinpayWebhook>;
|
|
2456
|
+
try { event = readCoinpayWebhook(JSON.parse(raw)); } catch { json(response, 400, { error: "invalid JSON" }); return; }
|
|
2457
|
+
if (!options.translationPasses) { json(response, 200, { settled: false, reason: "billing is off here" }); return; }
|
|
2458
|
+
try {
|
|
2459
|
+
json(response, 200, await options.translationPasses.settle(event.paymentId));
|
|
2460
|
+
} catch (error) {
|
|
2461
|
+
// A 5xx makes CoinPay deliver it again, which is what an unreachable
|
|
2462
|
+
// CoinPay or database needs.
|
|
2463
|
+
console.error(`nixamp: CoinPay webhook for ${event.paymentId} could not settle: ${error instanceof Error ? error.message : String(error)}`);
|
|
2464
|
+
json(response, 500, { error: "could not settle; retry" });
|
|
2465
|
+
}
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2420
2469
|
// --- the party line ---------------------------------------------------
|
|
2421
2470
|
//
|
|
2422
2471
|
// Ahead of the share-key check because the caller is a telephone. Telnyx
|
|
@@ -6423,9 +6472,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6423
6472
|
// pocket can administer it from anywhere by signing in as the same person.
|
|
6424
6473
|
// Following outlives every stream, so unlike the rest of this it wants a
|
|
6425
6474
|
// database. Only where there is one: a nixamp on a laptop has no followers.
|
|
6475
|
+
const databaseUrl = libpqDatabaseUrl(process.env["DATABASE_URL"]);
|
|
6426
6476
|
const pool =
|
|
6427
|
-
options.directory &&
|
|
6428
|
-
? new pg.Pool({ connectionString:
|
|
6477
|
+
options.directory && databaseUrl
|
|
6478
|
+
? new pg.Pool({ connectionString: databaseUrl })
|
|
6429
6479
|
: undefined;
|
|
6430
6480
|
const follows = pool ? new Follows(pool) : undefined;
|
|
6431
6481
|
const favorites = pool ? new Favorites(pool) : undefined;
|
|
@@ -6452,9 +6502,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6452
6502
|
// tokens out of the same table -- an OAuth token IS a nixamp token with a
|
|
6453
6503
|
// client's name on it, which is why every existing route understands one.
|
|
6454
6504
|
const accounts =
|
|
6455
|
-
options.directory &&
|
|
6505
|
+
options.directory && databaseUrl
|
|
6456
6506
|
? new Accounts({
|
|
6457
|
-
connectionString:
|
|
6507
|
+
connectionString: databaseUrl,
|
|
6458
6508
|
secret: process.env["NIXAMP_JWT_SECRET"] ?? "",
|
|
6459
6509
|
})
|
|
6460
6510
|
: undefined;
|
|
@@ -6465,7 +6515,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
6465
6515
|
// Account servers meter paid providers by default, including when checkout
|
|
6466
6516
|
// is temporarily unconfigured. Self-hosters can explicitly sponsor usage.
|
|
6467
6517
|
const translationPasses = accounts && pool && process.env["NIXAMP_TRANSLATION_BILLING"] !== "off"
|
|
6468
|
-
? new TranslationPasses({ db: pool, key:
|
|
6518
|
+
? new TranslationPasses({ db: pool, key: coinpayKeyFrom(), site: nixampSite }) : undefined;
|
|
6469
6519
|
if (translationPasses) void translationPasses.access().catch(() => console.error("nixamp: translation checkout could not warm; it will retry on demand"));
|
|
6470
6520
|
const liveVoice = accounts && process.env["NIXAMP_DUBBING"] !== "off" ? new LiveVoice({
|
|
6471
6521
|
...(pool ? { db: pool } : {}),
|
|
@@ -7015,6 +7065,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
7015
7065
|
...(speech ? { speech } : {}),
|
|
7016
7066
|
...(liveVoice ? { liveVoice } : {}),
|
|
7017
7067
|
...(translationPasses ? { translationPasses } : {}),
|
|
7068
|
+
...(process.env["COINPAY_WEBHOOK_SECRET"] ? { coinpayWebhookSecret: process.env["COINPAY_WEBHOOK_SECRET"] } : {}),
|
|
7018
7069
|
...(sharedTranslations ? { sharedTranslations } : {}),
|
|
7019
7070
|
...(translator ? { translator } : {}),
|
|
7020
7071
|
...(transcripts ? { transcripts } : {}),
|
|
@@ -1,26 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
-
import { lookup } from "node:dns/promises";
|
|
6
|
-
import { BlockList, isIP } from "node:net";
|
|
7
|
-
import http from "node:http";
|
|
8
|
-
import https from "node:https";
|
|
9
|
-
import { spawn } from "node:child_process";
|
|
10
|
-
import { Interpreter, type Caption } from "./live-interpreter.ts";
|
|
11
|
-
import { LiveVoice, LIVE_VOICE_LANGUAGES } from "./live-voice.ts";
|
|
12
|
-
import { SpeechError } from "./speech.ts";
|
|
13
|
-
import type { Translator } from "./translate.ts";
|
|
14
|
-
import type { TranslationMeter, TranslationUsage } from "./translation-passes.ts";
|
|
1
|
+
/** Shared live translation, from @profullstack/live-translate, limited to nixamp channels. */
|
|
2
|
+
import { SharedTranslations as Base, liveInput as baseLiveInput, SpeechError, type OpenLiveInput } from "@profullstack/live-translate";
|
|
3
|
+
export { publicStreamAddress, type LiveInput, type OpenLiveInput, type SharedEvent } from "@profullstack/live-translate";
|
|
15
4
|
|
|
16
|
-
|
|
17
|
-
for (const [ip, bits] of [["0.0.0.0",8],["10.0.0.0",8],["100.64.0.0",10],["127.0.0.0",8],["169.254.0.0",16],["172.16.0.0",12],["192.0.0.0",24],["192.0.2.0",24],["192.168.0.0",16],["198.18.0.0",15],["198.51.100.0",24],["203.0.113.0",24],["224.0.0.0",4],["240.0.0.0",4]] as const) blocked.addSubnet(ip, bits, "ipv4");
|
|
18
|
-
const v6global = new BlockList(); v6global.addSubnet("2000::", 3, "ipv6");
|
|
19
|
-
blocked.addSubnet("2001:db8::", 32, "ipv6"); blocked.addSubnet("2002::", 16, "ipv6"); blocked.addSubnet("2001::", 32, "ipv6");
|
|
20
|
-
export function publicStreamAddress(address: string): boolean {
|
|
21
|
-
const family = isIP(address);
|
|
22
|
-
return family === 4 ? !blocked.check(address, "ipv4") : family === 6 && v6global.check(address, "ipv6") && !blocked.check(address, "ipv6");
|
|
23
|
-
}
|
|
5
|
+
/** Only a live nixamp channel (`/api/channels/<id>`, optionally `?k=`). */
|
|
24
6
|
export function sharedSource(raw: string): URL {
|
|
25
7
|
let url: URL;
|
|
26
8
|
try { url = new URL(raw); } catch { throw new SpeechError("Choose a live Nixamp channel.", 400); }
|
|
@@ -28,205 +10,8 @@ export function sharedSource(raw: string): URL {
|
|
|
28
10
|
if (url.toString().length > 2048) throw new SpeechError("Invalid live source.", 400);
|
|
29
11
|
url.searchParams.sort(); return url;
|
|
30
12
|
}
|
|
31
|
-
export
|
|
32
|
-
export type OpenLiveInput = (source: URL, pcm: (bytes: Buffer) => void, ended: () => void, signal: AbortSignal) => Promise<LiveInput>;
|
|
33
|
-
|
|
34
|
-
/** Resolve once and pin that public address; no redirects or ffmpeg URL fetches. */
|
|
35
|
-
export function liveInput(ffmpeg: string[] = ["ffmpeg"]): OpenLiveInput {
|
|
36
|
-
return async (source, pcm, ended, signal) => {
|
|
37
|
-
const hostname = source.hostname.replace(/^\[|\]$/g, "");
|
|
38
|
-
const addresses = await lookup(hostname, { all: true });
|
|
39
|
-
if (!addresses.length || addresses.some(one => !publicStreamAddress(one.address))) throw new SpeechError("This live source is not publicly reachable.", 400);
|
|
40
|
-
signal.throwIfAborted();
|
|
41
|
-
const selected = addresses[0]!;
|
|
42
|
-
const child = spawn(ffmpeg[0]!, [...ffmpeg.slice(1), "-v", "error", "-nostats", "-protocol_whitelist", "pipe", "-readrate", "1", "-analyzeduration", "500000", "-probesize", "262144", "-i", "pipe:0", "-vn", "-ac", "1", "-ar", "16000", "-f", "s16le", "pipe:1"], { stdio: ["pipe", "pipe", "ignore"] });
|
|
43
|
-
let stopped = false;
|
|
44
|
-
const request = (source.protocol === "https:" ? https : http).get(source, {
|
|
45
|
-
signal, headers: { "user-agent": "Nixamp shared translated audio", accept: "audio/*,video/*,application/octet-stream" },
|
|
46
|
-
lookup: (_name, options, callback) => { if (typeof options === "object" && options.all) callback(null, [selected]); else callback(null, selected.address, selected.family); },
|
|
47
|
-
});
|
|
48
|
-
const stop = (): void => { if (stopped) return; stopped = true; request.destroy(); child.stdin.destroy(); child.kill("SIGKILL"); signal.removeEventListener("abort", stop); };
|
|
49
|
-
const end = (): void => { if (!stopped) { stop(); ended(); } };
|
|
50
|
-
signal.addEventListener("abort", stop, { once: true });
|
|
51
|
-
child.stdout.on("data", pcm); child.on("error", end); child.on("close", end); child.stdin.on("error", end);
|
|
52
|
-
request.on("response", response => {
|
|
53
|
-
if (response.statusCode !== 200) { response.destroy(); end(); return; }
|
|
54
|
-
response.on("error", end); response.on("end", end); response.pipe(child.stdin);
|
|
55
|
-
});
|
|
56
|
-
request.on("error", end);
|
|
57
|
-
request.setTimeout(15_000, end);
|
|
58
|
-
return { stop };
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export type SharedEvent = { type: "status"; listeners: number } | { type: "line"; id: string; line: Caption } | { type: "audio"; id: string; data: string } | { type: "end"; id: string } | { type: "error"; error: string; retryable?: boolean };
|
|
63
|
-
type Member = { by: string; send: (event: SharedEvent) => void; close: () => void };
|
|
64
|
-
interface Feed { id: string; source: URL; language: string; members: Set<Member>; controller: AbortController; input?: LiveInput; interpreter: Interpreter; meter: TranslationMeter; reservations: Map<string, string[]>; queue: Caption[]; speaking: boolean; voiceFailures: number; previous: Buffer; pending: Buffer; until: number; heartbeat?: ReturnType<typeof setInterval>; }
|
|
65
|
-
|
|
66
|
-
export class SharedTranslations {
|
|
67
|
-
private readonly feeds = new Map<string, Feed>();
|
|
68
|
-
constructor(private readonly options: { voice: LiveVoice; translator: Pick<Translator, "translate">; billing?: TranslationMeter; open?: OpenLiveInput }) {}
|
|
69
|
-
get size(): number { return this.feeds.size; }
|
|
13
|
+
export function liveInput(ffmpeg: string[] = ["ffmpeg"]): OpenLiveInput { return baseLiveInput(ffmpeg, "Nixamp shared translated audio"); }
|
|
70
14
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const source = sharedSource(raw);
|
|
74
|
-
if (!LIVE_VOICE_LANGUAGES.has(language)) throw new SpeechError("Choose a supported audio language.", 400);
|
|
75
|
-
const id = `dub-${createHash("sha256").update(`${source}|${language}`).digest("hex").slice(0, 40)}`;
|
|
76
|
-
const capacity = (): void => {
|
|
77
|
-
const connections = [...this.feeds.values()].reduce((total, feed) => total + [...feed.members].filter(member => member.by === by).length, 0);
|
|
78
|
-
if (connections >= 2) throw new SpeechError("Translated audio is already open in two players for this account.", 429);
|
|
79
|
-
const feed = this.feeds.get(id);
|
|
80
|
-
if (!feed && this.feeds.size >= 4) throw new SpeechError("Live translation is busy. Try again shortly.", 429);
|
|
81
|
-
if (feed && feed.members.size >= 1000) throw new SpeechError("This translated stream is full. Try again shortly.", 429);
|
|
82
|
-
};
|
|
83
|
-
capacity();
|
|
84
|
-
if (this.options.billing?.begin) await this.options.billing.begin(by, id);
|
|
85
|
-
else await this.options.billing?.require(by, id);
|
|
86
|
-
capacity(); // Other joins can finish during the account/database check.
|
|
87
|
-
let feed = this.feeds.get(id);
|
|
88
|
-
if (!feed) {
|
|
89
|
-
feed = this.make(id, source, language); this.feeds.set(id, feed);
|
|
90
|
-
const current = feed;
|
|
91
|
-
current.heartbeat = setInterval(() => void this.checkMembers(current), 10_000); current.heartbeat.unref?.();
|
|
92
|
-
}
|
|
93
|
-
const member = { by, send, close }; feed.members.add(member);
|
|
94
|
-
this.broadcast(feed, { type: "status", listeners: feed.members.size });
|
|
95
|
-
if (feed.members.size === 1 && !feed.input) void this.start(feed);
|
|
96
|
-
const current = feed;
|
|
97
|
-
return () => this.leave(current, member);
|
|
98
|
-
}
|
|
99
|
-
private leave(feed: Feed, member: Member): void {
|
|
100
|
-
if (!feed.members.delete(member)) return;
|
|
101
|
-
if (!feed.members.size) {
|
|
102
|
-
if (feed.heartbeat) clearInterval(feed.heartbeat);
|
|
103
|
-
feed.controller.abort(); feed.input?.stop(); feed.interpreter.reset(); feed.queue = [];
|
|
104
|
-
this.feeds.delete(feed.id);
|
|
105
|
-
} else this.broadcast(feed, { type: "status", listeners: feed.members.size });
|
|
106
|
-
}
|
|
107
|
-
private async checkMembers(feed: Feed): Promise<void> {
|
|
108
|
-
const accounts = [...new Set([...feed.members].map(member => member.by))];
|
|
109
|
-
let funded = new Set(accounts), message = "This free session ended and audio credit is used up. Start a remaining free session or buy a pass.";
|
|
110
|
-
try {
|
|
111
|
-
if (this.options.billing?.eligible) funded = new Set(await this.options.billing.eligible(accounts, feed.id));
|
|
112
|
-
else if (this.options.billing) {
|
|
113
|
-
const results = await Promise.allSettled(accounts.map(by => this.options.billing!.require(by, feed.id)));
|
|
114
|
-
funded = new Set(accounts.filter((_by, index) => results[index]?.status === "fulfilled"));
|
|
115
|
-
}
|
|
116
|
-
} catch { funded.clear(); message = "Your translation access could not be checked. Try again shortly."; }
|
|
117
|
-
for (const member of [...feed.members]) if (accounts.includes(member.by) && !funded.has(member.by)) {
|
|
118
|
-
try { member.send({ type: "error", error: message }); } catch { /* disconnected */ }
|
|
119
|
-
finally { this.leave(feed, member); member.close(); }
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
private broadcast(feed: Feed, event: SharedEvent): void {
|
|
123
|
-
for (const member of [...feed.members]) {
|
|
124
|
-
try { member.send(event); } catch { this.leave(feed, member); member.close(); }
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
private stop(feed: Feed, error: string, retryable = false): void {
|
|
128
|
-
this.broadcast(feed, { type: "error", error, retryable });
|
|
129
|
-
for (const member of [...feed.members]) { this.leave(feed, member); member.close(); }
|
|
130
|
-
}
|
|
131
|
-
private make(id: string, source: URL, language: string): Feed {
|
|
132
|
-
const feed = { id, source, language, members: new Set<Member>(), controller: new AbortController(), reservations: new Map<string, string[]>(), queue: [], speaking: false, voiceFailures: 0, previous: Buffer.alloc(0), pending: Buffer.alloc(0), until: Date.now() } as unknown as Feed;
|
|
133
|
-
// Each account uses free access first, then the same published credit rate.
|
|
134
|
-
// Additional listeners do not trigger recognition or synthesis.
|
|
135
|
-
feed.meter = {
|
|
136
|
-
require: async () => { if (!feed.members.size) throw new SpeechError("No listeners.", 410); },
|
|
137
|
-
reserve: async (_by: string, kind: TranslationUsage, units: number) => {
|
|
138
|
-
const ids: string[] = [];
|
|
139
|
-
const accounts = [...new Set([...feed.members].map(member => member.by))];
|
|
140
|
-
const accepted = new Set<string>();
|
|
141
|
-
const billing = this.options.billing;
|
|
142
|
-
if (billing?.reserveMany) {
|
|
143
|
-
for (const charged of await billing.reserveMany(accounts, kind, units, feed.id)) { ids.push(charged.id); accepted.add(charged.by); }
|
|
144
|
-
} else {
|
|
145
|
-
const results = await Promise.allSettled(accounts.map(async account => ({ by: account, id: await billing?.reserve(account, kind, units, feed.id) })));
|
|
146
|
-
let failure: unknown;
|
|
147
|
-
for (const result of results) {
|
|
148
|
-
if (result.status === "fulfilled") { accepted.add(result.value.by); if (result.value.id) ids.push(result.value.id); }
|
|
149
|
-
else if (!(result.reason instanceof SpeechError) || result.reason.status !== 402) failure = result.reason;
|
|
150
|
-
}
|
|
151
|
-
if (failure) { await Promise.all(ids.map(id => billing!.refund(id))); throw failure; }
|
|
152
|
-
}
|
|
153
|
-
for (const member of [...feed.members]) if (accounts.includes(member.by) && !accepted.has(member.by)) {
|
|
154
|
-
try { member.send({ type: "error", error: "This free session ended and audio credit is used up. Start a remaining free session or buy a pass." }); }
|
|
155
|
-
catch { /* disconnected */ } finally { this.leave(feed, member); member.close(); }
|
|
156
|
-
}
|
|
157
|
-
if (!feed.members.size) { await Promise.all(ids.map(id => this.options.billing!.refund(id))); throw new SpeechError("No funded listeners.", 402); }
|
|
158
|
-
const reservation = randomUUID(); feed.reservations.set(reservation, ids); return reservation;
|
|
159
|
-
},
|
|
160
|
-
commit: async id => { const ids = feed.reservations.get(id) ?? []; if (this.options.billing?.commitMany) await this.options.billing.commitMany(ids); else await Promise.all(ids.map(id => this.options.billing!.commit(id))); feed.reservations.delete(id); },
|
|
161
|
-
refund: async id => { const ids = feed.reservations.get(id) ?? []; if (this.options.billing?.refundMany) await this.options.billing.refundMany(ids); else await Promise.all(ids.map(id => this.options.billing!.refund(id))); feed.reservations.delete(id); },
|
|
162
|
-
};
|
|
163
|
-
let voices: Awaited<ReturnType<LiveVoice["voices"]>> = [];
|
|
164
|
-
feed.interpreter = new Interpreter({
|
|
165
|
-
language: () => language, speakers: () => true, voices: () => voices, channel: () => id,
|
|
166
|
-
status: text => { if (!feed.controller.signal.aborted && !feed.members.size) this.stop(feed, text); },
|
|
167
|
-
failed: message => this.stop(feed, message),
|
|
168
|
-
lines: lines => {
|
|
169
|
-
feed.queue.push(...lines);
|
|
170
|
-
feed.queue = feed.queue.filter(line => Date.now() - line.until <= 12_000).slice(-24);
|
|
171
|
-
if (!feed.speaking) void this.speak(feed);
|
|
172
|
-
},
|
|
173
|
-
fetcher: (async (url, init) => {
|
|
174
|
-
feed.controller.signal.throwIfAborted();
|
|
175
|
-
const signal = AbortSignal.any([feed.controller.signal, ...(init?.signal ? [init.signal] : [])]);
|
|
176
|
-
if (String(url).includes("/speakers")) {
|
|
177
|
-
voices = await this.options.voice.voices();
|
|
178
|
-
return Response.json(await this.options.voice.hear(new Uint8Array(init?.body as Uint8Array), id, signal, feed.meter));
|
|
179
|
-
}
|
|
180
|
-
const body = JSON.parse(String(init?.body));
|
|
181
|
-
return Response.json(await this.options.translator.translate(body.texts, body.from, body.to, { by: id, deadline: Date.now() + 10_000 }));
|
|
182
|
-
}) as typeof fetch,
|
|
183
|
-
});
|
|
184
|
-
return feed;
|
|
185
|
-
}
|
|
186
|
-
private async start(feed: Feed): Promise<void> {
|
|
187
|
-
try {
|
|
188
|
-
const input = await (this.options.open ?? liveInput())(feed.source, bytes => {
|
|
189
|
-
if (feed.controller.signal.aborted) return;
|
|
190
|
-
feed.pending = Buffer.concat([feed.pending, bytes]);
|
|
191
|
-
while (feed.pending.length >= 64_000) {
|
|
192
|
-
const fresh = feed.pending.subarray(0, 64_000); feed.pending = feed.pending.subarray(64_000);
|
|
193
|
-
const pcm = Buffer.concat([feed.previous, fresh]); feed.previous = pcm.subarray(-128_000);
|
|
194
|
-
feed.until += 2000;
|
|
195
|
-
if (Math.abs(Date.now() - feed.until) > 2000) feed.until = Date.now();
|
|
196
|
-
const samples = new Float32Array(pcm.length / 2);
|
|
197
|
-
for (let index = 0; index < samples.length; index++) samples[index] = pcm.readInt16LE(index * 2) / 32768;
|
|
198
|
-
feed.interpreter.push({ samples, at: feed.until - samples.length / 16, until: feed.until, freshAt: feed.until - 2000 });
|
|
199
|
-
}
|
|
200
|
-
}, () => this.stop(feed, "The live source disconnected.", true), feed.controller.signal);
|
|
201
|
-
if (feed.controller.signal.aborted) input.stop(); else feed.input = input;
|
|
202
|
-
} catch (error) { this.stop(feed, error instanceof SpeechError ? error.message : "The live source could not be opened."); }
|
|
203
|
-
}
|
|
204
|
-
private async speak(feed: Feed): Promise<void> {
|
|
205
|
-
feed.speaking = true;
|
|
206
|
-
try {
|
|
207
|
-
while (feed.queue.length && !feed.controller.signal.aborted) {
|
|
208
|
-
const line = feed.queue.shift()!;
|
|
209
|
-
if (Date.now() - line.until > 12_000) continue;
|
|
210
|
-
try {
|
|
211
|
-
const response = await this.options.voice.stream({ text: line.text, language: feed.language, channel: feed.id, speaker: line.speaker, voice: feed.interpreter.tracker.speakers.get(line.speaker ?? "")?.voice }, feed.id, feed.controller.signal, feed.meter);
|
|
212
|
-
const id = randomUUID();
|
|
213
|
-
this.broadcast(feed, { type: "line", id, line });
|
|
214
|
-
const reader = response.body!.getReader();
|
|
215
|
-
try {
|
|
216
|
-
while (true) {
|
|
217
|
-
const { done, value } = await reader.read(); if (done) break;
|
|
218
|
-
for (let at = 0; at < value.length; at += 16_384) this.broadcast(feed, { type: "audio", id, data: Buffer.from(value.subarray(at, at + 16_384)).toString("base64") });
|
|
219
|
-
}
|
|
220
|
-
} finally { reader.releaseLock(); this.broadcast(feed, { type: "end", id }); }
|
|
221
|
-
feed.voiceFailures = 0;
|
|
222
|
-
} catch (error) {
|
|
223
|
-
if (feed.controller.signal.aborted) return;
|
|
224
|
-
if (!transientAudioError(error) || ++feed.voiceFailures >= 3) throw error;
|
|
225
|
-
// A provider failure skips this phrase; the source and other listeners
|
|
226
|
-
// remain connected. Never replay partial speech or retry a charge.
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
} catch (error) { if (!feed.controller.signal.aborted) this.stop(feed, error instanceof SpeechError ? error.message : "Translated audio was interrupted."); }
|
|
230
|
-
finally { feed.speaking = false; }
|
|
231
|
-
}
|
|
15
|
+
export class SharedTranslations extends Base {
|
|
16
|
+
constructor(options: ConstructorParameters<typeof Base>[0]) { super({ parse: sharedSource, open: liveInput(), ...options }); }
|
|
232
17
|
}
|
package/src/speaker-turns.ts
CHANGED
|
@@ -1,34 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* word timestamps before assigning a voice; these are not people's identities. */
|
|
3
|
-
import { reliableText, type Wav } from "./speech.ts";
|
|
4
|
-
import { voiceProfile, type VoiceProfile } from "./voice-profile.ts";
|
|
5
|
-
|
|
6
|
-
export interface SpeakerTurn { start: number; end: number; text: string; speaker: string; profile: VoiceProfile; words: { text: string; start: number; end: number }[]; }
|
|
7
|
-
export interface SpeakerTranscript { language: string; seconds: number; turns: SpeakerTurn[]; }
|
|
8
|
-
export interface ScribeResult {
|
|
9
|
-
language_code?: string;
|
|
10
|
-
words?: { text: string; start: number; end: number; type: string; speaker_id?: string | null }[];
|
|
11
|
-
}
|
|
12
|
-
const ISO: Record<string, string> = Object.fromEntries("eng:en spa:es deu:de ger:de fra:fr fre:fr por:pt ita:it nld:nl dut:nl swe:sv dan:da fin:fi rus:ru ukr:uk ces:cs cze:cs hun:hu cmn:zh zho:zh ara:ar hin:hi vie:vi ind:id jpn:ja kor:ko pol:pl tur:tr ron:ro bul:bg ell:el nor:no nob:no nno:no".split(" ").map(pair => pair.split(":")));
|
|
13
|
-
|
|
14
|
-
export function speakerTurns(body: ScribeResult, wav: Wav): SpeakerTranscript {
|
|
15
|
-
const seconds = wav.samples.length / wav.rate;
|
|
16
|
-
const language = ISO[body.language_code ?? ""] ?? body.language_code ?? "";
|
|
17
|
-
const turns: SpeakerTurn[] = [];
|
|
18
|
-
for (const word of (body.words ?? []).slice(0, 1500)) {
|
|
19
|
-
if (word.type !== "word" || typeof word.text !== "string" || !Number.isFinite(word.start) || !Number.isFinite(word.end) || word.start < 0 || word.end < word.start || word.end > seconds + 0.5) continue;
|
|
20
|
-
const speaker = typeof word.speaker_id === "string" && /^[\w-]{1,80}$/.test(word.speaker_id) ? word.speaker_id : "unknown";
|
|
21
|
-
const last = turns.at(-1);
|
|
22
|
-
if (last && last.speaker === speaker && word.start - last.end < 0.8 && last.text.length + word.text.length < 400) {
|
|
23
|
-
last.text += ` ${word.text}`; last.end = Math.max(last.end, word.end);
|
|
24
|
-
last.words.push({ text: word.text, start: word.start, end: word.end });
|
|
25
|
-
} else turns.push({ start: word.start, end: word.end, text: word.text, speaker, profile: "unknown", words: [{ text: word.text, start: word.start, end: word.end }] });
|
|
26
|
-
}
|
|
27
|
-
const pcm = Buffer.alloc(wav.samples.length * 2);
|
|
28
|
-
wav.samples.forEach((sample, i) => pcm.writeInt16LE(Math.round(Math.max(-1, Math.min(1, sample)) * 32767), i * 2));
|
|
29
|
-
for (const turn of turns) {
|
|
30
|
-
turn.text = reliableText(turn.text, Math.max(1, turn.end - turn.start));
|
|
31
|
-
turn.profile = voiceProfile(pcm.subarray(Math.floor(turn.start * 16000) * 2, Math.ceil(turn.end * 16000) * 2));
|
|
32
|
-
}
|
|
33
|
-
return { language, seconds, turns: turns.filter(turn => turn.text !== "") };
|
|
34
|
-
}
|
|
1
|
+
export { speakerTurns, type SpeakerTurn, type SpeakerTranscript, type ScribeResult } from "@profullstack/live-translate";
|
package/src/speech.ts
CHANGED
|
@@ -43,11 +43,10 @@ export const DEFAULT_MODEL = "onnx-community/whisper-base";
|
|
|
43
43
|
/** Stored live lines from older recognition settings must be heard again. */
|
|
44
44
|
export const NATIVE_REVISION = "native-v2";
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
46
|
+
/** The package's class, so an error thrown inside @profullstack/live-translate
|
|
47
|
+
* is still `instanceof SpeechError` to the server's handlers. */
|
|
48
|
+
export { SpeechError } from "@profullstack/live-translate/core";
|
|
49
|
+
import { SpeechError } from "@profullstack/live-translate/core";
|
|
51
50
|
|
|
52
51
|
/** A stretch of the clip and what was said in it, seconds from the clip's start. */
|
|
53
52
|
export interface Segment {
|
package/src/tickets.ts
CHANGED
|
@@ -185,7 +185,7 @@ export function ticketsFromEnv(
|
|
|
185
185
|
site: string,
|
|
186
186
|
env: NodeJS.ProcessEnv = process.env,
|
|
187
187
|
): TicketOptions | null {
|
|
188
|
-
const coinpayKey = env["COINPAY_X402_KEY"]
|
|
188
|
+
const coinpayKey = env["COINPAY_API_KEY"] || env["COINPAY_X402_KEY"] || "";
|
|
189
189
|
if (!coinpayKey) return null;
|
|
190
190
|
return {
|
|
191
191
|
coinpayKey,
|