nixamp 0.28.44 → 0.29.1

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 (89) hide show
  1. package/dist/audio.js +3 -0
  2. package/dist/channels.d.ts +14 -0
  3. package/dist/channels.js +16 -3
  4. package/dist/decoder-policy.d.ts +20 -0
  5. package/dist/decoder-policy.js +34 -0
  6. package/dist/destinations.d.ts +5 -0
  7. package/dist/destinations.js +82 -0
  8. package/dist/library.d.ts +2 -0
  9. package/dist/library.js +4 -1
  10. package/dist/links.d.ts +4 -0
  11. package/dist/links.js +20 -2
  12. package/dist/live-interpreter.d.ts +1 -72
  13. package/dist/live-interpreter.js +1 -269
  14. package/dist/live-recovery.d.ts +1 -6
  15. package/dist/live-recovery.js +1 -11
  16. package/dist/live-voice.d.ts +6 -76
  17. package/dist/live-voice.js +6 -352
  18. package/dist/owner.js +4 -0
  19. package/dist/pcm-wav.d.ts +1 -2
  20. package/dist/pcm-wav.js +1 -27
  21. package/dist/playlist-input.d.ts +46 -0
  22. package/dist/playlist-input.js +158 -54
  23. package/dist/publish-choice.d.ts +21 -0
  24. package/dist/publish-choice.js +17 -0
  25. package/dist/server.js +66 -11
  26. package/dist/share.d.ts +1 -1
  27. package/dist/share.js +5 -4
  28. package/dist/shared-translation.d.ts +6 -53
  29. package/dist/shared-translation.js +7 -336
  30. package/dist/speaker-turns.d.ts +1 -32
  31. package/dist/speaker-turns.js +1 -30
  32. package/dist/speech.d.ts +3 -5
  33. package/dist/speech.js +4 -7
  34. package/dist/translation-passes.d.ts +5 -125
  35. package/dist/translation-passes.js +5 -294
  36. package/dist/upgrade-allowance.d.ts +1 -23
  37. package/dist/upgrade-allowance.js +1 -73
  38. package/dist/voice-profile.d.ts +1 -7
  39. package/dist/voice-profile.js +1 -53
  40. package/dist/voice-provider.d.ts +1 -9
  41. package/dist/voice-provider.js +1 -27
  42. package/package.json +2 -1
  43. package/src/audio.ts +3 -0
  44. package/src/channels.ts +26 -4
  45. package/src/decoder-policy.ts +33 -0
  46. package/src/destinations.ts +78 -0
  47. package/src/library.ts +6 -1
  48. package/src/links.ts +23 -4
  49. package/src/live-interpreter.ts +1 -213
  50. package/src/live-recovery.ts +1 -10
  51. package/src/live-voice.ts +6 -304
  52. package/src/owner.ts +3 -0
  53. package/src/pcm-wav.ts +1 -26
  54. package/src/playlist-input.ts +172 -50
  55. package/src/publish-choice.ts +35 -0
  56. package/src/server.ts +58 -11
  57. package/src/share.ts +5 -4
  58. package/src/shared-translation.ts +7 -222
  59. package/src/speaker-turns.ts +1 -34
  60. package/src/speech.ts +4 -5
  61. package/src/translation-passes.ts +6 -292
  62. package/src/upgrade-allowance.ts +1 -72
  63. package/src/voice-profile.ts +1 -46
  64. package/src/voice-provider.ts +1 -22
  65. package/web/dist/assets/{ar-CLNm1oA7.js → ar-D6_ZFDan.js} +1 -1
  66. package/web/dist/assets/{cs-Cptn8RbB.js → cs-DEPGOGEV.js} +1 -1
  67. package/web/dist/assets/{da-CRbNCFOu.js → da-CQw7SPC9.js} +1 -1
  68. package/web/dist/assets/{de-D4DnC5NQ.js → de-OWnobbah.js} +1 -1
  69. package/web/dist/assets/{es-DAHnD9bs.js → es-qMpyH9bM.js} +1 -1
  70. package/web/dist/assets/{fi-Csbl41Vz.js → fi-DpqqwJTD.js} +1 -1
  71. package/web/dist/assets/{fr-jZYQJed8.js → fr-BiO_ceen.js} +1 -1
  72. package/web/dist/assets/{hi-DjT9u0v_.js → hi-D-q5wZuY.js} +1 -1
  73. package/web/dist/assets/{hls-3VKVEQE3-BjAVnvGY.js → hls-3VKVEQE3-D2W4K8VR.js} +1 -1
  74. package/web/dist/assets/{hu-CVrU8gC4.js → hu-FIjHyhVC.js} +1 -1
  75. package/web/dist/assets/{id-B_gN24X5.js → id-gmB6lu56.js} +1 -1
  76. package/web/dist/assets/{index-BueBHHcv.css → index-Bo3utcDw.css} +1 -1
  77. package/web/dist/assets/index-UmGvzuun.js +22 -0
  78. package/web/dist/assets/{it-BUC1dMpV.js → it-Dxfo713A.js} +1 -1
  79. package/web/dist/assets/{mpegts-CnqmXTu2.js → mpegts-BT4XGr_E.js} +1 -1
  80. package/web/dist/assets/{mpegts-LO6RVLD6-B4QgG3Xb.js → mpegts-LO6RVLD6-CpZnlq0W.js} +1 -1
  81. package/web/dist/assets/{nl-CGi-aIrz.js → nl-mk5eN3Io.js} +1 -1
  82. package/web/dist/assets/{ru-YIbS63cK.js → ru-B2psU0kN.js} +1 -1
  83. package/web/dist/assets/{sv-CuJUpOCk.js → sv-Cg6waoNZ.js} +1 -1
  84. package/web/dist/assets/{uk-DLfG8YhV.js → uk-BcFkukLd.js} +1 -1
  85. package/web/dist/assets/{vi-nv2siY2T.js → vi-CnqIC1Sz.js} +1 -1
  86. package/web/dist/assets/{zh-P5x8w1OW.js → zh-CvnXIFvN.js} +1 -1
  87. package/web/dist/index.html +7 -12
  88. package/web/dist/sw.js +23 -23
  89. package/web/dist/assets/index-c3uxZS8_.js +0 -3
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Whether this server goes in the public directory.
3
+ *
4
+ * A listing is a phone code and a link anybody can open, so it is a thing
5
+ * somebody chose, not a thing that happens because a daemon had no terminal
6
+ * to ask on. The choice is asked once, or taken from a flag, and written into
7
+ * config.json beside the library, and every later start reads it back.
8
+ *
9
+ * Servers that were listed before the choice was written down keep being
10
+ * listed: a daemon without a terminal used to publish by itself, and quietly
11
+ * taking it off the directory on an update would be changing somebody's
12
+ * choice for them. A server new to this machine is not listed until asked.
13
+ */
14
+ export type PublishFlag = "ask" | "yes" | "no";
15
+
16
+ /**
17
+ * "yes" or "no" when the answer is already known, "ask" when there is a
18
+ * person at the terminal to ask.
19
+ */
20
+ export function publishChoice(
21
+ flag: PublishFlag,
22
+ recorded: boolean | undefined,
23
+ interactive: boolean,
24
+ established: boolean,
25
+ ): "yes" | "no" | "ask" {
26
+ if (flag !== "ask") return flag;
27
+ if (recorded !== undefined) return recorded ? "yes" : "no";
28
+ if (interactive) return "ask";
29
+ return established ? "yes" : "no";
30
+ }
31
+
32
+ /** Whether to write the answer down: only a choice nobody had made yet. */
33
+ export function shouldRecord(flag: PublishFlag, recorded: boolean | undefined): boolean {
34
+ return flag === "ask" && recorded === undefined;
35
+ }
package/src/server.ts CHANGED
@@ -9,7 +9,9 @@
9
9
  * a WebSocket because SSE is plain HTTP: no dependency, and it reconnects on
