nixamp 0.27.1 → 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.
- package/README.md +53 -20
- package/backtoschool/dist/assets/{index-6vtCMzi1.css → index-Bot4FEXQ.css} +1 -1
- package/backtoschool/dist/assets/index-Cjai7LgT.js +93 -0
- package/backtoschool/dist/index.html +36 -9
- package/dist/accounts.d.ts +2 -0
- package/dist/accounts.js +9 -2
- package/dist/classroom.d.ts +15 -0
- package/dist/classroom.js +54 -0
- package/dist/event-writer.d.ts +39 -0
- package/dist/event-writer.js +139 -0
- package/dist/live-api.d.ts +2 -0
- package/dist/live-api.js +37 -2
- package/dist/live-events.d.ts +7 -1
- package/dist/live-events.js +88 -12
- package/dist/live-interpreter.d.ts +3 -1
- package/dist/live-interpreter.js +109 -83
- package/dist/live-recovery.d.ts +6 -0
- package/dist/live-recovery.js +11 -0
- package/dist/live-voice.d.ts +1 -1
- package/dist/live-voice.js +7 -7
- package/dist/mail-sender.d.ts +9 -0
- package/dist/mail-sender.js +11 -0
- package/dist/notify.d.ts +3 -3
- package/dist/notify.js +8 -5
- package/dist/password-reset-page.d.ts +2 -0
- package/dist/password-reset-page.js +106 -0
- package/dist/password-reset.d.ts +34 -0
- package/dist/password-reset.js +121 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +109 -3
- package/dist/shared-translation.d.ts +1 -0
- package/dist/shared-translation.js +57 -40
- package/dist/tokens.d.ts +2 -0
- package/dist/tokens.js +2 -0
- package/dist/translation-passes.d.ts +13 -8
- package/dist/translation-passes.js +31 -20
- package/dist/upgrade-allowance.d.ts +23 -0
- package/dist/upgrade-allowance.js +70 -0
- package/package.json +1 -1
- package/src/accounts.ts +8 -2
- package/src/classroom.ts +52 -0
- package/src/event-writer.ts +126 -0
- package/src/live-api.ts +26 -2
- package/src/live-events.ts +81 -13
- package/src/live-interpreter.ts +87 -68
- package/src/live-recovery.ts +10 -0
- package/src/live-voice.ts +7 -7
- package/src/mail-sender.ts +12 -0
- package/src/notify.ts +10 -7
- package/src/password-reset-page.ts +108 -0
- package/src/password-reset.ts +134 -0
- package/src/server.ts +91 -3
- package/src/shared-translation.ts +47 -33
- package/src/tokens.ts +3 -0
- package/src/translation-passes.ts +35 -25
- package/src/types/auth-system.d.ts +10 -0
- package/src/upgrade-allowance.ts +69 -0
- package/web/dist/assets/background.worker-Hz7oaQ8X.js +547 -0
- package/web/dist/assets/{hls-3VKVEQE3-C1ttVP7K.js → hls-3VKVEQE3-_BiJh76x.js} +1 -1
- package/web/dist/assets/index-BgLxkXKe.js +3 -0
- package/web/dist/assets/{index-BmR7T0zX.css → index-D34dTY18.css} +1 -1
- package/web/dist/assets/{mpegts-CVYPgepU.js → mpegts-CV0glck7.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Dw2p8qGf.js → mpegts-LO6RVLD6-iuTMg-O6.js} +1 -1
- package/web/dist/index.html +6 -5
- package/web/dist/sw.js +7 -7
- package/backtoschool/dist/assets/index-D7Oas1m5.js +0 -88
- package/web/dist/assets/background.worker-DH8ag5Q4.js +0 -547
- package/web/dist/assets/index-3WZwGw04.js +0 -3
package/src/live-events.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classroomBroadcast, publicWebUrl, type ClassroomSettings } from "./classroom.ts";
|
|
1
2
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
3
|
import type { Queryable } from "./follows.ts";
|
|
3
4
|
|
|
@@ -37,7 +38,7 @@ export type LiveEventKind = (typeof LIVE_EVENT_KINDS)[number];
|
|
|
37
38
|
export type InvitationRole = (typeof INVITATION_ROLES)[number];
|
|
38
39
|
export type InvitationState = (typeof INVITATION_STATES)[number];
|
|
39
40
|
|
|
40
|
-
export interface LiveEvent {
|
|
41
|
+
export interface LiveEvent extends ClassroomSettings {
|
|
41
42
|
id: string;
|
|
42
43
|
slug: string;
|
|
43
44
|
ownerId: string;
|
|
@@ -94,6 +95,11 @@ export interface CreatedInvitation extends EventInvitation {
|
|
|
94
95
|
|
|
95
96
|
export interface CreateLiveEventInput {
|
|
96
97
|
title: unknown;
|
|
98
|
+
broadcastUrl?: unknown;
|
|
99
|
+
hostName?: unknown;
|
|
100
|
+
homepageUrl?: unknown;
|
|
101
|
+
avatarUrl?: unknown;
|
|
102
|
+
recurrence?: unknown;
|
|
97
103
|
description?: unknown;
|
|
98
104
|
topic?: unknown;
|
|
99
105
|
kind?: unknown;
|
|
@@ -187,6 +193,7 @@ const EVENT_SCHEMA = `
|
|
|
187
193
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS ticket_currency TEXT NOT NULL DEFAULT 'USD';
|
|
188
194
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS ticket_minutes INTEGER NOT NULL DEFAULT 1440;
|
|
189
195
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS pay_to TEXT;
|
|
196
|
+
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS classroom JSONB NOT NULL DEFAULT '{}'::jsonb;
|
|
190
197
|
CREATE INDEX IF NOT EXISTS live_events_kind
|
|
191
198
|
ON live_events (kind, status, starts_at);
|
|
192
199
|
|
|
@@ -203,7 +210,7 @@ const EVENT_SCHEMA = `
|
|
|
203
210
|
END LOOP;
|
|
204
211
|
FOR stale IN
|
|
205
212
|
SELECT conname FROM pg_constraint
|
|
206
|
-
WHERE conrelid = 'live_event_invitations'
|
|
213
|
+
WHERE conrelid = to_regclass('live_event_invitations') AND contype = 'c'
|
|
207
214
|
AND conname <> 'live_event_invitations_role_allowed'
|
|
208
215
|
AND pg_get_constraintdef(oid) LIKE '%moderator%'
|
|
209
216
|
LOOP
|
|
@@ -292,6 +299,37 @@ function text(value: unknown, name: string, limit: number, required = false): st
|
|
|
292
299
|
return cleaned;
|
|
293
300
|
}
|
|
294
301
|
|
|
302
|
+
function classroomSettings(input: CreateLiveEventInput | UpdateLiveEventInput, current: ClassroomSettings = {}): ClassroomSettings {
|
|
303
|
+
const settings: ClassroomSettings = {};
|
|
304
|
+
for (const key of ["hostName", "homepageUrl", "avatarUrl", "broadcastUrl"] as const) {
|
|
305
|
+
const value = input[key] === undefined ? current[key] ?? "" : text(input[key], key, key === "hostName" ? 100 : 2048);
|
|
306
|
+
if (!value) continue;
|
|
307
|
+
if (key === "hostName") settings[key] = value;
|
|
308
|
+
else if (key === "broadcastUrl") {
|
|
309
|
+
const broadcast = classroomBroadcast(value);
|
|
310
|
+
if (!broadcast) throw new LiveEventError("Use a Pairux live/join link or a Nixamp Share link with a stream selected. Admin links cannot be shared.", 422);
|
|
311
|
+
settings[key] = broadcast.url;
|
|
312
|
+
} else {
|
|
313
|
+
const safe = publicWebUrl(value);
|
|
314
|
+
if (!safe) throw new LiveEventError(`${key} must be an HTTPS link without credentials`, 422);
|
|
315
|
+
settings[key] = safe;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const recurrence = input.recurrence === undefined ? current.recurrence ?? "none" : input.recurrence;
|
|
319
|
+
if (recurrence !== "none" && recurrence !== "daily" && recurrence !== "weekly") {
|
|
320
|
+
throw new LiveEventError("recurrence must be none, daily, or weekly", 422);
|
|
321
|
+
}
|
|
322
|
+
if (recurrence !== "none") settings.recurrence = recurrence;
|
|
323
|
+
return settings;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function classroomTimezone(value: unknown): string {
|
|
327
|
+
const zone = text(value, "timezone", 100, true);
|
|
328
|
+
try { new Intl.DateTimeFormat("en", { timeZone: zone }).format(); }
|
|
329
|
+
catch { throw new LiveEventError("timezone must be a valid time zone", 422); }
|
|
330
|
+
return zone;
|
|
331
|
+
}
|
|
332
|
+
|
|
295
333
|
function timestamp(value: unknown, name: string): string | null {
|
|
296
334
|
if (value === undefined || value === null || value === "") return null;
|
|
297
335
|
if (typeof value !== "string") throw new LiveEventError(`${name} must be an ISO timestamp`, 422);
|
|
@@ -374,6 +412,7 @@ export function payToAddress(value: unknown, name = "payTo"): string {
|
|
|
374
412
|
|
|
375
413
|
function eventFrom(row: Record<string, unknown>): LiveEvent {
|
|
376
414
|
return {
|
|
415
|
+
...((row["classroom"] && typeof row["classroom"] === "object") ? row["classroom"] as ClassroomSettings : {}),
|
|
377
416
|
id: String(row["id"] ?? ""),
|
|
378
417
|
slug: String(row["slug"] ?? ""),
|
|
379
418
|
ownerId: String(row["owner_id"] ?? ""),
|
|
@@ -544,6 +583,8 @@ export class LiveEvents {
|
|
|
544
583
|
if (ticketPriceCents > 0 && !payTo) {
|
|
545
584
|
throw new LiveEventError("a ticketed event needs a payTo address", 422);
|
|
546
585
|
}
|
|
586
|
+
const classroom = classroomSettings(input);
|
|
587
|
+
if (classroom.recurrence && !startsAt) throw new LiveEventError("a recurring class needs a start time", 422);
|
|
547
588
|
const baseSlug = eventSlug(title);
|
|
548
589
|
const values = [
|
|
549
590
|
id,
|
|
@@ -560,7 +601,7 @@ export class LiveEvents {
|
|
|
560
601
|
payTo,
|
|
561
602
|
startsAt,
|
|
562
603
|
endsAt,
|
|
563
|
-
|
|
604
|
+
classroomTimezone(input.timezone ?? "UTC"),
|
|
564
605
|
optionalInteger(input.expectedDurationMinutes, "expectedDurationMinutes"),
|
|
565
606
|
startsAt ? "scheduled" : "draft",
|
|
566
607
|
enumValue(input.visibility, LIVE_EVENT_VISIBILITIES, "public", "visibility"),
|
|
@@ -569,6 +610,7 @@ export class LiveEvents {
|
|
|
569
610
|
boolean(input.handRaiseEnabled, true, "handRaiseEnabled"),
|
|
570
611
|
boolean(input.recordingEnabled, false, "recordingEnabled"),
|
|
571
612
|
text(input.layoutId, "layoutId", 100),
|
|
613
|
+
JSON.stringify(classroom),
|
|
572
614
|
];
|
|
573
615
|
await this.ensure();
|
|
574
616
|
let rows: Record<string, unknown>[];
|
|
@@ -579,10 +621,10 @@ export class LiveEvents {
|
|
|
579
621
|
ticket_price_cents, ticket_currency, ticket_minutes, pay_to,
|
|
580
622
|
starts_at, ends_at, timezone,
|
|
581
623
|
expected_duration_minutes, status, visibility, room_id, chat_enabled,
|
|
582
|
-
hand_raise_enabled, recording_enabled, layout_id
|
|
624
|
+
hand_raise_enabled, recording_enabled, layout_id, classroom
|
|
583
625
|
) VALUES (
|
|
584
626
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NULLIF($12, ''), $13, $14, $15,
|
|
585
|
-
$16, $17, $18, $19, $20, $21, $22, NULLIF($23, '')
|
|
627
|
+
$16, $17, $18, $19, $20, $21, $22, NULLIF($23, ''), $24::jsonb
|
|
586
628
|
) RETURNING *`,
|
|
587
629
|
values,
|
|
588
630
|
));
|
|
@@ -595,10 +637,10 @@ export class LiveEvents {
|
|
|
595
637
|
ticket_price_cents, ticket_currency, ticket_minutes, pay_to,
|
|
596
638
|
starts_at, ends_at, timezone,
|
|
597
639
|
expected_duration_minutes, status, visibility, room_id, chat_enabled,
|
|
598
|
-
hand_raise_enabled, recording_enabled, layout_id
|
|
640
|
+
hand_raise_enabled, recording_enabled, layout_id, classroom
|
|
599
641
|
) VALUES (
|
|
600
642
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NULLIF($12, ''), $13, $14, $15,
|
|
601
|
-
$16, $17, $18, $19, $20, $21, $22, NULLIF($23, '')
|
|
643
|
+
$16, $17, $18, $19, $20, $21, $22, NULLIF($23, ''), $24::jsonb
|
|
602
644
|
) RETURNING *`,
|
|
603
645
|
values,
|
|
604
646
|
));
|
|
@@ -722,9 +764,9 @@ export class LiveEvents {
|
|
|
722
764
|
}
|
|
723
765
|
|
|
724
766
|
const title = input.title === undefined ? current.title : text(input.title, "title", 160, true);
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
767
|
+
let startsAt = input.startsAt === undefined ? current.startsAt ?? null : timestamp(input.startsAt, "startsAt");
|
|
768
|
+
let endsAt = input.endsAt === undefined ? current.endsAt ?? null : timestamp(input.endsAt, "endsAt");
|
|
769
|
+
let doorsOpenAt = input.doorsOpenAt === undefined
|
|
728
770
|
? current.doorsOpenAt ?? null
|
|
729
771
|
: timestamp(input.doorsOpenAt, "doorsOpenAt");
|
|
730
772
|
if (startsAt && endsAt && endsAt <= startsAt) {
|
|
@@ -738,7 +780,7 @@ export class LiveEvents {
|
|
|
738
780
|
if (ticketPriceCents > 0 && !payTo) {
|
|
739
781
|
throw new LiveEventError("a ticketed event needs a payTo address", 422);
|
|
740
782
|
}
|
|
741
|
-
|
|
783
|
+
let nextStatus = enumValue(input.status, LIVE_EVENT_STATUSES, current.status, "status");
|
|
742
784
|
if (!canTransition(current.status, nextStatus)) {
|
|
743
785
|
throw new LiveEventError(`an event cannot move from ${current.status} to ${nextStatus}`, 409);
|
|
744
786
|
}
|
|
@@ -746,6 +788,30 @@ export class LiveEvents {
|
|
|
746
788
|
throw new LiveEventError("a scheduled event needs startsAt", 422);
|
|
747
789
|
}
|
|
748
790
|
|
|
791
|
+
const classroom = classroomSettings(input, current);
|
|
792
|
+
const timezone = input.timezone === undefined ? current.timezone : classroomTimezone(input.timezone);
|
|
793
|
+
if (classroom.recurrence && !startsAt) throw new LiveEventError("a recurring class needs a start time", 422);
|
|
794
|
+
let recurringNext = false;
|
|
795
|
+
if (nextStatus === "ended" && classroom.recurrence && startsAt) {
|
|
796
|
+
// Calendar arithmetic in Postgres preserves the host's local time over DST.
|
|
797
|
+
// Consider only the next three candidates, even if many sessions were missed.
|
|
798
|
+
const days = classroom.recurrence === "daily" ? 1 : 7;
|
|
799
|
+
const next = await this.db.query(`WITH anchor AS (
|
|
800
|
+
SELECT $1::timestamptz AT TIME ZONE $2 AS local_start
|
|
801
|
+
), candidates AS (
|
|
802
|
+
SELECT (local_start + n * $3::int * interval '1 day') AT TIME ZONE $2 AS starts_at
|
|
803
|
+
FROM anchor CROSS JOIN LATERAL generate_series(
|
|
804
|
+
GREATEST(1, ((now() AT TIME ZONE $2)::date - local_start::date) / $3::int),
|
|
805
|
+
GREATEST(1, ((now() AT TIME ZONE $2)::date - local_start::date) / $3::int) + 2
|
|
806
|
+
) n
|
|
807
|
+
) SELECT starts_at FROM candidates
|
|
808
|
+
WHERE starts_at > GREATEST(now(), $1::timestamptz) ORDER BY starts_at LIMIT 1`, [startsAt, timezone, days]);
|
|
809
|
+
const nextStart = iso(next.rows[0]?.["starts_at"]);
|
|
810
|
+
if (!nextStart) throw new LiveEventError("could not schedule the next class", 503);
|
|
811
|
+
startsAt = nextStart; endsAt = null; doorsOpenAt = null;
|
|
812
|
+
nextStatus = "scheduled"; recurringNext = true;
|
|
813
|
+
}
|
|
814
|
+
|
|
749
815
|
const { rows } = await this.db.query(
|
|
750
816
|
`UPDATE live_events SET
|
|
751
817
|
title = $4,
|
|
@@ -768,6 +834,7 @@ export class LiveEvents {
|
|
|
768
834
|
ticket_currency = $21,
|
|
769
835
|
ticket_minutes = $22,
|
|
770
836
|
pay_to = NULLIF($23, ''),
|
|
837
|
+
classroom = $24::jsonb,
|
|
771
838
|
version = version + 1,
|
|
772
839
|
updated_at = now()
|
|
773
840
|
WHERE (id = $1 OR slug = $1) AND owner_id = $2 AND version = $3
|
|
@@ -781,7 +848,7 @@ export class LiveEvents {
|
|
|
781
848
|
input.topic === undefined ? current.topic ?? "" : text(input.topic, "topic", 100),
|
|
782
849
|
startsAt,
|
|
783
850
|
endsAt,
|
|
784
|
-
|
|
851
|
+
timezone,
|
|
785
852
|
input.expectedDurationMinutes === undefined
|
|
786
853
|
? current.expectedDurationMinutes ?? null
|
|
787
854
|
: optionalInteger(input.expectedDurationMinutes, "expectedDurationMinutes"),
|
|
@@ -790,7 +857,7 @@ export class LiveEvents {
|
|
|
790
857
|
boolean(input.chatEnabled, current.chatEnabled, "chatEnabled"),
|
|
791
858
|
boolean(input.handRaiseEnabled, current.handRaiseEnabled, "handRaiseEnabled"),
|
|
792
859
|
boolean(input.recordingEnabled, current.recordingEnabled, "recordingEnabled"),
|
|
793
|
-
input.recordingId === undefined ? current.recordingId ?? null : text(input.recordingId, "recordingId", 160) || null,
|
|
860
|
+
recurringNext ? null : input.recordingId === undefined ? current.recordingId ?? null : text(input.recordingId, "recordingId", 160) || null,
|
|
794
861
|
input.layoutId === undefined ? current.layoutId ?? null : text(input.layoutId, "layoutId", 100) || null,
|
|
795
862
|
enumValue(input.kind, LIVE_EVENT_KINDS, current.kind, "kind"),
|
|
796
863
|
doorsOpenAt,
|
|
@@ -800,6 +867,7 @@ export class LiveEvents {
|
|
|
800
867
|
: text(input.ticketCurrency, "ticketCurrency", 8, true).toUpperCase(),
|
|
801
868
|
minutes(input.ticketMinutes, current.ticketMinutes, "ticketMinutes"),
|
|
802
869
|
payTo,
|
|
870
|
+
JSON.stringify(classroom),
|
|
803
871
|
],
|
|
804
872
|
);
|
|
805
873
|
const row = rows[0];
|
package/src/live-interpreter.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { audioError, transientAudioError } from "./live-recovery.ts";
|
|
1
2
|
import { encodeWav } from "./pcm-wav.ts";
|
|
2
3
|
import type { SpeakerTranscript, SpeakerTurn } from "./speaker-turns.ts";
|
|
3
4
|
export interface Caption { channel: string; at: number; until: number; text: string; language?: string; original?: string; sourceLanguage?: string; speaker?: string; voiceProfile?: "lower" | "higher" | "unknown"; }
|
|
@@ -58,17 +59,20 @@ export class Interpreter {
|
|
|
58
59
|
private controller: AbortController | null = null;
|
|
59
60
|
private translating: number | null = null;
|
|
60
61
|
private translationController: AbortController | null = null;
|
|
62
|
+
private recognitionFailures = 0;
|
|
63
|
+
private translationFailures = 0;
|
|
61
64
|
private translations: { lines: Caption[]; language: string; target: string; until: number }[] = [];
|
|
62
65
|
constructor(private readonly options: {
|
|
63
66
|
language: () => string; speakers: () => boolean; voices: () => VoiceChoice[];
|
|
64
67
|
channel: () => string; lines: (lines: Caption[]) => void;
|
|
65
|
-
status: (text: string) => void; failed: () => void;
|
|
68
|
+
status: (text: string) => void; failed: (message: string) => void;
|
|
66
69
|
fetcher?: typeof fetch;
|
|
67
70
|
}) {}
|
|
68
71
|
|
|
69
72
|
reset(): void {
|
|
70
73
|
this.generation++; this.controller?.abort(); this.translationController?.abort();
|
|
71
74
|
this.pending = null; this.running = null; this.translating = null;
|
|
75
|
+
this.recognitionFailures = this.translationFailures = 0;
|
|
72
76
|
this.translations = []; this.committedUntil = null; this.tracker.reset();
|
|
73
77
|
}
|
|
74
78
|
push(window: AudioWindow): void {
|
|
@@ -79,7 +83,7 @@ export class Interpreter {
|
|
|
79
83
|
private async json(path: string, init: RequestInit, signal: AbortSignal): Promise<any> {
|
|
80
84
|
const response = await (this.options.fetcher ?? fetch)(path, { ...init, signal });
|
|
81
85
|
const body = await response.json();
|
|
82
|
-
if (!response.ok) throw
|
|
86
|
+
if (!response.ok) throw audioError(body.error || "Audio translation is unavailable.", response.status);
|
|
83
87
|
return body;
|
|
84
88
|
}
|
|
85
89
|
private async run(generation: number): Promise<void> {
|
|
@@ -87,60 +91,68 @@ export class Interpreter {
|
|
|
87
91
|
try {
|
|
88
92
|
while (this.pending && generation === this.generation) {
|
|
89
93
|
const window = this.pending; this.pending = null;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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();
|
|
126
136
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
window.until - (window.at + phrase[0]!.start * 1000) >= 3000)) emit();
|
|
137
|
+
} else if (heard.text) {
|
|
138
|
+
lines = [{ channel: this.options.channel(), at: window.freshAt, until: window.until, text: heard.text, language }];
|
|
130
139
|
}
|
|
131
|
-
|
|
132
|
-
|
|
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…");
|
|
133
152
|
}
|
|
134
|
-
if (!lines.length) continue;
|
|
135
|
-
this.committedUntil = Math.max(this.committedUntil ?? window.freshAt, ...lines.map(line => line.until));
|
|
136
|
-
if (this.translations.length >= 12) throw new Error("Translation fell behind. Try again after the language model has warmed up.");
|
|
137
|
-
this.translations.push({ lines, language, target, until: window.until });
|
|
138
|
-
// Recognition of the next audio window can proceed while the text
|
|
139
|
-
// model translates this phrase. Voice synthesis is a third stage.
|
|
140
|
-
if (this.translating === null) void this.translate(this.generation);
|
|
141
153
|
}
|
|
142
154
|
} catch (error) {
|
|
143
|
-
if (generation === this.generation) {
|
|
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); }
|
|
144
156
|
} finally { if (this.running === generation) this.running = null; }
|
|
145
157
|
}
|
|
146
158
|
|
|
@@ -149,26 +161,33 @@ export class Interpreter {
|
|
|
149
161
|
try {
|
|
150
162
|
while (this.translations.length && generation === this.generation) {
|
|
151
163
|
const item = this.translations.shift()!;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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…");
|
|
164
187
|
}
|
|
165
|
-
if (generation !== this.generation) return;
|
|
166
|
-
if (Date.now() - until > 12_000) throw new Error("Translation fell behind. Try again after the language model has warmed up.");
|
|
167
|
-
this.options.lines(lines.flatMap(line => splitCaption(line)));
|
|
168
|
-
this.options.status(target ? `${language} → ${target} · live translation` : `Original audio language: ${language || "detecting…"}`);
|
|
169
188
|
}
|
|
170
189
|
} catch (error) {
|
|
171
|
-
if (generation === this.generation) {
|
|
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); }
|
|
172
191
|
} finally { if (this.translating === generation) this.translating = null; }
|
|
173
192
|
}
|
|
174
193
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
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
|
+
}
|
package/src/live-voice.ts
CHANGED
|
@@ -57,9 +57,9 @@ export class LiveVoice {
|
|
|
57
57
|
/** Optional diarization, billed only while a signed-in listener requests it.
|
|
58
58
|
* Rolling audio is bounded to 15 seconds, including overlap. Every second
|
|
59
59
|
* submitted (also repeated context) consumes the persistent provider budget. */
|
|
60
|
-
async hear(bytes: Uint8Array, by: string, signal?: AbortSignal, meter = this.billing): Promise<SpeakerTranscript> {
|
|
60
|
+
async hear(bytes: Uint8Array, by: string, signal?: AbortSignal, meter = this.billing, resource = ""): Promise<SpeakerTranscript> {
|
|
61
61
|
if (!this.available()) throw new SpeechError("speaker voices are unavailable", 503);
|
|
62
|
-
await meter?.require(by);
|
|
62
|
+
await meter?.require(by, resource);
|
|
63
63
|
const wav = decodeWav(bytes);
|
|
64
64
|
const seconds = wav.samples.length / wav.rate;
|
|
65
65
|
if (wav.rate !== 16000 || wav.channels !== 1 || seconds < 0.2 || seconds > 15.1) throw new SpeechError("send up to 15 seconds of mono 16 kHz WAV", 400);
|
|
@@ -86,7 +86,7 @@ export class LiveVoice {
|
|
|
86
86
|
form.set("tag_audio_events", "false");
|
|
87
87
|
form.set("timestamps_granularity", "word");
|
|
88
88
|
// No language_code: preserve the source language, including Spanish.
|
|
89
|
-
reservation = await meter?.reserve(by, "transcription", wav.samples.length);
|
|
89
|
+
reservation = await meter?.reserve(by, "transcription", wav.samples.length, resource);
|
|
90
90
|
const answer = await this.fetcher("https://api.elevenlabs.io/v1/speech-to-text", {
|
|
91
91
|
method: "POST", headers: { "xi-api-key": this.key }, body: form,
|
|
92
92
|
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(10_000)]) : AbortSignal.timeout(10_000),
|
|
@@ -122,7 +122,7 @@ export class LiveVoice {
|
|
|
122
122
|
async grant(by: string, channel: string): Promise<{ token: string; expires: number }> {
|
|
123
123
|
if (!/^[\w-]{1,80}$/.test(channel)) throw new SpeechError("choose a playback session or live channel", 400);
|
|
124
124
|
if (!this.requests.check(`grant:${by}`, { allowed: 10, windowMs: 60_000 }).ok) throw new SpeechError("too many audio authorization requests", 429);
|
|
125
|
-
await this.billing?.require(by);
|
|
125
|
+
await this.billing?.require(by, channel);
|
|
126
126
|
for (const [token, grant] of this.grants) if (grant.expires <= this.now()) this.grants.delete(token);
|
|
127
127
|
if (this.grants.size >= 5000) throw new SpeechError("audio authorization is busy", 503);
|
|
128
128
|
const token = `nxd_${randomBytes(32).toString("base64url")}`;
|
|
@@ -200,7 +200,7 @@ export class LiveVoice {
|
|
|
200
200
|
/** Stream the first request immediately; concurrent listeners share its cached result. */
|
|
201
201
|
async stream(ask: VoiceRequest, by: string, signal?: AbortSignal, meter = this.billing): Promise<Response> {
|
|
202
202
|
this.checkRequest(by);
|
|
203
|
-
await meter?.require(by);
|
|
203
|
+
await meter?.require(by, ask.channel);
|
|
204
204
|
const text = typeof ask.text === "string" ? ask.text.trim() : "";
|
|
205
205
|
if (!text || text.length > 600) throw new SpeechError("translated audio needs a caption of 1–600 characters", 400);
|
|
206
206
|
if (!LIVE_VOICE_LANGUAGES.has(ask.language)) throw new SpeechError("this language is not supported by Flash voices", 400);
|
|
@@ -220,7 +220,7 @@ export class LiveVoice {
|
|
|
220
220
|
if (cached || pending) {
|
|
221
221
|
const bytes = cached?.bytes ?? await pending!;
|
|
222
222
|
signal?.throwIfAborted();
|
|
223
|
-
const paid = await meter?.reserve(by, "voice", text.length);
|
|
223
|
+
const paid = await meter?.reserve(by, "voice", text.length, ask.channel);
|
|
224
224
|
if (paid) await meter!.commit(paid);
|
|
225
225
|
return new Response(new Uint8Array(bytes), { headers: HEADERS });
|
|
226
226
|
}
|
|
@@ -239,7 +239,7 @@ export class LiveVoice {
|
|
|
239
239
|
try {
|
|
240
240
|
await this.charge(by, ask.channel ?? "direct", text.length);
|
|
241
241
|
signal?.throwIfAborted();
|
|
242
|
-
reservation = await meter?.reserve(by, "voice", text.length);
|
|
242
|
+
reservation = await meter?.reserve(by, "voice", text.length, ask.channel);
|
|
243
243
|
const response = await this.fetcher(`https://api.elevenlabs.io/v1/text-to-speech/${voice.id}/stream?output_format=pcm_16000`, {
|
|
244
244
|
method: "POST",
|
|
245
245
|
headers: { "xi-api-key": this.key, "content-type": "application/json" },
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface MailSender { apiKey: string; from: string }
|
|
2
|
+
export interface BrandedMailSender extends MailSender { backtoschool?: MailSender }
|
|
3
|
+
|
|
4
|
+
/** Only the exact school origins select its verified sender and credentials. */
|
|
5
|
+
export function mailSender(options: BrandedMailSender, link: string): MailSender {
|
|
6
|
+
try {
|
|
7
|
+
const url = new URL(link);
|
|
8
|
+
if (options.backtoschool && url.protocol === "https:" &&
|
|
9
|
+
["backtoschool.help", "www.backtoschool.help"].includes(url.hostname)) return options.backtoschool;
|
|
10
|
+
} catch { /* A malformed link never changes the sender. */ }
|
|
11
|
+
return options;
|
|
12
|
+
}
|
package/src/notify.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { mailSender, type BrandedMailSender } from "./mail-sender.ts";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Telling followers a broadcaster went live, wherever they are.
|
|
3
5
|
*
|
|
@@ -119,27 +121,28 @@ export async function notifyAll(
|
|
|
119
121
|
* blocked, and no dependency: a fetch is the whole client.
|
|
120
122
|
*/
|
|
121
123
|
export function resendEmail(
|
|
122
|
-
{ apiKey, from, fetch = globalThis.fetch, onEvent }: {
|
|
123
|
-
|
|
124
|
-
from: string;
|
|
124
|
+
{ apiKey, from, backtoschool, reason = "You are getting this because you follow them on nixamp.", fetch = globalThis.fetch, onEvent }: BrandedMailSender & {
|
|
125
|
+
reason?: string;
|
|
125
126
|
fetch?: typeof globalThis.fetch;
|
|
126
127
|
onEvent?: (message: string) => void;
|
|
127
128
|
},
|
|
128
129
|
): (to: string, note: Notification) => Promise<boolean> {
|
|
129
130
|
return async (to, note) => {
|
|
130
131
|
try {
|
|
132
|
+
const sender = mailSender({apiKey, from, backtoschool}, note.url);
|
|
131
133
|
const response = await fetch("https://api.resend.com/emails", {
|
|
132
134
|
method: "POST",
|
|
133
|
-
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
|
|
135
|
+
headers: { authorization: `Bearer ${sender.apiKey}`, "content-type": "application/json" },
|
|
136
|
+
signal: AbortSignal.timeout(15_000),
|
|
134
137
|
body: JSON.stringify({
|
|
135
|
-
from,
|
|
138
|
+
from: sender.from,
|
|
136
139
|
to: [to],
|
|
137
140
|
subject: note.title,
|
|
138
|
-
text: `${note.body}\n\n${note.url}\n\
|
|
141
|
+
text: `${note.body}\n\n${note.url}\n\n${reason}`,
|
|
139
142
|
html:
|
|
140
143
|
`<p>${escapeHtml(note.body)}</p>` +
|
|
141
144
|
`<p><a href="${escapeHtml(note.url)}">${escapeHtml(note.url)}</a></p>` +
|
|
142
|
-
`<p style="color:#666;font-size:12px"
|
|
145
|
+
`<p style="color:#666;font-size:12px">${escapeHtml(reason)}</p>`,
|
|
143
146
|
}),
|
|
144
147
|
});
|
|
145
148
|
if (!response.ok) {
|