nixamp 0.26.7 → 0.27.0

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.
@@ -0,0 +1,97 @@
1
+ import type { Queryable } from "./follows.ts";
2
+ export declare const TRANSLATION_MULTIPLIER = 5;
3
+ export declare const TRANSLATION_PLANS: readonly [{
4
+ readonly id: "day";
5
+ readonly name: "Day";
6
+ readonly days: 1;
7
+ readonly priceCents: 500;
8
+ }, {
9
+ readonly id: "week";
10
+ readonly name: "Week";
11
+ readonly days: 7;
12
+ readonly priceCents: 2500;
13
+ }, {
14
+ readonly id: "month";
15
+ readonly name: "Month";
16
+ readonly days: 30;
17
+ readonly priceCents: 10000;
18
+ }];
19
+ export type TranslationPlan = typeof TRANSLATION_PLANS[number];
20
+ export type TranslationUsage = "transcription" | "voice";
21
+ export interface TranslationMeter {
22
+ require(by: string): Promise<void>;
23
+ eligible?(accounts: string[]): Promise<string[]>;
24
+ reserve(by: string, kind: TranslationUsage, units: number): Promise<string>;
25
+ reserveMany?(accounts: string[], kind: TranslationUsage, units: number): Promise<{
26
+ by: string;
27
+ id: string;
28
+ }[]>;
29
+ commitMany?(ids: string[]): Promise<void>;
30
+ refundMany?(ids: string[]): Promise<void>;
31
+ commit(id: string): Promise<void>;
32
+ refund(id: string): Promise<void>;
33
+ }
34
+ export interface TranslationAccess {
35
+ required: boolean;
36
+ available: boolean;
37
+ balanceMicros: number;
38
+ expires: string | null;
39
+ plans: readonly TranslationPlan[];
40
+ coins: string[];
41
+ orders: {
42
+ id: string;
43
+ url: string;
44
+ plan: string;
45
+ }[];
46
+ rates: {
47
+ transcriptionHour: number;
48
+ voiceThousand: number;
49
+ multiplier: number;
50
+ };
51
+ }
52
+ /** At the published rates: Scribe $0.22/submitted hour, Flash $0.05/1k chars.
53
+ * Transcription units are 16 kHz samples; overlap is charged exactly as sent. */
54
+ export declare function translationCost(kind: TranslationUsage, units: number): {
55
+ cost: number;
56
+ charge: number;
57
+ };
58
+ export declare function verifyTranslationPayment(payment: Record<string, unknown>, id: string, priceCents: number): boolean;
59
+ export declare class TranslationPasses implements TranslationMeter {
60
+ private readonly options;
61
+ private schema;
62
+ private merchant;
63
+ private merchantUntil;
64
+ private readonly fetcher;
65
+ private readonly now;
66
+ constructor(options: {
67
+ db: Queryable;
68
+ key: string;
69
+ site: string;
70
+ fetcher?: typeof fetch;
71
+ now?: () => number;
72
+ });
73
+ private ensure;
74
+ private provider;
75
+ private settings;
76
+ access(by?: string): Promise<TranslationAccess>;
77
+ require(by: string): Promise<void>;
78
+ eligible(accounts: string[]): Promise<string[]>;
79
+ reserve(by: string, kind: TranslationUsage, units: number): Promise<string>;
80
+ /** Batch a shared stream's access charges in one database round trip. */
81
+ reserveMany(accounts: string[], kind: TranslationUsage, units: number): Promise<{
82
+ by: string;
83
+ id: string;
84
+ }[]>;
85
+ commit(id: string): Promise<void>;
86
+ refund(id: string): Promise<void>;
87
+ commitMany(ids: string[]): Promise<void>;
88
+ refundMany(ids: string[]): Promise<void>;
89
+ checkout(by: string, planId: string, coin: string, requestKey: string): Promise<{
90
+ id: string;
91
+ url: string;
92
+ }>;
93
+ check(by: string, id: string): Promise<{
94
+ status: string;
95
+ access: TranslationAccess;
96
+ }>;
97
+ }
@@ -0,0 +1,227 @@
1
+ /** Account-bound, prepaid translation credit. Money is integer micro-USD;
2
+ * each listening account reserves 5x base provider cost for paid access. */
3
+ import { randomUUID } from "node:crypto";
4
+ import { SpeechError } from "./speech.js";
5
+ export const TRANSLATION_MULTIPLIER = 5;
6
+ export const TRANSLATION_PLANS = [
7
+ { id: "day", name: "Day", days: 1, priceCents: 500 },
8
+ { id: "week", name: "Week", days: 7, priceCents: 2500 },
9
+ { id: "month", name: "Month", days: 30, priceCents: 10000 },
10
+ ];
11
+ const COINS = ["USDC_POL", "USDC_SOL", "SOL", "POL", "BTC", "ETH", "USDC_ETH", "BCH", "DOGE"];
12
+ const ID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
13
+ const DAY = 86_400_000;
14
+ const PAID = new Set(["confirmed", "forwarding", "forwarded", "forwarding_failed", "completed"]);
15
+ /** At the published rates: Scribe $0.22/submitted hour, Flash $0.05/1k chars.
16
+ * Transcription units are 16 kHz samples; overlap is charged exactly as sent. */
17
+ export function translationCost(kind, units) {
18
+ if (!["voice", "transcription"].includes(kind) || !Number.isSafeInteger(units) || units < 1 || (kind === "voice" ? units > 600 : units > 241600))
19
+ throw new SpeechError("invalid translation usage", 400);
20
+ const cost = Math.ceil(kind === "voice" ? units * 50 : units * 220_000 / 57_600_000);
21
+ return { cost, charge: cost * TRANSLATION_MULTIPLIER };
22
+ }
23
+ function cents(value) {
24
+ if (typeof value !== "number" && typeof value !== "string")
25
+ return -1;
26
+ const text = String(value);
27
+ if (!/^\d+(?:\.\d{1,2}0*)?$/.test(text))
28
+ return -1;
29
+ const result = Math.round(Number(text) * 100);
30
+ return Number.isSafeInteger(result) ? result : -1;
31
+ }
32
+ export function verifyTranslationPayment(payment, id, priceCents) {
33
+ return payment["id"] === id && PAID.has(String(payment["status"])) &&
34
+ String(payment["currency"]).toUpperCase() === "USD" && cents(payment["amount"]) === priceCents;
35
+ }
36
+ export class TranslationPasses {
37
+ options;
38
+ schema = null;
39
+ merchant = null;
40
+ merchantUntil = 0;
41
+ fetcher;
42
+ now;
43
+ constructor(options) {
44
+ this.options = options;
45
+ this.fetcher = options.fetcher ?? fetch;
46
+ this.now = options.now ?? Date.now;
47
+ }
48
+ async ensure() {
49
+ this.schema ??= (async () => {
50
+ await this.options.db.query(`CREATE TABLE IF NOT EXISTS translation_wallets (
51
+ by_account TEXT PRIMARY KEY, balance_micros BIGINT NOT NULL CHECK (balance_micros >= 0), expires_at TIMESTAMPTZ NOT NULL)`);
52
+ await this.options.db.query(`CREATE TABLE IF NOT EXISTS translation_orders (
53
+ id TEXT PRIMARY KEY, by_account TEXT NOT NULL, request_key TEXT NOT NULL, plan TEXT NOT NULL,
54
+ price_cents INTEGER NOT NULL CHECK (price_cents > 0), days INTEGER NOT NULL,
55
+ business_id TEXT NOT NULL, coin TEXT NOT NULL, payment_id TEXT UNIQUE,
56
+ status TEXT NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ NOT NULL,
57
+ credited_at TIMESTAMPTZ, expires_at TIMESTAMPTZ, UNIQUE(by_account, request_key))`);
58
+ await this.options.db.query(`CREATE TABLE IF NOT EXISTS translation_usage (
59
+ id TEXT PRIMARY KEY, by_account TEXT NOT NULL, kind TEXT NOT NULL, units BIGINT NOT NULL,
60
+ cost_micros BIGINT NOT NULL, charge_micros BIGINT NOT NULL, status TEXT NOT NULL DEFAULT 'reserved',
61
+ created_at TIMESTAMPTZ NOT NULL)`);
62
+ await this.options.db.query(`CREATE TABLE IF NOT EXISTS translation_checkout_limits (bucket TEXT PRIMARY KEY, attempts INTEGER NOT NULL)`);
63
+ await this.options.db.query("CREATE INDEX IF NOT EXISTS translation_orders_account ON translation_orders (by_account, created_at)");
64
+ })().catch(error => { this.schema = null; throw error; });
65
+ await this.schema;
66
+ }
67
+ async provider(path, init = {}) {
68
+ if (!this.options.key)
69
+ throw new SpeechError("Translation checkout is unavailable.", 503);
70
+ const response = await this.fetcher(`https://coinpayportal.com${path}`, {
71
+ ...init, signal: AbortSignal.timeout(15_000), headers: { authorization: `Bearer ${this.options.key}`, "content-type": "application/json", ...init.headers },
72
+ });
73
+ const body = await response.json();
74
+ if (!response.ok || body.success !== true)
75
+ throw new SpeechError("The payment service is unavailable. Your purchase has not been activated; retry to check its status.", 502);
76
+ return body;
77
+ }
78
+ async settings() {
79
+ if (!this.merchant || this.now() >= this.merchantUntil) {
80
+ this.merchantUntil = this.now() + 600_000;
81
+ this.merchant = this.provider("/api/supported-coins").then(body => {
82
+ if (!ID.test(body.business_id))
83
+ throw new SpeechError("Translation checkout is unavailable.", 503);
84
+ const coins = COINS.filter(symbol => body.coins?.some((coin) => coin.symbol === symbol && coin.is_active && coin.has_wallet));
85
+ return { business: body.business_id, coins };
86
+ }).catch(error => { this.merchant = null; throw error; });
87
+ }
88
+ return this.merchant;
89
+ }
90
+ async access(by) {
91
+ await this.ensure();
92
+ const config = await this.settings().catch(() => ({ business: "", coins: [] }));
93
+ const row = by ? (await this.options.db.query("SELECT balance_micros, expires_at FROM translation_wallets WHERE by_account = $1 AND expires_at > $2", [by, new Date(this.now())])).rows[0] : null;
94
+ const orders = by ? (await this.options.db.query("SELECT id, payment_id, plan FROM translation_orders WHERE by_account = $1 AND status = 'pending' AND payment_id IS NOT NULL ORDER BY created_at DESC LIMIT 5", [by])).rows.map(order => ({ id: String(order["id"]), plan: String(order["plan"]), url: `https://coinpayportal.com/pay/${order["payment_id"]}` })) : [];
95
+ return { required: true, available: config.coins.length > 0, balanceMicros: Number(row?.["balance_micros"] ?? 0),
96
+ expires: row ? new Date(String(row["expires_at"])).toISOString() : null, plans: TRANSLATION_PLANS, coins: config.coins, orders,
97
+ rates: { transcriptionHour: 0.22, voiceThousand: 0.05, multiplier: TRANSLATION_MULTIPLIER } };
98
+ }
99
+ async require(by) {
100
+ await this.ensure();
101
+ const found = await this.options.db.query("SELECT by_account FROM translation_wallets WHERE by_account = $1 AND expires_at > $2 AND balance_micros > 0", [by, new Date(this.now())]);
102
+ if (!found.rows.length)
103
+ throw new SpeechError("Buy a translation pass to enable translated audio.", 402);
104
+ }
105
+ async eligible(accounts) {
106
+ await this.ensure();
107
+ const result = await this.options.db.query("SELECT by_account FROM translation_wallets WHERE by_account = ANY($1::text[]) AND expires_at > $2 AND balance_micros > 0", [accounts, new Date(this.now())]);
108
+ return result.rows.map(row => String(row["by_account"]));
109
+ }
110
+ async reserve(by, kind, units) {
111
+ await this.ensure();
112
+ const { cost, charge } = translationCost(kind, units), id = randomUUID();
113
+ const result = await this.options.db.query(`WITH debit AS (
114
+ UPDATE translation_wallets SET balance_micros = balance_micros - $2
115
+ WHERE by_account = $1 AND expires_at > $3 AND balance_micros >= $2 RETURNING by_account)
116
+ INSERT INTO translation_usage (id, by_account, kind, units, cost_micros, charge_micros, created_at)
117
+ SELECT $4, by_account, $5, $6, $7, $2, $3 FROM debit RETURNING id`, [by, charge, new Date(this.now()), id, kind, units, cost]);
118
+ if (!result.rows.length)
119
+ throw new SpeechError("Your translation balance is used up or expired. Buy another pass to continue.", 402);
120
+ return id;
121
+ }
122
+ /** Batch a shared stream's access charges in one database round trip. */
123
+ async reserveMany(accounts, kind, units) {
124
+ await this.ensure();
125
+ const { cost, charge } = translationCost(kind, units);
126
+ const result = await this.options.db.query(`WITH debit AS (
127
+ UPDATE translation_wallets SET balance_micros = balance_micros - $2
128
+ WHERE by_account = ANY($1::text[]) AND expires_at > $3 AND balance_micros >= $2 RETURNING by_account)
129
+ INSERT INTO translation_usage (id, by_account, kind, units, cost_micros, charge_micros, created_at)
130
+ SELECT gen_random_uuid()::text, by_account, $4, $5, $6, $2, $3 FROM debit RETURNING id, by_account`, [[...new Set(accounts)], charge, new Date(this.now()), kind, units, cost]);
131
+ return result.rows.map(row => ({ by: String(row["by_account"]), id: String(row["id"]) }));
132
+ }
133
+ async commit(id) {
134
+ await this.options.db.query("UPDATE translation_usage SET status = 'charged' WHERE id = $1 AND status = 'reserved'", [id]);
135
+ }
136
+ async refund(id) {
137
+ await this.options.db.query(`WITH undone AS (
138
+ UPDATE translation_usage SET status = 'refunded' WHERE id = $1 AND status = 'reserved'
139
+ RETURNING by_account, charge_micros)
140
+ UPDATE translation_wallets AS wallet SET balance_micros = wallet.balance_micros + undone.charge_micros
141
+ FROM undone WHERE wallet.by_account = undone.by_account`, [id]);
142
+ }
143
+ async commitMany(ids) {
144
+ await this.options.db.query("UPDATE translation_usage SET status = 'charged' WHERE id = ANY($1::text[]) AND status = 'reserved'", [ids]);
145
+ }
146
+ async refundMany(ids) {
147
+ await this.options.db.query(`WITH undone AS (
148
+ UPDATE translation_usage SET status = 'refunded' WHERE id = ANY($1::text[]) AND status = 'reserved'
149
+ RETURNING by_account, charge_micros), totals AS (SELECT by_account, SUM(charge_micros) AS charge FROM undone GROUP BY by_account)
150
+ UPDATE translation_wallets AS wallet SET balance_micros = wallet.balance_micros + totals.charge
151
+ FROM totals WHERE wallet.by_account = totals.by_account`, [ids]);
152
+ }
153
+ async checkout(by, planId, coin, requestKey) {
154
+ await this.ensure();
155
+ const plan = TRANSLATION_PLANS.find(plan => plan.id === planId);
156
+ if (!plan || !ID.test(requestKey))
157
+ throw new SpeechError("Choose a translation pass.", 400);
158
+ const config = await this.settings();
159
+ if (!config.coins.includes(coin))
160
+ throw new SpeechError("Choose an available payment currency.", 400);
161
+ // Durable caps across restarts/replicas protect the merchant's invoice quota.
162
+ // Retrying an existing order does not consume another invoice allowance.
163
+ const existing = await this.options.db.query("SELECT id FROM translation_orders WHERE by_account = $1 AND request_key = $2", [by, requestKey]);
164
+ if (!existing.rows.length) {
165
+ for (const [scope, limit] of [[`account:${by}`, 5], ["server", 50]]) {
166
+ const limited = await this.options.db.query(`INSERT INTO translation_checkout_limits (bucket, attempts) VALUES ($1, 1)
167
+ ON CONFLICT (bucket) DO UPDATE SET attempts = translation_checkout_limits.attempts + 1
168
+ WHERE translation_checkout_limits.attempts < $2 RETURNING attempts`, [`${scope}:${Math.floor(this.now() / DAY)}`, limit]);
169
+ if (!limited.rows.length)
170
+ throw new SpeechError("Too many new checkouts today. Resume an existing purchase or try tomorrow.", 429);
171
+ }
172
+ }
173
+ const id = randomUUID();
174
+ await this.options.db.query(`INSERT INTO translation_orders (id, by_account, request_key, plan, price_cents, days, business_id, coin, created_at)
175
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (by_account, request_key) DO NOTHING`, [id, by, requestKey, plan.id, plan.priceCents, plan.days, config.business, coin, new Date(this.now())]);
176
+ const order = (await this.options.db.query("SELECT * FROM translation_orders WHERE by_account = $1 AND request_key = $2", [by, requestKey])).rows[0];
177
+ if (order["plan"] !== plan.id || order["coin"] !== coin)
178
+ throw new SpeechError("This checkout already belongs to a different pass. Start a new purchase.", 409);
179
+ let paymentId = String(order["payment_id"] ?? "");
180
+ if (!paymentId) {
181
+ const created = await this.provider("/api/payments/create", {
182
+ method: "POST", headers: { "idempotency-key": `nixamp-translation-${order["id"]}` },
183
+ body: JSON.stringify({ business_id: order["business_id"], amount_usd: Number(order["price_cents"]) / 100,
184
+ currency: coin.toLowerCase(), payment_method: "crypto", description: `Nixamp translated audio: ${plan.name} pass`,
185
+ metadata: { app: "nixamp", product: "translation", order_id: order["id"] },
186
+ redirect_url: `${this.options.site}/?translation_order=${order["id"]}` }),
187
+ });
188
+ paymentId = created.payment?.id;
189
+ if (!ID.test(paymentId ?? ""))
190
+ throw new SpeechError("Checkout did not return a valid payment. Retry this purchase.", 502);
191
+ await this.options.db.query("UPDATE translation_orders SET payment_id = $2 WHERE id = $1 AND payment_id IS NULL", [order["id"], paymentId]);
192
+ }
193
+ return { id: String(order["id"]), url: `https://coinpayportal.com/pay/${paymentId}` };
194
+ }
195
+ async check(by, id) {
196
+ if (!ID.test(id))
197
+ throw new SpeechError("Purchase not found.", 404);
198
+ await this.ensure();
199
+ const order = (await this.options.db.query("SELECT * FROM translation_orders WHERE id = $1 AND by_account = $2", [id, by])).rows[0];
200
+ if (!order)
201
+ throw new SpeechError("Purchase not found.", 404);
202
+ let status = String(order["status"]);
203
+ if (["pending", "expired"].includes(status) && order["payment_id"]) {
204
+ const result = await this.provider(`/api/payments/${encodeURIComponent(String(order["payment_id"]))}`);
205
+ const payment = result.payment;
206
+ if (payment && verifyTranslationPayment(payment, String(order["payment_id"]), Number(order["price_cents"]))) {
207
+ const now = new Date(this.now()), expires = new Date(this.now() + Number(order["days"]) * DAY);
208
+ // One statement atomically marks the order and adds credit. A retry,
209
+ // second tab or second replica cannot activate the same money twice.
210
+ await this.options.db.query(`WITH paid AS (
211
+ UPDATE translation_orders SET status = 'paid', credited_at = $3, expires_at = $4
212
+ WHERE id = $1 AND by_account = $2 AND status IN ('pending', 'expired') RETURNING by_account, price_cents)
213
+ INSERT INTO translation_wallets (by_account, balance_micros, expires_at)
214
+ SELECT by_account, price_cents::bigint * 10000, $4 FROM paid
215
+ ON CONFLICT (by_account) DO UPDATE SET
216
+ balance_micros = CASE WHEN translation_wallets.expires_at > $3 THEN translation_wallets.balance_micros ELSE 0 END + EXCLUDED.balance_micros,
217
+ expires_at = GREATEST(translation_wallets.expires_at, EXCLUDED.expires_at)`, [id, by, now, expires]);
218
+ status = "paid";
219
+ }
220
+ else if (payment && ["expired", "failed", "refunded"].includes(payment.status)) {
221
+ status = payment.status;
222
+ await this.options.db.query("UPDATE translation_orders SET status = $3 WHERE id = $1 AND by_account = $2 AND status = 'pending'", [id, by, status]);
223
+ }
224
+ }
225
+ return { status, access: await this.access(by) };
226
+ }
227
+ }
@@ -0,0 +1,6 @@
1
+ export interface WebSite {
2
+ web: string;
3
+ site: string;
4
+ }
5
+ /** Explicit origins only: a caller's Host header must never become an invite URL. */
6
+ export declare function webSitesFromEnv(value?: string | undefined): Map<string, WebSite>;
@@ -0,0 +1,26 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ /** Explicit origins only: a caller's Host header must never become an invite URL. */
4
+ export function webSitesFromEnv(value = process.env["NIXAMP_WEB_SITES"]) {
5
+ const sites = new Map();
6
+ if (!value)
7
+ return sites;
8
+ const entries = JSON.parse(value);
9
+ if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
10
+ throw new Error("NIXAMP_WEB_SITES must map public origins to web directories");
11
+ }
12
+ for (const [origin, directory] of Object.entries(entries)) {
13
+ const url = new URL(origin);
14
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password
15
+ || url.pathname !== "/" || url.search || url.hash || typeof directory !== "string") {
16
+ throw new Error(`Invalid web site: ${origin}`);
17
+ }
18
+ const web = resolve(directory);
19
+ if (!existsSync(join(web, "index.html")))
20
+ throw new Error(`Missing web build: ${web}`);
21
+ if (sites.has(url.host))
22
+ throw new Error(`Duplicate web host: ${url.host}`);
23
+ sites.set(url.host, { web, site: url.origin });
24
+ }
25
+ return sites;
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.26.7",
3
+ "version": "0.27.0",
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",
@@ -0,0 +1,194 @@
1
+ import { encodeWav } from "./pcm-wav.ts";
2
+ import type { SpeakerTranscript, SpeakerTurn } from "./speaker-turns.ts";
3
+ export interface Caption { channel: string; at: number; until: number; text: string; language?: string; original?: string; sourceLanguage?: string; speaker?: string; voiceProfile?: "lower" | "higher" | "unknown"; }
4
+ export interface VoiceChoice { id: string; name: string; gender: string; language: string; }
5
+ export interface AudioWindow { samples: Float32Array; at: number; until: number; freshAt: number; }
6
+
7
+ export interface Speaker { id: string; profile: string; voice: string; }
8
+ /** Match provider labels across overlapping timestamps. A speaker absent from
9
+ * the rolling context gets a new label; pitch alone never identifies someone. */
10
+ export class SpeakerTracker {
11
+ private previous: { start: number; end: number; speaker: string }[] = [];
12
+ private sequence = 0;
13
+ readonly speakers = new Map<string, Speaker>();
14
+ constructor(private readonly random: () => number = Math.random) {}
15
+ reset(): void { this.previous = []; this.sequence = 0; this.speakers.clear(); }
16
+
17
+ reconcile(turns: SpeakerTurn[], at: number, voices: VoiceChoice[]): Map<string, Speaker> {
18
+ const links = new Map<string, Speaker>();
19
+ const used = new Set<string>();
20
+ const candidates: { local: string; global: string; overlap: number }[] = [];
21
+ const totals = new Map<string, number>();
22
+ for (const turn of turns) for (const old of this.previous) {
23
+ const overlap = Math.min(at + turn.end * 1000, old.end) - Math.max(at + turn.start * 1000, old.start);
24
+ if (overlap > 120) { const key = `${turn.speaker}|${old.speaker}`; totals.set(key, (totals.get(key) ?? 0) + overlap); }
25
+ }
26
+ for (const [key, overlap] of totals) { const [local, global] = key.split("|"); candidates.push({ local: local!, global: global!, overlap }); }
27
+ for (const one of candidates.sort((a, b) => b.overlap - a.overlap)) {
28
+ const speaker = this.speakers.get(one.global);
29
+ if (speaker && !links.has(one.local) && !used.has(one.global)) { links.set(one.local, speaker); used.add(one.global); }
30
+ }
31
+ for (const turn of turns) {
32
+ let speaker = links.get(turn.speaker);
33
+ if (!speaker) {
34
+ // Assign contrasting stock voices, without guessing a person's
35
+ // gender from pitch or a noisy, short opening phrase.
36
+ const taken = new Set([...this.speakers.values()].map(one => one.voice));
37
+ const available = voices.filter(voice => !taken.has(voice.id));
38
+ const pool = available.length ? available : voices;
39
+ const voice = pool[Math.floor(this.random() * pool.length)];
40
+ speaker = { id: `speaker-${++this.sequence}`, profile: turn.profile, voice: voice?.id ?? "auto" };
41
+ this.speakers.set(speaker.id, speaker); links.set(turn.speaker, speaker);
42
+ }
43
+ }
44
+ this.previous = turns.map(turn => ({ start: at + turn.start * 1000, end: at + turn.end * 1000, speaker: links.get(turn.speaker)!.id }));
45
+ const present = new Set(this.previous.map(turn => turn.speaker));
46
+ for (const key of this.speakers.keys()) if (this.speakers.size > 64 && !present.has(key)) this.speakers.delete(key);
47
+ return links;
48
+ }
49
+ }
50
+
51
+ /** Listener-local interpretation for the playing media. One request in flight and only the latest pending audio window. */
52
+ export class Interpreter {
53
+ readonly tracker = new SpeakerTracker();
54
+ private generation = 0;
55
+ private pending: AudioWindow | null = null;
56
+ private committedUntil: number | null = null;
57
+ private running: number | null = null;
58
+ private controller: AbortController | null = null;
59
+ private translating: number | null = null;
60
+ private translationController: AbortController | null = null;
61
+ private translations: { lines: Caption[]; language: string; target: string; until: number }[] = [];
62
+ constructor(private readonly options: {
63
+ language: () => string; speakers: () => boolean; voices: () => VoiceChoice[];
64
+ channel: () => string; lines: (lines: Caption[]) => void;
65
+ status: (text: string) => void; failed: () => void;
66
+ fetcher?: typeof fetch;
67
+ }) {}
68
+
69
+ reset(): void {
70
+ this.generation++; this.controller?.abort(); this.translationController?.abort();
71
+ this.pending = null; this.running = null; this.translating = null;
72
+ this.translations = []; this.committedUntil = null; this.tracker.reset();
73
+ }
74
+ push(window: AudioWindow): void {
75
+ this.committedUntil ??= window.freshAt;
76
+ this.pending = window;
77
+ if (this.running === null) void this.run(this.generation);
78
+ }
79
+ private async json(path: string, init: RequestInit, signal: AbortSignal): Promise<any> {
80
+ const response = await (this.options.fetcher ?? fetch)(path, { ...init, signal });
81
+ const body = await response.json();
82
+ if (!response.ok) throw new Error(body.error || "Audio translation is unavailable.");
83
+ return body;
84
+ }
85
+ private async run(generation: number): Promise<void> {
86
+ this.running = generation;
87
+ try {
88
+ while (this.pending && generation === this.generation) {
89
+ const window = this.pending; this.pending = null;
90
+ if (Date.now() - window.until > 12_000) continue;
91
+ const controller = new AbortController(); this.controller = controller;
92
+ const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(12_000)]);
93
+ const speakers = this.options.speakers(), target = this.options.language();
94
+ const heard = await this.json(speakers ? "/api/v1/speech/speakers" : "/api/v1/speech/transcribe?live=1", {
95
+ method: "POST", headers: { "content-type": "audio/wav" }, body: new Uint8Array(encodeWav(speakers ? window.samples : window.samples.slice(-80_000))),
96
+ }, signal) as SpeakerTranscript & { text?: string };
97
+ if (generation !== this.generation) return;
98
+ const language = heard.language;
99
+ let lines: Caption[] = [];
100
+ if (speakers) {
101
+ const links = this.tracker.reconcile(heard.turns ?? [], window.at, this.options.voices());
102
+ // The watermark follows emitted words, not the newest capture
103
+ // interval: an overlapping window can recover audio that arrived
104
+ // while recognition/translation was busy. Keep unfinished phrases
105
+ // in that overlap so short capture intervals do not split every
106
+ // sentence (especially damaging when German changes word order).
107
+ const cutoff = this.committedUntil ?? window.freshAt;
108
+ const turns = heard.turns ?? [];
109
+ for (const [index, turn] of turns.entries()) {
110
+ const speaker = links.get(turn.speaker)!;
111
+ const words = turn.words.filter(word =>
112
+ window.at + (word.start + word.end) * 500 >= cutoff &&
113
+ (window.at + word.end * 1000 <= window.until - 100 || /[.!?。!?]$/.test(word.text)));
114
+ let phrase: typeof words = [];
115
+ const emit = (): void => {
116
+ if (!phrase.length) return;
117
+ lines.push({ channel: this.options.channel(), at: window.at + phrase[0]!.start * 1000,
118
+ until: window.at + phrase.at(-1)!.end * 1000, text: phrase.map(word => word.text).join(" ").trim(),
119
+ language, speaker: speaker.id, voiceProfile: turn.profile });
120
+ phrase = [];
121
+ };
122
+ for (const word of words) {
123
+ if (phrase.length && word.start - phrase.at(-1)!.end >= 0.45) emit();
124
+ phrase.push(word);
125
+ if (/[.!?。!?]$/.test(word.text) || phrase.map(word => word.text).join(" ").length >= 400) emit();
126
+ }
127
+ if (phrase.length && (index < turns.length - 1 ||
128
+ window.until - (window.at + phrase.at(-1)!.end * 1000) >= 350 ||
129
+ window.until - (window.at + phrase[0]!.start * 1000) >= 3000)) emit();
130
+ }
131
+ } else if (heard.text) {
132
+ lines = [{ channel: this.options.channel(), at: window.freshAt, until: window.until, text: heard.text, language }];
133
+ }
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
+ }
142
+ } catch (error) {
143
+ if (generation === this.generation) { this.reset(); this.options.failed(); this.options.status(error instanceof Error ? error.message : "Live translation stopped."); }
144
+ } finally { if (this.running === generation) this.running = null; }
145
+ }
146
+
147
+ private async translate(generation: number): Promise<void> {
148
+ this.translating = generation;
149
+ try {
150
+ while (this.translations.length && generation === this.generation) {
151
+ const item = this.translations.shift()!;
152
+ let { lines } = item;
153
+ const { language, target, until } = item;
154
+ if (Date.now() - until > 12_000) throw new Error("Translation fell behind. Try again after the language model has warmed up.");
155
+ const controller = new AbortController(); this.translationController = controller;
156
+ if (target && language !== target) {
157
+ if (!language) throw new Error("The audio language could not be detected. Waiting for clearer speech.");
158
+ const translated = await this.json("/api/v1/translate?live=1", {
159
+ method: "POST", headers: { "content-type": "application/json" },
160
+ body: JSON.stringify({ texts: lines.map(line => line.text), from: language, to: target }),
161
+ }, AbortSignal.any([controller.signal, AbortSignal.timeout(12_000)])) as { texts: string[] };
162
+ if (translated.texts.length !== lines.length || translated.texts.some(text => !text.trim())) throw new Error("Translation omitted a phrase. Enable translated audio again to retry.");
163
+ lines = lines.map((line, i) => ({ ...line, original: line.text, sourceLanguage: language, language: target, text: translated.texts[i]! }));
164
+ }
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
+ }
170
+ } catch (error) {
171
+ if (generation === this.generation) { this.reset(); this.options.failed(); this.options.status(error instanceof Error ? error.message : "Live translation stopped."); }
172
+ } finally { if (this.translating === generation) this.translating = null; }
173
+ }
174
+ }
175
+
176
+ /** Preserve all words while respecting the speech API's character limit. */
177
+ export function splitCaption(line: Caption): Caption[] {
178
+ const text = line.text.trim();
179
+ if (!text) return [];
180
+ const parts: string[] = [];
181
+ let rest = text;
182
+ while (rest.length > 600) {
183
+ const space = rest.lastIndexOf(" ", 600);
184
+ const end = space > 300 ? space : 600;
185
+ parts.push(rest.slice(0, end)); rest = rest.slice(end).trimStart();
186
+ }
187
+ if (rest) parts.push(rest);
188
+ let offset = 0;
189
+ return parts.map(part => {
190
+ const at = line.at + (line.until - line.at) * offset / text.length;
191
+ offset += part.length + 1;
192
+ return { ...line, text: part, at, until: Math.min(line.until, line.at + (line.until - line.at) * offset / text.length) };
193
+ });
194
+ }