nixamp 0.28.44 → 0.28.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/live-interpreter.d.ts +1 -72
- package/dist/live-interpreter.js +1 -269
- package/dist/live-recovery.d.ts +1 -6
- package/dist/live-recovery.js +1 -11
- package/dist/live-voice.d.ts +6 -76
- package/dist/live-voice.js +6 -352
- package/dist/owner.js +4 -0
- package/dist/pcm-wav.d.ts +1 -2
- package/dist/pcm-wav.js +1 -27
- package/dist/shared-translation.d.ts +6 -53
- package/dist/shared-translation.js +7 -336
- package/dist/speaker-turns.d.ts +1 -32
- package/dist/speaker-turns.js +1 -30
- package/dist/speech.d.ts +3 -5
- package/dist/speech.js +4 -7
- package/dist/translation-passes.d.ts +5 -125
- package/dist/translation-passes.js +5 -294
- package/dist/upgrade-allowance.d.ts +1 -23
- package/dist/upgrade-allowance.js +1 -73
- package/dist/voice-profile.d.ts +1 -7
- package/dist/voice-profile.js +1 -53
- package/dist/voice-provider.d.ts +1 -9
- package/dist/voice-provider.js +1 -27
- package/package.json +2 -1
- package/src/live-interpreter.ts +1 -213
- package/src/live-recovery.ts +1 -10
- package/src/live-voice.ts +6 -304
- package/src/owner.ts +3 -0
- package/src/pcm-wav.ts +1 -26
- package/src/shared-translation.ts +7 -222
- package/src/speaker-turns.ts +1 -34
- package/src/speech.ts +4 -5
- package/src/translation-passes.ts +6 -292
- package/src/upgrade-allowance.ts +1 -72
- package/src/voice-profile.ts +1 -46
- package/src/voice-provider.ts +1 -22
- package/web/dist/assets/{ar-CLNm1oA7.js → ar-8fDonUoB.js} +1 -1
- package/web/dist/assets/{cs-Cptn8RbB.js → cs-DCClolTy.js} +1 -1
- package/web/dist/assets/{da-CRbNCFOu.js → da-C3RPhXUd.js} +1 -1
- package/web/dist/assets/{de-D4DnC5NQ.js → de-CYBoEomk.js} +1 -1
- package/web/dist/assets/{es-DAHnD9bs.js → es-DS3ls4xT.js} +1 -1
- package/web/dist/assets/{fi-Csbl41Vz.js → fi-DbCZpO89.js} +1 -1
- package/web/dist/assets/{fr-jZYQJed8.js → fr-D0z3UpdL.js} +1 -1
- package/web/dist/assets/{hi-DjT9u0v_.js → hi-BTU3sRlQ.js} +1 -1
- package/web/dist/assets/{hls-3VKVEQE3-BjAVnvGY.js → hls-3VKVEQE3-CQXTVHn_.js} +1 -1
- package/web/dist/assets/{hu-CVrU8gC4.js → hu-B37Q8ldT.js} +1 -1
- package/web/dist/assets/{id-B_gN24X5.js → id-4pdXNa4G.js} +1 -1
- package/web/dist/assets/index-BDBbN5Qy.js +22 -0
- package/web/dist/assets/{index-BueBHHcv.css → index-Bo3utcDw.css} +1 -1
- package/web/dist/assets/{it-BUC1dMpV.js → it-CTe69-UY.js} +1 -1
- package/web/dist/assets/{mpegts-CnqmXTu2.js → mpegts-BBmkDppm.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-B4QgG3Xb.js → mpegts-LO6RVLD6-CUVdjYFi.js} +1 -1
- package/web/dist/assets/{nl-CGi-aIrz.js → nl-CxrmHrMn.js} +1 -1
- package/web/dist/assets/{ru-YIbS63cK.js → ru-zjVCfIKq.js} +1 -1
- package/web/dist/assets/{sv-CuJUpOCk.js → sv-CEoiT2Lr.js} +1 -1
- package/web/dist/assets/{uk-DLfG8YhV.js → uk-ZC6JzQvD.js} +1 -1
- package/web/dist/assets/{vi-nv2siY2T.js → vi-D9anF-cl.js} +1 -1
- package/web/dist/assets/{zh-P5x8w1OW.js → zh-BeLYdC0I.js} +1 -1
- package/web/dist/index.html +7 -12
- package/web/dist/sw.js +23 -23
- package/web/dist/assets/index-c3uxZS8_.js +0 -3
|
@@ -1,26 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
-
import { lookup } from "node:dns/promises";
|
|
6
|
-
import { BlockList, isIP } from "node:net";
|
|
7
|
-
import http from "node:http";
|
|
8
|
-
import https from "node:https";
|
|
9
|
-
import { spawn } from "node:child_process";
|
|
10
|
-
import { Interpreter, type Caption } from "./live-interpreter.ts";
|
|
11
|
-
import { LiveVoice, LIVE_VOICE_LANGUAGES } from "./live-voice.ts";
|
|
12
|
-
import { SpeechError } from "./speech.ts";
|
|
13
|
-
import type { Translator } from "./translate.ts";
|
|
14
|
-
import type { TranslationMeter, TranslationUsage } from "./translation-passes.ts";
|
|
1
|
+
/** Shared live translation, from @profullstack/live-translate, limited to nixamp channels. */
|
|
2
|
+
import { SharedTranslations as Base, liveInput as baseLiveInput, SpeechError, type OpenLiveInput } from "@profullstack/live-translate";
|
|
3
|
+
export { publicStreamAddress, type LiveInput, type OpenLiveInput, type SharedEvent } from "@profullstack/live-translate";
|
|
15
4
|
|
|
16
|
-
|
|
17
|
-
for (const [ip, bits] of [["0.0.0.0",8],["10.0.0.0",8],["100.64.0.0",10],["127.0.0.0",8],["169.254.0.0",16],["172.16.0.0",12],["192.0.0.0",24],["192.0.2.0",24],["192.168.0.0",16],["198.18.0.0",15],["198.51.100.0",24],["203.0.113.0",24],["224.0.0.0",4],["240.0.0.0",4]] as const) blocked.addSubnet(ip, bits, "ipv4");
|
|
18
|
-
const v6global = new BlockList(); v6global.addSubnet("2000::", 3, "ipv6");
|
|
19
|
-
blocked.addSubnet("2001:db8::", 32, "ipv6"); blocked.addSubnet("2002::", 16, "ipv6"); blocked.addSubnet("2001::", 32, "ipv6");
|
|
20
|
-
export function publicStreamAddress(address: string): boolean {
|
|
21
|
-
const family = isIP(address);
|
|
22
|
-
return family === 4 ? !blocked.check(address, "ipv4") : family === 6 && v6global.check(address, "ipv6") && !blocked.check(address, "ipv6");
|
|
23
|
-
}
|
|
5
|
+
/** Only a live nixamp channel (`/api/channels/<id>`, optionally `?k=`). */
|
|
24
6
|
export function sharedSource(raw: string): URL {
|
|
25
7
|
let url: URL;
|
|
26
8
|
try { url = new URL(raw); } catch { throw new SpeechError("Choose a live Nixamp channel.", 400); }
|
|
@@ -28,205 +10,8 @@ export function sharedSource(raw: string): URL {
|
|
|
28
10
|
if (url.toString().length > 2048) throw new SpeechError("Invalid live source.", 400);
|
|
29
11
|
url.searchParams.sort(); return url;
|
|
30
12
|
}
|
|
31
|
-
export
|
|
32
|
-
export type OpenLiveInput = (source: URL, pcm: (bytes: Buffer) => void, ended: () => void, signal: AbortSignal) => Promise<LiveInput>;
|
|
33
|
-
|
|
34
|
-
/** Resolve once and pin that public address; no redirects or ffmpeg URL fetches. */
|
|
35
|
-
export function liveInput(ffmpeg: string[] = ["ffmpeg"]): OpenLiveInput {
|
|
36
|
-
return async (source, pcm, ended, signal) => {
|
|
37
|
-
const hostname = source.hostname.replace(/^\[|\]$/g, "");
|
|
38
|
-
const addresses = await lookup(hostname, { all: true });
|
|
39
|
-
if (!addresses.length || addresses.some(one => !publicStreamAddress(one.address))) throw new SpeechError("This live source is not publicly reachable.", 400);
|
|
40
|
-
signal.throwIfAborted();
|
|
41
|
-
const selected = addresses[0]!;
|
|
42
|
-
const child = spawn(ffmpeg[0]!, [...ffmpeg.slice(1), "-v", "error", "-nostats", "-protocol_whitelist", "pipe", "-readrate", "1", "-analyzeduration", "500000", "-probesize", "262144", "-i", "pipe:0", "-vn", "-ac", "1", "-ar", "16000", "-f", "s16le", "pipe:1"], { stdio: ["pipe", "pipe", "ignore"] });
|
|
43
|
-
let stopped = false;
|
|
44
|
-
const request = (source.protocol === "https:" ? https : http).get(source, {
|
|
45
|
-
signal, headers: { "user-agent": "Nixamp shared translated audio", accept: "audio/*,video/*,application/octet-stream" },
|
|
46
|
-
lookup: (_name, options, callback) => { if (typeof options === "object" && options.all) callback(null, [selected]); else callback(null, selected.address, selected.family); },
|
|
47
|
-
});
|
|
48
|
-
const stop = (): void => { if (stopped) return; stopped = true; request.destroy(); child.stdin.destroy(); child.kill("SIGKILL"); signal.removeEventListener("abort", stop); };
|
|
49
|
-
const end = (): void => { if (!stopped) { stop(); ended(); } };
|
|
50
|
-
signal.addEventListener("abort", stop, { once: true });
|
|
51
|
-
child.stdout.on("data", pcm); child.on("error", end); child.on("close", end); child.stdin.on("error", end);
|
|
52
|
-
request.on("response", response => {
|
|
53
|
-
if (response.statusCode !== 200) { response.destroy(); end(); return; }
|
|
54
|
-
response.on("error", end); response.on("end", end); response.pipe(child.stdin);
|
|
55
|
-
});
|
|
56
|
-
request.on("error", end);
|
|
57
|
-
request.setTimeout(15_000, end);
|
|
58
|
-
return { stop };
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export type SharedEvent = { type: "status"; listeners: number } | { type: "line"; id: string; line: Caption } | { type: "audio"; id: string; data: string } | { type: "end"; id: string } | { type: "error"; error: string; retryable?: boolean };
|
|
63
|
-
type Member = { by: string; send: (event: SharedEvent) => void; close: () => void };
|
|
64
|
-
interface Feed { id: string; source: URL; language: string; members: Set<Member>; controller: AbortController; input?: LiveInput; interpreter: Interpreter; meter: TranslationMeter; reservations: Map<string, string[]>; queue: Caption[]; speaking: boolean; voiceFailures: number; previous: Buffer; pending: Buffer; until: number; heartbeat?: ReturnType<typeof setInterval>; }
|
|
65
|
-
|
|
66
|
-
export class SharedTranslations {
|
|
67
|
-
private readonly feeds = new Map<string, Feed>();
|
|
68
|
-
constructor(private readonly options: { voice: LiveVoice; translator: Pick<Translator, "translate">; billing?: TranslationMeter; open?: OpenLiveInput }) {}
|
|
69
|
-
get size(): number { return this.feeds.size; }
|
|
13
|
+
export function liveInput(ffmpeg: string[] = ["ffmpeg"]): OpenLiveInput { return baseLiveInput(ffmpeg, "Nixamp shared translated audio"); }
|
|
70
14
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const source = sharedSource(raw);
|
|
74
|
-
if (!LIVE_VOICE_LANGUAGES.has(language)) throw new SpeechError("Choose a supported audio language.", 400);
|
|
75
|
-
const id = `dub-${createHash("sha256").update(`${source}|${language}`).digest("hex").slice(0, 40)}`;
|
|
76
|
-
const capacity = (): void => {
|
|
77
|
-
const connections = [...this.feeds.values()].reduce((total, feed) => total + [...feed.members].filter(member => member.by === by).length, 0);
|
|
78
|
-
if (connections >= 2) throw new SpeechError("Translated audio is already open in two players for this account.", 429);
|
|
79
|
-
const feed = this.feeds.get(id);
|
|
80
|
-
if (!feed && this.feeds.size >= 4) throw new SpeechError("Live translation is busy. Try again shortly.", 429);
|
|
81
|
-
if (feed && feed.members.size >= 1000) throw new SpeechError("This translated stream is full. Try again shortly.", 429);
|
|
82
|
-
};
|
|
83
|
-
capacity();
|
|
84
|
-
if (this.options.billing?.begin) await this.options.billing.begin(by, id);
|
|
85
|
-
else await this.options.billing?.require(by, id);
|
|
86
|
-
capacity(); // Other joins can finish during the account/database check.
|
|
87
|
-
let feed = this.feeds.get(id);
|
|
88
|
-
if (!feed) {
|
|
89
|
-
feed = this.make(id, source, language); this.feeds.set(id, feed);
|
|
90
|
-
const current = feed;
|
|
91
|
-
current.heartbeat = setInterval(() => void this.checkMembers(current), 10_000); current.heartbeat.unref?.();
|
|
92
|
-
}
|
|
93
|
-
const member = { by, send, close }; feed.members.add(member);
|
|
94
|
-
this.broadcast(feed, { type: "status", listeners: feed.members.size });
|
|
95
|
-
if (feed.members.size === 1 && !feed.input) void this.start(feed);
|
|
96
|
-
const current = feed;
|
|
97
|
-
return () => this.leave(current, member);
|
|
98
|
-
}
|
|
99
|
-
private leave(feed: Feed, member: Member): void {
|
|
100
|
-
if (!feed.members.delete(member)) return;
|
|
101
|
-
if (!feed.members.size) {
|
|
102
|
-
if (feed.heartbeat) clearInterval(feed.heartbeat);
|
|
103
|
-
feed.controller.abort(); feed.input?.stop(); feed.interpreter.reset(); feed.queue = [];
|
|
104
|
-
this.feeds.delete(feed.id);
|
|
105
|
-
} else this.broadcast(feed, { type: "status", listeners: feed.members.size });
|
|
106
|
-
}
|
|
107
|
-
private async checkMembers(feed: Feed): Promise<void> {
|
|
108
|
-
const accounts = [...new Set([...feed.members].map(member => member.by))];
|
|
109
|
-
let funded = new Set(accounts), message = "This free session ended and audio credit is used up. Start a remaining free session or buy a pass.";
|
|
110
|
-
try {
|
|
111
|
-
if (this.options.billing?.eligible) funded = new Set(await this.options.billing.eligible(accounts, feed.id));
|
|
112
|
-
else if (this.options.billing) {
|
|
113
|
-
const results = await Promise.allSettled(accounts.map(by => this.options.billing!.require(by, feed.id)));
|
|
114
|
-
funded = new Set(accounts.filter((_by, index) => results[index]?.status === "fulfilled"));
|
|
115
|
-
}
|
|
116
|
-
} catch { funded.clear(); message = "Your translation access could not be checked. Try again shortly."; }
|
|
117
|
-
for (const member of [...feed.members]) if (accounts.includes(member.by) && !funded.has(member.by)) {
|
|
118
|
-
try { member.send({ type: "error", error: message }); } catch { /* disconnected */ }
|
|
119
|
-
finally { this.leave(feed, member); member.close(); }
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
private broadcast(feed: Feed, event: SharedEvent): void {
|
|
123
|
-
for (const member of [...feed.members]) {
|
|
124
|
-
try { member.send(event); } catch { this.leave(feed, member); member.close(); }
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
private stop(feed: Feed, error: string, retryable = false): void {
|
|
128
|
-
this.broadcast(feed, { type: "error", error, retryable });
|
|
129
|
-
for (const member of [...feed.members]) { this.leave(feed, member); member.close(); }
|
|
130
|
-
}
|
|
131
|
-
private make(id: string, source: URL, language: string): Feed {
|
|
132
|
-
const feed = { id, source, language, members: new Set<Member>(), controller: new AbortController(), reservations: new Map<string, string[]>(), queue: [], speaking: false, voiceFailures: 0, previous: Buffer.alloc(0), pending: Buffer.alloc(0), until: Date.now() } as unknown as Feed;
|
|
133
|
-
// Each account uses free access first, then the same published credit rate.
|
|
134
|
-
// Additional listeners do not trigger recognition or synthesis.
|
|
135
|
-
feed.meter = {
|
|
136
|
-
require: async () => { if (!feed.members.size) throw new SpeechError("No listeners.", 410); },
|
|
137
|
-
reserve: async (_by: string, kind: TranslationUsage, units: number) => {
|
|
138
|
-
const ids: string[] = [];
|
|
139
|
-
const accounts = [...new Set([...feed.members].map(member => member.by))];
|
|
140
|
-
const accepted = new Set<string>();
|
|
141
|
-
const billing = this.options.billing;
|
|
142
|
-
if (billing?.reserveMany) {
|
|
143
|
-
for (const charged of await billing.reserveMany(accounts, kind, units, feed.id)) { ids.push(charged.id); accepted.add(charged.by); }
|
|
144
|
-
} else {
|
|
145
|
-
const results = await Promise.allSettled(accounts.map(async account => ({ by: account, id: await billing?.reserve(account, kind, units, feed.id) })));
|
|
146
|
-
let failure: unknown;
|
|
147
|
-
for (const result of results) {
|
|
148
|
-
if (result.status === "fulfilled") { accepted.add(result.value.by); if (result.value.id) ids.push(result.value.id); }
|
|
149
|
-
else if (!(result.reason instanceof SpeechError) || result.reason.status !== 402) failure = result.reason;
|
|
150
|
-
}
|
|
151
|
-
if (failure) { await Promise.all(ids.map(id => billing!.refund(id))); throw failure; }
|
|
152
|
-
}
|
|
153
|
-
for (const member of [...feed.members]) if (accounts.includes(member.by) && !accepted.has(member.by)) {
|
|
154
|
-
try { member.send({ type: "error", error: "This free session ended and audio credit is used up. Start a remaining free session or buy a pass." }); }
|
|
155
|
-
catch { /* disconnected */ } finally { this.leave(feed, member); member.close(); }
|
|
156
|
-
}
|
|
157
|
-
if (!feed.members.size) { await Promise.all(ids.map(id => this.options.billing!.refund(id))); throw new SpeechError("No funded listeners.", 402); }
|
|
158
|
-
const reservation = randomUUID(); feed.reservations.set(reservation, ids); return reservation;
|
|
159
|
-
},
|
|
160
|
-
commit: async id => { const ids = feed.reservations.get(id) ?? []; if (this.options.billing?.commitMany) await this.options.billing.commitMany(ids); else await Promise.all(ids.map(id => this.options.billing!.commit(id))); feed.reservations.delete(id); },
|
|
161
|
-
refund: async id => { const ids = feed.reservations.get(id) ?? []; if (this.options.billing?.refundMany) await this.options.billing.refundMany(ids); else await Promise.all(ids.map(id => this.options.billing!.refund(id))); feed.reservations.delete(id); },
|
|
162
|
-
};
|
|
163
|
-
let voices: Awaited<ReturnType<LiveVoice["voices"]>> = [];
|
|
164
|
-
feed.interpreter = new Interpreter({
|
|
165
|
-
language: () => language, speakers: () => true, voices: () => voices, channel: () => id,
|
|
166
|
-
status: text => { if (!feed.controller.signal.aborted && !feed.members.size) this.stop(feed, text); },
|
|
167
|
-
failed: message => this.stop(feed, message),
|
|
168
|
-
lines: lines => {
|
|
169
|
-
feed.queue.push(...lines);
|
|
170
|
-
feed.queue = feed.queue.filter(line => Date.now() - line.until <= 12_000).slice(-24);
|
|
171
|
-
if (!feed.speaking) void this.speak(feed);
|
|
172
|
-
},
|
|
173
|
-
fetcher: (async (url, init) => {
|
|
174
|
-
feed.controller.signal.throwIfAborted();
|
|
175
|
-
const signal = AbortSignal.any([feed.controller.signal, ...(init?.signal ? [init.signal] : [])]);
|
|
176
|
-
if (String(url).includes("/speakers")) {
|
|
177
|
-
voices = await this.options.voice.voices();
|
|
178
|
-
return Response.json(await this.options.voice.hear(new Uint8Array(init?.body as Uint8Array), id, signal, feed.meter));
|
|
179
|
-
}
|
|
180
|
-
const body = JSON.parse(String(init?.body));
|
|
181
|
-
return Response.json(await this.options.translator.translate(body.texts, body.from, body.to, { by: id, deadline: Date.now() + 10_000 }));
|
|
182
|
-
}) as typeof fetch,
|
|
183
|
-
});
|
|
184
|
-
return feed;
|
|
185
|
-
}
|
|
186
|
-
private async start(feed: Feed): Promise<void> {
|
|
187
|
-
try {
|
|
188
|
-
const input = await (this.options.open ?? liveInput())(feed.source, bytes => {
|
|
189
|
-
if (feed.controller.signal.aborted) return;
|
|
190
|
-
feed.pending = Buffer.concat([feed.pending, bytes]);
|
|
191
|
-
while (feed.pending.length >= 64_000) {
|
|
192
|
-
const fresh = feed.pending.subarray(0, 64_000); feed.pending = feed.pending.subarray(64_000);
|
|
193
|
-
const pcm = Buffer.concat([feed.previous, fresh]); feed.previous = pcm.subarray(-128_000);
|
|
194
|
-
feed.until += 2000;
|
|
195
|
-
if (Math.abs(Date.now() - feed.until) > 2000) feed.until = Date.now();
|
|
196
|
-
const samples = new Float32Array(pcm.length / 2);
|
|
197
|
-
for (let index = 0; index < samples.length; index++) samples[index] = pcm.readInt16LE(index * 2) / 32768;
|
|
198
|
-
feed.interpreter.push({ samples, at: feed.until - samples.length / 16, until: feed.until, freshAt: feed.until - 2000 });
|
|
199
|
-
}
|
|
200
|
-
}, () => this.stop(feed, "The live source disconnected.", true), feed.controller.signal);
|
|
201
|
-
if (feed.controller.signal.aborted) input.stop(); else feed.input = input;
|
|
202
|
-
} catch (error) { this.stop(feed, error instanceof SpeechError ? error.message : "The live source could not be opened."); }
|
|
203
|
-
}
|
|
204
|
-
private async speak(feed: Feed): Promise<void> {
|
|
205
|
-
feed.speaking = true;
|
|
206
|
-
try {
|
|
207
|
-
while (feed.queue.length && !feed.controller.signal.aborted) {
|
|
208
|
-
const line = feed.queue.shift()!;
|
|
209
|
-
if (Date.now() - line.until > 12_000) continue;
|
|
210
|
-
try {
|
|
211
|
-
const response = await this.options.voice.stream({ text: line.text, language: feed.language, channel: feed.id, speaker: line.speaker, voice: feed.interpreter.tracker.speakers.get(line.speaker ?? "")?.voice }, feed.id, feed.controller.signal, feed.meter);
|
|
212
|
-
const id = randomUUID();
|
|
213
|
-
this.broadcast(feed, { type: "line", id, line });
|
|
214
|
-
const reader = response.body!.getReader();
|
|
215
|
-
try {
|
|
216
|
-
while (true) {
|
|
217
|
-
const { done, value } = await reader.read(); if (done) break;
|
|
218
|
-
for (let at = 0; at < value.length; at += 16_384) this.broadcast(feed, { type: "audio", id, data: Buffer.from(value.subarray(at, at + 16_384)).toString("base64") });
|
|
219
|
-
}
|
|
220
|
-
} finally { reader.releaseLock(); this.broadcast(feed, { type: "end", id }); }
|
|
221
|
-
feed.voiceFailures = 0;
|
|
222
|
-
} catch (error) {
|
|
223
|
-
if (feed.controller.signal.aborted) return;
|
|
224
|
-
if (!transientAudioError(error) || ++feed.voiceFailures >= 3) throw error;
|
|
225
|
-
// A provider failure skips this phrase; the source and other listeners
|
|
226
|
-
// remain connected. Never replay partial speech or retry a charge.
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
} catch (error) { if (!feed.controller.signal.aborted) this.stop(feed, error instanceof SpeechError ? error.message : "Translated audio was interrupted."); }
|
|
230
|
-
finally { feed.speaking = false; }
|
|
231
|
-
}
|
|
15
|
+
export class SharedTranslations extends Base {
|
|
16
|
+
constructor(options: ConstructorParameters<typeof Base>[0]) { super({ parse: sharedSource, open: liveInput(), ...options }); }
|
|
232
17
|
}
|
package/src/speaker-turns.ts
CHANGED
|
@@ -1,34 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* word timestamps before assigning a voice; these are not people's identities. */
|
|
3
|
-
import { reliableText, type Wav } from "./speech.ts";
|
|
4
|
-
import { voiceProfile, type VoiceProfile } from "./voice-profile.ts";
|
|
5
|
-
|
|
6
|
-
export interface SpeakerTurn { start: number; end: number; text: string; speaker: string; profile: VoiceProfile; words: { text: string; start: number; end: number }[]; }
|
|
7
|
-
export interface SpeakerTranscript { language: string; seconds: number; turns: SpeakerTurn[]; }
|
|
8
|
-
export interface ScribeResult {
|
|
9
|
-
language_code?: string;
|
|
10
|
-
words?: { text: string; start: number; end: number; type: string; speaker_id?: string | null }[];
|
|
11
|
-
}
|
|
12
|
-
const ISO: Record<string, string> = Object.fromEntries("eng:en spa:es deu:de ger:de fra:fr fre:fr por:pt ita:it nld:nl dut:nl swe:sv dan:da fin:fi rus:ru ukr:uk ces:cs cze:cs hun:hu cmn:zh zho:zh ara:ar hin:hi vie:vi ind:id jpn:ja kor:ko pol:pl tur:tr ron:ro bul:bg ell:el nor:no nob:no nno:no".split(" ").map(pair => pair.split(":")));
|
|
13
|
-
|
|
14
|
-
export function speakerTurns(body: ScribeResult, wav: Wav): SpeakerTranscript {
|
|
15
|
-
const seconds = wav.samples.length / wav.rate;
|
|
16
|
-
const language = ISO[body.language_code ?? ""] ?? body.language_code ?? "";
|
|
17
|
-
const turns: SpeakerTurn[] = [];
|
|
18
|
-
for (const word of (body.words ?? []).slice(0, 1500)) {
|
|
19
|
-
if (word.type !== "word" || typeof word.text !== "string" || !Number.isFinite(word.start) || !Number.isFinite(word.end) || word.start < 0 || word.end < word.start || word.end > seconds + 0.5) continue;
|
|
20
|
-
const speaker = typeof word.speaker_id === "string" && /^[\w-]{1,80}$/.test(word.speaker_id) ? word.speaker_id : "unknown";
|
|
21
|
-
const last = turns.at(-1);
|
|
22
|
-
if (last && last.speaker === speaker && word.start - last.end < 0.8 && last.text.length + word.text.length < 400) {
|
|
23
|
-
last.text += ` ${word.text}`; last.end = Math.max(last.end, word.end);
|
|
24
|
-
last.words.push({ text: word.text, start: word.start, end: word.end });
|
|
25
|
-
} else turns.push({ start: word.start, end: word.end, text: word.text, speaker, profile: "unknown", words: [{ text: word.text, start: word.start, end: word.end }] });
|
|
26
|
-
}
|
|
27
|
-
const pcm = Buffer.alloc(wav.samples.length * 2);
|
|
28
|
-
wav.samples.forEach((sample, i) => pcm.writeInt16LE(Math.round(Math.max(-1, Math.min(1, sample)) * 32767), i * 2));
|
|
29
|
-
for (const turn of turns) {
|
|
30
|
-
turn.text = reliableText(turn.text, Math.max(1, turn.end - turn.start));
|
|
31
|
-
turn.profile = voiceProfile(pcm.subarray(Math.floor(turn.start * 16000) * 2, Math.ceil(turn.end * 16000) * 2));
|
|
32
|
-
}
|
|
33
|
-
return { language, seconds, turns: turns.filter(turn => turn.text !== "") };
|
|
34
|
-
}
|
|
1
|
+
export { speakerTurns, type SpeakerTurn, type SpeakerTranscript, type ScribeResult } from "@profullstack/live-translate";
|
package/src/speech.ts
CHANGED
|
@@ -43,11 +43,10 @@ export const DEFAULT_MODEL = "onnx-community/whisper-base";
|
|
|
43
43
|
/** Stored live lines from older recognition settings must be heard again. */
|
|
44
44
|
export const NATIVE_REVISION = "native-v2";
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
46
|
+
/** The package's class, so an error thrown inside @profullstack/live-translate
|
|
47
|
+
* is still `instanceof SpeechError` to the server's handlers. */
|
|
48
|
+
export { SpeechError } from "@profullstack/live-translate/core";
|
|
49
|
+
import { SpeechError } from "@profullstack/live-translate/core";
|
|
51
50
|
|
|
52
51
|
/** A stretch of the clip and what was said in it, seconds from the clip's start. */
|
|
53
52
|
export interface Segment {
|
|
@@ -1,294 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import { SpeechError } from "./speech.ts";
|
|
6
|
-
import { UpgradeAllowances, type UpgradeAllowance } from "./upgrade-allowance.ts";
|
|
1
|
+
/** Prepaid translation credit, from @profullstack/live-translate, sold as nixamp. */
|
|
2
|
+
import { TranslationPasses as Base } from "@profullstack/live-translate";
|
|
3
|
+
export { TRANSLATION_MULTIPLIER, TRANSLATION_PLANS, translationCost, verifyTranslationPayment, coinpayKeyFrom, verifyCoinpaySignature, readCoinpayWebhook,
|
|
4
|
+
type TranslationPlan, type TranslationUsage, type TranslationMeter, type TranslationAccess } from "@profullstack/live-translate";
|
|
7
5
|
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
{ id: "day", name: "Day", days: 1, priceCents: 100 },
|
|
11
|
-
{ id: "month", name: "Month", days: 30, priceCents: 500 },
|
|
12
|
-
] as const;
|
|
13
|
-
export type TranslationPlan = typeof TRANSLATION_PLANS[number];
|
|
14
|
-
export type TranslationUsage = "transcription" | "voice";
|
|
15
|
-
export interface TranslationMeter {
|
|
16
|
-
begin?(by: string, resource: string): Promise<void>;
|
|
17
|
-
require(by: string, resource?: string): Promise<void>;
|
|
18
|
-
eligible?(accounts: string[], resource?: string): Promise<string[]>;
|
|
19
|
-
reserve(by: string, kind: TranslationUsage, units: number, resource?: string): Promise<string>;
|
|
20
|
-
reserveMany?(accounts: string[], kind: TranslationUsage, units: number, resource?: string): Promise<{ by: string; id: string }[]>;
|
|
21
|
-
commitMany?(ids: string[]): Promise<void>;
|
|
22
|
-
refundMany?(ids: string[]): Promise<void>;
|
|
23
|
-
commit(id: string): Promise<void>;
|
|
24
|
-
refund(id: string): Promise<void>;
|
|
25
|
-
}
|
|
26
|
-
export interface TranslationAccess {
|
|
27
|
-
required: boolean; available: boolean; balanceMicros: number; expires: string | null;
|
|
28
|
-
plans: readonly TranslationPlan[]; coins: string[]; orders: { id: string; url: string; plan: string }[];
|
|
29
|
-
rates: { transcriptionHour: number; voiceThousand: number; multiplier: number };
|
|
30
|
-
free: UpgradeAllowance;
|
|
31
|
-
}
|
|
32
|
-
const COINS = ["USDC_POL", "USDC_SOL", "SOL", "POL", "BTC", "ETH", "USDC_ETH", "BCH", "DOGE"];
|
|
33
|
-
const ID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
|
|
34
|
-
const DAY = 86_400_000;
|
|
35
|
-
const PAID = new Set(["confirmed", "forwarding", "forwarded", "forwarding_failed", "completed"]);
|
|
36
|
-
|
|
37
|
-
/** At the published rates: Scribe $0.22/submitted hour, Flash $0.05/1k chars.
|
|
38
|
-
* Transcription units are 16 kHz samples; overlap is charged exactly as sent. */
|
|
39
|
-
export function translationCost(kind: TranslationUsage, units: number): { cost: number; charge: number } {
|
|
40
|
-
if (!["voice", "transcription"].includes(kind) || !Number.isSafeInteger(units) || units < 1 || (kind === "voice" ? units > 600 : units > 241600)) throw new SpeechError("invalid translation usage", 400);
|
|
41
|
-
const cost = Math.ceil(kind === "voice" ? units * 50 : units * 220_000 / 57_600_000);
|
|
42
|
-
return { cost, charge: cost * TRANSLATION_MULTIPLIER };
|
|
43
|
-
}
|
|
44
|
-
function cents(value: unknown): number {
|
|
45
|
-
if (typeof value !== "number" && typeof value !== "string") return -1;
|
|
46
|
-
const text = String(value);
|
|
47
|
-
if (!/^\d+(?:\.\d{1,2}0*)?$/.test(text)) return -1;
|
|
48
|
-
const result = Math.round(Number(text) * 100);
|
|
49
|
-
return Number.isSafeInteger(result) ? result : -1;
|
|
50
|
-
}
|
|
51
|
-
export function verifyTranslationPayment(payment: Record<string, unknown>, id: string, priceCents: number): boolean {
|
|
52
|
-
return payment["id"] === id && PAID.has(String(payment["status"])) &&
|
|
53
|
-
String(payment["currency"]).toUpperCase() === "USD" && cents(payment["amount"]) === priceCents;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Which CoinPay key this deployment sells with. `COINPAY_API_KEY` is nixamp's
|
|
57
|
-
* own business; `COINPAY_X402_KEY` is the older name, still honoured so a box
|
|
58
|
-
* that has only it keeps selling. The own key wins, because the fleet-wide
|
|
59
|
-
* x402 key belongs to another site's business and would book nixamp's sales
|
|
60
|
-
* (and send its webhooks) there. */
|
|
61
|
-
export function coinpayKeyFrom(env: NodeJS.ProcessEnv = process.env): string {
|
|
62
|
-
return env["COINPAY_API_KEY"] || env["COINPAY_X402_KEY"] || "";
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** CoinPay signs `t=<unix>,v1=<hex HMAC-SHA256 of "t.rawBody">` and allows
|
|
66
|
-
* 300 s of skew. Computed over the raw bytes: reserialising changes them. */
|
|
67
|
-
export function verifyCoinpaySignature(raw: string, header: string | undefined, secret: string, nowMs = Date.now()): boolean {
|
|
68
|
-
if (!secret || !header) return false;
|
|
69
|
-
let t = "", v1 = "";
|
|
70
|
-
for (const part of header.split(",")) {
|
|
71
|
-
const at = part.indexOf("=");
|
|
72
|
-
const name = part.slice(0, at).trim(), value = part.slice(at + 1).trim();
|
|
73
|
-
if (name === "t") t = value; else if (name === "v1") v1 = value;
|
|
74
|
-
}
|
|
75
|
-
if (!/^\d{1,12}$/.test(t) || !/^[a-f0-9]{64}$/i.test(v1)) return false;
|
|
76
|
-
if (Math.abs(nowMs / 1000 - Number(t)) > 300) return false;
|
|
77
|
-
const expected = createHmac("sha256", secret).update(`${t}.${raw}`).digest();
|
|
78
|
-
return timingSafeEqual(expected, Buffer.from(v1, "hex"));
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** CoinPay's webhook nests the payment under `data`, and its top-level `id` is
|
|
82
|
-
* the EVENT (`evt_…`), not the payment. Read `data` first; the flat shape is
|
|
83
|
-
* only the older test sender's. */
|
|
84
|
-
export function readCoinpayWebhook(payload: unknown): { type: string; paymentId: string; status: string } {
|
|
85
|
-
const body = payload && typeof payload === "object" ? payload as Record<string, unknown> : {};
|
|
86
|
-
const data = body["data"] && typeof body["data"] === "object" ? body["data"] as Record<string, unknown> : body;
|
|
87
|
-
return {
|
|
88
|
-
type: String(body["type"] ?? ""),
|
|
89
|
-
paymentId: String(data["payment_id"] ?? data["id"] ?? ""),
|
|
90
|
-
status: String(data["status"] ?? "").toLowerCase(),
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export class TranslationPasses implements TranslationMeter {
|
|
95
|
-
private schema: Promise<void> | null = null;
|
|
96
|
-
private merchant: Promise<{ business: string; coins: string[] }> | null = null;
|
|
97
|
-
private merchantUntil = 0;
|
|
98
|
-
private readonly fetcher: typeof fetch;
|
|
99
|
-
private readonly now: () => number;
|
|
100
|
-
readonly free: UpgradeAllowances;
|
|
101
|
-
constructor(private readonly options: { db: Queryable; key: string; site: string; fetcher?: typeof fetch; now?: () => number }) {
|
|
102
|
-
this.fetcher = options.fetcher ?? fetch; this.now = options.now ?? Date.now;
|
|
103
|
-
this.free = new UpgradeAllowances(options.db, this.now);
|
|
104
|
-
}
|
|
105
|
-
private async ensure(): Promise<void> {
|
|
106
|
-
this.schema ??= (async () => {
|
|
107
|
-
await this.free.ensure();
|
|
108
|
-
await this.options.db.query(`CREATE TABLE IF NOT EXISTS translation_wallets (
|
|
109
|
-
by_account TEXT PRIMARY KEY, balance_micros BIGINT NOT NULL CHECK (balance_micros >= 0), expires_at TIMESTAMPTZ NOT NULL)`);
|
|
110
|
-
await this.options.db.query(`CREATE TABLE IF NOT EXISTS translation_orders (
|
|
111
|
-
id TEXT PRIMARY KEY, by_account TEXT NOT NULL, request_key TEXT NOT NULL, plan TEXT NOT NULL,
|
|
112
|
-
price_cents INTEGER NOT NULL CHECK (price_cents > 0), days INTEGER NOT NULL,
|
|
113
|
-
business_id TEXT NOT NULL, coin TEXT NOT NULL, payment_id TEXT UNIQUE,
|
|
114
|
-
status TEXT NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ NOT NULL,
|
|
115
|
-
credited_at TIMESTAMPTZ, expires_at TIMESTAMPTZ, UNIQUE(by_account, request_key))`);
|
|
116
|
-
await this.options.db.query(`CREATE TABLE IF NOT EXISTS translation_usage (
|
|
117
|
-
id TEXT PRIMARY KEY, by_account TEXT NOT NULL, kind TEXT NOT NULL, units BIGINT NOT NULL,
|
|
118
|
-
cost_micros BIGINT NOT NULL, charge_micros BIGINT NOT NULL, status TEXT NOT NULL DEFAULT 'reserved',
|
|
119
|
-
created_at TIMESTAMPTZ NOT NULL)`);
|
|
120
|
-
await this.options.db.query(`CREATE TABLE IF NOT EXISTS translation_checkout_limits (bucket TEXT PRIMARY KEY, attempts INTEGER NOT NULL)`);
|
|
121
|
-
await this.options.db.query("CREATE INDEX IF NOT EXISTS translation_orders_account ON translation_orders (by_account, created_at)");
|
|
122
|
-
})().catch(error => { this.schema = null; throw error; });
|
|
123
|
-
await this.schema;
|
|
124
|
-
}
|
|
125
|
-
private async provider(path: string, init: RequestInit = {}): Promise<any> {
|
|
126
|
-
if (!this.options.key) throw new SpeechError("Translation checkout is unavailable.", 503);
|
|
127
|
-
const response = await this.fetcher(`https://coinpayportal.com${path}`, {
|
|
128
|
-
...init, signal: AbortSignal.timeout(15_000), headers: { authorization: `Bearer ${this.options.key}`, "content-type": "application/json", ...init.headers },
|
|
129
|
-
});
|
|
130
|
-
const body = await response.json();
|
|
131
|
-
if (!response.ok || body.success !== true) throw new SpeechError("The payment service is unavailable. Your purchase has not been activated; retry to check its status.", 502);
|
|
132
|
-
return body;
|
|
133
|
-
}
|
|
134
|
-
private async settings(): Promise<{ business: string; coins: string[] }> {
|
|
135
|
-
if (!this.merchant || this.now() >= this.merchantUntil) {
|
|
136
|
-
this.merchantUntil = this.now() + 600_000;
|
|
137
|
-
this.merchant = this.provider("/api/supported-coins").then(body => {
|
|
138
|
-
if (!ID.test(body.business_id)) throw new SpeechError("Translation checkout is unavailable.", 503);
|
|
139
|
-
const coins = COINS.filter(symbol => body.coins?.some((coin: any) => coin.symbol === symbol && coin.is_active && coin.has_wallet));
|
|
140
|
-
return { business: body.business_id as string, coins };
|
|
141
|
-
}).catch(error => { this.merchant = null; throw error; });
|
|
142
|
-
}
|
|
143
|
-
return this.merchant;
|
|
144
|
-
}
|
|
145
|
-
async access(by?: string): Promise<TranslationAccess> {
|
|
146
|
-
await this.ensure();
|
|
147
|
-
const config = await this.settings().catch(() => ({ business: "", coins: [] as string[] }));
|
|
148
|
-
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;
|
|
149
|
-
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"]}` })) : [];
|
|
150
|
-
return { required: true, available: config.coins.length > 0, balanceMicros: Number(row?.["balance_micros"] ?? 0),
|
|
151
|
-
expires: row ? new Date(String(row["expires_at"])).toISOString() : null, plans: TRANSLATION_PLANS, coins: config.coins, orders, free: await this.free.access(by),
|
|
152
|
-
rates: { transcriptionHour: 0.22, voiceThousand: 0.05, multiplier: TRANSLATION_MULTIPLIER } };
|
|
153
|
-
}
|
|
154
|
-
async begin(by: string, resource: string): Promise<void> {
|
|
155
|
-
await this.ensure();
|
|
156
|
-
if (await this.free.begin(by, "translation", resource)) return;
|
|
157
|
-
await this.require(by, resource);
|
|
158
|
-
}
|
|
159
|
-
async require(by: string, resource = ""): Promise<void> {
|
|
160
|
-
await this.ensure();
|
|
161
|
-
if ((await this.free.active([by], "translation", resource, true)).length) return;
|
|
162
|
-
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())]);
|
|
163
|
-
if (!found.rows.length) throw new SpeechError("Buy a pass or start a remaining free session: this session has no free time or audio credit.", 402);
|
|
164
|
-
}
|
|
165
|
-
async eligible(accounts: string[], resource = ""): Promise<string[]> {
|
|
166
|
-
await this.ensure();
|
|
167
|
-
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())]);
|
|
168
|
-
return [...new Set([...result.rows.map(row => String(row["by_account"])), ...await this.free.active(accounts, "translation", resource, true)])];
|
|
169
|
-
}
|
|
170
|
-
async reserve(by: string, kind: TranslationUsage, units: number, resource = ""): Promise<string> {
|
|
171
|
-
const result = await this.reserveMany([by], kind, units, resource);
|
|
172
|
-
if (!result.length) throw new SpeechError("This free session ended and audio credit is used up. Start a remaining free session or buy a pass.", 402);
|
|
173
|
-
return result[0]!.id;
|
|
174
|
-
}
|
|
175
|
-
/** Batch a shared stream's access charges in one database round trip. */
|
|
176
|
-
async reserveMany(accounts: string[], kind: TranslationUsage, units: number, resource = ""): Promise<{ by: string; id: string }[]> {
|
|
177
|
-
await this.ensure();
|
|
178
|
-
const { cost, charge } = translationCost(kind, units);
|
|
179
|
-
const now = this.now(), key = resource ? this.free.key("translation", resource) : "";
|
|
180
|
-
const result = await this.options.db.query(`WITH free AS (
|
|
181
|
-
SELECT DISTINCT by_account FROM upgrade_daily_uses WHERE by_account = ANY($1::text[])
|
|
182
|
-
AND (sessions->>$7)::bigint > $8), debit AS (
|
|
183
|
-
UPDATE translation_wallets SET balance_micros = balance_micros - $2
|
|
184
|
-
WHERE by_account = ANY($1::text[]) AND expires_at > $3 AND balance_micros >= $2
|
|
185
|
-
AND by_account NOT IN (SELECT by_account FROM free) RETURNING by_account), admitted AS (
|
|
186
|
-
SELECT by_account, 0::bigint AS charge FROM free UNION ALL SELECT by_account, $2::bigint FROM debit)
|
|
187
|
-
INSERT INTO translation_usage (id, by_account, kind, units, cost_micros, charge_micros, created_at)
|
|
188
|
-
SELECT gen_random_uuid()::text, by_account, $4, $5, $6, charge, $3 FROM admitted RETURNING id, by_account`,
|
|
189
|
-
[[...new Set(accounts)], charge, new Date(now), kind, units, cost, key, now]);
|
|
190
|
-
return result.rows.map(row => ({ by: String(row["by_account"]), id: String(row["id"]) }));
|
|
191
|
-
}
|
|
192
|
-
async commit(id: string): Promise<void> {
|
|
193
|
-
await this.options.db.query("UPDATE translation_usage SET status = 'charged' WHERE id = $1 AND status = 'reserved'", [id]);
|
|
194
|
-
}
|
|
195
|
-
async refund(id: string): Promise<void> {
|
|
196
|
-
await this.options.db.query(`WITH undone AS (
|
|
197
|
-
UPDATE translation_usage SET status = 'refunded' WHERE id = $1 AND status = 'reserved'
|
|
198
|
-
RETURNING by_account, charge_micros)
|
|
199
|
-
UPDATE translation_wallets AS wallet SET balance_micros = wallet.balance_micros + undone.charge_micros
|
|
200
|
-
FROM undone WHERE wallet.by_account = undone.by_account`, [id]);
|
|
201
|
-
}
|
|
202
|
-
async commitMany(ids: string[]): Promise<void> {
|
|
203
|
-
await this.options.db.query("UPDATE translation_usage SET status = 'charged' WHERE id = ANY($1::text[]) AND status = 'reserved'", [ids]);
|
|
204
|
-
}
|
|
205
|
-
async refundMany(ids: string[]): Promise<void> {
|
|
206
|
-
await this.options.db.query(`WITH undone AS (
|
|
207
|
-
UPDATE translation_usage SET status = 'refunded' WHERE id = ANY($1::text[]) AND status = 'reserved'
|
|
208
|
-
RETURNING by_account, charge_micros), totals AS (SELECT by_account, SUM(charge_micros) AS charge FROM undone GROUP BY by_account)
|
|
209
|
-
UPDATE translation_wallets AS wallet SET balance_micros = wallet.balance_micros + totals.charge
|
|
210
|
-
FROM totals WHERE wallet.by_account = totals.by_account`, [ids]);
|
|
211
|
-
}
|
|
212
|
-
async checkout(by: string, planId: string, coin: string, requestKey: string): Promise<{ id: string; url: string }> {
|
|
213
|
-
await this.ensure();
|
|
214
|
-
const plan = TRANSLATION_PLANS.find(plan => plan.id === planId);
|
|
215
|
-
if (!plan || !ID.test(requestKey)) throw new SpeechError("Choose a translation pass.", 400);
|
|
216
|
-
const config = await this.settings();
|
|
217
|
-
if (!config.coins.includes(coin)) throw new SpeechError("Choose an available payment currency.", 400);
|
|
218
|
-
// Durable caps across restarts/replicas protect the merchant's invoice quota.
|
|
219
|
-
// Retrying an existing order does not consume another invoice allowance.
|
|
220
|
-
const existing = await this.options.db.query("SELECT id FROM translation_orders WHERE by_account = $1 AND request_key = $2", [by, requestKey]);
|
|
221
|
-
if (!existing.rows.length) {
|
|
222
|
-
for (const [scope, limit] of [[`account:${by}`, 5], ["server", 50]] as const) {
|
|
223
|
-
const limited = await this.options.db.query(`INSERT INTO translation_checkout_limits (bucket, attempts) VALUES ($1, 1)
|
|
224
|
-
ON CONFLICT (bucket) DO UPDATE SET attempts = translation_checkout_limits.attempts + 1
|
|
225
|
-
WHERE translation_checkout_limits.attempts < $2 RETURNING attempts`, [`${scope}:${Math.floor(this.now() / DAY)}`, limit]);
|
|
226
|
-
if (!limited.rows.length) throw new SpeechError("Too many new checkouts today. Resume an existing purchase or try tomorrow.", 429);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
const id = randomUUID();
|
|
230
|
-
await this.options.db.query(`INSERT INTO translation_orders (id, by_account, request_key, plan, price_cents, days, business_id, coin, created_at)
|
|
231
|
-
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (by_account, request_key) DO NOTHING`,
|
|
232
|
-
[id, by, requestKey, plan.id, plan.priceCents, plan.days, config.business, coin, new Date(this.now())]);
|
|
233
|
-
const order = (await this.options.db.query("SELECT * FROM translation_orders WHERE by_account = $1 AND request_key = $2", [by, requestKey])).rows[0]!;
|
|
234
|
-
if (order["plan"] !== plan.id || order["coin"] !== coin) throw new SpeechError("This checkout already belongs to a different pass. Start a new purchase.", 409);
|
|
235
|
-
let paymentId = String(order["payment_id"] ?? "");
|
|
236
|
-
if (!paymentId) {
|
|
237
|
-
const created = await this.provider("/api/payments/create", {
|
|
238
|
-
method: "POST", headers: { "idempotency-key": `nixamp-translation-${order["id"]}` },
|
|
239
|
-
body: JSON.stringify({ business_id: order["business_id"], amount_usd: Number(order["price_cents"]) / 100,
|
|
240
|
-
currency: coin.toLowerCase(), payment_method: "crypto", description: `Nixamp translated audio: ${plan.name} pass`,
|
|
241
|
-
metadata: { app: "nixamp", product: "translation", order_id: order["id"] },
|
|
242
|
-
redirect_url: `${this.options.site}/?translation_order=${order["id"]}` }),
|
|
243
|
-
});
|
|
244
|
-
paymentId = created.payment?.id;
|
|
245
|
-
if (!ID.test(paymentId ?? "")) throw new SpeechError("Checkout did not return a valid payment. Retry this purchase.", 502);
|
|
246
|
-
await this.options.db.query("UPDATE translation_orders SET payment_id = $2 WHERE id = $1 AND payment_id IS NULL", [order["id"], paymentId]);
|
|
247
|
-
}
|
|
248
|
-
return { id: String(order["id"]), url: `https://coinpayportal.com/pay/${paymentId}` };
|
|
249
|
-
}
|
|
250
|
-
/**
|
|
251
|
-
* A CoinPay webhook only says "look again". The order is settled by asking
|
|
252
|
-
* CoinPay, exactly as a returning buyer's check does, so a replayed or
|
|
253
|
-
* stale body cannot credit anything and a second delivery (confirmed, then
|
|
254
|
-
* forwarded) credits nothing twice. A payment that is not ours is answered,
|
|
255
|
-
* not failed: CoinPay would otherwise retry it forever.
|
|
256
|
-
*/
|
|
257
|
-
async settle(paymentId: string): Promise<{ settled: boolean; status?: string; reason?: string }> {
|
|
258
|
-
if (!ID.test(paymentId)) return { settled: false, reason: "unknown payment" };
|
|
259
|
-
await this.ensure();
|
|
260
|
-
const order = (await this.options.db.query("SELECT id, by_account FROM translation_orders WHERE payment_id = $1", [paymentId])).rows[0];
|
|
261
|
-
if (!order) return { settled: false, reason: "unknown payment" };
|
|
262
|
-
const { status } = await this.check(String(order["by_account"]), String(order["id"]));
|
|
263
|
-
return { settled: status === "paid", status };
|
|
264
|
-
}
|
|
265
|
-
async check(by: string, id: string): Promise<{ status: string; access: TranslationAccess }> {
|
|
266
|
-
if (!ID.test(id)) throw new SpeechError("Purchase not found.", 404);
|
|
267
|
-
await this.ensure();
|
|
268
|
-
const order = (await this.options.db.query("SELECT * FROM translation_orders WHERE id = $1 AND by_account = $2", [id, by])).rows[0];
|
|
269
|
-
if (!order) throw new SpeechError("Purchase not found.", 404);
|
|
270
|
-
let status = String(order["status"]);
|
|
271
|
-
if (["pending", "expired"].includes(status) && order["payment_id"]) {
|
|
272
|
-
const result = await this.provider(`/api/payments/${encodeURIComponent(String(order["payment_id"]))}`);
|
|
273
|
-
const payment = result.payment;
|
|
274
|
-
if (payment && verifyTranslationPayment(payment, String(order["payment_id"]), Number(order["price_cents"]))) {
|
|
275
|
-
const now = new Date(this.now()), expires = new Date(this.now() + Number(order["days"]) * DAY);
|
|
276
|
-
// One statement atomically marks the order and adds credit. A retry,
|
|
277
|
-
// second tab or second replica cannot activate the same money twice.
|
|
278
|
-
await this.options.db.query(`WITH paid AS (
|
|
279
|
-
UPDATE translation_orders SET status = 'paid', credited_at = $3, expires_at = $4
|
|
280
|
-
WHERE id = $1 AND by_account = $2 AND status IN ('pending', 'expired') RETURNING by_account, price_cents)
|
|
281
|
-
INSERT INTO translation_wallets (by_account, balance_micros, expires_at)
|
|
282
|
-
SELECT by_account, price_cents::bigint * 10000, $4 FROM paid
|
|
283
|
-
ON CONFLICT (by_account) DO UPDATE SET
|
|
284
|
-
balance_micros = CASE WHEN translation_wallets.expires_at > $3 THEN translation_wallets.balance_micros ELSE 0 END + EXCLUDED.balance_micros,
|
|
285
|
-
expires_at = GREATEST(translation_wallets.expires_at, EXCLUDED.expires_at)`, [id, by, now, expires]);
|
|
286
|
-
status = "paid";
|
|
287
|
-
} else if (payment && ["expired", "failed", "refunded"].includes(payment.status)) {
|
|
288
|
-
status = payment.status;
|
|
289
|
-
await this.options.db.query("UPDATE translation_orders SET status = $3 WHERE id = $1 AND by_account = $2 AND status = 'pending'", [id, by, status]);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
return { status, access: await this.access(by) };
|
|
293
|
-
}
|
|
6
|
+
export class TranslationPasses extends Base {
|
|
7
|
+
constructor(options: ConstructorParameters<typeof Base>[0]) { super({ app: "nixamp", label: "Nixamp translated audio", ...options }); }
|
|
294
8
|
}
|