10
10
  * its own when the laptop running the remote goes to sleep.
11
11
  */
12
- import { createReadStream, statSync } from "node:fs";
12
+ import { publishChoice, shouldRecord } from "./publish-choice.ts";
13
+ import { protocolArgs } from "./decoder-policy.ts";
14
+ import { createReadStream, existsSync, statSync } from "node:fs";
13
15
  import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
14
16
  import { createServer as createHttpsServer } from "node:https";
15
17
  import { randomBytes } from "node:crypto";
@@ -68,6 +70,7 @@ import { Directory, ENDED_TTL_MS, parseAnnouncement, type LineupEntry, type List
68
70
  import { ArtCache, coverArtOf, stillFrom } from "./art.ts";
69
71
  import { PartyLine, telnyxSms } from "./partyline.ts";
70
72
  import { HlsPackagers, segmentType, withKey } from "./hls.ts";
73
+ import { refusedDestination } from "./destinations.ts";
71
74
  import { CompressionService } from "./compression/service.ts";
72
75
  import { handleChannelCompression, handleCompressionApi, handleStaticRelay, type RouteContext } from "./compression/routes.ts";
73
76
  import { DEFAULT_SITE as NICHEDB, Enricher, type EnrichKind, FIXTURE_TTL_MS } from "./enrich.ts";
@@ -86,7 +89,7 @@ import { Porkbun, isIPv4, isIPv6, type DnsZone } from "./dns.ts";
86
89
  import { NameError, Names } from "./names.ts";
87
90
  import { AcmeIssuer, Certs } from "./certs.ts";
88
91
  import { claimName, fetchCert, humanizeSource, labelFor, readCertFiles, writeCertFiles } from "./naming.ts";
89
- import { forbiddenLibrary, readLibrary } from "./library.ts";
92
+ import { forbiddenLibrary, readConfig, readLibrary, writeConfig } from "./library.ts";
90
93
  import { createThrottle, presentedCredential, type Throttle } from "@profullstack/throttle";
91
94
  import { Durable } from "./durable.ts";
92
95
  import { notifyAll, resendEmail, webPush, type Notification } from "./notify.ts";
@@ -1976,7 +1979,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1976
1979
  json(response, 404, { error: "not found" });
1977
1980
  return;
1978
1981
  }
