nixamp 0.27.2 → 0.27.3

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 (42) hide show
  1. package/README.md +39 -16
  2. package/backtoschool/dist/assets/{index-OE2xWZUH.css → index-Bot4FEXQ.css} +1 -1
  3. package/backtoschool/dist/assets/index-Cjai7LgT.js +93 -0
  4. package/backtoschool/dist/index.html +13 -2
  5. package/dist/event-writer.d.ts +39 -0
  6. package/dist/event-writer.js +139 -0
  7. package/dist/live-api.d.ts +2 -0
  8. package/dist/live-api.js +37 -2
  9. package/dist/live-interpreter.d.ts +3 -1
  10. package/dist/live-interpreter.js +109 -83
  11. package/dist/live-recovery.d.ts +6 -0
  12. package/dist/live-recovery.js +11 -0
  13. package/dist/mail-sender.d.ts +9 -0
  14. package/dist/mail-sender.js +11 -0
  15. package/dist/notify.d.ts +3 -3
  16. package/dist/notify.js +8 -5
  17. package/dist/password-reset.d.ts +2 -3
  18. package/dist/password-reset.js +4 -2
  19. package/dist/server.d.ts +2 -0
  20. package/dist/server.js +13 -1
  21. package/dist/shared-translation.d.ts +1 -0
  22. package/dist/shared-translation.js +32 -23
  23. package/package.json +1 -1
  24. package/src/event-writer.ts +126 -0
  25. package/src/live-api.ts +26 -2
  26. package/src/live-interpreter.ts +87 -68
  27. package/src/live-recovery.ts +10 -0
  28. package/src/mail-sender.ts +12 -0
  29. package/src/notify.ts +10 -7
  30. package/src/password-reset.ts +5 -5
  31. package/src/server.ts +14 -1
  32. package/src/shared-translation.ts +27 -19
  33. package/web/dist/assets/background.worker-Hz7oaQ8X.js +547 -0
  34. package/web/dist/assets/{hls-3VKVEQE3-CAL4qBEq.js → hls-3VKVEQE3-_BiJh76x.js} +1 -1
  35. package/web/dist/assets/index-BgLxkXKe.js +3 -0
  36. package/web/dist/assets/{mpegts-H9oC2fAK.js → mpegts-CV0glck7.js} +1 -1
  37. package/web/dist/assets/{mpegts-LO6RVLD6-Bkd9Sqr6.js → mpegts-LO6RVLD6-iuTMg-O6.js} +1 -1
  38. package/web/dist/index.html +1 -1
  39. package/web/dist/sw.js +6 -6
  40. package/backtoschool/dist/assets/index-pQ_uH4bZ.js +0 -93
  41. package/web/dist/assets/background.worker-DH8ag5Q4.js +0 -547
  42. package/web/dist/assets/index-CLeEWlms.js +0 -3
