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.
- package/dist/audio.js +3 -0
- package/dist/channels.d.ts +14 -0
- package/dist/channels.js +16 -3
- package/dist/decoder-policy.d.ts +20 -0
- package/dist/decoder-policy.js +34 -0
- package/dist/destinations.d.ts +5 -0
- package/dist/destinations.js +82 -0
- package/dist/library.d.ts +2 -0
- package/dist/library.js +4 -1
- package/dist/links.d.ts +4 -0
- package/dist/links.js +20 -2
- 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/pcm-wav.d.ts +1 -2
- package/dist/pcm-wav.js +1 -27
- package/dist/playlist-input.d.ts +46 -0
- package/dist/playlist-input.js +158 -54
- package/dist/publish-choice.d.ts +21 -0
- package/dist/publish-choice.js +17 -0
- package/dist/server.js +66 -11
- package/dist/share.d.ts +1 -1
- package/dist/share.js +5 -4
- 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/translation-passes.d.ts +5 -125
- package/dist/translation-passes.js +5 -294
- 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 +2 -1
- package/src/audio.ts +3 -0
- package/src/channels.ts +26 -4
- package/src/decoder-policy.ts +33 -0
- package/src/destinations.ts +78 -0
- package/src/library.ts +6 -1
- package/src/links.ts +23 -4
- 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/pcm-wav.ts +1 -26
- package/src/playlist-input.ts +172 -50
- package/src/publish-choice.ts +35 -0
- package/src/server.ts +58 -11
- package/src/share.ts +5 -4
- package/src/shared-translation.ts +7 -222
- package/src/speaker-turns.ts +1 -34
- package/src/speech.ts +4 -5
- package/src/translation-passes.ts +6 -292
- package/src/upgrade-allowance.ts +1 -72
- package/src/voice-profile.ts +1 -46
- package/src/voice-provider.ts +1 -22
- package/web/dist/assets/{ar-CLNm1oA7.js → ar-D6_ZFDan.js} +1 -1
- package/web/dist/assets/{cs-Cptn8RbB.js → cs-DEPGOGEV.js} +1 -1
- package/web/dist/assets/{da-CRbNCFOu.js → da-CQw7SPC9.js} +1 -1
- package/web/dist/assets/{de-D4DnC5NQ.js → de-OWnobbah.js} +1 -1
- package/web/dist/assets/{es-DAHnD9bs.js → es-qMpyH9bM.js} +1 -1
- package/web/dist/assets/{fi-Csbl41Vz.js → fi-DpqqwJTD.js} +1 -1
- package/web/dist/assets/{fr-jZYQJed8.js → fr-BiO_ceen.js} +1 -1
- package/web/dist/assets/{hi-DjT9u0v_.js → hi-D-q5wZuY.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-BjAVnvGY.js → hls-3VKVEQE3-D2W4K8VR.js} +1 -1
- package/web/dist/assets/{hu-CVrU8gC4.js → hu-FIjHyhVC.js} +1 -1
- package/web/dist/assets/{id-B_gN24X5.js → id-gmB6lu56.js} +1 -1
- package/web/dist/assets/{index-BueBHHcv.css → index-Bo3utcDw.css} +1 -1
- package/web/dist/assets/index-UmGvzuun.js +22 -0
- package/web/dist/assets/{it-BUC1dMpV.js → it-Dxfo713A.js} +1 -1
- package/web/dist/assets/{mpegts-CnqmXTu2.js → mpegts-BT4XGr_E.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-B4QgG3Xb.js → mpegts-LO6RVLD6-CpZnlq0W.js} +1 -1
- package/web/dist/assets/{nl-CGi-aIrz.js → nl-mk5eN3Io.js} +1 -1
- package/web/dist/assets/{ru-YIbS63cK.js → ru-B2psU0kN.js} +1 -1
- package/web/dist/assets/{sv-CuJUpOCk.js → sv-Cg6waoNZ.js} +1 -1
- package/web/dist/assets/{uk-DLfG8YhV.js → uk-BcFkukLd.js} +1 -1
- package/web/dist/assets/{vi-nv2siY2T.js → vi-CnqIC1Sz.js} +1 -1
- package/web/dist/assets/{zh-P5x8w1OW.js → zh-CvnXIFvN.js} +1 -1
- package/web/dist/index.html +7 -12
- package/web/dist/sw.js +23 -23
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
7480
|
-
|
|
7481
|
-
|
|
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.
|
|
142
|
-
// a plain-http address on your own network
|
|
143
|
-
|
|
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
|
-
/**
|
|
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 {
|