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.
Files changed (72) hide show
  1. package/README.md +8 -3
  2. package/dist/accounts.js +9 -1
  3. package/dist/live-interpreter.d.ts +1 -72
  4. package/dist/live-interpreter.js +1 -269
  5. package/dist/live-recovery.d.ts +1 -6
  6. package/dist/live-recovery.js +1 -11
  7. package/dist/live-voice.d.ts +6 -76
  8. package/dist/live-voice.js +6 -352
  9. package/dist/owner.js +4 -0
  10. package/dist/paywall.js +1 -1
  11. package/dist/pcm-wav.d.ts +1 -2
  12. package/dist/pcm-wav.js +1 -27
  13. package/dist/server.d.ts +16 -0
  14. package/dist/server.js +77 -6
  15. package/dist/shared-translation.d.ts +6 -53
  16. package/dist/shared-translation.js +7 -336
  17. package/dist/speaker-turns.d.ts +1 -32
  18. package/dist/speaker-turns.js +1 -30
  19. package/dist/speech.d.ts +3 -5
  20. package/dist/speech.js +4 -7
  21. package/dist/tickets.js +1 -1
  22. package/dist/translation-passes.d.ts +5 -96
  23. package/dist/translation-passes.js +5 -236
  24. package/dist/upgrade-allowance.d.ts +1 -23
  25. package/dist/upgrade-allowance.js +1 -73
  26. package/dist/voice-profile.d.ts +1 -7
  27. package/dist/voice-profile.js +1 -53
  28. package/dist/voice-provider.d.ts +1 -9
  29. package/dist/voice-provider.js +1 -27
  30. package/package.json +4 -2
  31. package/src/accounts.ts +9 -1
  32. package/src/live-interpreter.ts +1 -213
  33. package/src/live-recovery.ts +1 -10
  34. package/src/live-voice.ts +6 -304
  35. package/src/owner.ts +3 -0
  36. package/src/paywall.ts +1 -1
  37. package/src/pcm-wav.ts +1 -26
  38. package/src/server.ts +57 -6
  39. package/src/shared-translation.ts +7 -222
  40. package/src/speaker-turns.ts +1 -34
  41. package/src/speech.ts +4 -5
  42. package/src/tickets.ts +1 -1
  43. package/src/translation-passes.ts +6 -239
  44. package/src/types/auth-system.d.ts +12 -3
  45. package/src/upgrade-allowance.ts +1 -72
  46. package/src/voice-profile.ts +1 -46
  47. package/src/voice-provider.ts +1 -22
  48. package/web/dist/.well-known/openwebring.json +6 -0
  49. package/web/dist/assets/{ar-CR2NEW77.js → ar-8fDonUoB.js} +1 -1
  50. package/web/dist/assets/{cs-ivXHTYBK.js → cs-DCClolTy.js} +1 -1
  51. package/web/dist/assets/{da-DQEGAEdk.js → da-C3RPhXUd.js} +1 -1
  52. package/web/dist/assets/{de-B5VaSv3O.js → de-CYBoEomk.js} +1 -1
  53. package/web/dist/assets/{es-DUTtfGnk.js → es-DS3ls4xT.js} +1 -1
  54. package/web/dist/assets/{fi-CzQZtpO5.js → fi-DbCZpO89.js} +1 -1
  55. package/web/dist/assets/{fr-C2g4DUBr.js → fr-D0z3UpdL.js} +1 -1
  56. package/web/dist/assets/{hi-DKB7ZsZ8.js → hi-BTU3sRlQ.js} +1 -1
  57. package/web/dist/assets/{hls-3VKVEQE3-Bc75Z5iH.js → hls-3VKVEQE3-CQXTVHn_.js} +1 -1
  58. package/web/dist/assets/{hu--oH7WvAR.js → hu-B37Q8ldT.js} +1 -1
  59. package/web/dist/assets/{id-CjlpBIJc.js → id-4pdXNa4G.js} +1 -1
  60. package/web/dist/assets/index-BDBbN5Qy.js +22 -0
  61. package/web/dist/assets/{it-Dbctup0F.js → it-CTe69-UY.js} +1 -1
  62. package/web/dist/assets/{mpegts-D9VECdKk.js → mpegts-BBmkDppm.js} +1 -1
  63. package/web/dist/assets/{mpegts-LO6RVLD6-CSb7Fz4U.js → mpegts-LO6RVLD6-CUVdjYFi.js} +1 -1
  64. package/web/dist/assets/{nl-BYw8ZbV_.js → nl-CxrmHrMn.js} +1 -1
  65. package/web/dist/assets/{ru-DFGYCrj-.js → ru-zjVCfIKq.js} +1 -1
  66. package/web/dist/assets/{sv-DbO1Sc1v.js → sv-CEoiT2Lr.js} +1 -1
  67. package/web/dist/assets/{uk-BTXHwsjV.js → uk-ZC6JzQvD.js} +1 -1
  68. package/web/dist/assets/{vi-DJ-vIPLS.js → vi-D9anF-cl.js} +1 -1
  69. package/web/dist/assets/{zh-D3Gju_Y2.js → zh-BeLYdC0I.js} +1 -1
  70. package/web/dist/index.html +6 -5
  71. package/web/dist/sw.js +23 -22
  72. 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 && process.env["DATABASE_URL"]
6428
- ? new pg.Pool({ connectionString: process.env["DATABASE_URL"] })
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 && process.env["DATABASE_URL"]
6505
+ options.directory && databaseUrl
6456
6506
  ? new Accounts({
6457
- connectionString: process.env["DATABASE_URL"],
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: process.env["COINPAY_X402_KEY"] ?? "", site: nixampSite }) : undefined;
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
- /** One live source/language pipeline, paid access for every listening account.
2
- * Browsers receive the same PCM, never upload a replacement for a public feed. */
3
- import { transientAudioError } from "./live-recovery.ts";
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
- const blocked = new BlockList();
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 interface LiveInput { stop(): void; }
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
- async join(raw: string, language: string, by: string, send: Member["send"], close: () => void): Promise<() => void> {
72
- if (!this.options.voice.available()) throw new SpeechError("Translated audio is unavailable.", 503);
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
  }
@@ -1,34 +1 @@
1
- /** Provider speaker IDs belong to a clip. Clients reconcile the overlapping
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
- export class SpeechError extends Error {
47
- constructor(message: string, readonly status: number) {
48
- super(message);
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,