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,33 @@
1
+ /**
2
+ * What a decoder may open while reading one source.
3
+ *
4
+ * ffmpeg follows references inside what it reads: an .m3u8 names segments, a
5
+ * concat list names files, a playlist on disk can name a URL. A file in
6
+ * somebody's library is untrusted input, and without a list of protocols it
7
+ * can make this machine fetch any address it likes, on the LAN included. So
8
+ * a file on disk may open files and pipes and nothing else, and a link may
9
+ * open the network protocols a stream needs and not the local file system.
10
+ *
11
+ * Sources this does not recognise (an ingest URL, a lavfi graph) are left
12
+ * alone rather than broken: the list is for what comes out of a library or a
13
+ * pasted link, which is where untrusted references come from.
14
+ */
15
+
16
+ /** A file in a library: itself, and the pipes the pipeline is built from. */
17
+ export const LOCAL_PROTOCOLS = "file,pipe,fd";
18
+
19
+ /** A link: the network, as streams use it, and never `file`. */
20
+ export const REMOTE_PROTOCOLS = [
21
+ "http", "https", "tcp", "tls", "crypto", "data", "httpproxy",
22
+ "rtmp", "rtmps", "rtmpt", "rtmpts", "rtmpe", "rtmpte", "ffrtmpcrypt", "ffrtmphttp",
23
+ ].join(",");
24
+
25
+ /** The `-protocol_whitelist` to put before this source's `-i`, or nothing. */
26
+ export function protocolArgs(source: string): string[] {
27
+ if (source === "-" || source.startsWith("pipe:")) return ["-protocol_whitelist", LOCAL_PROTOCOLS];
28
+ if (/^(https?|rtmp[a-z]*):\/\//i.test(source)) return ["-protocol_whitelist", REMOTE_PROTOCOLS];
29
+ if (/^file:/i.test(source)) return ["-protocol_whitelist", LOCAL_PROTOCOLS];
30
+ // Any other scheme (two letters or more, so `C:\` is still a path).
31
+ if (/^[a-z][a-z0-9+.-]+:/i.test(source)) return [];
32
+ return ["-protocol_whitelist", LOCAL_PROTOCOLS];
33
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Whether a link somebody else pasted may be fetched from this machine.
3
+ *
4
+ * A member -- any nixamp.com account -- may put a link on the air on a server
5
+ * that is not theirs. Fetching it is a request from inside the owner's
6
+ * network, so a link to the router, the cloud metadata address or a service
7
+ * on localhost would be the member reaching things only the owner can. The
8
+ * owner may fetch what they like: it is their network. A member's link has to
9
+ * resolve to a public address, every address it resolves to, at every hop
10
+ * this code follows.
11
+ *
12
+ * What it cannot see: a redirect yt-dlp or ffmpeg follows on its own, and a
13
+ * name that resolves differently a second later. The decoder's protocol list
14
+ * (decoder-policy.ts) keeps those off the file system; keeping them off the
15
+ * LAN entirely needs the fetch to happen here, which is later work.
16
+ */
17
+ import { lookup as dnsLookup } from "node:dns/promises";
18
+ import { isIP } from "node:net";
19
+
20
+ export type Lookup = (host: string) => Promise<string[]>;
21
+
22
+ const defaultLookup: Lookup = async (host) =>
23
+ (await dnsLookup(host, { all: true, verbatim: true })).map((one) => one.address);
24
+
25
+ function v4Number(address: string): number {
26
+ return address.split(".").reduce((sum, part) => sum * 256 + Number(part), 0);
27
+ }
28
+
29
+ function inV4(address: string, base: string, bits: number): boolean {
30
+ const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
31
+ return ((v4Number(address) & mask) >>> 0) === ((v4Number(base) & mask) >>> 0);
32
+ }
33
+
34
+ const PRIVATE_V4: Array<[string, number]> = [
35
+ ["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
36
+ ["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24],
37
+ ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24],
38
+ ["224.0.0.0", 4], ["240.0.0.0", 4],
39
+ ];
40
+
41
+ /** Not somewhere on the public internet: loopback, private, link-local, reserved. */
42
+ export function isPrivateAddress(address: string): boolean {
43
+ const kind = isIP(address);
44
+ if (kind === 4) return PRIVATE_V4.some(([base, bits]) => inV4(address, base, bits));
45
+ if (kind !== 6) return true;
46
+ const lower = address.toLowerCase();
47
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
48
+ if (mapped) return isPrivateAddress(mapped[1]!);
49
+ if (lower === "::" || lower === "::1") return true;
50
+ const first = parseInt(lower.split(":")[0] || "0", 16);
51
+ // fc00::/7 unique local, fe80::/10 link-local, ff00::/8 multicast.
52
+ if ((first & 0xfe00) === 0xfc00) return true;
53
+ if ((first & 0xffc0) === 0xfe80) return true;
54
+ if ((first & 0xff00) === 0xff00) return true;
55
+ return false;
56
+ }
57
+
58
+ /** "" when this link may be fetched for a member, else why not. */
59
+ export async function refusedDestination(link: string, lookup: Lookup = defaultLookup): Promise<string> {
60
+ let url: URL;
61
+ try {
62
+ url = new URL(link);
63
+ } catch {
64
+ return "that is not a link";
65
+ }
66
+ if (!/^(https?|rtmps?):$/i.test(url.protocol)) return "only http, https and rtmp links can be played here";
67
+ const host = url.hostname.replace(/^\[|\]$/g, "");
68
+ if (host === "" || /^localhost$/i.test(host) || /\.localhost$/i.test(host)) return "that link points at this machine";
69
+ let addresses: string[];
70
+ try {
71
+ addresses = isIP(host) ? [host] : await lookup(host);
72
+ } catch {
73
+ return "that link's host does not resolve";
74
+ }
75
+ if (addresses.length === 0) return "that link's host does not resolve";
76
+ if (addresses.some(isPrivateAddress)) return "that link points inside a private network, which only this server's owner may play";
77
+ return "";
78
+ }
package/src/library.ts CHANGED
@@ -23,6 +23,8 @@ import { stateDir } from "./daemon.ts";
23
23
  export interface Config {
24
24
  /** The folder the daemon serves when it is not told which. */
25
25
  library?: string;
26
+ /** Whether this machine is listed in the public directory; unset until chosen. */
27
+ publish?: boolean;
26
28
  }
27
29
 
28
30
  export function configPath(): string {
@@ -34,7 +36,10 @@ export function readConfig(): Config {
34
36
  const parsed = JSON.parse(readFileSync(configPath(), "utf8")) as unknown;
35
37
  if (typeof parsed !== "object" || parsed === null) return {};
36
38
  const record = parsed as Record<string, unknown>;
37
- return { ...(typeof record["library"] === "string" ? { library: record["library"] } : {}) };
39
+ return {
40
+ ...(typeof record["library"] === "string" ? { library: record["library"] } : {}),
41
+ ...(typeof record["publish"] === "boolean" ? { publish: record["publish"] } : {}),
42
+ };
38
43
  } catch {
39
44
  return {};
40
45
  }
package/src/links.ts CHANGED
@@ -156,6 +156,11 @@ export function playlistFrom(text: string, base: string): { hls: boolean; source
156
156
  return { hls: false, sources: entries.map((entry) => entry.source), image };
157
157
  }
158
158
 
159
+ /** Asked of every address before it is fetched: "" to go ahead, else why not. */
160
+ export type Guard = (url: string) => Promise<string>;
161
+
162
+ const PLAYLIST_MAX_REDIRECTS = 5;
163
+
159
164
  /**
160
165
  * A pasted .m3u, read and turned into a channel's worth of entries. Fetched
161
166
  * here rather than by yt-dlp, which would take the first entry and stop, or
@@ -163,14 +168,28 @@ export function playlistFrom(text: string, base: string): { hls: boolean; source
163
168
  */
164
169
  export async function resolvePlaylist(
165
170
  url: string,
166
- options: { timeoutMs?: number; fetcher?: typeof fetch } = {},
171
+ options: { timeoutMs?: number; fetcher?: typeof fetch; guard?: Guard } = {},
167
172
  ): Promise<ResolvedLink | { error: string }> {
168
173
  const get = options.fetcher ?? fetch;
169
174
  const abort = new AbortController();
170
175
  const timer = setTimeout(() => abort.abort(), options.timeoutMs ?? PLAYLIST_FETCH_TIMEOUT_MS);
171
176
  let text = "";
172
177
  try {
173
- const answer = await get(url, { signal: abort.signal, headers: { "user-agent": "nixamp" }, redirect: "follow" });
178
+ // Followed here, one hop at a time, when there is a guard: a public
179
+ // address that redirects to the router is the router.
180
+ let at = url;
181
+ let answer: Response;
182
+ for (let hop = 0; ; hop += 1) {
183
+ if (options.guard) {
184
+ const refused = await options.guard(at);
185
+ if (refused) return { error: refused };
186
+ }
187
+ answer = await get(at, { signal: abort.signal, headers: { "user-agent": "nixamp" }, redirect: options.guard ? "manual" : "follow" });
188
+ const next = answer.headers.get("location");
189
+ if (!options.guard || answer.status < 300 || answer.status >= 400 || !next) break;
190
+ if (hop >= PLAYLIST_MAX_REDIRECTS) return { error: "that playlist redirects too many times" };
191
+ at = new URL(next, at).toString();
192
+ }
174
193
  if (!answer.ok) return { error: `that playlist could not be fetched (HTTP ${answer.status})` };
175
194
  text = (await answer.text()).slice(0, PLAYLIST_MAX_BYTES);
176
195
  } catch (error) {
@@ -493,10 +512,10 @@ export const RESOLVE_TIMEOUT_MS = 60_000;
493
512
  export async function resolveLink(
494
513
  ytdlp: string[] | null,
495
514
  url: string,
496
- options: { cookies?: string; timeoutMs?: number; format?: string } = {},
515
+ options: { cookies?: string; timeoutMs?: number; format?: string; guard?: Guard } = {},
497
516
  ): Promise<ResolvedLink | { error: string }> {
498
517
  if (isDirectMedia(url)) return directLink(url);
499
- if (isPlaylistLink(url)) return resolvePlaylist(url, { timeoutMs: options.timeoutMs });
518
+ if (isPlaylistLink(url)) return resolvePlaylist(url, { timeoutMs: options.timeoutMs, guard: options.guard });
500
519
  // Without yt-dlp, a link is taken as the media it may well be: an IPTV
501
520
  // feed has no extension and no page behind it, and ffprobe -- asked next,
502
521
  // before anything goes on the air -- tells a stream from a web page in a
@@ -1,213 +1 @@
1
- import { audioError, transientAudioError } from "./live-recovery.ts";
2
- import { encodeWav } from "./pcm-wav.ts";
3
- import type { SpeakerTranscript, SpeakerTurn } from "./speaker-turns.ts";
4
- export interface Caption { channel: string; at: number; until: number; text: string; language?: string; original?: string; sourceLanguage?: string; speaker?: string; voiceProfile?: "lower" | "higher" | "unknown"; }
5
- export interface VoiceChoice { id: string; name: string; gender: string; language: string; }
6
- export interface AudioWindow { samples: Float32Array; at: number; until: number; freshAt: number; }
7
-
8
- export interface Speaker { id: string; profile: string; voice: string; }
9
- /** Match provider labels across overlapping timestamps. A speaker absent from
10
- * the rolling context gets a new label; pitch alone never identifies someone. */
11
- export class SpeakerTracker {
12
- private previous: { start: number; end: number; speaker: string }[] = [];
13
- private sequence = 0;
14
- readonly speakers = new Map<string, Speaker>();
15
- constructor(private readonly random: () => number = Math.random) {}
16
- reset(): void { this.previous = []; this.sequence = 0; this.speakers.clear(); }
17
-
18
- reconcile(turns: SpeakerTurn[], at: number, voices: VoiceChoice[]): Map<string, Speaker> {
19
- const links = new Map<string, Speaker>();
20
- const used = new Set<string>();
21
- const candidates: { local: string; global: string; overlap: number }[] = [];
22
- const totals = new Map<string, number>();
23
- for (const turn of turns) for (const old of this.previous) {
24
- const overlap = Math.min(at + turn.end * 1000, old.end) - Math.max(at + turn.start * 1000, old.start);
25
- if (overlap > 120) { const key = `${turn.speaker}|${old.speaker}`; totals.set(key, (totals.get(key) ?? 0) + overlap); }
26
- }
27
- for (const [key, overlap] of totals) { const [local, global] = key.split("|"); candidates.push({ local: local!, global: global!, overlap }); }
28
- for (const one of candidates.sort((a, b) => b.overlap - a.overlap)) {
29
- const speaker = this.speakers.get(one.global);
30
- if (speaker && !links.has(one.local) && !used.has(one.global)) { links.set(one.local, speaker); used.add(one.global); }
31
- }
32
- for (const turn of turns) {
33
- let speaker = links.get(turn.speaker);
34
- if (!speaker) {
35
- // Assign contrasting stock voices, without guessing a person's
36
- // gender from pitch or a noisy, short opening phrase.
37
- const taken = new Set([...this.speakers.values()].map(one => one.voice));
38
- const available = voices.filter(voice => !taken.has(voice.id));
39
- const pool = available.length ? available : voices;
40
- const voice = pool[Math.floor(this.random() * pool.length)];
41
- speaker = { id: `speaker-${++this.sequence}`, profile: turn.profile, voice: voice?.id ?? "auto" };
42
- this.speakers.set(speaker.id, speaker); links.set(turn.speaker, speaker);
43
- }
44
- }
45
- this.previous = turns.map(turn => ({ start: at + turn.start * 1000, end: at + turn.end * 1000, speaker: links.get(turn.speaker)!.id }));
46
- const present = new Set(this.previous.map(turn => turn.speaker));
47
- for (const key of this.speakers.keys()) if (this.speakers.size > 64 && !present.has(key)) this.speakers.delete(key);
48
- return links;
49
- }
50
- }
51
-
52
- /** Listener-local interpretation for the playing media. One request in flight and only the latest pending audio window. */
53
- export class Interpreter {
54
- readonly tracker = new SpeakerTracker();
55
- private generation = 0;
56
- private pending: AudioWindow | null = null;
57
- private committedUntil: number | null = null;
58
- private running: number | null = null;
59
- private controller: AbortController | null = null;
60
- private translating: number | null = null;
61
- private translationController: AbortController | null = null;
62
- private recognitionFailures = 0;
63
- private translationFailures = 0;
64
- private translations: { lines: Caption[]; language: string; target: string; until: number }[] = [];
65
- constructor(private readonly options: {
66
- language: () => string; speakers: () => boolean; voices: () => VoiceChoice[];
67
- channel: () => string; lines: (lines: Caption[]) => void;
68
- status: (text: string) => void; failed: (message: string) => void;
69
- fetcher?: typeof fetch;
70
- }) {}
71
-
72
- reset(): void {
73
- this.generation++; this.controller?.abort(); this.translationController?.abort();
74
- this.pending = null; this.running = null; this.translating = null;
75
- this.recognitionFailures = this.translationFailures = 0;
76
- this.translations = []; this.committedUntil = null; this.tracker.reset();
77
- }
78
- push(window: AudioWindow): void {
79
- this.committedUntil ??= window.freshAt;
80
- this.pending = window;
81
- if (this.running === null) void this.run(this.generation);
82
- }
83
- private async json(path: string, init: RequestInit, signal: AbortSignal): Promise<any> {
84
- const response = await (this.options.fetcher ?? fetch)(path, { ...init, signal });
85
- const body = await response.json();
86
- if (!response.ok) throw audioError(body.error || "Audio translation is unavailable.", response.status);
87
- return body;
88
- }
89
- private async run(generation: number): Promise<void> {
90
- this.running = generation;
91
- try {
92
- while (this.pending && generation === this.generation) {
93
- const window = this.pending; this.pending = null;
94
- try {
95
- if (Date.now() - window.until > 12_000) continue;
96
- const controller = new AbortController(); this.controller = controller;
97
- const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(12_000)]);
98
- const speakers = this.options.speakers(), target = this.options.language();
99
- const heard = await this.json(speakers ? "/api/v1/speech/speakers" : "/api/v1/speech/transcribe?live=1", {
100
- method: "POST", headers: { "content-type": "audio/wav" }, body: new Uint8Array(encodeWav(speakers ? window.samples : window.samples.slice(-80_000))),
101
- }, signal) as SpeakerTranscript & { text?: string };
102
- if (generation !== this.generation) return;
103
- this.recognitionFailures = 0;
104
- const language = heard.language;
105
- let lines: Caption[] = [];
106
- if (speakers) {
107
- const links = this.tracker.reconcile(heard.turns ?? [], window.at, this.options.voices());
108
- // The watermark follows emitted words, not the newest capture
109
- // interval: an overlapping window can recover audio that arrived
110
- // while recognition/translation was busy. Keep unfinished phrases
111
- // in that overlap so short capture intervals do not split every
112
- // sentence (especially damaging when German changes word order).
113
- const cutoff = this.committedUntil ?? window.freshAt;
114
- const turns = heard.turns ?? [];
115
- for (const [index, turn] of turns.entries()) {
116
- const speaker = links.get(turn.speaker)!;
117
- const words = turn.words.filter(word =>
118
- window.at + (word.start + word.end) * 500 >= cutoff &&
119
- (window.at + word.end * 1000 <= window.until - 100 || /[.!?。!?]$/.test(word.text)));
120
- let phrase: typeof words = [];
121
- const emit = (): void => {
122
- if (!phrase.length) return;
123
- lines.push({ channel: this.options.channel(), at: window.at + phrase[0]!.start * 1000,
124
- until: window.at + phrase.at(-1)!.end * 1000, text: phrase.map(word => word.text).join(" ").trim(),
125
- language, speaker: speaker.id, voiceProfile: turn.profile });
126
- phrase = [];
127
- };
128
- for (const word of words) {
129
- if (phrase.length && word.start - phrase.at(-1)!.end >= 0.45) emit();
130
- phrase.push(word);
131
- if (/[.!?。!?]$/.test(word.text) || phrase.map(word => word.text).join(" ").length >= 400) emit();
132
- }
133
- if (phrase.length && (index < turns.length - 1 ||
134
- window.until - (window.at + phrase.at(-1)!.end * 1000) >= 350 ||
135
- window.until - (window.at + phrase[0]!.start * 1000) >= 3000)) emit();
136
- }
137
- } else if (heard.text) {
138
- lines = [{ channel: this.options.channel(), at: window.freshAt, until: window.until, text: heard.text, language }];
139
- }
140
- if (!lines.length) continue;
141
- this.committedUntil = Math.max(this.committedUntil ?? window.freshAt, ...lines.map(line => line.until));
142
- // Drop expired work instead of turning off an otherwise healthy stream.
143
- this.translations = this.translations.filter(item => Date.now() - item.until <= 12_000).slice(-11);
144
- this.translations.push({ lines, language, target, until: window.until });
145
- // Recognition of the next audio window can proceed while the text
146
- // model translates this phrase. Voice synthesis is a third stage.
147
- if (this.translating === null) void this.translate(this.generation);
148
- } catch (error) {
149
- if (generation !== this.generation) return;
150
- if (!transientAudioError(error) || ++this.recognitionFailures >= 3) throw error;
151
- this.options.status("Speech recognition interrupted · listening for the next phrase…");
152
- }
153
- }
154
- } catch (error) {
155
- if (generation === this.generation) { const message = error instanceof Error ? error.message : "Live translation stopped."; this.reset(); this.options.failed(message); this.options.status(message); }
156
- } finally { if (this.running === generation) this.running = null; }
157
- }
158
-
159
- private async translate(generation: number): Promise<void> {
160
- this.translating = generation;
161
- try {
162
- while (this.translations.length && generation === this.generation) {
163
- const item = this.translations.shift()!;
164
- try {
165
- let { lines } = item;
166
- const { language, target, until } = item;
167
- if (Date.now() - until > 12_000) continue;
168
- const controller = new AbortController(); this.translationController = controller;
169
- if (target && language !== target) {
170
- if (!language) throw audioError("The audio language could not be detected. Waiting for clearer speech.", 502);
171
- const translated = await this.json("/api/v1/translate?live=1", {
172
- method: "POST", headers: { "content-type": "application/json" },
173
- body: JSON.stringify({ texts: lines.map(line => line.text), from: language, to: target }),
174
- }, AbortSignal.any([controller.signal, AbortSignal.timeout(12_000)])) as { texts: string[] };
175
- if (translated.texts.length !== lines.length || translated.texts.some(text => !text.trim())) throw audioError("Translation omitted a phrase. Waiting for the next phrase.", 502);
176
- lines = lines.map((line, i) => ({ ...line, original: line.text, sourceLanguage: language, language: target, text: translated.texts[i]! }));
177
- }
178
- if (generation !== this.generation) return;
179
- if (Date.now() - until > 12_000) continue;
180
- this.translationFailures = 0;
181
- this.options.lines(lines.flatMap(line => splitCaption(line)));
182
- this.options.status(target ? `${language} → ${target} · live translation` : `Original audio language: ${language || "detecting…"}`);
183
- } catch (error) {
184
- if (generation !== this.generation) return;
185
- if (!transientAudioError(error) || ++this.translationFailures >= 3) throw error;
186
- this.options.status("Translation interrupted · catching up with live speech…");
187
- }
188
- }
189
- } catch (error) {
190
- if (generation === this.generation) { const message = error instanceof Error ? error.message : "Live translation stopped."; this.reset(); this.options.failed(message); this.options.status(message); }
191
- } finally { if (this.translating === generation) this.translating = null; }
192
- }
193
- }
194
-
195
- /** Preserve all words while respecting the speech API's character limit. */
196
- export function splitCaption(line: Caption): Caption[] {
197
- const text = line.text.trim();
198
- if (!text) return [];
199
- const parts: string[] = [];
200
- let rest = text;
201
- while (rest.length > 600) {
202
- const space = rest.lastIndexOf(" ", 600);
203
- const end = space > 300 ? space : 600;
204
- parts.push(rest.slice(0, end)); rest = rest.slice(end).trimStart();
205
- }
206
- if (rest) parts.push(rest);
207
- let offset = 0;
208
- return parts.map(part => {
209
- const at = line.at + (line.until - line.at) * offset / text.length;
210
- offset += part.length + 1;
211
- return { ...line, text: part, at, until: Math.min(line.until, line.at + (line.until - line.at) * offset / text.length) };
212
- });
213
- }
1
+ export { Interpreter, SpeakerTracker, splitCaption, type Caption, type VoiceChoice, type AudioWindow, type Speaker } from "@profullstack/live-translate/core";
@@ -1,10 +1 @@
1
- /** Access, billing and throttling failures are terminal. Only transient service
2
- * and transport failures may recover, with a bounded consecutive failure count. */
3
- export function transientAudioError(error: unknown): boolean {
4
- const status = (error as { status?: number } | null)?.status;
5
- if (typeof status === "number") return [408, 502, 503, 504].includes(status);
6
- return error instanceof TypeError || (error instanceof Error && ["AbortError", "TimeoutError"].includes(error.name));
7
- }
8
- export function audioError(message: string, status: number): Error & { status: number } {
9
- return Object.assign(new Error(message), { status });
10
- }
1
+ export { audioError, transientAudioError } from "@profullstack/live-translate/core";