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,295 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export const TRANSLATION_MULTIPLIER = 5;
|
|
7
|
-
export const TRANSLATION_PLANS = [
|
|
8
|
-
{ id: "day", name: "Day", days: 1, priceCents: 100 },
|
|
9
|
-
{ id: "month", name: "Month", days: 30, priceCents: 500 },
|
|
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
|
-
/** Which CoinPay key this deployment sells with. `COINPAY_API_KEY` is nixamp's
|
|
37
|
-
* own business; `COINPAY_X402_KEY` is the older name, still honoured so a box
|
|
38
|
-
* that has only it keeps selling. The own key wins, because the fleet-wide
|
|
39
|
-
* x402 key belongs to another site's business and would book nixamp's sales
|
|
40
|
-
* (and send its webhooks) there. */
|
|
41
|
-
export function coinpayKeyFrom(env = process.env) {
|
|
42
|
-
return env["COINPAY_API_KEY"] || env["COINPAY_X402_KEY"] || "";
|
|
43
|
-
}
|
|
44
|
-
/** CoinPay signs `t=<unix>,v1=<hex HMAC-SHA256 of "t.rawBody">` and allows
|
|
45
|
-
* 300 s of skew. Computed over the raw bytes: reserialising changes them. */
|
|
46
|
-
export function verifyCoinpaySignature(raw, header, secret, nowMs = Date.now()) {
|
|
47
|
-
if (!secret || !header)
|
|
48
|
-
return false;
|
|
49
|
-
let t = "", v1 = "";
|
|
50
|
-
for (const part of header.split(",")) {
|
|
51
|
-
const at = part.indexOf("=");
|
|
52
|
-
const name = part.slice(0, at).trim(), value = part.slice(at + 1).trim();
|
|
53
|
-
if (name === "t")
|
|
54
|
-
t = value;
|
|
55
|
-
else if (name === "v1")
|
|
56
|
-
v1 = value;
|
|
57
|
-
}
|
|
58
|
-
if (!/^\d{1,12}$/.test(t) || !/^[a-f0-9]{64}$/i.test(v1))
|
|
59
|
-
return false;
|
|
60
|
-
if (Math.abs(nowMs / 1000 - Number(t)) > 300)
|
|
61
|
-
return false;
|
|
62
|
-
const expected = createHmac("sha256", secret).update(`${t}.${raw}`).digest();
|
|
63
|
-
return timingSafeEqual(expected, Buffer.from(v1, "hex"));
|
|
64
|
-
}
|
|
65
|
-
/** CoinPay's webhook nests the payment under `data`, and its top-level `id` is
|
|
66
|
-
* the EVENT (`evt_…`), not the payment. Read `data` first; the flat shape is
|
|
67
|
-
* only the older test sender's. */
|
|
68
|
-
export function readCoinpayWebhook(payload) {
|
|
69
|
-
const body = payload && typeof payload === "object" ? payload : {};
|
|
70
|
-
const data = body["data"] && typeof body["data"] === "object" ? body["data"] : body;
|
|
71
|
-
return {
|
|
72
|
-
type: String(body["type"] ?? ""),
|
|
73
|
-
paymentId: String(data["payment_id"] ?? data["id"] ?? ""),
|
|
74
|
-
status: String(data["status"] ?? "").toLowerCase(),
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
export class TranslationPasses {
|
|
78
|
-
options;
|
|
79
|
-
schema = null;
|
|
80
|
-
merchant = null;
|
|
81
|
-
merchantUntil = 0;
|
|
82
|
-
fetcher;
|
|
83
|
-
now;
|
|
84
|
-
free;
|
|
85
|
-
constructor(options) {
|
|
86
|
-
this.options = options;
|
|
87
|
-
this.fetcher = options.fetcher ?? fetch;
|
|
88
|
-
this.now = options.now ?? Date.now;
|
|
89
|
-
this.free = new UpgradeAllowances(options.db, this.now);
|
|
90
|
-
}
|
|
91
|
-
async ensure() {
|
|
92
|
-
this.schema ??= (async () => {
|
|
93
|
-
await this.free.ensure();
|
|
94
|
-
await this.options.db.query(`CREATE TABLE IF NOT EXISTS translation_wallets (
|
|
95
|
-
by_account TEXT PRIMARY KEY, balance_micros BIGINT NOT NULL CHECK (balance_micros >= 0), expires_at TIMESTAMPTZ NOT NULL)`);
|
|
96
|
-
await this.options.db.query(`CREATE TABLE IF NOT EXISTS translation_orders (
|
|
97
|
-
id TEXT PRIMARY KEY, by_account TEXT NOT NULL, request_key TEXT NOT NULL, plan TEXT NOT NULL,
|
|
98
|
-
price_cents INTEGER NOT NULL CHECK (price_cents > 0), days INTEGER NOT NULL,
|
|
99
|
-
business_id TEXT NOT NULL, coin TEXT NOT NULL, payment_id TEXT UNIQUE,
|
|
100
|
-
status TEXT NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ NOT NULL,
|
|
101
|
-
credited_at TIMESTAMPTZ, expires_at TIMESTAMPTZ, UNIQUE(by_account, request_key))`);
|
|
102
|
-
await this.options.db.query(`CREATE TABLE IF NOT EXISTS translation_usage (
|
|
103
|
-
id TEXT PRIMARY KEY, by_account TEXT NOT NULL, kind TEXT NOT NULL, units BIGINT NOT NULL,
|
|
104
|
-
cost_micros BIGINT NOT NULL, charge_micros BIGINT NOT NULL, status TEXT NOT NULL DEFAULT 'reserved',
|
|
105
|
-
created_at TIMESTAMPTZ NOT NULL)`);
|
|
106
|
-
await this.options.db.query(`CREATE TABLE IF NOT EXISTS translation_checkout_limits (bucket TEXT PRIMARY KEY, attempts INTEGER NOT NULL)`);
|
|
107
|
-
await this.options.db.query("CREATE INDEX IF NOT EXISTS translation_orders_account ON translation_orders (by_account, created_at)");
|
|
108
|
-
})().catch(error => { this.schema = null; throw error; });
|
|
109
|
-
await this.schema;
|
|
110
|
-
}
|
|
111
|
-
async provider(path, init = {}) {
|
|
112
|
-
if (!this.options.key)
|
|
113
|
-
throw new SpeechError("Translation checkout is unavailable.", 503);
|
|
114
|
-
const response = await this.fetcher(`https://coinpayportal.com${path}`, {
|
|
115
|
-
...init, signal: AbortSignal.timeout(15_000), headers: { authorization: `Bearer ${this.options.key}`, "content-type": "application/json", ...init.headers },
|
|
116
|
-
});
|
|
117
|
-
const body = await response.json();
|
|
118
|
-
if (!response.ok || body.success !== true)
|
|
119
|
-
throw new SpeechError("The payment service is unavailable. Your purchase has not been activated; retry to check its status.", 502);
|
|
120
|
-
return body;
|
|
121
|
-
}
|
|
122
|
-
async settings() {
|
|
123
|
-
if (!this.merchant || this.now() >= this.merchantUntil) {
|
|
124
|
-
this.merchantUntil = this.now() + 600_000;
|
|
125
|
-
this.merchant = this.provider("/api/supported-coins").then(body => {
|
|
126
|
-
if (!ID.test(body.business_id))
|
|
127
|
-
throw new SpeechError("Translation checkout is unavailable.", 503);
|
|
128
|
-
const coins = COINS.filter(symbol => body.coins?.some((coin) => coin.symbol === symbol && coin.is_active && coin.has_wallet));
|
|
129
|
-
return { business: body.business_id, coins };
|
|
130
|
-
}).catch(error => { this.merchant = null; throw error; });
|
|
131
|
-
}
|
|
132
|
-
return this.merchant;
|
|
133
|
-
}
|
|
134
|
-
async access(by) {
|
|
135
|
-
await this.ensure();
|
|
136
|
-
const config = await this.settings().catch(() => ({ business: "", coins: [] }));
|
|
137
|
-
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;
|
|
138
|
-
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"]}` })) : [];
|
|
139
|
-
return { required: true, available: config.coins.length > 0, balanceMicros: Number(row?.["balance_micros"] ?? 0),
|
|
140
|
-
expires: row ? new Date(String(row["expires_at"])).toISOString() : null, plans: TRANSLATION_PLANS, coins: config.coins, orders, free: await this.free.access(by),
|
|
141
|
-
rates: { transcriptionHour: 0.22, voiceThousand: 0.05, multiplier: TRANSLATION_MULTIPLIER } };
|
|
142
|
-
}
|
|
143
|
-
async begin(by, resource) {
|
|
144
|
-
await this.ensure();
|
|
145
|
-
if (await this.free.begin(by, "translation", resource))
|
|
146
|
-
return;
|
|
147
|
-
await this.require(by, resource);
|
|
148
|
-
}
|
|
149
|
-
async require(by, resource = "") {
|
|
150
|
-
await this.ensure();
|
|
151
|
-
if ((await this.free.active([by], "translation", resource, true)).length)
|
|
152
|
-
return;
|
|
153
|
-
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())]);
|
|
154
|
-
if (!found.rows.length)
|
|
155
|
-
throw new SpeechError("Buy a pass or start a remaining free session: this session has no free time or audio credit.", 402);
|
|
156
|
-
}
|
|
157
|
-
async eligible(accounts, resource = "") {
|
|
158
|
-
await this.ensure();
|
|
159
|
-
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())]);
|
|
160
|
-
return [...new Set([...result.rows.map(row => String(row["by_account"])), ...await this.free.active(accounts, "translation", resource, true)])];
|
|
161
|
-
}
|
|
162
|
-
async reserve(by, kind, units, resource = "") {
|
|
163
|
-
const result = await this.reserveMany([by], kind, units, resource);
|
|
164
|
-
if (!result.length)
|
|
165
|
-
throw new SpeechError("This free session ended and audio credit is used up. Start a remaining free session or buy a pass.", 402);
|
|
166
|
-
return result[0].id;
|
|
167
|
-
}
|
|
168
|
-
/** Batch a shared stream's access charges in one database round trip. */
|
|
169
|
-
async reserveMany(accounts, kind, units, resource = "") {
|
|
170
|
-
await this.ensure();
|
|
171
|
-
const { cost, charge } = translationCost(kind, units);
|
|
172
|
-
const now = this.now(), key = resource ? this.free.key("translation", resource) : "";
|
|
173
|
-
const result = await this.options.db.query(`WITH free AS (
|
|
174
|
-
SELECT DISTINCT by_account FROM upgrade_daily_uses WHERE by_account = ANY($1::text[])
|
|
175
|
-
AND (sessions->>$7)::bigint > $8), debit AS (
|
|
176
|
-
UPDATE translation_wallets SET balance_micros = balance_micros - $2
|
|
177
|
-
WHERE by_account = ANY($1::text[]) AND expires_at > $3 AND balance_micros >= $2
|
|
178
|
-
AND by_account NOT IN (SELECT by_account FROM free) RETURNING by_account), admitted AS (
|
|
179
|
-
SELECT by_account, 0::bigint AS charge FROM free UNION ALL SELECT by_account, $2::bigint FROM debit)
|
|
180
|
-
INSERT INTO translation_usage (id, by_account, kind, units, cost_micros, charge_micros, created_at)
|
|
181
|
-
SELECT gen_random_uuid()::text, by_account, $4, $5, $6, charge, $3 FROM admitted RETURNING id, by_account`, [[...new Set(accounts)], charge, new Date(now), kind, units, cost, key, now]);
|
|
182
|
-
return result.rows.map(row => ({ by: String(row["by_account"]), id: String(row["id"]) }));
|
|
183
|
-
}
|
|
184
|
-
async commit(id) {
|
|
185
|
-
await this.options.db.query("UPDATE translation_usage SET status = 'charged' WHERE id = $1 AND status = 'reserved'", [id]);
|
|
186
|
-
}
|
|
187
|
-
async refund(id) {
|
|
188
|
-
await this.options.db.query(`WITH undone AS (
|
|
189
|
-
UPDATE translation_usage SET status = 'refunded' WHERE id = $1 AND status = 'reserved'
|
|
190
|
-
RETURNING by_account, charge_micros)
|
|
191
|
-
UPDATE translation_wallets AS wallet SET balance_micros = wallet.balance_micros + undone.charge_micros
|
|
192
|
-
FROM undone WHERE wallet.by_account = undone.by_account`, [id]);
|
|
193
|
-
}
|
|
194
|
-
async commitMany(ids) {
|
|
195
|
-
await this.options.db.query("UPDATE translation_usage SET status = 'charged' WHERE id = ANY($1::text[]) AND status = 'reserved'", [ids]);
|
|
196
|
-
}
|
|
197
|
-
async refundMany(ids) {
|
|
198
|
-
await this.options.db.query(`WITH undone AS (
|
|
199
|
-
UPDATE translation_usage SET status = 'refunded' WHERE id = ANY($1::text[]) AND status = 'reserved'
|
|
200
|
-
RETURNING by_account, charge_micros), totals AS (SELECT by_account, SUM(charge_micros) AS charge FROM undone GROUP BY by_account)
|
|
201
|
-
UPDATE translation_wallets AS wallet SET balance_micros = wallet.balance_micros + totals.charge
|
|
202
|
-
FROM totals WHERE wallet.by_account = totals.by_account`, [ids]);
|
|
203
|
-
}
|
|
204
|
-
async checkout(by, planId, coin, requestKey) {
|
|
205
|
-
await this.ensure();
|
|
206
|
-
const plan = TRANSLATION_PLANS.find(plan => plan.id === planId);
|
|
207
|
-
if (!plan || !ID.test(requestKey))
|
|
208
|
-
throw new SpeechError("Choose a translation pass.", 400);
|
|
209
|
-
const config = await this.settings();
|
|
210
|
-
if (!config.coins.includes(coin))
|
|
211
|
-
throw new SpeechError("Choose an available payment currency.", 400);
|
|
212
|
-
// Durable caps across restarts/replicas protect the merchant's invoice quota.
|
|
213
|
-
// Retrying an existing order does not consume another invoice allowance.
|
|
214
|
-
const existing = await this.options.db.query("SELECT id FROM translation_orders WHERE by_account = $1 AND request_key = $2", [by, requestKey]);
|
|
215
|
-
if (!existing.rows.length) {
|
|
216
|
-
for (const [scope, limit] of [[`account:${by}`, 5], ["server", 50]]) {
|
|
217
|
-
const limited = await this.options.db.query(`INSERT INTO translation_checkout_limits (bucket, attempts) VALUES ($1, 1)
|
|
218
|
-
ON CONFLICT (bucket) DO UPDATE SET attempts = translation_checkout_limits.attempts + 1
|
|
219
|
-
WHERE translation_checkout_limits.attempts < $2 RETURNING attempts`, [`${scope}:${Math.floor(this.now() / DAY)}`, limit]);
|
|
220
|
-
if (!limited.rows.length)
|
|
221
|
-
throw new SpeechError("Too many new checkouts today. Resume an existing purchase or try tomorrow.", 429);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
const id = randomUUID();
|
|
225
|
-
await this.options.db.query(`INSERT INTO translation_orders (id, by_account, request_key, plan, price_cents, days, business_id, coin, created_at)
|
|
226
|
-
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())]);
|
|
227
|
-
const order = (await this.options.db.query("SELECT * FROM translation_orders WHERE by_account = $1 AND request_key = $2", [by, requestKey])).rows[0];
|
|
228
|
-
if (order["plan"] !== plan.id || order["coin"] !== coin)
|
|
229
|
-
throw new SpeechError("This checkout already belongs to a different pass. Start a new purchase.", 409);
|
|
230
|
-
let paymentId = String(order["payment_id"] ?? "");
|
|
231
|
-
if (!paymentId) {
|
|
232
|
-
const created = await this.provider("/api/payments/create", {
|
|
233
|
-
method: "POST", headers: { "idempotency-key": `nixamp-translation-${order["id"]}` },
|
|
234
|
-
body: JSON.stringify({ business_id: order["business_id"], amount_usd: Number(order["price_cents"]) / 100,
|
|
235
|
-
currency: coin.toLowerCase(), payment_method: "crypto", description: `Nixamp translated audio: ${plan.name} pass`,
|
|
236
|
-
metadata: { app: "nixamp", product: "translation", order_id: order["id"] },
|
|
237
|
-
redirect_url: `${this.options.site}/?translation_order=${order["id"]}` }),
|
|
238
|
-
});
|
|
239
|
-
paymentId = created.payment?.id;
|
|
240
|
-
if (!ID.test(paymentId ?? ""))
|
|
241
|
-
throw new SpeechError("Checkout did not return a valid payment. Retry this purchase.", 502);
|
|
242
|
-
await this.options.db.query("UPDATE translation_orders SET payment_id = $2 WHERE id = $1 AND payment_id IS NULL", [order["id"], paymentId]);
|
|
243
|
-
}
|
|
244
|
-
return { id: String(order["id"]), url: `https://coinpayportal.com/pay/${paymentId}` };
|
|
245
|
-
}
|
|
246
|
-
/**
|
|
247
|
-
* A CoinPay webhook only says "look again". The order is settled by asking
|
|
248
|
-
* CoinPay, exactly as a returning buyer's check does, so a replayed or
|
|
249
|
-
* stale body cannot credit anything and a second delivery (confirmed, then
|
|
250
|
-
* forwarded) credits nothing twice. A payment that is not ours is answered,
|
|
251
|
-
* not failed: CoinPay would otherwise retry it forever.
|
|
252
|
-
*/
|
|
253
|
-
async settle(paymentId) {
|
|
254
|
-
if (!ID.test(paymentId))
|
|
255
|
-
return { settled: false, reason: "unknown payment" };
|
|
256
|
-
await this.ensure();
|
|
257
|
-
const order = (await this.options.db.query("SELECT id, by_account FROM translation_orders WHERE payment_id = $1", [paymentId])).rows[0];
|
|
258
|
-
if (!order)
|
|
259
|
-
return { settled: false, reason: "unknown payment" };
|
|
260
|
-
const { status } = await this.check(String(order["by_account"]), String(order["id"]));
|
|
261
|
-
return { settled: status === "paid", status };
|
|
262
|
-
}
|
|
263
|
-
async check(by, id) {
|
|
264
|
-
if (!ID.test(id))
|
|
265
|
-
throw new SpeechError("Purchase not found.", 404);
|
|
266
|
-
await this.ensure();
|
|
267
|
-
const order = (await this.options.db.query("SELECT * FROM translation_orders WHERE id = $1 AND by_account = $2", [id, by])).rows[0];
|
|
268
|
-
if (!order)
|
|
269
|
-
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
|
-
}
|
|
288
|
-
else if (payment && ["expired", "failed", "refunded"].includes(payment.status)) {
|
|
289
|
-
status = payment.status;
|
|
290
|
-
await this.options.db.query("UPDATE translation_orders SET status = $3 WHERE id = $1 AND by_account = $2 AND status = 'pending'", [id, by, status]);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
return { status, access: await this.access(by) };
|
|
294
|
-
}
|
|
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 } from "@profullstack/live-translate";
|
|
4
|
+
export class TranslationPasses extends Base {
|
|
5
|
+
constructor(options) { super({ app: "nixamp", label: "Nixamp translated audio", ...options }); }
|
|
295
6
|
}
|
|
@@ -1,23 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export declare const FREE_UPGRADE_SESSIONS = 10;
|
|
3
|
-
export declare const FREE_UPGRADE_RECONNECT_SECONDS = 90;
|
|
4
|
-
export interface UpgradeAllowance {
|
|
5
|
-
limit: number;
|
|
6
|
-
remaining: number;
|
|
7
|
-
reconnectSeconds: number;
|
|
8
|
-
resets: string;
|
|
9
|
-
activeUntil: string | null;
|
|
10
|
-
}
|
|
11
|
-
export declare class UpgradeAllowances {
|
|
12
|
-
private readonly db;
|
|
13
|
-
private readonly now;
|
|
14
|
-
private schema;
|
|
15
|
-
constructor(db: Queryable, now?: () => number);
|
|
16
|
-
ensure(): Promise<void>;
|
|
17
|
-
key(product: string, resource: string): string;
|
|
18
|
-
access(by?: string): Promise<UpgradeAllowance>;
|
|
19
|
-
/** One atomic row serializes concurrent starts across tabs, products and replicas.
|
|
20
|
-
* Active use renews a short lease. Reconnecting reuses it; idle leases expire. */
|
|
21
|
-
begin(by: string, product: string, resource: string): Promise<boolean>;
|
|
22
|
-
active(accounts: string[], product: string, resource: string, renew?: boolean): Promise<string[]>;
|
|
23
|
-
}
|
|
1
|
+
export { UpgradeAllowances, FREE_UPGRADE_SESSIONS, FREE_UPGRADE_RECONNECT_SECONDS, type UpgradeAllowance } from "@profullstack/live-translate";
|
|
@@ -1,73 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* Claim only on explicit use, never from balance polling or provider chunks. */
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
4
|
-
import { SpeechError } from "./speech.js";
|
|
5
|
-
export const FREE_UPGRADE_SESSIONS = 10;
|
|
6
|
-
export const FREE_UPGRADE_RECONNECT_SECONDS = 90;
|
|
7
|
-
const DAY = 86_400_000;
|
|
8
|
-
export class UpgradeAllowances {
|
|
9
|
-
db;
|
|
10
|
-
now;
|
|
11
|
-
schema = null;
|
|
12
|
-
constructor(db, now = Date.now) {
|
|
13
|
-
this.db = db;
|
|
14
|
-
this.now = now;
|
|
15
|
-
}
|
|
16
|
-
async ensure() {
|
|
17
|
-
this.schema ??= this.db.query(`CREATE TABLE IF NOT EXISTS upgrade_daily_uses (
|
|
18
|
-
by_account TEXT NOT NULL, day BIGINT NOT NULL,
|
|
19
|
-
uses INTEGER NOT NULL CHECK (uses >= 1),
|
|
20
|
-
sessions JSONB NOT NULL, PRIMARY KEY (by_account, day))`)
|
|
21
|
-
.then(() => this.db.query(`ALTER TABLE upgrade_daily_uses DROP CONSTRAINT IF EXISTS upgrade_daily_uses_uses_check`))
|
|
22
|
-
.then(() => this.db.query(`ALTER TABLE upgrade_daily_uses ADD CONSTRAINT upgrade_daily_uses_uses_check CHECK (uses BETWEEN 1 AND 10)`))
|
|
23
|
-
.then(() => undefined)
|
|
24
|
-
.catch(error => { this.schema = null; throw error; });
|
|
25
|
-
await this.schema;
|
|
26
|
-
}
|
|
27
|
-
key(product, resource) {
|
|
28
|
-
if (!/^[a-z][a-z0-9-]{0,63}$/.test(product) || !/^[\w-]{1,80}$/.test(resource))
|
|
29
|
-
throw new SpeechError("Choose a panel session.", 400);
|
|
30
|
-
return createHash("sha256").update(`${product}|${resource}`).digest("hex");
|
|
31
|
-
}
|
|
32
|
-
async access(by) {
|
|
33
|
-
await this.ensure();
|
|
34
|
-
const now = this.now(), day = Math.floor(now / DAY);
|
|
35
|
-
const rows = by ? (await this.db.query("SELECT uses, day, sessions FROM upgrade_daily_uses WHERE by_account = $1", [by])).rows : [];
|
|
36
|
-
const today = rows.find(row => Number(row["day"]) === day);
|
|
37
|
-
const active = Math.max(0, ...rows.flatMap(row => Object.values(row["sessions"]).map(Number)));
|
|
38
|
-
return { limit: FREE_UPGRADE_SESSIONS, remaining: Math.max(0, FREE_UPGRADE_SESSIONS - Number(today?.["uses"] ?? 0)),
|
|
39
|
-
reconnectSeconds: FREE_UPGRADE_RECONNECT_SECONDS, resets: new Date((day + 1) * DAY).toISOString(), activeUntil: active > now ? new Date(active).toISOString() : null };
|
|
40
|
-
}
|
|
41
|
-
/** One atomic row serializes concurrent starts across tabs, products and replicas.
|
|
42
|
-
* Active use renews a short lease. Reconnecting reuses it; idle leases expire. */
|
|
43
|
-
async begin(by, product, resource) {
|
|
44
|
-
if (!by)
|
|
45
|
-
throw new SpeechError("Sign in to use your free sessions.", 401);
|
|
46
|
-
const key = this.key(product, resource);
|
|
47
|
-
if ((await this.active([by], product, resource, true)).length)
|
|
48
|
-
return true;
|
|
49
|
-
await this.ensure();
|
|
50
|
-
const now = this.now(), day = Math.floor(now / DAY), until = now + FREE_UPGRADE_RECONNECT_SECONDS * 1000;
|
|
51
|
-
const result = await this.db.query(`INSERT INTO upgrade_daily_uses AS daily (by_account, day, uses, sessions)
|
|
52
|
-
VALUES ($1, $2, 1, jsonb_build_object($3::text, $5::bigint))
|
|
53
|
-
ON CONFLICT (by_account, day) DO UPDATE SET
|
|
54
|
-
uses = CASE WHEN COALESCE((daily.sessions->>$3)::bigint, 0) > $4 THEN daily.uses ELSE daily.uses + 1 END,
|
|
55
|
-
sessions = daily.sessions || EXCLUDED.sessions
|
|
56
|
-
WHERE COALESCE((daily.sessions->>$3)::bigint, 0) > $4 OR daily.uses < $6
|
|
57
|
-
RETURNING uses`, [by, day, key, now, until, FREE_UPGRADE_SESSIONS]);
|
|
58
|
-
return result.rows.length > 0;
|
|
59
|
-
}
|
|
60
|
-
async active(accounts, product, resource, renew = false) {
|
|
61
|
-
if (!resource || !accounts.length)
|
|
62
|
-
return [];
|
|
63
|
-
const key = this.key(product, resource);
|
|
64
|
-
await this.ensure();
|
|
65
|
-
const now = this.now();
|
|
66
|
-
const result = renew
|
|
67
|
-
? await this.db.query(`UPDATE upgrade_daily_uses SET sessions = sessions || jsonb_build_object($2::text, $4::bigint)
|
|
68
|
-
WHERE by_account = ANY($1::text[]) AND (sessions->>$2)::bigint > $3 RETURNING by_account`, [accounts, key, now, now + FREE_UPGRADE_RECONNECT_SECONDS * 1000])
|
|
69
|
-
: await this.db.query(`SELECT by_account FROM upgrade_daily_uses
|
|
70
|
-
WHERE by_account = ANY($1::text[]) AND (sessions->>$2)::bigint > $3`, [accounts, key, now]);
|
|
71
|
-
return result.rows.map(row => String(row["by_account"]));
|
|
72
|
-
}
|
|
73
|
-
}
|
|
1
|
+
export { UpgradeAllowances, FREE_UPGRADE_SESSIONS, FREE_UPGRADE_RECONNECT_SECONDS } from "@profullstack/live-translate";
|
package/dist/voice-profile.d.ts
CHANGED
|
@@ -1,7 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export type VoiceProfile = "lower" | "higher" | "unknown";
|
|
3
|
-
/** Median fundamental frequency from periodic voiced frames, sampled at 8 kHz.
|
|
4
|
-
* Ambiguous pitch, overlapping voices and unvoiced sound use the selected default.
|
|
5
|
-
* This is deliberately inexpensive and does not load another model beside ASR.
|
|
6
|
-
*/
|
|
7
|
-
export declare function voiceProfile(pcm: Buffer): VoiceProfile;
|
|
1
|
+
export { voiceProfile, type VoiceProfile } from "@profullstack/live-translate";
|
package/dist/voice-profile.js
CHANGED
|
@@ -1,53 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* Ambiguous pitch, overlapping voices and unvoiced sound use the selected default.
|
|
3
|
-
* This is deliberately inexpensive and does not load another model beside ASR.
|
|
4
|
-
*/
|
|
5
|
-
export function voiceProfile(pcm) {
|
|
6
|
-
const pitches = [];
|
|
7
|
-
const frame = 320;
|
|
8
|
-
const samples = Math.floor(pcm.length / 4);
|
|
9
|
-
for (let start = 0; start + frame < samples; start += 1600) {
|
|
10
|
-
const values = new Float32Array(frame);
|
|
11
|
-
let mean = 0;
|
|
12
|
-
for (let i = 0; i < frame; i++)
|
|
13
|
-
mean += values[i] = pcm.readInt16LE((start + i) * 4) / 32768;
|
|
14
|
-
mean /= frame;
|
|
15
|
-
let energy = 0;
|
|
16
|
-
for (let i = 0; i < frame; i++) {
|
|
17
|
-
values[i] = values[i] - mean;
|
|
18
|
-
energy += values[i] ** 2;
|
|
19
|
-
}
|
|
20
|
-
if (Math.sqrt(energy / frame) < 0.01)
|
|
21
|
-
continue;
|
|
22
|
-
let best = 0;
|
|
23
|
-
let period = 0;
|
|
24
|
-
// Prefer the first strong peak; a later multiple of the period has the same correlation.
|
|
25
|
-
const correlations = [];
|
|
26
|
-
for (let lag = 20; lag <= 114; lag++) {
|
|
27
|
-
let cross = 0, left = 0, right = 0;
|
|
28
|
-
for (let i = 0; i < frame - lag; i++) {
|
|
29
|
-
const a = values[i], b = values[i + lag];
|
|
30
|
-
cross += a * b;
|
|
31
|
-
left += a * a;
|
|
32
|
-
right += b * b;
|
|
33
|
-
}
|
|
34
|
-
correlations[lag] = cross / Math.sqrt(left * right || 1);
|
|
35
|
-
}
|
|
36
|
-
for (let lag = 21; lag < 114; lag++) {
|
|
37
|
-
const score = correlations[lag];
|
|
38
|
-
if (score > 0.8 && score > correlations[lag - 1] && score >= correlations[lag + 1] && score > best + 0.03) {
|
|
39
|
-
best = score;
|
|
40
|
-
period = lag;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
if (period)
|
|
44
|
-
pitches.push(8000 / period);
|
|
45
|
-
}
|
|
46
|
-
if (pitches.length < 3)
|
|
47
|
-
return "unknown";
|
|
48
|
-
pitches.sort((a, b) => a - b);
|
|
49
|
-
const median = pitches[Math.floor(pitches.length / 2)];
|
|
50
|
-
const lower = pitches.filter((pitch) => pitch < 155).length / pitches.length;
|
|
51
|
-
const higher = pitches.filter((pitch) => pitch > 185).length / pitches.length;
|
|
52
|
-
return median < 155 && lower >= 0.75 ? "lower" : median > 185 && higher >= 0.75 ? "higher" : "unknown";
|
|
53
|
-
}
|
|
1
|
+
export { voiceProfile } from "@profullstack/live-translate";
|
package/dist/voice-provider.d.ts
CHANGED
|
@@ -1,9 +1 @@
|
|
|
1
|
-
|
|
2
|
-
/** Only fixed messages/codes leave this boundary: provider bodies can contain
|
|
3
|
-
* account information. Authorization and billing errors must not be retried
|
|
4
|
-
* as transient 502s by the live player. */
|
|
5
|
-
export declare function voiceProviderFailure(response: Response): Promise<{
|
|
6
|
-
error: SpeechError;
|
|
7
|
-
code: string;
|
|
8
|
-
cooldownMs: number;
|
|
9
|
-
}>;
|
|
1
|
+
export { voiceProviderFailure } from "@profullstack/live-translate";
|
package/dist/voice-provider.js
CHANGED
|
@@ -1,27 +1 @@
|
|
|
1
|
-
|
|
2
|
-
/** Only fixed messages/codes leave this boundary: provider bodies can contain
|
|
3
|
-
* account information. Authorization and billing errors must not be retried
|
|
4
|
-
* as transient 502s by the live player. */
|
|
5
|
-
export async function voiceProviderFailure(response) {
|
|
6
|
-
let code = "unknown";
|
|
7
|
-
try {
|
|
8
|
-
const body = await response.json();
|
|
9
|
-
const status = body?.detail?.status;
|
|
10
|
-
if (typeof status === "string" && ["quota_exceeded", "invalid_api_key", "missing_permissions", "subscription_required", "subscription_expired", "payment_required", "payment_issue", "too_many_concurrent_requests", "rate_limit_exceeded"].includes(status))
|
|
11
|
-
code = status;
|
|
12
|
-
}
|
|
13
|
-
catch { /* Non-JSON provider failures still have an HTTP status. */ }
|
|
14
|
-
if (code === "quota_exceeded")
|
|
15
|
-
return { code, cooldownMs: 300_000, error: new SpeechError("ElevenLabs credits are exhausted. The site owner needs to check the provider balance.", 402) };
|
|
16
|
-
if (code === "payment_issue")
|
|
17
|
-
return { code, cooldownMs: 300_000, error: new SpeechError("ElevenLabs blocked transcription because its subscription payment failed. The site owner needs to complete the outstanding ElevenLabs invoice.", 402) };
|
|
18
|
-
if (["subscription_required", "subscription_expired", "payment_required"].includes(code) || response.status === 402)
|
|
19
|
-
return { code, cooldownMs: 300_000, error: new SpeechError("ElevenLabs billing needs attention. The site owner needs to check the provider subscription.", 402) };
|
|
20
|
-
if (["invalid_api_key", "missing_permissions"].includes(code) || [401, 403].includes(response.status))
|
|
21
|
-
return { code, cooldownMs: 300_000, error: new SpeechError("ElevenLabs rejected transcription or voice access. The site owner needs to check the provider key, permissions, and billing.", 424) };
|
|
22
|
-
if (response.status === 429) {
|
|
23
|
-
const retry = Number(response.headers.get("retry-after"));
|
|
24
|
-
return { code, cooldownMs: Math.min(300_000, Math.max(30_000, Number.isFinite(retry) ? retry * 1000 : 0)), error: new SpeechError("ElevenLabs is rate limited. Wait before enabling translated audio again.", 429) };
|
|
25
|
-
}
|
|
26
|
-
return { code, cooldownMs: 10_000, error: new SpeechError("ElevenLabs is temporarily unavailable. Try translated audio again shortly.", 502) };
|
|
27
|
-
}
|
|
1
|
+
export { voiceProviderFailure } from "@profullstack/live-translate";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nixamp",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.45",
|
|
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",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"@profullstack/auth-system": "^0.8.3",
|
|
49
49
|
"@profullstack/hqtui": "^0.5.0",
|
|
50
|
+
"@profullstack/live-translate": "^0.1.0",
|
|
50
51
|
"@profullstack/notifications": "^0.1.0",
|
|
51
52
|
"@profullstack/synconfig": "^0.1.2",
|
|
52
53
|
"@profullstack/throttle": "0.2.2",
|