1979
- response.writeHead(302, { ...CORS, "set-cookie": keyCookie(offered), location: "/" });
1982
+ response.writeHead(302, { ...CORS, "set-cookie": keyCookie(offered, (request.socket as { encrypted?: boolean }).encrypted === true || (options.secureCookies ?? false)), location: "/" });
1980
1983
  response.end();
1981
1984
  return;
1982
1985
  }
@@ -2563,6 +2566,10 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2563
2566
  // port is open from another device before wondering whether the link is
2564
2567
  // wrong, and it says nothing about the library.
2565
2568
  const eventChannelRequest = Boolean(channelId && await eventForChannel());
2569
+ // Whether this request holds only the listen key. A listener may browse
2570
+ // and hear the library, but where it lives on this machine is the
2571
+ // owner's business: the absolute path names the user account and the disk.
2572
+ let listening = false;
2566
2573
  if (
2567
2574
  key !== null &&
2568
2575
  path !== "/api/health" &&
@@ -2571,6 +2578,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2571
2578
  !eventChannelRequest
2572
2579
  ) {
2573
2580
  let scope = scopeOf(keyFrom(request, url), key, listenKey);
2581
+ // Kept for the routes below that answer a listener with less.
2582
+ const settle = (): void => { listening = scope === "listen"; };
2574
2583
  // A key is how somebody who was invited proves it. It is not the only way
2575
2584
  // to be allowed in: the person who owns this server is allowed in whether
2576
2585
  // or not they still have the link, and their nixamp.com session says who
@@ -2596,6 +2605,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2596
2605
  });
2597
2606
  return;
2598
2607
  }
2608
+ settle();
2599
2609
  if (scope === "listen" && !allowedForListening(path)) {
2600
2610
  json(response, 403, { error: "this link can listen, not drive" });
2601
2611
  return;
@@ -4631,11 +4641,32 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
4631
4641
  json(response, 429, { error: `this server is already carrying ${MAX_ON_DEMAND} channels on demand; try again in a minute` });
4632
4642
  return;
4633
4643
  }
4634
- const resolved = known ?? await resolveLink(options.ytdlp ?? null, link, { cookies: options.cookies ?? "" });
4644
+ // A member's link is fetched from inside the owner's network, so it has
4645
+ // to lead somewhere public: the link, every hop of a playlist, and
4646
+ // whatever yt-dlp says the media is. The owner's is theirs to aim.
4647
+ const guard = liveBy && !known ? (one: string) => refusedDestination(one) : undefined;
4648
+ if (guard) {
4649
+ const refused = await guard(link);
4650
+ if (refused) {
4651
+ json(response, 403, { error: refused });
4652
+ return;
4653
+ }
4654
+ }
4655
+ const resolved = known ?? await resolveLink(options.ytdlp ?? null, link, { cookies: options.cookies ?? "", guard });
4635
4656
  if ("error" in resolved) {
4636
- json(response, 422, { error: resolved.error });
4657
+ json(response, guard && resolved.error.includes("private network") ? 403 : 422, { error: resolved.error });
4637
4658
  return;
4638
4659
  }
4660
+ if (guard) {
4661
+ const reached = [resolved.media, resolved.audio, ...(resolved.playlist ?? [])].filter((one) => one && /^(https?|rtmps?):\/\//i.test(one));
4662
+ for (const one of [...new Set(reached)].slice(0, 200)) {
4663
+ const refused = await guard(one);
4664
+ if (refused) {
4665
+ json(response, 403, { error: refused });
4666
+ return;
4667
+ }
4668
+ }
4669
+ }
4639
4670
  links.set(link, resolved);
4640
4671
  // A channel already on -- put back by a restart, which forgets where
4641
4672
  // it came from -- learns its picture and its line from the link pasted
@@ -5494,7 +5525,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
5494
5525
  }
5495
5526
 