@@ -49,6 +49,8 @@ export declare class Interpreter {
49
49
  private controller;
50
50
  private translating;
51
51
  private translationController;
52
+ private recognitionFailures;
53
+ private translationFailures;
52
54
  private translations;
53
55
  constructor(options: {
54
56
  language: () => string;
@@ -57,7 +59,7 @@ export declare class Interpreter {
57
59
  channel: () => string;
58
60
  lines: (lines: Caption[]) => void;
59
61
  status: (text: string) => void;
60
- failed: () => void;
62
+ failed: (message: string) => void;
61
63
  fetcher?: typeof fetch;
62
64
  });
63
65
  reset(): void;
@@ -1,3 +1,4 @@
1
+ import { audioError, transientAudioError } from "./live-recovery.js";
1
2
  import { encodeWav } from "./pcm-wav.js";
2
3
  /** Match provider labels across overlapping timestamps. A speaker absent from
3
4
  * the rolling context gets a new label; pitch alone never identifies someone. */
@@ -67,6 +68,8 @@ export class Interpreter {
67
68
  controller = null;
68
69
  translating = null;
69
70
  translationController = null;
71
+ recognitionFailures = 0;
72
+ translationFailures = 0;
70
73
  translations = [];
71
74
  constructor(options) {
72
75
  this.options = options;
@@ -78,6 +81,7 @@ export class Interpreter {
78
81
  this.pending = null;
79
82
  this.running = null;
80
83
  this.translating = null;
84
+ this.recognitionFailures = this.translationFailures = 0;
81
85
  this.translations = [];
82
86
  this.committedUntil = null;
83
87
  this.tracker.reset();
@@ -92,7 +96,7 @@ export class Interpreter {
92
96
  const response = await (this.options.fetcher ?? fetch)(path, { ...init, signal });
93
97
  const body = await response.json();
94
98
  if (!response.ok)
95
- throw new Error(body.error || "Audio translation is unavailable.");
99
+ throw audioError(body.error || "Audio translation is unavailable.", response.status);
96
100
  return body;
97
101
  }
98
102
  async run(generation) {
@@ -101,74 +105,85 @@ export class Interpreter {
101
105
  while (this.pending && generation === this.generation) {
102
106
  const window = this.pending;
103
107
  this.pending = null;
104
- if (Date.now() - window.until > 12_000)
105
- continue;
106
- const controller = new AbortController();
107
- this.controller = controller;
108
- const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(12_000)]);
109
- const speakers = this.options.speakers(), target = this.options.language();
110
- const heard = await this.json(speakers ? "/api/v1/speech/speakers" : "/api/v1/speech/transcribe?live=1", {
111
- method: "POST", headers: { "content-type": "audio/wav" }, body: new Uint8Array(encodeWav(speakers ? window.samples : window.samples.slice(-80_000))),
112
- }, signal);
113
- if (generation !== this.generation)
114
- return;
115
- const language = heard.language;
116
- let lines = [];
117
- if (speakers) {
118
- const links = this.tracker.reconcile(heard.turns ?? [], window.at, this.options.voices());
119
- // The watermark follows emitted words, not the newest capture
120
- // interval: an overlapping window can recover audio that arrived
121
- // while recognition/translation was busy. Keep unfinished phrases
122
- // in that overlap so short capture intervals do not split every
123
- // sentence (especially damaging when German changes word order).
124
- const cutoff = this.committedUntil ?? window.freshAt;
125
- const turns = heard.turns ?? [];
126
- for (const [index, turn] of turns.entries()) {
127
- const speaker = links.get(turn.speaker);
128
- const words = turn.words.filter(word => window.at + (word.start + word.end) * 500 >= cutoff &&
129
- (window.at + word.end * 1000 <= window.until - 100 || /[.!?。!?]$/.test(word.text)));
130
- let phrase = [];
131
- const emit = () => {
132
- if (!phrase.length)
133
- return;
134
- lines.push({ channel: this.options.channel(), at: window.at + phrase[0].start * 1000,
135
- until: window.at + phrase.at(-1).end * 1000, text: phrase.map(word => word.text).join(" ").trim(),
136
- language, speaker: speaker.id, voiceProfile: turn.profile });
137
- phrase = [];
138
- };
139
- for (const word of words) {
140
- if (phrase.length && word.start - phrase.at(-1).end >= 0.45)
141
- emit();
142
- phrase.push(word);
143
- if (/[.!?。!?]$/.test(word.text) || phrase.map(word => word.text).join(" ").length >= 400)
108
+ try {
109
+ if (Date.now() - window.until > 12_000)
110
+ continue;
111
+ const controller = new AbortController();
112
+ this.controller = controller;
113
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(12_000)]);
114
+ const speakers = this.options.speakers(), target = this.options.language();
115
+ const heard = await this.json(speakers ? "/api/v1/speech/speakers" : "/api/v1/speech/transcribe?live=1", {
116
+ method: "POST", headers: { "content-type": "audio/wav" }, body: new Uint8Array(encodeWav(speakers ? window.samples : window.samples.slice(-80_000))),
117
+ }, signal);
118
+ if (generation !== this.generation)
119
+ return;
120
+ this.recognitionFailures = 0;
121
+ const language = heard.language;
122
+ let lines = [];
123
+ if (speakers) {
124
+ const links = this.tracker.reconcile(heard.turns ?? [], window.at, this.options.voices());
125
+ // The watermark follows emitted words, not the newest capture
126
+ // interval: an overlapping window can recover audio that arrived
127
+ // while recognition/translation was busy. Keep unfinished phrases
128
+ // in that overlap so short capture intervals do not split every
129
+ // sentence (especially damaging when German changes word order).
130
+ const cutoff = this.committedUntil ?? window.freshAt;
131
+ const turns = heard.turns ?? [];
132
+ for (const [index, turn] of turns.entries()) {
133
+ const speaker = links.get(turn.speaker);
134
+ const words = turn.words.filter(word => window.at + (word.start + word.end) * 500 >= cutoff &&
135
+ (window.at + word.end * 1000 <= window.until - 100 || /[.!?。!?]$/.test(word.text)));
136
+ let phrase = [];
137
+ const emit = () => {
138
+ if (!phrase.length)
139
+ return;
140
+ lines.push({ channel: this.options.channel(), at: window.at + phrase[0].start * 1000,
141
+ until: window.at + phrase.at(-1).end * 1000, text: phrase.map(word => word.text).join(" ").trim(),
142
+ language, speaker: speaker.id, voiceProfile: turn.profile });
143
+ phrase = [];
144
+ };
145
+ for (const word of words) {
146
+ if (phrase.length && word.start - phrase.at(-1).end >= 0.45)
147
+ emit();
148
+ phrase.push(word);
149
+ if (/[.!?。!?]$/.test(word.text) || phrase.map(word => word.text).join(" ").length >= 400)
150
+ emit();
151
+ }
152
+ if (phrase.length && (index < turns.length - 1 ||
153
+ window.until - (window.at + phrase.at(-1).end * 1000) >= 350 ||
154
+ window.until - (window.at + phrase[0].start * 1000) >= 3000))
144
155
  emit();
145
156
  }
146
- if (phrase.length && (index < turns.length - 1 ||
147
- window.until - (window.at + phrase.at(-1).end * 1000) >= 350 ||
148
- window.until - (window.at + phrase[0].start * 1000) >= 3000))
149
- emit();
150
157
  }
158
+ else if (heard.text) {
159
+ lines = [{ channel: this.options.channel(), at: window.freshAt, until: window.until, text: heard.text, language }];
160
+ }
161
+ if (!lines.length)
162
+ continue;
163
+ this.committedUntil = Math.max(this.committedUntil ?? window.freshAt, ...lines.map(line => line.until));
164
+ // Drop expired work instead of turning off an otherwise healthy stream.
165
+ this.translations = this.translations.filter(item => Date.now() - item.until <= 12_000).slice(-11);
166
+ this.translations.push({ lines, language, target, until: window.until });
167
+ // Recognition of the next audio window can proceed while the text
168
+ // model translates this phrase. Voice synthesis is a third stage.
169
+ if (this.translating === null)
170
+ void this.translate(this.generation);
151
171
  }
152
- else if (heard.text) {
153
- lines = [{ channel: this.options.channel(), at: window.freshAt, until: window.until, text: heard.text, language }];
172
+ catch (error) {
173
+ if (generation !== this.generation)
174
+ return;
175
+ if (!transientAudioError(error) || ++this.recognitionFailures >= 3)
176
+ throw error;
177
+ this.options.status("Speech recognition interrupted · listening for the next phrase…");
154
178
  }
155
- if (!lines.length)
156
- continue;
157
- this.committedUntil = Math.max(this.committedUntil ?? window.freshAt, ...lines.map(line => line.until));
158
- if (this.translations.length >= 12)
159
- throw new Error("Translation fell behind. Try again after the language model has warmed up.");
160
- this.translations.push({ lines, language, target, until: window.until });
161
- // Recognition of the next audio window can proceed while the text
162
- // model translates this phrase. Voice synthesis is a third stage.
163
- if (this.translating === null)
164
- void this.translate(this.generation);
165
179
  }
166
180
  }
167
181
  catch (error) {
168
182
  if (generation === this.generation) {
183
+ const message = error instanceof Error ? error.message : "Live translation stopped.";
169
184
  this.reset();
170
- this.options.failed();
171
- this.options.status(error instanceof Error ? error.message : "Live translation stopped.");
185
+ this.options.failed(message);
186
+ this.options.status(message);
172
187
  }
173
188
  }
174
189
  finally {
@@ -181,36 +196,47 @@ export class Interpreter {
181
196
  try {
182
197
  while (this.translations.length && generation === this.generation) {
183
198
  const item = this.translations.shift();
184
- let { lines } = item;
185
- const { language, target, until } = item;
186
- if (Date.now() - until > 12_000)
187
- throw new Error("Translation fell behind. Try again after the language model has warmed up.");
188
- const controller = new AbortController();
189
- this.translationController = controller;
190
- if (target && language !== target) {
191
- if (!language)
192
- throw new Error("The audio language could not be detected. Waiting for clearer speech.");
193
- const translated = await this.json("/api/v1/translate?live=1", {
194
- method: "POST", headers: { "content-type": "application/json" },
195
- body: JSON.stringify({ texts: lines.map(line => line.text), from: language, to: target }),
196
- }, AbortSignal.any([controller.signal, AbortSignal.timeout(12_000)]));
197
- if (translated.texts.length !== lines.length || translated.texts.some(text => !text.trim()))
198
- throw new Error("Translation omitted a phrase. Enable translated audio again to retry.");
199
- lines = lines.map((line, i) => ({ ...line, original: line.text, sourceLanguage: language, language: target, text: translated.texts[i] }));
199
+ try {
200
+ let { lines } = item;
201
+ const { language, target, until } = item;
202
+ if (Date.now() - until > 12_000)
203
+ continue;
204
+ const controller = new AbortController();
205
+ this.translationController = controller;
206
+ if (target && language !== target) {
207
+ if (!language)
208
+ throw audioError("The audio language could not be detected. Waiting for clearer speech.", 502);
209
+ const translated = await this.json("/api/v1/translate?live=1", {
210
+ method: "POST", headers: { "content-type": "application/json" },
211
+ body: JSON.stringify({ texts: lines.map(line => line.text), from: language, to: target }),
212
+ }, AbortSignal.any([controller.signal, AbortSignal.timeout(12_000)]));
213
+ if (translated.texts.length !== lines.length || translated.texts.some(text => !text.trim()))
214
+ throw audioError("Translation omitted a phrase. Waiting for the next phrase.", 502);
215
+ lines = lines.map((line, i) => ({ ...line, original: line.text, sourceLanguage: language, language: target, text: translated.texts[i] }));
216
+ }
217
+ if (generation !== this.generation)
218
+ return;
219
+ if (Date.now() - until > 12_000)
220
+ continue;
221
+ this.translationFailures = 0;
222
+ this.options.lines(lines.flatMap(line => splitCaption(line)));
223
+ this.options.status(target ? `${language} → ${target} · live translation` : `Original audio language: ${language || "detecting…"}`);
224
+ }
225
+ catch (error) {
226
+ if (generation !== this.generation)
227
+ return;
228
+ if (!transientAudioError(error) || ++this.translationFailures >= 3)
229
+ throw error;
230
+ this.options.status("Translation interrupted · catching up with live speech…");
200
231
  }
201
- if (generation !== this.generation)
202
- return;
203
- if (Date.now() - until > 12_000)
204
- throw new Error("Translation fell behind. Try again after the language model has warmed up.");
205
- this.options.lines(lines.flatMap(line => splitCaption(line)));
206
- this.options.status(target ? `${language} → ${target} · live translation` : `Original audio language: ${language || "detecting…"}`);
207
232
  }
208
233
  }
209
234
  catch (error) {
210
235
  if (generation === this.generation) {
236
+ const message = error instanceof Error ? error.message : "Live translation stopped.";
211
237
  this.reset();
212
- this.options.failed();
213
- this.options.status(error instanceof Error ? error.message : "Live translation stopped.");
238
+ this.options.failed(message);
239
+ this.options.status(message);
214
240
  }
215
241
  }
216
242
  finally {
@@ -0,0 +1,6 @@
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 declare function transientAudioError(error: unknown): boolean;
4
+ export declare function audioError(message: string, status: number): Error & {
5
+ status: number;
6
+ };
@@ -0,0 +1,11 @@
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) {
4
+ const status = error?.status;
5
+ if (typeof status === "number")
6
+ return [408, 502, 503, 504].includes(status);
7
+ return error instanceof TypeError || (error instanceof Error && ["AbortError", "TimeoutError"].includes(error.name));
8
+ }
9
+ export function audioError(message, status) {
10
+ return Object.assign(new Error(message), { status });
11
+ }
@@ -0,0 +1,9 @@
1
+ export interface MailSender {
2
+ apiKey: string;
3
+ from: string;
4
+ }
5
+ export interface BrandedMailSender extends MailSender {
6
+ backtoschool?: MailSender;
7
+ }
8
+ /** Only the exact school origins select its verified sender and credentials. */
9
+ export declare function mailSender(options: BrandedMailSender, link: string): MailSender;
@@ -0,0 +1,11 @@
1
+ /** Only the exact school origins select its verified sender and credentials. */
2
+ export function mailSender(options, link) {
3
+ try {
4
+ const url = new URL(link);
5
+ if (options.backtoschool && url.protocol === "https:" &&
6
+ ["backtoschool.help", "www.backtoschool.help"].includes(url.hostname))
7
+ return options.backtoschool;
8
+ }
9
+ catch { /* A malformed link never changes the sender. */ }
10
+ return options;
11
+ }
package/dist/notify.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type BrandedMailSender } from "./mail-sender.ts";
1
2
  /**
2
3
  * Telling followers a broadcaster went live, wherever they are.
3
4
  *
@@ -58,9 +59,8 @@ export declare function notifyAll(audience: readonly Reachable[], note: Notifica
58
59
  * HTTP rather than SMTP so there is no connection to hold, no port to be
59
60
  * blocked, and no dependency: a fetch is the whole client.
60
61
  */
61
- export declare function resendEmail({ apiKey, from, fetch, onEvent }: {
62
- apiKey: string;
63
- from: string;
62
+ export declare function resendEmail({ apiKey, from, backtoschool, reason, fetch, onEvent }: BrandedMailSender & {
63
+ reason?: string;
64
64
  fetch?: typeof globalThis.fetch;
65
65
  onEvent?: (message: string) => void;
66
66
  }): (to: string, note: Notification) => Promise<boolean>;
package/dist/notify.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { mailSender } from "./mail-sender.js";
1
2
  /**
2
3
  * Tell an audience, on every channel each of them wants.
3
4
  *
@@ -52,20 +53,22 @@ export async function notifyAll(audience, note, channels) {
52
53
  * HTTP rather than SMTP so there is no connection to hold, no port to be
53
54
  * blocked, and no dependency: a fetch is the whole client.
54
55
  */
55
- export function resendEmail({ apiKey, from, fetch = globalThis.fetch, onEvent }) {
56
+ export function resendEmail({ apiKey, from, backtoschool, reason = "You are getting this because you follow them on nixamp.", fetch = globalThis.fetch, onEvent }) {
56
57
  return async (to, note) => {
57
58
  try {
59
+ const sender = mailSender({ apiKey, from, backtoschool }, note.url);
58
60
  const response = await fetch("https://api.resend.com/emails", {
59
61
  method: "POST",
60
- headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
62
+ headers: { authorization: `Bearer ${sender.apiKey}`, "content-type": "application/json" },
63
+ signal: AbortSignal.timeout(15_000),
61
64
  body: JSON.stringify({
62
- from,
65
+ from: sender.from,
63
66
  to: [to],
64
67
  subject: note.title,
65
- text: `${note.body}\n\n${note.url}\n\nYou are getting this because you follow them on nixamp.`,
68
+ text: `${note.body}\n\n${note.url}\n\n${reason}`,
66
69
  html: `<p>${escapeHtml(note.body)}</p>` +
67
70
  `<p><a href="${escapeHtml(note.url)}">${escapeHtml(note.url)}</a></p>` +
68
- `<p style="color:#666;font-size:12px">You are getting this because you follow them on nixamp.</p>`,
71
+ `<p style="color:#666;font-size:12px">${escapeHtml(reason)}</p>`,
69
72
  }),
70
73
  });
71
74
  if (!response.ok) {
@@ -1,5 +1,6 @@
1
1
  import type { Queryable } from "./follows.ts";
2
2
  import type { Tokens } from "./tokens.ts";
3
+ import { type BrandedMailSender } from "./mail-sender.ts";
3
4
  export declare const RESET_MESSAGE = "If an account exists for that email, you\u2019ll receive a password reset link shortly.";
4
5
  export declare const RESET_INVALID = "This reset link is invalid, expired, or already used. Request a new link.";
5
6
  interface Passwords {
@@ -27,9 +28,7 @@ export declare class PasswordResets {
27
28
  acceptsLegacyToken(userId: string, token: string): Promise<boolean>;
28
29
  }
29
30
  /** Transactional recovery mail has no follower footer and never logs a token. */
30
- export declare function resendPasswordReset(options: {
31
- apiKey: string;
32
- from: string;
31
+ export declare function resendPasswordReset(options: BrandedMailSender & {
33
32
  fetch?: typeof globalThis.fetch;
34
33
  }): ResetMail;
35
34
  export {};
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
+ import { mailSender } from "./mail-sender.js";
2
3
  export const RESET_MESSAGE = "If an account exists for that email, you’ll receive a password reset link shortly.";
3
4
  export const RESET_INVALID = "This reset link is invalid, expired, or already used. Request a new link.";
4
5
  const LIFETIME_MS = 30 * 60_000;
@@ -105,12 +106,13 @@ export class PasswordResets {
105
106
  export function resendPasswordReset(options) {
106
107
  return async (email, link) => {
107
108
  const host = new URL(link).hostname;
109
+ const sender = mailSender(options, link);
108
110
  const response = await (options.fetch ?? globalThis.fetch)("https://api.resend.com/emails", {
109
111
  method: "POST",
110
- headers: { authorization: `Bearer ${options.apiKey}`, "content-type": "application/json" },
112
+ headers: { authorization: `Bearer ${sender.apiKey}`, "content-type": "application/json" },
111
113
  signal: AbortSignal.timeout(15_000),
112
114
  body: JSON.stringify({
113
- from: options.from, to: [email], subject: `Reset your ${host} password`,
115
+ from: sender.from, to: [email], subject: `Reset your ${host} password`,
114
116
  text: `Someone requested a password reset for your account on ${host}.\n\nChoose a new password using this link:\n${link}\n\nThis link expires in ${LIFETIME_MS / 60_000} minutes and works once. Resetting your password signs out existing sessions.\n\nIf you did not request this, you can ignore this email. Your password will stay the same.`,
115
117
  }),
116
118
  });
package/dist/server.d.ts CHANGED
@@ -29,6 +29,7 @@ import { Names } from "./names.ts";
29
29
  import { Certs } from "./certs.ts";
30
30
  import { type Throttle } from "@profullstack/throttle";
31
31
  import { type Notification } from "./notify.ts";
32
+ import { EventWriter } from "./event-writer.ts";
32
33
  import { LiveEvents } from "./live-events.ts";
33
34
  import { Tickets } from "./tickets.ts";
34
35
  import { Layouts } from "./layouts.ts";
@@ -703,6 +704,7 @@ export interface HandlerOptions {
703
704
  settingsSync?: SettingsSync;
704
705
  /** Scheduled and live sessions, kept by NixAmp and shared by branded clients. */
705
706
  events?: LiveEvents;
707
+ eventWriter?: EventWriter;
706
708
  /** Versioned panel layouts, including event and user overrides. */
707
709
  layouts?: Layouts;
708
710
  /** Persistent participation state that must not be coupled to live audio. */
package/dist/server.js CHANGED
@@ -66,6 +66,7 @@ import { createThrottle, presentedCredential } from "@profullstack/throttle";
66
66
  import { Durable } from "./durable.js";
67
67
  import { notifyAll, resendEmail, webPush } from "./notify.js";
68
68
  import { inviteSubject, inviteText, isEmail, isPhone, watchLink } from "./invite.js";
69
+ import { EventWriter } from "./event-writer.js";
69
70
  import { handleLiveApi } from "./live-api.js";
70
71
  import { LiveEvents, eventStructuredData, isTicketed } from "./live-events.js";
71
72
  import { Tickets, needsTicket, ticketFrom, ticketsFromEnv } from "./tickets.js";
@@ -1448,6 +1449,7 @@ export function createHandler(engine, options) {
1448
1449
  return;
1449
1450
  if (options.events && await handleLiveApi(request, response, url, {
1450
1451
  events: options.events,
1452
+ ...(options.eventWriter ? { eventWriter: options.eventWriter } : {}),
1451
1453
  ...(options.accounts ? { accounts: options.accounts } : {}),
1452
1454
  ...(options.layouts ? { layouts: options.layouts } : {}),
1453
1455
  ...(options.rooms ? { rooms: options.rooms } : {}),
@@ -5599,6 +5601,10 @@ export async function serve(argv, version = "0.1.0") {
5599
5601
  const favorites = pool ? new Favorites(pool) : undefined;
5600
5602
  const settingsSync = pool ? new SettingsSync(pool) : undefined;
5601
5603
  const nixampSite = (process.env["NIXAMP_SITE"] ?? DEFAULT_DIRECTORY).replace(/\/+$/, "");
5604
+ const schoolMailKey = process.env["BACKTOSCHOOL_RESEND_API_KEY"] ?? process.env["RESEND_API_KEY"];
5605
+ const schoolMail = schoolMailKey && process.env["BACKTOSCHOOL_MAIL_FROM"] ? {
5606
+ apiKey: schoolMailKey, from: process.env["BACKTOSCHOOL_MAIL_FROM"],
5607
+ } : undefined;
5602
5608
  const events = pool ? new LiveEvents(pool) : undefined;
5603
5609
  const layouts = pool ? new Layouts(pool) : undefined;
5604
5610
  const rooms = pool ? new Rooms(pool) : undefined;
@@ -6118,7 +6124,10 @@ export async function serve(argv, version = "0.1.0") {
6118
6124
  ...(follows ? { follows, vapidPublicKey } : {}),
6119
6125
  ...(favorites ? { favorites } : {}),
6120
6126
  ...(settingsSync ? { settingsSync } : {}),
6121
- ...(events ? { events } : {}),
6127
+ ...(events ? { events, eventWriter: new EventWriter({
6128
+ openaiKey: process.env["OPENAI_API_KEY"], anthropicKey: process.env["ANTHROPIC_API_KEY"],
6129
+ openaiModel: process.env["NIXAMP_WRITER_OPENAI_MODEL"], claudeModel: process.env["NIXAMP_WRITER_CLAUDE_MODEL"],
6130
+ }) } : {}),
6122
6131
  ...(layouts ? { layouts } : {}),
6123
6132
  ...(rooms ? { rooms } : {}),
6124
6133
  ...(trollbox ? { trollbox } : {}),
@@ -6135,6 +6144,7 @@ export async function serve(argv, version = "0.1.0") {
6135
6144
  resetMail: resendPasswordReset({
6136
6145
  apiKey: process.env["RESEND_API_KEY"],
6137
6146
  from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>",
6147
+ backtoschool: schoolMail,
6138
6148
  }),
6139
6149
  } : {}),
6140
6150
  // Other people's OpenProfiles, read for the voice a line is spoken in,
@@ -6159,6 +6169,8 @@ export async function serve(argv, version = "0.1.0") {
6159
6169
  email: resendEmail({
6160
6170
  apiKey: process.env["RESEND_API_KEY"],
6161
6171
  from: process.env["NIXAMP_MAIL_FROM"] ?? "nixamp <notifications@nixamp.com>",
6172
+ backtoschool: schoolMail,
6173
+ reason: "You received this email because a host invited you to join a live session.",
6162
6174
  onEvent: (message) => console.log(message),
6163
6175
  }),
6164
6176
  }
@@ -27,6 +27,7 @@ export type SharedEvent = {
27
27
  } | {
28
28
  type: "error";
29
29
  error: string;
30
+ retryable?: boolean;
30
31
  };
31
32
  type Member = {
32
33
  by: string;
@@ -1,5 +1,6 @@
1
1
  /** One live source/language pipeline, paid access for every listening account.
2
2
  * Browsers receive the same PCM, never upload a replacement for a public feed. */
3
+ import { transientAudioError } from "./live-recovery.js";
3
4
  import { createHash, randomUUID } from "node:crypto";
4
5
  import { lookup } from "node:dns/promises";
5
6
  import { BlockList, isIP } from "node:net";
@@ -179,15 +180,15 @@ export class SharedTranslations {
179
180
  }
180
181
  }
181
182
  }
182
- stop(feed, error) {
183
- this.broadcast(feed, { type: "error", error });
183
+ stop(feed, error, retryable = false) {
184
+ this.broadcast(feed, { type: "error", error, retryable });
184
185
  for (const member of [...feed.members]) {
185
186
  this.leave(feed, member);
186
187
  member.close();
187
188
  }
188
189
  }
189
190
  make(id, source, language) {
190
- const feed = { id, source, language, members: new Set(), controller: new AbortController(), reservations: new Map(), queue: [], speaking: false, previous: Buffer.alloc(0), pending: Buffer.alloc(0), until: Date.now() };
191
+ const feed = { id, source, language, members: new Set(), controller: new AbortController(), reservations: new Map(), queue: [], speaking: false, voiceFailures: 0, previous: Buffer.alloc(0), pending: Buffer.alloc(0), until: Date.now() };
191
192
  // Each account uses free access first, then the same published credit rate.
192
193
  // Additional listeners do not trigger recognition or synthesis.
193
194
  feed.meter = {
@@ -254,13 +255,10 @@ export class SharedTranslations {
254
255
  language: () => language, speakers: () => true, voices: () => voices, channel: () => id,
255
256
  status: text => { if (!feed.controller.signal.aborted && !feed.members.size)
256
257
  this.stop(feed, text); },
257
- failed: () => this.stop(feed, "Live translation stopped. Enable it again to retry."),
258
+ failed: message => this.stop(feed, message),
258
259
  lines: lines => {
259
260
  feed.queue.push(...lines);
260
- if (feed.queue.length > 24) {
261
- this.stop(feed, "Live translation fell behind. Enable it again to retry.");
262
- return;
263
- }
261
+ feed.queue = feed.queue.filter(line => Date.now() - line.until <= 12_000).slice(-24);
264
262
  if (!feed.speaking)
265
263
  void this.speak(feed);
266
264
  },
@@ -296,7 +294,7 @@ export class SharedTranslations {
296
294
  samples[index] = pcm.readInt16LE(index * 2) / 32768;
297
295
  feed.interpreter.push({ samples, at: feed.until - samples.length / 16, until: feed.until, freshAt: feed.until - 2000 });
298
296
  }
299
- }, () => this.stop(feed, "The live source stopped. Enable translated audio again when playback resumes."), feed.controller.signal);
297
+ }, () => this.stop(feed, "The live source disconnected.", true), feed.controller.signal);
300
298
  if (feed.controller.signal.aborted)
301
299
  input.stop();
302
300
  else
@@ -312,24 +310,35 @@ export class SharedTranslations {
312
310
  while (feed.queue.length && !feed.controller.signal.aborted) {
313
311
  const line = feed.queue.shift();
314
312
  if (Date.now() - line.until > 12_000)
315
- throw new SpeechError("Live translation fell behind. Enable it again to retry.", 503);
316
- 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);
317
- const id = randomUUID();
318
- this.broadcast(feed, { type: "line", id, line });
319
- const reader = response.body.getReader();
313
+ continue;
320
314
  try {
321
- while (true) {
322
- const { done, value } = await reader.read();
323
- if (done)
324
- break;
325
- for (let at = 0; at < value.length; at += 16_384)
326
- this.broadcast(feed, { type: "audio", id, data: Buffer.from(value.subarray(at, at + 16_384)).toString("base64") });
315
+ 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);
316
+ const id = randomUUID();
317
+ this.broadcast(feed, { type: "line", id, line });
318
+ const reader = response.body.getReader();
319
+ try {
320
+ while (true) {
321
+ const { done, value } = await reader.read();
322
+ if (done)
323
+ break;
324
+ for (let at = 0; at < value.length; at += 16_384)
325
+ this.broadcast(feed, { type: "audio", id, data: Buffer.from(value.subarray(at, at + 16_384)).toString("base64") });
326
+ }
327
327
  }
328
+ finally {
329
+ reader.releaseLock();
330
+ this.broadcast(feed, { type: "end", id });
331
+ }
332
+ feed.voiceFailures = 0;
328
333
  }
329
- finally {
330
- reader.releaseLock();
334
+ catch (error) {
335
+ if (feed.controller.signal.aborted)
336
+ return;
337
+ if (!transientAudioError(error) || ++feed.voiceFailures >= 3)
338
+ throw error;
339
+ // A provider failure skips this phrase; the source and other listeners
340
+ // remain connected. Never replay partial speech or retry a charge.
331
341
  }
332
- this.broadcast(feed, { type: "end", id });
333
342
  }
334
343
  }
335
344
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.27.2",
3
+ "version": "0.27.3",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",