5496
5527
  if (path === "/api/state") {
5497
- json(response, 200, engine.snapshot());
5528
+ json(response, 200, listening ? { ...engine.snapshot(), root: "" } : engine.snapshot());
5498
5529
  return;
5499
5530
  }
5500
5531
 
@@ -5562,7 +5593,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
5562
5593
  "x-accel-buffering": "no",
5563
5594
  });
5564
5595
  const send = (snapshot: Snapshot): void => {
5565
- response.write(`data: ${JSON.stringify(snapshot)}\n\n`);
5596
+ response.write(`data: ${JSON.stringify(listening ? { ...snapshot, root: "" } : snapshot)}\n\n`);
5566
5597
  };
5567
5598
  const unsubscribe = engine.subscribe(send);
5568
5599
  // A comment line keeps proxies from closing an idle stream.
@@ -6039,6 +6070,7 @@ async function liveAudio(
6039
6070
  // A live source has no beginning to seek from.
6040
6071
  ...(from > 1 && !isRemote(source) ? ["-ss", String(from)] : []),
6041
6072
  "-re",
6073
+ ...protocolArgs(source),
6042
6074
  "-i", source,
6043
6075
  ...(asVideo
6044
6076
  // Copied where it can be, because a room full of viewers is a room
@@ -6157,6 +6189,7 @@ function pipeFfmpeg(
6157
6189
  // when they are handed to it for a file on disk.
6158
6190
  ...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
6159
6191
  ...inputArgs,
6192
+ ...protocolArgs(source),
6160
6193
  "-i", source,
6161
6194
  ...outputArgs,
6162
6195
  "-",
@@ -6341,6 +6374,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
6341
6374
  // still works after a restart -- and a server is restarted to pick up a new
6342
6375
  // version, which is to say often. `--new-key` mints a fresh pair and forgets
6343
6376
  // the old one, which is the way to revoke a link that got out.
6377
+ // Whether this port had keys before this start: a server that has run
6378
+ // here before, whose directory listing (see publishChoice) is kept.
6379
+ const established = existsSync(join(stateDir(), "keys.json")) &&
6380
+ (() => { try { return String(options.port) in (JSON.parse(readFileSync(join(stateDir(), "keys.json"), "utf8")) as object); } catch { return false; } })();
6344
6381
  const remembered = options.key ? rememberedKeys(stateDir(), options.port, options.newKey) : null;
6345
6382
  const key = remembered?.key ?? null;
6346
6383
  // Kept whether or not it is published, so `nixamp admin` and the operator
@@ -7475,10 +7512,20 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
7475
7512
  // public at all.
7476
7513
  //
7477
7514
  // Still asked when a person is at the terminal to answer, because that is
7478
- // somebody who can say no.
7479
- const wanted = options.publish === "yes" || !process.stdin.isTTY
7480
- ? true
7481
- : await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
7515
+ // somebody who can say no -- and since 0.29 the answer is kept, so a
7516
+ // daemon started later does what the person said, not what it guesses.
7517
+ const recorded = readConfig().publish;
7518
+ const choice = publishChoice(options.publish, recorded, Boolean(process.stdin.isTTY), established);
7519
+ const wanted = choice === "ask"
7520
+ ? await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`)
7521
+ : choice === "yes";
7522
+ if (shouldRecord(options.publish, recorded)) {
7523
+ try { writeConfig({ publish: wanted }); } catch { /* unwritable state: asked again next time */ }
7524
+ }
7525
+ if (!wanted && options.publish === "ask") {
7526
+ console.log("");
7527
+ console.log(` Not listed at ${DEFAULT_DIRECTORY}/directory. Start with --publish to list it.`);
7528
+ }
7482
7529
 
7483
7530
  if (wanted) {
7484
7531
  publisher = makePublisher();
package/src/share.ts CHANGED
@@ -136,11 +136,12 @@ export function keyFrom(request: IncomingMessage, url: URL): string | null {
136
136
  }
137
137
 
138
138
  /** The Set-Cookie for a browser that just opened the link. */
139
- export function keyCookie(key: string): string {
139
+ export function keyCookie(key: string, secure = false): string {
140
140
  // HttpOnly because nothing in the page reads it: the browser attaches it to
141
- // every same-origin request by itself. No Secure, because the whole point is
142
- // a plain-http address on your own network.
143
- return `${KEY_COOKIE}=${encodeURIComponent(key)}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`;
141
+ // every same-origin request by itself. Secure only when the link was opened
142
+ // over https: a plain-http address on your own network is still a thing,
143
+ // and a Secure cookie there would never be sent back.
144
+ return `${KEY_COOKIE}=${encodeURIComponent(key)}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly${secure ? "; Secure" : ""}`;
144
145
  }
145
146
 
146
147
  /**
@@ -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 {